[MERGE] forward port branch 11.0 up to d70518d04f

This commit is contained in:
Christophe Simonis
2017-10-21 15:22:36 +02:00
151 changed files with 2218 additions and 614 deletions
+4 -4
View File
@@ -72,12 +72,12 @@ from ${object.company_id.name}.
<div>
% set record = ctx.get('record')
% set company = record and record.company_id or user.company_id
<table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed" summary="o_mail_notification">
<table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed; border-collapse:separate;" summary="o_mail_notification">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;">
<tr>
<td valign="middle">
<span style="font-size:20px; color:white; font-weight: bold;">
@@ -94,7 +94,7 @@ from ${object.company_id.name}.
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px;">
<table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px; border-collapse:separate;">
<tbody>
<td valign="top" style="font-family:Arial,Helvetica,sans-serif; color: #555; font-size: 14px;">
${object.body | safe}
@@ -106,7 +106,7 @@ from ${object.company_id.name}.
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;">
<tr>
<td valign="middle" align="left" style="color: #fff; padding-top: 10px; padding-bottom: 10px; font-size: 12px;">
${company.name}<br/>
+127 -38
View File
@@ -848,6 +848,7 @@ class AccountTax(models.Model):
price_unit * quantity eventually affected by previous taxes (if tax is include_base_amount XOR price_include)
"""
self.ensure_one()
price_include = self._context.get('force_price_include', self.price_include)
if self.amount_type == 'fixed':
# Use copysign to take into account the sign of the base amount which includes the sign
# of the quantity and the sign of the price_unit
@@ -861,11 +862,11 @@ class AccountTax(models.Model):
return math.copysign(quantity, base_amount) * self.amount
else:
return quantity * self.amount
if (self.amount_type == 'percent' and not self.price_include) or (self.amount_type == 'division' and self.price_include):
if (self.amount_type == 'percent' and not price_include) or (self.amount_type == 'division' and self.price_include):
return base_amount * self.amount / 100
if self.amount_type == 'percent' and self.price_include:
if self.amount_type == 'percent' and price_include:
return base_amount - (base_amount / (1 + self.amount / 100))
if self.amount_type == 'division' and not self.price_include:
if self.amount_type == 'division' and not price_include:
return base_amount / (1 - self.amount / 100) - base_amount
@api.multi
@@ -899,13 +900,58 @@ class AccountTax(models.Model):
'analytic': boolean,
}]
} """
# 1) Flatten the taxes.
def collect_taxes(self, all_taxes=None):
# Collect all the taxes recursively ordered by the sequence.
# Example:
# group | seq | sub-group |
# ------------|-----------|
# | 1 | |
# ------------|-----------|
# t | 2 | | seq | |
# | | | 4 | |
# | | | 5 | |
# | | | 6 | |
# | | |
# ------------|-----------|
# | 3 | |
# ------------|-----------|
# Result: 1-4-5-6-3
if not all_taxes:
all_taxes = self.env['account.tax']
for tax in self.sorted(key=lambda r: r.sequence):
if tax.amount_type == 'group':
all_taxes = collect_taxes(tax.children_tax_ids, all_taxes)
else:
all_taxes += tax
return all_taxes
taxes = collect_taxes(self)
# 2) Avoid dealing with taxes mixing price_include=False && include_base_amount=True
# with price_include=True
base_excluded_flag = False # price_include=False && include_base_amount=True
included_flag = False # price_include=True
for tax in taxes:
if tax.price_include:
included_flag = True
elif tax.include_base_amount:
base_excluded_flag = True
if base_excluded_flag and included_flag:
raise UserError(_('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.'))
# 3) Deal with the rounding methods
if len(self) == 0:
company_id = self.env.user.company_id
else:
company_id = self[0].company_id
if not currency:
currency = company_id.currency_id
taxes = []
# By default, for each tax, tax amount will first be computed
# and rounded at the 'Account' decimal precision for each
# PO/SO/invoice line and then these rounded amounts will be
@@ -930,50 +976,93 @@ class AccountTax(models.Model):
if not round_tax:
prec += 5
base_values = self.env.context.get('base_values')
if not base_values:
total_excluded = total_included = base = round(price_unit * quantity, prec)
# 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation.
# tax | base | amount |
# /\ ----------------------------
# || tax_1 | XXXX | | <- we are looking for that, it's the total_excluded
# || tax_2 | | |
# || tax_3 | | |
# || ... | .. | .. |
# ----------------------------
def recompute_base(base_amount, fixed_amount, percent_amount):
# Recompute the new base amount based on included fixed/percent amount and the current base amount.
# Example:
# tax | amount |
# ------------------
# tax_1 | 10% |
# tax_2 | 15 |
# tax_3 | 20% |
# ------------------
# if base_amount = 145, the new base is computed as:
# (145 - 15) / (1.0 + ((10 + 20) / 100.0)) = 130 / 1.3 = 100
if fixed_amount == 0.0 and percent_amount == 0.0:
return base_amount
return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0)
base = round(price_unit * quantity, prec)
# For the computation of move lines, we could have a negative base value.
# In this case, compute all with positive values and negative them at the end.
if base < 0:
base = -base
sign = -1
else:
total_excluded, total_included, base = base_values
sign = 1
# Sorting key is mandatory in this case. When no key is provided, sorted() will perform a
# search. However, the search method is overridden in account.tax in order to add a domain
# depending on the context. This domain might filter out some taxes from self, e.g. in the
# case of group taxes.
for tax in self.sorted(key=lambda r: r.sequence):
if tax.amount_type == 'group':
children = tax.children_tax_ids.with_context(base_values=(total_excluded, total_included, base))
ret = children.compute_all(price_unit, currency, quantity, product, partner)
total_excluded = ret['total_excluded']
base = ret['base'] if tax.include_base_amount else base
total_included = ret['total_included']
tax_amount = total_included - total_excluded
taxes += ret['taxes']
continue
# Keep track of the accumulated included fixed/percent amount.
incl_fixed_amount = incl_percent_amount = 0
for tax in reversed(taxes):
if tax.include_base_amount:
base = recompute_base(base, incl_fixed_amount, incl_percent_amount)
incl_fixed_amount = incl_percent_amount = 0
if tax.price_include:
if tax.amount_type == 'fixed':
incl_fixed_amount += tax.amount
elif tax.amount_type == 'percent':
incl_percent_amount += tax.amount
# Start the computation of accumulated amounts at the total_excluded value.
total_excluded = total_included = base = recompute_base(base, incl_fixed_amount, incl_percent_amount)
tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner)
# 5) Iterate the taxes in the sequence order to fill missing base/amount values.
# tax | base | amount |
# || ----------------------------
# || tax_1 | OK | XXXX |
# || tax_2 | XXXX | XXXX |
# || tax_3 | XXXX | XXXX |
# \/ ... | .. | .. |
# ----------------------------
taxes_vals = []
for tax in taxes:
# Compute the amount of the tax but don't deal with the price_include because it's already
# took into account on the base amount except for 'division' tax:
# (tax.amount_type == 'percent' && not tax.price_include)
# == (tax.amount_type == 'division' && tax.price_include)
tax_amount = tax.with_context(force_price_include=False)._compute_amount(base, price_unit, quantity, product, partner)
if not round_tax:
tax_amount = round(tax_amount, prec)
else:
tax_amount = currency.round(tax_amount)
if tax.price_include:
total_excluded -= tax_amount
base -= tax_amount
else:
total_included += tax_amount
# Keep base amount used for the current tax
# Suppose:
# seq | amount | incl | incl_base | base | amount
# -----------------------------------------------
# 1 | 10 % | t | t | 100.0 | 10.0
# -----------------------------------------------
# ... the next computation must be done using 100.0 + 10.0 = 110.0 as base but
# the tax base of this tax will be 100.0.
tax_base = base
if tax.include_base_amount:
base += tax_amount
taxes.append({
# The total_included amount is computed as the sum of total_excluded with all tax_amount
total_included += tax_amount
taxes_vals.append({
'id': tax.id,
'name': tax.with_context(**{'lang': partner.lang} if partner else {}).name,
'amount': tax_amount,
'base': tax_base,
'amount': sign * tax_amount,
'base': round(sign * tax_base, prec),
'sequence': tax.sequence,
'account_id': tax.account_id.id,
'refund_account_id': tax.refund_account_id.id,
@@ -981,10 +1070,10 @@ class AccountTax(models.Model):
})
return {
'taxes': sorted(taxes, key=lambda k: k['sequence']),
'total_excluded': currency.round(total_excluded) if round_total else total_excluded,
'total_included': currency.round(total_included) if round_total else total_included,
'base': base,
'taxes': taxes_vals,
'total_excluded': sign * (currency.round(total_excluded) if round_total else total_excluded),
'total_included': sign * (currency.round(total_included) if round_total else total_included),
'base': round(sign * base, prec),
}
@api.model
@@ -500,10 +500,10 @@ class AccountBankStatementLine(models.Model):
if self.amount_currency and self.currency_id:
amount = self.amount_currency
amount_currency = self.amount
amount_currency_str = amount_currency > 0 and amount_currency or -amount_currency
amount_currency_str = formatLang(self.env, amount_currency_str, currency_obj=statement_currency)
amount_currency_str = formatLang(self.env, abs(amount_currency), currency_obj=statement_currency)
else:
amount = self.amount
amount_currency = amount
amount_currency_str = ""
amount_str = formatLang(self.env, abs(amount), currency_obj=self.currency_id or statement_currency)
@@ -525,6 +525,7 @@ class AccountBankStatementLine(models.Model):
'partner_name': self.partner_id.name,
'communication_partner_name': self.partner_name,
'amount_currency_str': amount_currency_str, # Amount in the statement currency
'amount_currency': amount_currency, # Amount in the statement currency
'has_no_partner': not self.partner_id.id,
}
if self.partner_id:
+3
View File
@@ -1588,6 +1588,9 @@ class AccountInvoiceLine(models.Model):
self.price_unit = 0.0
domain['uom_id'] = []
else:
# Use the purchase uom by default
self.uom_id = self.product_id.uom_po_id
if part.lang:
product = self.product_id.with_context(lang=part.lang)
else:
@@ -175,7 +175,7 @@ class account_journal(models.Model):
# optimization to read sum of balance from account_move_line
account_ids = tuple(ac for ac in [self.default_debit_account_id.id, self.default_credit_account_id.id] if ac)
if account_ids:
amount_field = 'balance' if not self.currency_id else 'amount_currency'
amount_field = 'balance' if (not self.currency_id or self.currency_id == self.company_id.currency_id) else 'amount_currency'
query = """SELECT sum(%s) FROM account_move_line WHERE account_id in %%s AND date <= %%s;""" % (amount_field,)
self.env.cr.execute(query, (account_ids, fields.Date.today(),))
query_results = self.env.cr.dictfetchall()
+37 -17
View File
@@ -698,19 +698,36 @@ class AccountMoveLine(models.Model):
'|', ('date_maturity', 'like', str),
'&', ('name', '!=', '/'), ('name', 'ilike', str)
]
try:
amount = float(str)
amount_domain = [
'|', ('amount_residual', '=', amount),
'|', ('amount_residual_currency', '=', amount),
'|', ('amount_residual', '=', -amount),
'|', ('amount_residual_currency', '=', -amount),
'&', ('account_id.internal_type', '=', 'liquidity'),
'|', '|', ('debit', '=', amount), ('credit', '=', amount), ('amount_currency', '=', amount),
]
str_domain = expression.OR([str_domain, amount_domain])
except:
pass
if str[0] in ['-', '+']:
try:
amounts_str = str.split('|')
for amount_str in amounts_str:
amount = amount_str[0] == '-' and float(amount_str) or float(amount_str[1:])
amount_domain = [
'|', ('amount_residual', '=', amount),
'|', ('amount_residual_currency', '=', amount),
'|', (amount_str[0] == '-' and 'credit' or 'debit', '=', float(amount_str[1:])),
('amount_currency', '=', amount),
]
str_domain = expression.OR([str_domain, amount_domain])
except:
pass
else:
try:
amount = float(str)
amount_domain = [
'|', ('amount_residual', '=', amount),
'|', ('amount_residual_currency', '=', amount),
'|', ('amount_residual', '=', -amount),
'|', ('amount_residual_currency', '=', -amount),
'&', ('account_id.internal_type', '=', 'liquidity'),
'|', '|', '|', ('debit', '=', amount), ('credit', '=', amount),
('amount_currency', '=', amount),
('amount_currency', '=', -amount),
]
str_domain = expression.OR([str_domain, amount_domain])
except:
pass
return str_domain
def _domain_move_lines_for_manual_reconciliation(self, account_id, partner_id=False, excluded_ids=None, str=False):
@@ -753,6 +770,7 @@ class AccountMoveLine(models.Model):
for line in self:
company_currency = line.account_id.company_id.currency_id
line_currency = (line.currency_id and line.amount_currency) and line.currency_id or company_currency
ret_line = {
'id': line.id,
'name': line.name and line.name != '/' and line.move_id.name + ': ' + line.name or line.move_id.name,
@@ -769,7 +787,7 @@ class AccountMoveLine(models.Model):
'journal_id': [line.journal_id.id, line.journal_id.display_name],
'partner_id': line.partner_id.id,
'partner_name': line.partner_id.name,
'currency_id': (line.currency_id and line.amount_currency) and line.currency_id.id or False,
'currency_id': line_currency.id,
}
debit = line.debit
@@ -784,7 +802,7 @@ class AccountMoveLine(models.Model):
# Get right debit / credit:
target_currency = target_currency or company_currency
line_currency = (line.currency_id and line.amount_currency) and line.currency_id or company_currency
amount_currency = debit - credit
amount_currency_str = ""
total_amount_currency_str = ""
if line_currency != company_currency and target_currency == line_currency:
@@ -809,9 +827,10 @@ class AccountMoveLine(models.Model):
actual_debit = debit > 0 and amount or 0.0
actual_credit = credit > 0 and -amount or 0.0
currency = company_currency
if line_currency != target_currency:
target_currency.compute(total_amount, line_currency)
amount_currency_str = formatLang(self.env, target_currency.compute(abs(actual_debit or actual_credit), line_currency), currency_obj=line_currency)
amount_currency = target_currency.compute(total_amount, line_currency)
amount_currency_str = formatLang(self.env, abs(amount_currency), currency_obj=line_currency)
total_amount_currency_str = formatLang(self.env, target_currency.compute(total_amount, line_currency), currency_obj=line_currency)
if currency != target_currency:
ctx = context.copy()
@@ -827,6 +846,7 @@ class AccountMoveLine(models.Model):
ret_line['amount_str'] = amount_str
ret_line['total_amount_str'] = total_amount_str
ret_line['amount_currency_str'] = amount_currency_str
ret_line['amount_currency'] = amount_currency
ret_line['total_amount_currency_str'] = total_amount_currency_str
ret.append(ret_line)
return ret
@@ -20,6 +20,7 @@ var StatementAction = Widget.extend(ControlPanelMixin, {
change_offset: '_onAction',
change_partner: '_onAction',
add_proposition: '_onAction',
search_balance_amount: '_onAction',
remove_proposition: '_onAction',
update_proposition: '_onAction',
create_proposition: '_onAction',
@@ -38,7 +39,11 @@ var StatementAction = Widget.extend(ControlPanelMixin, {
// used to instanciate the action interface
ActionRenderer: ReconciliationRenderer.StatementRenderer,
// used to instanciate each widget line
LineRenderer: ReconciliationRenderer.LineRenderer
LineRenderer: ReconciliationRenderer.LineRenderer,
// used context params
params: ['statement_ids'],
// number of moves lines displayed in 'match' mode
limitMoveLines: 5,
},
/**
@@ -51,11 +56,25 @@ var StatementAction = Widget.extend(ControlPanelMixin, {
this._super.apply(this, arguments);
this.action_manager = parent;
this.params = params;
this.model = new this.config.Model(this, {modelName: "account.bank.statement.line"});
this.model = new this.config.Model(this, {
modelName: "account.bank.statement.line",
limitMoveLines: params.params && params.params.limitMoveLines || this.config.limitMoveLines,
});
this.widgets = [];
if (!this.action_manager) {
this.set_cp_bus(new Widget());
}
// Adding values from the context is necessary to put this information in the url via the action manager so that
// you can retrieve it if the person shares his url or presses f5
_.each(params.params, function (value, name) {
params.context[name] = name.indexOf('_ids') !== -1 ? _.map((value+'').split(), parseFloat) : value;
});
params.params = {};
_.each(this.config.params, function (name) {
if (params.context[name]) {
params.params[name] = name.indexOf('_ids') !== -1 && _.isArray(params.context[name]) ? params.context[name].join() : params.context[name];
}
});
},
/**
@@ -305,8 +324,15 @@ var ManualAction = StatementAction.extend({
config: {
Model: ReconciliationModel.ManualModel,
ActionRenderer: ReconciliationRenderer.ManualRenderer,
LineRenderer: ReconciliationRenderer.ManualLineRenderer
LineRenderer: ReconciliationRenderer.ManualLineRenderer,
params: ['company_ids', 'mode', 'partner_ids', 'account_ids'],
limitMoveLines: 10,
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* call 'validate' or 'autoReconciliation' model method then destroy the
* reconcilied lines, update the not reconcilied and update the action
@@ -66,6 +66,7 @@ var _t = core._t;
* }
* mv_lines: object - idem than reconciliation_proposition
* offset: integer
* limitMoveLines: integer
* filter: string
* [createForm]: {
* account_id: {
@@ -95,8 +96,11 @@ var StatementModel = BasicModel.extend({
/**
* @override
*
* @param {Widget} parent
* @param {object} options
*/
init: function () {
init: function (parent, options) {
this._super.apply(this, arguments);
this.reconcileModels = [];
this.lines = {};
@@ -104,6 +108,7 @@ var StatementModel = BasicModel.extend({
this.valuemax = 0;
this.alreadyDisplayed = [];
this.defaultDisplayQty = 10;
this.limitMoveLines = options && options.limitMoveLines || 5;
},
//--------------------------------------------------------------------------
@@ -162,7 +167,9 @@ var StatementModel = BasicModel.extend({
* @returns {Deferred}
*/
changeFilter: function (handle, filter) {
this.getLine(handle).filter = filter;
var line = this.getLine(handle);
line.filter = filter;
line.offset = 0;
return this._performMoveLine(handle);
},
/**
@@ -219,7 +226,7 @@ var StatementModel = BasicModel.extend({
* @returns {Deferred}
*/
changeOffset: function (handle, offset) {
this.getLine(handle).offset += offset;
this.getLine(handle).offset += (offset > 0 ? 1 : -1) * this.limitMoveLines;
return this._performMoveLine(handle);
},
/**
@@ -360,14 +367,14 @@ var StatementModel = BasicModel.extend({
self.context.journal_id = statement.journal_id;
_.each(statement.st_lines_ids, function (id) {
self.lines[_.uniqueId('rline')] = {
'id': id,
'reconciled': false,
'mode': 'inactive',
'mv_lines': [],
'offset': 0,
'filter': "",
'reconciliation_proposition': [],
'reconcileModels': []
id: id,
reconciled: false,
mode: 'inactive',
mv_lines: [],
offset: 0,
filter: "",
reconciliation_proposition: [],
reconcileModels: [],
};
});
});
@@ -489,6 +496,25 @@ var StatementModel = BasicModel.extend({
}
return def;
},
searchBalanceAmount: function (handle) {
var line = this.getLine(handle);
var amount = line.balance.amount;
var amount_str = _.str.sprintf('%.2f', Math.abs(amount));
amount_str = (amount > '0' ? '-' : '+') + amount_str;
if (line.balance.amount_currency) {
var amount_currency = line.balance.amount_currency;
var amount_currency_str = _.str.sprintf('%.2f', Math.abs(amount_currency));
amount_str += '|' + (amount_currency > '0' ? '-' : '+') + amount_currency_str;
}
if (amount_str === line.filter) {
line.filter = '';
line.offset = 0;
return this.changeMode(handle, 'create');
}
line.filter = amount_str;
line.offset = 0;
return this.changeMode(handle, 'match');
},
/**
* Force the partial reconciliation to display the reconciliate button.
* This method should only be called when there is onely one proposition.
@@ -728,7 +754,7 @@ var StatementModel = BasicModel.extend({
return !p.is_tax || p.link !== prop.id;
});
var args = [[prop.tax_id.id], prop.base_amount, line.st_line.currency_id];
var args = [[prop.tax_id.id], prop.base_amount, formatOptions.currency_id];
tax_defs.push(self._rpc({
model: 'account.tax',
method: 'json_friendly_compute_all',
@@ -758,11 +784,7 @@ var StatementModel = BasicModel.extend({
});
}));
} else {
var currencyData = {
currency_id: line.st_line.currency_id
};
prop.amount_str = field_utils.format.monetary(Math.abs(prop.amount), {}, currencyData);
prop.amount_str = field_utils.format.monetary(Math.abs(prop.amount), {}, formatOptions);
prop.display = self._isDisplayedProposition(prop);
prop.invalid = !self._isValid(prop);
}
@@ -774,19 +796,32 @@ var StatementModel = BasicModel.extend({
});
line.reconciliation_proposition = reconciliation_proposition;
var amount_currency = 0;
var total = line.st_line.amount || 0;
_.each(line.reconciliation_proposition, function (prop) {
var isOtherCurrencyId = _.uniq(_.pluck(_.reject(reconciliation_proposition, 'invalid'), 'currency_id'));
isOtherCurrencyId = isOtherCurrencyId.length === 1 && !total && isOtherCurrencyId[0] !== formatOptions.currency_id ? isOtherCurrencyId[0] : false;
_.each(reconciliation_proposition, function (prop) {
if (!prop.invalid) {
total -= prop.amount;
if (isOtherCurrencyId) {
amount_currency -= prop.amount_currency;
}
}
});
total = Math.round(total*1000)/1000 || 0;
line.balance = {
'amount': total,
'amount_str': field_utils.format.monetary(Math.abs(total), {}, formatOptions),
'account_code': self.accounts[line.st_line.open_balance_account_id],
amount: total,
amount_str: field_utils.format.monetary(Math.abs(total), {}, formatOptions),
currency_id: isOtherCurrencyId,
amount_currency: isOtherCurrencyId ? amount_currency : false,
amount_currency_str: isOtherCurrencyId ? field_utils.format.monetary(Math.abs(amount_currency), {}, {
currency_id: isOtherCurrencyId
}) : false,
account_code: self.accounts[line.st_line.open_balance_account_id],
};
line.balance.type = line.balance.amount ? (line.balance.amount > 0 && line.st_line.partner_id ? 0 : -1) : 1;
line.balance.type = (isOtherCurrencyId && amount_currency || line.balance.amount) ?
(line.balance.amount > 0 && line.st_line.partner_id ? 0 : -1) : 1;
});
},
/**
@@ -851,6 +886,7 @@ var StatementModel = BasicModel.extend({
return l.id === data.st_line.id;
});
line.visible = true;
line.limitMoveLines = self.limitMoveLines;
_.extend(line, data);
self._formatLineProposition(line, line.reconciliation_proposition);
if (!line.reconciliation_proposition.length) {
@@ -989,7 +1025,7 @@ var StatementModel = BasicModel.extend({
})));
var filter = line.filter || "";
var offset = line.offset;
var limit = 6;
var limit = this.limitMoveLines+1;
return this._rpc({
model: 'account.bank.statement.line',
method: 'get_move_lines_for_reconciliation_widget',
@@ -1196,6 +1232,8 @@ var ManualModel = StatementModel.extend({
if (line.reconciled) {
return;
}
line.filter = "";
line.offset = 0;
defs.push(self._performMoveLine(handle).then(function () {
if(!line.mv_lines.length) {
self.valuenow++;
@@ -1243,7 +1281,7 @@ var ManualModel = StatementModel.extend({
*/
_computeLine: function (line) {
return this._super(line).then(function () {
var props = line.reconciliation_proposition;
var props = _.reject(line.reconciliation_proposition, 'invalid');
line.balance.type = -1;
if (!line.balance.amount && props.length) {
line.balance.type = 1;
@@ -1265,15 +1303,16 @@ var ManualModel = StatementModel.extend({
*/
_formatLine: function (type, data) {
var line = this.lines[_.uniqueId('rline')] = _.extend(data, {
'type': type,
'reconciled': false,
'mode': 'inactive',
'offset': 0,
'filter': "",
'reconcileModels': [],
'account_id': this._formatNameGet([data.account_id, data.account_name]),
'st_line': data,
'visible': true
type: type,
reconciled: false,
mode: 'inactive',
offset: 0,
limitMoveLines: this.limitMoveLines,
filter: "",
reconcileModels: [],
account_id: this._formatNameGet([data.account_id, data.account_name]),
st_line: data,
visible: true
});
this._formatLineProposition(line, line.reconciliation_proposition);
if (!line.reconciliation_proposition.length) {
@@ -1337,7 +1376,7 @@ var ManualModel = StatementModel.extend({
})));
var filter = line.filter || "";
var offset = line.offset;
var limit = 6;
var limit = this.limitMoveLines+1;
var args = [line.account_id.id, line.partner_id, excluded_ids, filter, offset, limit];
return this._rpc({
model: 'account.move.line',
@@ -250,7 +250,8 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
events: {
'click .accounting_view caption .o_buttons button': '_onValidate',
'click .accounting_view thead td': '_onTogglePanel',
'click .accounting_view tfoot td': '_onShowPanel',
'click .accounting_view tfoot td:not(.cell_left,.cell_right)': '_onShowPanel',
'click tfoot .cell_left, tfoot .cell_right': '_onSearchBalanceAmount',
'input input.filter': '_onFilterChange',
'click .match_controls .fa-chevron-left:not(.disabled)': '_onPrevious',
'click .match_controls .fa-chevron-right:not(.disabled)': '_onNext',
@@ -386,7 +387,7 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
// mv_lines
var $mv_lines = this.$('.match table tbody').empty();
_.each(state.mv_lines.slice(0,5), function (line) {
_.each(state.mv_lines.slice(0, state.limitMoveLines), function (line) {
var $line = $(qweb.render("reconciliation.line.mv_line", {'line': line, 'state': state}));
if (!isNaN(line.id)) {
$('<span class="line_info_button fa fa-info-circle"/>')
@@ -395,13 +396,19 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
}
$mv_lines.append($line);
});
this.$('.match .fa-chevron-right').toggleClass('disabled', state.mv_lines.length <= 5);
this.$('.match .fa-chevron-right').toggleClass('disabled', state.mv_lines.length <= state.limitMoveLines);
this.$('.match .fa-chevron-left').toggleClass('disabled', !state.offset);
this.$('.match').css('max-height', !state.mv_lines.length && !state.filter.length ? '0px' : '');
// balance
this.$('.popover').remove();
this.$('table tfoot').html(qweb.render("reconciliation.line.balance", {'state': state}));
// filter
if (_.str.strip(this.$('input.filter').val()) !== state.filter) {
this.$('input.filter').val(state.filter);
}
// create form
if (state.createForm) {
if (!this.fields.account_id) {
@@ -590,6 +597,12 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
var mode = this.$el.data('mode') === 'inactive' ? 'match' : 'inactive';
this.trigger_up('change_mode', {'data': mode});
},
/**
* @private
*/
_onSearchBalanceAmount: function () {
this.trigger_up('search_balance_amount');
},
/**
* @private
*/
@@ -637,13 +650,13 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
* @private
*/
_onPrevious: function () {
this.trigger_up('change_offset', {'data': -5});
this.trigger_up('change_offset', {'data': -1});
},
/**
* @private
*/
_onNext: function () {
this.trigger_up('change_offset', {'data': 5});
this.trigger_up('change_offset', {'data': 1});
},
/**
* @private
@@ -25,7 +25,7 @@
<div class="notification_area"/>
</div>
<div class="o_reconciliation_lines"/>
<div t-if="widget._initialState.valuemax > widget._initialState.defaultDisplayQty">
<div t-if="widget._initialState.valuemax &gt; widget._initialState.defaultDisplayQty">
<button class="btn btn-default js_load_more">Load more</button>
</div>
<div class="pull-right text-muted">Tip: Hit CTRL-Enter to reconcile all the balanced items in the sheet.</div>
@@ -129,8 +129,8 @@
<td class="cell_account_code"><t t-esc="state.balance.account_code"/></td>
<td class="cell_due_date"></td>
<td class="cell_label"><t t-if="state.balance.amount &lt; 0">Create Write-off</t><t t-elif="state.st_line.partner_id">Open balance</t><t t-else="">Choose counterpart</t></td>
<td class="cell_left"><t t-if="state.balance.amount &lt; 0"><t t-raw="state.balance.amount_str"/></t></td>
<td class="cell_right"><t t-if="state.balance.amount &gt; 0"><t t-raw="state.balance.amount_str"/></t></td>
<td class="cell_left"><t t-if="state.balance.amount &lt; 0"><span t-if="state.balance.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{state.balance.currency_id%8} line_info_button fa fa-money" t-att-data-content="state.balance.amount_currency_str"/><t t-raw="state.balance.amount_str"/></t></td>
<td class="cell_right"><t t-if="state.balance.amount &gt; 0"><span t-if="state.balance.amount_currency_str" t-attf-class="o_multi_currency o_multi_currency_color_#{state.balance.currency_id%8} line_info_button fa fa-money" t-att-data-content="state.balance.amount_currency_str"/><t t-raw="state.balance.amount_str"/></t></td>
<td class="cell_info_popover"></td>
</tr>
</t>
@@ -475,7 +475,10 @@ var session = {
var options = {
context: {
statement_ids: [4]
}
},
params: {
limitMoveLines: 5,
},
};
Datas.params = {
@@ -1051,7 +1054,7 @@ QUnit.module('account', {
var clientAction = new ReconciliationClientAction.ManualAction(null, this.params.options);
testUtils.addMockEnvironment(clientAction, {
'data': this.params.data,
data: this.params.data,
session: this.params.session,
});
clientAction.appendTo($('#qunit-fixture'));
+204 -30
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from odoo.addons.account.tests.account_test_users import AccountTestUsers
from odoo.tests.common import TransactionCase
from odoo.tools import float_compare
import time
@@ -27,6 +28,12 @@ class TestTax(AccountTestUsers):
'amount': 10,
'sequence': 3,
})
self.percent_tax_bis = self.tax_model.create({
'name': "Percent tax bis",
'amount_type': 'percent',
'amount': 21,
'sequence': 3,
})
self.division_tax = self.tax_model.create({
'name': "Division tax",
'amount_type': 'division',
@@ -67,47 +74,125 @@ class TestTax(AccountTestUsers):
self.bank_account = self.bank_journal.default_debit_account_id
self.expense_account = self.env['account.account'].search([('user_type_id.type', '=', 'payable')], limit=1) #Should be done by onchange later
def _check_compute_all_results(self, base, total_included, total_excluded, taxes, res):
self.assertAlmostEqual(res['base'], base)
self.assertAlmostEqual(res['total_included'], total_included)
self.assertAlmostEqual(res['total_excluded'], total_excluded)
for i in range(0, len(taxes)):
self.assertAlmostEqual(res['taxes'][i]['base'], taxes[i][0])
self.assertAlmostEqual(res['taxes'][i]['amount'], taxes[i][1])
def test_tax_group_of_group_tax(self):
self.fixed_tax.include_base_amount = True
self.group_tax.include_base_amount = True
self.group_of_group_tax.include_base_amount = True
res = self.group_of_group_tax.compute_all(200.0)
self.assertEquals(res['total_excluded'], 200.0)
# After calculation of first group
# base = 210
# total_included = 231
# Base of the first grouped is passed
# Base after the second group (220) is dropped.
# Base of the group of groups is passed out,
# so we obtain base as after first group
self.assertEquals(res['base'], 210.0)
self.assertEquals(res['total_included'], 263.0)
self._check_compute_all_results(
220, # 'base'
263, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 10.0), # | 1 | 10 | | t
(210.0, 21.0), # | 3 | 10% | |
(210.0, 10.0), # | 1 | 10 | | t
(220.0, 22.0), # | 3 | 10% | |
# ---------------------------------------------------
],
res
)
def test_tax_group(self):
res = self.group_tax.compute_all(200.0)
self.assertEquals(res['total_excluded'], 200.0)
self.assertEquals(res['total_included'], 230.0)
self.assertEquals(len(res['taxes']), 2)
self.assertEquals(res['taxes'][0]['amount'], 10.0)
self.assertEquals(res['taxes'][1]['amount'], 20.0)
self._check_compute_all_results(
200, # 'base'
230, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 10.0), # | 1 | 10 | |
(200.0, 20.0), # | 3 | 10% | |
# ---------------------------------------------------
],
res
)
def test_tax_percent_division(self):
self.division_tax.price_include = True
self.division_tax.include_base_amount = True
res_division = self.division_tax.compute_all(200.0)
self._check_compute_all_results(
220, # 'base'
220, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 20.0), # | 4 | 10/ | t | t
# ---------------------------------------------------
],
res_division
)
self.percent_tax.price_include = False
self.percent_tax.include_base_amount = False
res_division = self.division_tax.compute_all(200.0)
res_percent = self.percent_tax.compute_all(200.0)
self.assertEquals(res_division['taxes'][0]['amount'], 20.0)
self.assertEquals(res_percent['taxes'][0]['amount'], 20.0)
self._check_compute_all_results(
200, # 'base'
220, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 20.0), # | 3 | 10% | |
# ---------------------------------------------------
],
res_percent
)
self.division_tax.price_include = False
self.division_tax.include_base_amount = False
res_division = self.division_tax.compute_all(200.0)
self._check_compute_all_results(
200, # 'base'
222.22, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 22.22), # | 4 | 10/ | |
# ---------------------------------------------------
],
res_division
)
self.percent_tax.price_include = True
self.percent_tax.include_base_amount = True
res_division = self.division_tax.compute_all(200.0)
res_percent = self.percent_tax.compute_all(200.0)
self.assertEquals(res_division['taxes'][0]['amount'], 22.22)
self.assertEquals(res_percent['taxes'][0]['amount'], 18.18)
self._check_compute_all_results(
200, # 'base'
200, # 'total_included'
181.82, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(181.82, 18.18), # | 3 | 10% | t | t
# ---------------------------------------------------
],
res_percent
)
self.percent_tax_bis.price_include = True
self.percent_tax_bis.include_base_amount = True
res_percent = self.percent_tax_bis.compute_all(7.0)
self._check_compute_all_results(
7.0, # 'base'
7.0, # 'total_included'
5.79, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(5.79, 1.21), # | 3 | 21% | t | t
# ---------------------------------------------------
],
res_percent
)
def test_tax_sequence_normalized_set(self):
self.division_tax.sequence = 1
@@ -115,19 +200,53 @@ class TestTax(AccountTestUsers):
self.percent_tax.sequence = 3
taxes_set = (self.group_tax | self.division_tax)
res = taxes_set.compute_all(200.0)
self.assertEquals(res['taxes'][0]['amount'], 22.22)
self.assertEquals(res['taxes'][1]['amount'], 10.0)
self.assertEquals(res['taxes'][2]['amount'], 20.0)
self._check_compute_all_results(
200, # 'base'
252.22, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 22.22), # | 1 | 10/ | |
(200.0, 10.0), # | 2 | 10 | |
(200.0, 20.0), # | 3 | 10% | |
# ---------------------------------------------------
],
res
)
def test_tax_include_base_amount(self):
self.fixed_tax.include_base_amount = True
res = self.group_tax.compute_all(200.0)
self.assertEquals(res['total_included'], 231.0)
self._check_compute_all_results(
210, # 'base'
231, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 10.0), # | 1 | 10 | | t
(210.0, 21.0), # | 3 | 10% | |
# ---------------------------------------------------
],
res
)
def test_tax_currency(self):
self.division_tax.amount = 15.0
res = self.division_tax.compute_all(200.0, currency=self.env.ref('base.VEF'))
self.assertAlmostEqual(res['total_included'], 235.2941)
self._check_compute_all_results(
200, # 'base'
235.2941, # 'total_included'
200, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(200.0, 35.2941), # | 4 | 15/ | |
# ---------------------------------------------------
],
res
)
def test_tax_move_lines_creation(self):
""" Test that creating a move.line with tax_ids generates the tax move lines and adjust line amount when a tax is price_include """
@@ -172,3 +291,58 @@ class TestTax(AccountTestUsers):
aml_with_taxes = move.line_ids.filtered(lambda l: set(l.tax_ids.ids) == set([self.group_tax.id, self.fixed_tax_bis.id]))
self.assertEquals(len(aml_with_taxes), 1)
self.assertEquals(aml_with_taxes.credit, 190)
def test_advanced_taxes_computation_0(self):
'''Test more advanced taxes computation (see issue 34471).'''
tax_1 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_1',
'amount_type': 'percent',
'amount': 10,
'price_include': True,
'include_base_amount': True,
'sequence': 1,
})
tax_2 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_2',
'amount_type': 'percent',
'amount': 10,
'sequence': 2,
})
tax_3 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_3',
'amount_type': 'percent',
'amount': 10,
'price_include': True,
'sequence': 3,
})
tax_4 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_4',
'amount_type': 'percent',
'amount': 10,
'sequence': 4,
})
tax_5 = self.env['account.tax'].create({
'name': 'test_advanced_taxes_computation_0_5',
'amount_type': 'percent',
'amount': 10,
'price_include': True,
'sequence': 5,
})
taxes = tax_1 + tax_2 + tax_3 + tax_4 + tax_5
res = taxes.compute_all(132.0)
self._check_compute_all_results(
110, # 'base'
154, # 'total_included'
100, # 'total_excluded'
[
# base , amount | seq | amount | incl | incl_base
# ---------------------------------------------------
(100.0, 10.0), # | 1 | 10% | t | t
(110.0, 11.0), # | 3 | 10% | |
(110.0, 11.0), # | 3 | 10% | t |
(110.0, 11.0), # | 3 | 10% | |
(110.0, 11.0), # | 3 | 10% | t |
# ---------------------------------------------------
],
res
)
+3 -1
View File
@@ -322,10 +322,12 @@
<field name="type_control_ids" widget="many2many_tags"/>
<field name="account_control_ids" widget="many2many_tags"/>
</group>
<group string="Miscellaneous" groups="account.group_account_user">
<group string="Miscellaneous">
<field name="inbound_payment_method_ids" widget="many2many_checkboxes" attrs="{'invisible': [('type', 'not in', ['bank', 'cash'])]}"/>
<field name="outbound_payment_method_ids" widget="many2many_checkboxes" attrs="{'invisible': [('type', 'not in', ['bank', 'cash'])]}"/>
<field name="group_invoice_lines" attrs="{'invisible': [('type', 'not in', ['sale', 'purchase'])]}"/>
</group>
<group string="Accounting App Options" groups="account.group_account_user">
<field name="profit_account_id" attrs="{'invisible': [('type', '!=', 'cash')]}"/>
<field name="loss_account_id" attrs="{'invisible': [('type', '!=', 'cash')]}"/>
<field name="show_on_dashboard" groups="base.group_no_one"/>
@@ -12,6 +12,16 @@ from odoo.addons import decimal_precision as dp
class AccountInvoice(models.Model):
_inherit = 'account.invoice'
@api.model
def _refund_cleanup_lines(self, lines):
result = super(AccountInvoice, self)._refund_cleanup_lines(lines)
for i, line in enumerate(lines):
for name, field in line._fields.items():
if name == 'asset_category_id':
result[i][2][name] = False
break
return result
@api.multi
def action_cancel(self):
res = super(AccountInvoice, self).action_cancel()
+6 -1
View File
@@ -6,6 +6,7 @@ import logging
import json
import werkzeug.urls
import werkzeug.utils
from werkzeug.exceptions import BadRequest
@@ -148,7 +149,11 @@ class OAuthController(http.Controller):
url = '/web#action=%s' % action
elif menu:
url = '/web#menu_id=%s' % menu
return login_and_redirect(*credentials, redirect_url=url)
resp = login_and_redirect(*credentials, redirect_url=url)
# Since /web is hardcoded, verify user has right to land on it
if werkzeug.urls.url_parse(resp.location).path == '/web' and not request.env.user.has_group('base.group_user'):
resp.location = '/'
return resp
except AttributeError:
# auth_signup is not installed
_logger.error("auth_signup not installed on database %s: oauth sign up cancelled." % (dbname,))
+15 -2
View File
@@ -597,9 +597,18 @@ class Import(models.TransientModel):
@api.multi
def _parse_import_data(self, data, import_fields, options):
""" Lauch first call to _parse_import_data_recursive with an
empty prefix. _parse_import_data_recursive will be run
recursively for each relational field.
"""
return self._parse_import_data_recursive(self.res_model, '', data, import_fields, options)
@api.multi
def _parse_import_data_recursive(self, model, prefix, data, import_fields, options):
# Get fields of type date/datetime
all_fields = self.env[self.res_model].fields_get()
all_fields = self.env[model].fields_get()
for name, field in all_fields.items():
name = prefix + name
if field['type'] in ('date', 'datetime') and name in import_fields:
# Parse date
index = import_fields.index(name)
@@ -618,7 +627,11 @@ class Import(models.TransientModel):
raise ValueError(_("Column %s contains incorrect values. Error in line %d: %s") % (name, num + 1, e))
except Exception as e:
raise ValueError(_("Error Parsing Date [%s:L%d]: %s") % (name, num + 1, e))
# Check if the field is in import_field and is a relational (followed by /)
# Also verify that the field name exactly match the import_field at the correct level.
elif any(name + '/' in import_field and name == import_field.split('/')[prefix.count('/')] for import_field in import_fields):
# Recursive call with the relational as new model and add the field name to the prefix
self._parse_import_data_recursive(field['relation'], name + '/', data, import_fields, options)
elif field['type'] in ('float', 'monetary') and name in import_fields:
# Parse float, sometimes float values from file have currency symbol or () to denote a negative value
# We should be able to manage both case
@@ -400,7 +400,33 @@ class test_convert_import_data(TransactionCase):
# if results empty, no errors
self.assertItemsEqual(results, [])
def test_parse_relational_fields(self):
""" Ensure that relational fields float and date are correctly
parsed during the import call.
"""
import_wizard = self.env['base_import.import'].create({
'res_model': 'res.partner',
'file': u'name,parent_id/id,parent_id/date,parent_id/credit_limit\n'
u'"foo","__export__.res_partner_1","2017年10月12日","5,69"\n'.encode('utf-8'),
'file_type': 'text/csv'
})
options = {
'date_format': '%Y年%m月%d日',
'quoting': '"',
'separator': ',',
'float_decimal_separator': ',',
'float_thousand_separator': '.',
'headers': True
}
data, import_fields = import_wizard._convert_import_data(
['name', 'parent_id/.id', 'parent_id/date', 'parent_id/credit_limit'],
options
)
result = import_wizard._parse_import_data(data, import_fields, options)
# Check if the data 5,69 as been correctly parsed.
self.assertEqual(float(result[0][-1]), 5.69)
self.assertEqual(str(result[0][-2]), '2017-10-12')
def test_filtered(self):
""" If ``False`` is provided as field mapping for a column,
+20
View File
@@ -18,6 +18,12 @@ from odoo import api, models, _
from odoo.tools.misc import ustr
from odoo.exceptions import ValidationError
_eu_country_vat = {
'GR': 'EL'
}
_eu_country_vat_inverse = {v: k for k, v in _eu_country_vat.items()}
_ref_vat = {
'at': 'ATU12345675',
'be': 'BE0477472701',
@@ -79,6 +85,7 @@ class ResPartner(models.Model):
# Foreign companies that trade with non-enterprises in the EU
# may have a VATIN starting with "EU" instead of a country code.
return True
country_code = _eu_country_vat_inverse.get(country_code, country_code)
return bool(self.env['res.country'].search([('code', '=ilike', country_code)]))
return check_func(vat_number)
@@ -96,6 +103,19 @@ class ResPartner(models.Model):
# country code or empty VAT number), so we fall back to the simple check.
return self.simple_vat_check(country_code, vat_number)
@api.model
def fix_eu_vat_number(self, country_id, vat):
europe = self.env.ref('base.europe')
country = self.env["res.country"].browse(country_id)
if not europe:
europe = self.env["res.country.group"].search([('name', '=', 'Europe')], limit=1)
if europe and country and country.id in europe.country_ids.ids:
vat = re.sub('[^A-Za-z0-9]', '', vat).upper()
country_code = _eu_country_vat.get(country.code, country.code).upper()
if vat[:2] != country_code:
vat = country_code + vat
return vat
@api.constrains('vat', 'commercial_partner_country_id')
def check_vat(self):
if self.env.context.get('company_id'):
@@ -6,6 +6,8 @@ import re
from odoo import api, models
from suds.client import Client
_logger = logging.getLogger(__name__)
try:
@@ -14,6 +16,7 @@ except ImportError:
_logger.warning('Python `stdnum` library not found, unable to call VIES service to detect address based on VAT number.')
stdnum_vat = None
class ResPartner(models.Model):
_inherit = 'res.partner'
@@ -40,7 +43,17 @@ class ResPartner(models.Model):
if not partner.vat:
return {}
if len(partner.vat) > 5 and partner.vat[:2].lower() in stdnum_vat.country_codes:
result = stdnum_vat.check_vies(partner.vat)
# Equivalent to stdnum_vat.check_vies(partner.vat).
# However, we want to add a custom timeout to the suds.client
# because by default, it's 120 seconds and this is to long.
try:
client = Client(stdnum_vat.vies_wsdl, timeout=5)
partner_vat = stdnum_vat.compact(partner.vat)
result = client.service.checkVat(partner_vat[:2], partner_vat[2:])
except:
# Avoid blocking the client when the service is unreachable/unavailable
return {}
if not result['valid']:
return {}
+12 -12
View File
@@ -670,27 +670,27 @@ class Meeting(models.Model):
1) if user add duration for 2 hours, return : August-23-2013 at (04-30 To 06-30) (Europe/Brussels)
2) if event all day ,return : AllDay, July-31-2013
"""
timezone = self._context.get('tz')
if not timezone:
timezone = self.env.user.partner_id.tz or 'UTC'
timezone = tools.ustr(timezone).encode('utf-8') # make safe for str{p,f}time()
timezone = self._context.get('tz') or self.env.user.partner_id.tz or 'UTC'
timezone = pycompat.to_native(timezone) # make safe for str{p,f}time()
# get date/time format according to context
format_date, format_time = self.with_context(tz=timezone)._get_date_formats()
format_date, format_time = self._get_date_formats()
# convert date and time into user timezone
date = fields.Datetime.context_timestamp(self.with_context(tz=timezone), fields.Datetime.from_string(start))
date_deadline = fields.Datetime.context_timestamp(self.with_context(tz=timezone), fields.Datetime.from_string(stop))
self_tz = self.with_context(tz=timezone)
date = fields.Datetime.context_timestamp(self_tz, fields.Datetime.from_string(start))
date_deadline = fields.Datetime.context_timestamp(self_tz, fields.Datetime.from_string(stop))
# convert into string the date and time, using user formats
date_str = date.strftime(format_date).decode('utf-8')
time_str = date.strftime(format_time).decode('utf-8')
to_text = pycompat.to_text
date_str = to_text(date.strftime(format_date))
time_str = to_text(date.strftime(format_time))
if zallday:
display_time = _("AllDay , %s") % (date_str)
elif zduration < 24:
duration = date + timedelta(hours=zduration)
duration_time = duration.strftime(format_time).decode('utf-8')
duration_time = to_text(duration.strftime(format_time))
display_time = _(u"%s at (%s To %s) (%s)") % (
date_str,
time_str,
@@ -698,8 +698,8 @@ class Meeting(models.Model):
timezone,
)
else:
dd_date = date_deadline.strftime(format_date).decode('utf-8')
dd_time = date_deadline.strftime(format_time).decode('utf-8')
dd_date = to_text(date_deadline.strftime(format_date))
dd_time = to_text(date_deadline.strftime(format_time))
display_time = _(u"%s at %s To\n %s at %s (%s)") % (
date_str,
time_str,
+8 -2
View File
@@ -1,9 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo.addons.mail.controllers.main import MailController
from odoo import http
_logger = logging.getLogger(__name__)
class CrmController(http.Controller):
@@ -12,8 +15,9 @@ class CrmController(http.Controller):
comparison, record, redirect = MailController._check_token_and_record_or_redirect('crm.lead', int(res_id), token)
if comparison and record:
try:
record.case_mark_won()
record.action_set_won()
except Exception:
_logger.exception("Could not mark crm.lead as won")
return MailController._redirect_to_messaging()
return redirect
@@ -22,8 +26,9 @@ class CrmController(http.Controller):
comparison, record, redirect = MailController._check_token_and_record_or_redirect('crm.lead', int(res_id), token)
if comparison and record:
try:
record.case_mark_lost()
record.action_set_lost()
except Exception:
_logger.exception("Could not mark crm.lead as lost")
return MailController._redirect_to_messaging()
return redirect
@@ -34,5 +39,6 @@ class CrmController(http.Controller):
try:
record.convert_opportunity(record.partner_id.id)
except Exception:
_logger.exception("Could not convert crm.lead to opportunity")
return MailController._redirect_to_messaging()
return redirect
+8
View File
@@ -267,6 +267,14 @@ class Lead(models.Model):
# ORM override (CRUD, fields_view_get, ...)
# ----------------------------------------
@api.model
def name_create(self, name):
res = super(Lead, self).name_create(name)
# update the probability of the lead if the stage is set to update it automatically
self.browse(res[0])._onchange_stage_id()
return res
@api.model
def create(self, vals):
# set up context used to find the lead's sales channel which is needed
@@ -104,17 +104,10 @@
<filter string="Archived" name="archived" domain="[('active', '=', False)]"/>
<group expand="0" string="Extended Filters">
<field name="partner_id" filter_domain="[('partner_id','child_of',self)]"/>
<field name="stage_id" domain="['|', ('team_id', '=', False), ('team_id', '=', 'team_id')]"/>
<field name="campaign_id"/>
<field name="medium_id"/>
<field name="source_id"/>
<field name="company_id" groups="base.group_multi_company"/>
<separator orientation="vertical"/>
<field name="stage_id" widget="selection" domain="['|', ('team_id', '=', False), ('team_id', '=', 'team_id')]"/>
<field name="campaign_id" widget="selection"/>
<field name="medium_id" widget="selection"/>
<field name="source_id" widget="selection"/>
<separator orientation="vertical"/>
<field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/>
<newline/>
<field name="create_date"/>
+1 -1
View File
@@ -43,7 +43,7 @@
<p class="oe_mt32 oe_mb32 text-justify">
Automate activities based on your sales scripts: calls, meetings, mails and quotations. Get all information you need, right on the opportunity: page viewed, mails received, etc.
</p>
<img src="crm_sc_02b.png">
<img class='img-responsive' src="crm_sc_02b.png">
</div>
</div>
</section>
+3 -3
View File
@@ -385,7 +385,7 @@
domain="[('activity_ids.user_id', '=', uid)]"/>
<separator/>
<filter string="Late Activities" name="activities_overdue"
domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"
domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]"
help="Show all records which has next action date is before today"/>
<filter string="Today Activities" name="activities_today"
domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/>
@@ -630,7 +630,7 @@
domain="[('activity_ids.user_id', '=', uid)]"/>
<separator/>
<filter string="Late Activities" name="activities_overdue"
domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"
domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]"
help="Show all opportunities for which the next action date is before today"/>
<filter string="Today Activities" name="activities_today"
domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/>
@@ -640,7 +640,7 @@
<separator/>
<filter string="Overdue Opportunities" name="overdue_opp"
domain="[('date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"
domain="[('date_deadline', '&lt;', context_today().strftime('%Y-%m-%d')), ('date_closed', '=', False)]"
help="Show all opportunities with a deadline for which the next action date is before today"/>
<filter string="To Close Today" name="close_today"
@@ -5,6 +5,7 @@ var core = require('web.core');
var Dialog = require('web.Dialog');
var framework = require('web.framework');
var Sidebar = require('web.Sidebar');
var field_utils = require('web.field_utils');
var _t = core._t;
@@ -61,6 +62,7 @@ Sidebar.include({
*/
_processAttachments: function (attachments) {
//to display number in name if more then one attachment which has same name.
var self = this;
_.chain(attachments)
.groupBy(function (attachment) { return attachment.name; })
.each(function (attachment) {
@@ -75,6 +77,10 @@ Sidebar.include({
if (a.type === "binary") {
a.url = '/web/content/' + a.id + '?download=true';
}
a.create_date = field_utils.parse.datetime(a.create_date, 'create_date', {isUTC: true});
a.create_date_string = field_utils.format.datetime(a.create_date, 'create_date', self.env.context.params);
a.write_date = field_utils.parse.datetime(a.write_date, 'write_date', {isUTC: true});
a.write_date_string = field_utils.format.datetime(a.write_date, 'write_date', self.env.context.params);
});
this.items.files = attachments;
},
+5 -6
View File
@@ -109,17 +109,16 @@ class HrExpense(models.Model):
raise UserError(_("You cannot report twice the same line!"))
if len(self.mapped('employee_id')) != 1:
raise UserError(_("You cannot report expenses for different employees in the same report!"))
expense_sheet = self.env['hr.expense.sheet'].create({
'expense_line_ids': [(4, line.id) for line in self],
'name': self[0].name if len(self.ids) == 1 else '',
'employee_id': self[0].employee_id.id,
})
return {
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'hr.expense.sheet',
'target': 'current',
'res_id': expense_sheet.id,
'context': {
'default_expense_line_ids': [line.id for line in self],
'default_employee_id': self[0].employee_id.id,
'default_name': self[0].name if len(self.ids) == 1 else ''
}
}
def _prepare_move_line(self, line):
@@ -42,6 +42,7 @@ class TimesheetAttendance(models.Model):
ts.unit_amount AS timesheet,
date_trunc('day', ts.date) AS date
FROM account_analytic_line AS ts
WHERE ts.project_id IS NOT NULL
) AS t
GROUP BY t.user_id, t.date
ORDER BY t.date
@@ -29,6 +29,7 @@
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'tree', 'view_id': ref('hr_timesheet.hr_timesheet_line_tree')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('hr_timesheet.hr_timesheet_line_form')})]"/>
<field name="domain">[('project_id','!=',False)]</field>
</record>
<record id="hr_timesheet_sheet_form" model="ir.ui.view">
+3 -3
View File
@@ -18,9 +18,9 @@ _logger = logging.getLogger(__name__)
# Those are the builtin raspberry pi USB modules, they should
# not appear in the list of connected devices.
BANNED_DEVICES = {
"0424:9514", # Standard Microsystem Corp. Builtin Ethernet module
"1d6b:0002", # Linux Foundation 2.0 root hub
"0424:ec00", # Standard Microsystem Corp. Other Builtin Ethernet module
"0424:9514", # Standard Microsystem Corp. Builtin Ethernet module
"1d6b:0002", # Linux Foundation 2.0 root hub
"0424:ec00", # Standard Microsystem Corp. Other Builtin Ethernet module
}
+5 -2
View File
@@ -8,6 +8,7 @@ import werkzeug.urls
import requests
from odoo import api, fields, models, exceptions
from odoo.tools import pycompat
_logger = logging.getLogger(__name__)
@@ -57,8 +58,10 @@ def jsonrpc(url, method='call', params=None):
e_class = InsufficientCreditError
elif name == 'AccessError':
e_class = exceptions.AccessError
else:
elif name == 'UserError':
e_class = exceptions.UserError
else:
raise requests.exceptions.ConnectionError()
e = e_class(message)
e.data = response['error']['data']
raise e
@@ -93,7 +96,7 @@ def charge(env, key, account_token, credit, description=None, credit_template=No
except InsufficientCreditError as e:
if credit_template:
arguments = json.loads(e.args[0])
arguments['body'] = env['ir.qweb'].render(credit_template)
arguments['body'] = pycompat.to_text(env['ir.qweb'].render(credit_template))
e.args = (json.dumps(arguments),)
raise e
try:
+2 -1
View File
@@ -71,6 +71,7 @@
<field name="model_id" ref="mail.model_mail_message"/>
<field name="auto_delete" eval="True"/>
<field name="body_html"><![CDATA[
% set company = ctx.get('company', user.company_id)
<div>
% if ctx.get('has_button_access'):
<div itemscope itemtype="http://schema.org/EmailMessage">
@@ -110,7 +111,7 @@
% endif
</td>
<td valign="center" align="right">
<img src="/logo.png" style="padding: 0px; margin: 0px; height: auto; max-width: 80px; max-height: 40px;" alt="${user.company_id.name}">
<img src="/logo.png?company=${company.id}" style="padding: 0px; margin: 0px; height: auto; max-width: 80px; max-height: 40px;" alt="${company.name}">
</td>
</tr>
<tr>
+2 -2
View File
@@ -103,8 +103,8 @@
</record>
<record id="mail_smiley_angry" model="mail.shortcode">
<field name="source">3:(</field>
<field name="unicode_source">😈</field>
<field name="substitution">&lt;img src='/mail/static/src/img/smiley/angry.png' alt='😈'/&gt;</field>
<field name="unicode_source">😠</field>
<field name="substitution">&lt;img src='/mail/static/src/img/smiley/angry.png' alt='😠'/&gt;</field>
<field name="description">Angry</field>
<field name="shortcode_type">image</field>
</record>
+1 -1
View File
@@ -251,7 +251,7 @@ class Channel(models.Model):
author = self.env['res.partner'].browse(message_dict.get('author_id', False))
if not author or author not in self.channel_partner_ids:
return {
'error_mesage': _('restricted to channel members'),
'error_message': _('restricted to channel members'),
}
return True
return super(Channel, self)._alias_check_contact(message, message_dict, alias)
+9 -8
View File
@@ -829,11 +829,13 @@ class MailThread(models.AbstractModel):
def _routing_warn(self, error_message, warn_suffix, message_id, route, raise_exception):
""" Tools method used in message_route_verify: whether to log a warning or raise an error """
full_message = _('Routing mail with Message-Id %s: route %s: %s') % (message_id, route, error_message)
short_message = _("Mailbox unavailable - %s") % error_message
full_message = ('Routing mail with Message-Id %s: route %s: %s' %
(message_id, route, error_message))
_logger.info(full_message + (warn_suffix and '; %s' % warn_suffix or ''))
if raise_exception:
raise ValueError(full_message)
else:
_logger.info(full_message + warn_suffix and '; %s' % warn_suffix or '')
# sender should not see private diagnostics info, just the error
raise ValueError(short_message)
def _routing_create_bounce_email(self, email_from, body_html, message):
bounce_to = tools.decode_message_header(message, 'Return-Path') or email_from
@@ -1387,6 +1389,7 @@ class MailThread(models.AbstractModel):
node.getparent().remove(node)
if postprocessed:
body = etree.tostring(root, pretty_print=False, encoding='UTF-8')
body = pycompat.to_native(body)
return body, attachments
def _message_extract_payload(self, message, save_original=False):
@@ -1495,10 +1498,8 @@ class MailThread(models.AbstractModel):
'message_type': 'email',
}
if not isinstance(message, Message):
# message_from_string works on a native str, so on py2 we need to
# encode incoming unicode strings
if pycompat.PY2 and not isinstance(message, str):
message = message.encode('utf-8')
# message_from_string works on a native str
message = pycompat.to_native(message)
message = email.message_from_string(message)
message_id = message['message-id']
+6 -1
View File
@@ -54,7 +54,6 @@ class Partner(models.Model):
website_url = 'http://%s' % user.company_id.website if not user.company_id.website.lower().startswith(('http:', 'https:')) else user.company_id.website
else:
website_url = False
company_name = user.company_id.name
model_name = False
if message.model:
@@ -74,9 +73,15 @@ class Partner(models.Model):
if message.res_id and message.model in self.env:
record = self.env[message.model].browse(message.res_id)
company = user.company_id;
if record and hasattr(record, 'company_id'):
company = record.company_id;
company_name = company.name;
return {
'signature': signature,
'website_url': website_url,
'company': company,
'company_name': company_name,
'model_name': model_name,
'record': record,
@@ -28,6 +28,9 @@
<field name="body_html" widget="html" readonly="1"
nolabel="1" options='{"safe": True}'/>
<field name="attachment_ids" widget="many2many_binary" readonly="1"/>
<footer>
<button string="Discard" class="btn-default" special="cancel"/>
</footer>
</form>
</field>
</record>
+2 -2
View File
@@ -17,10 +17,10 @@ class MassMailController(http.Controller):
if mailing.exists():
res_id = res_id and int(res_id)
res_ids = []
if mailing.mailing_model_name == 'mail.mass_mailing.contact':
if mailing.mailing_model_name == 'mail.mass_mailing.list':
contacts = request.env['mail.mass_mailing.contact'].sudo().search([
('email', '=', email),
('list_id', 'in', [mailing_list.id for mailing_list in mailing.contact_list_ids])
('list_ids', 'in', [mailing_list.id for mailing_list in mailing.contact_list_ids])
])
res_ids = contacts.ids
else:
+11 -1
View File
@@ -15,6 +15,16 @@ from odoo.tools.translate import html_translate
_logger = logging.getLogger(__name__)
MASS_MAILING_BUSINESS_MODELS = [
'crm.lead',
'event.registration',
'hr.applicant',
'res.partner',
'event.track',
'sale.order',
'mail.mass_mailing.list',
]
class MassMailingTag(models.Model):
"""Model of categories of mass mailing, i.e. marketing, newsletter, ... """
_name = 'mail.mass_mailing.tag'
@@ -320,7 +330,7 @@ class MassMailing(models.Model):
default=lambda self: self.env['mail.message']._get_default_from())
# recipients
mailing_model_real = fields.Char(compute='_compute_model', string='Recipients Real Model', default='mail.mass_mailing.contact', required=True)
mailing_model_id = fields.Many2one('ir.model', string='Recipients Model', domain="[('is_mail_thread', '=', True)]",
mailing_model_id = fields.Many2one('ir.model', string='Recipients Model', domain=[('model', 'in', MASS_MAILING_BUSINESS_MODELS)],
default=lambda self: self.env.ref('mass_mailing.model_mail_mass_mailing_list').id)
mailing_model_name = fields.Char(related='mailing_model_id.model', string='Recipients Model Name')
mailing_domain = fields.Char(string='Domain', oldname='domain', default=[])
@@ -404,4 +404,15 @@ odoo_top[callback+"_updown"] = function (value, fields_values, field_name) {
if ($editable_area.html().indexOf('on_change_model_and_list') !== -1) {
$editable_area.empty();
}
// Adding compatibility for the outlook compliance of mailings.
// Commit of such compatibility : a14f89c8663c9cafecb1cc26918055e023ecbe42
options.registry.background.include({
start: function() {
this._super();
var $table_target = this.$target.find('table:first');
if ($table_target) {
this.$target = $table_target;
}
}
});
});
@@ -415,7 +415,7 @@
</div>
<label for="mailing_model_id" string="Recipients"/>
<div>
<field name="mailing_model_id"/>
<field name="mailing_model_id" widget="selection"/>
<field name="mailing_model_name" invisible="1"/>
<field name="mailing_model_real" invisible="1"/>
<field name="mailing_domain" widget="domain"
+4 -4
View File
@@ -36,7 +36,7 @@ class AccountInvoiceLine(models.Model):
# Product line has changed to a membership product
date_from = line.product_id.membership_date_from
date_to = line.product_id.membership_date_to
if line.invoice_id.date_invoice > date_from and line.invoice_id.date_invoice < date_to:
if line.invoice_id.date_invoice > (date_from or '0000-00-00') and line.invoice_id.date_invoice < (date_to or '0000-00-00'):
date_from = line.invoice_id.date_invoice
MemberLine.create({
'partner': line.invoice_id.partner_id.id,
@@ -60,9 +60,9 @@ class AccountInvoiceLine(models.Model):
invoice_line.product_id.membership and \
not MemberLine.search([('account_invoice_line', '=', invoice_line.id)]):
# Product line is a membership product
date_from = invoice_line.product_id.membership_date_from or '0000-00-00'
date_to = invoice_line.product_id.membership_date_to or '0000-00-00'
if date_from < (invoice_line.invoice_id.date_invoice or '0000-00-00') < date_to:
date_from = invoice_line.product_id.membership_date_from
date_to = invoice_line.product_id.membership_date_to
if date_from and date_from < (invoice_line.invoice_id.date_invoice or '0000-00-00') < (date_to or '0000-00-00'):
date_from = invoice_line.invoice_id.date_invoice
MemberLine.create({
'partner': invoice_line.invoice_id.partner_id.id,
+2 -2
View File
@@ -108,7 +108,7 @@ class Partner(models.Model):
s = 4
if partner.member_lines:
for mline in partner.member_lines:
if mline.date_to >= today and mline.date_from <= today:
if (mline.date_to or '0000-00-00') >= today and (mline.date_from or '0000-00-00') <= today:
if mline.account_invoice_line.invoice_id:
mstate = mline.account_invoice_line.invoice_id.state
if mstate == 'paid':
@@ -126,7 +126,7 @@ class Partner(models.Model):
s = 3
if s == 4:
for mline in partner.member_lines:
if mline.date_from < today and mline.date_to < today and mline.date_from <= mline.date_to and mline.account_invoice_line and mline.account_invoice_line.invoice_id.state == 'paid':
if (mline.date_from or '0000-00-00') < today and (mline.date_to or '0000-00-00') < today and (mline.date_from or '0000-00-00') <= (mline.date_to or '0000-00-00') and mline.account_invoice_line and mline.account_invoice_line.invoice_id.state == 'paid':
s = 5
else:
s = 6
+18 -24
View File
@@ -6,8 +6,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 11.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-10-10 11:34+0000\n"
"PO-Revision-Date: 2017-10-10 11:34+0000\n"
"POT-Creation-Date: 2017-10-16 11:13+0000\n"
"PO-Revision-Date: 2017-10-16 11:13+0000\n"
"Last-Translator: <>\n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
@@ -423,18 +423,12 @@ msgstr ""
#: model:ir.actions.act_window,name:mrp.template_open_bom
#: model:ir.model.fields,field_description:mrp.field_product_product_bom_ids
#: model:ir.model.fields,field_description:mrp.field_product_template_bom_ids
#: model:ir.ui.view,arch_db:mrp.mrp_bom_line_tree_view
#: model:ir.ui.view,arch_db:mrp.mrp_bom_tree_view
#: model:ir.ui.view,arch_db:mrp.product_product_form_view_bom_button
#: model:ir.ui.view,arch_db:mrp.product_template_form_view_bom_button
msgid "Bill of Materials"
msgstr ""
#. module: mrp
#: model:ir.actions.act_window,name:mrp.mrp_bom_line_action_from_bom
msgid "Bill of Materials Structure"
msgstr ""
#. module: mrp
#: model:ir.model.fields,help:mrp.field_mrp_production_bom_id
msgid "Bill of Materials allow you to define the list of required raw materials to make a finished product."
@@ -1202,6 +1196,21 @@ msgstr ""
msgid "If the manufacturing order is unlocked you can add to the initial demand."
msgstr ""
#. module: mrp
#: model:ir.ui.view,arch_db:mrp.mrp_bom_form_view
msgid "If the product is a finished product: When processing a sales\n"
" order for this product, the delivery order will contain the raw\n"
" materials, instead of the finished product."
msgstr ""
#. module: mrp
#: model:ir.ui.view,arch_db:mrp.mrp_bom_form_view
msgid "If the product is a semi-finished product: When processing a\n"
" manufacturing order that contains that product as component,\n"
" the raw materials of that product will be added to the\n"
" manufacturing order of the final product."
msgstr ""
#. module: mrp
#: model:ir.ui.view,arch_db:mrp.mrp_workcenter_kanban
#: model:ir.ui.view,arch_db:mrp.stock_production_type_kanban
@@ -1301,14 +1310,10 @@ msgstr ""
#. module: mrp
#: model:ir.ui.view,arch_db:mrp.view_mrp_bom_filter
#: selection:mrp.bom,type:0
msgid "Kit"
msgstr ""
#. module: mrp
#: model:ir.model.fields,help:mrp.field_mrp_bom_type
msgid "Kit (Phantom): When processing a sales order for this product, the delivery order will contain the raw materials, instead of the finished product."
msgstr ""
#. module: mrp
#: model:product.product,name:mrp.product_product_laptop_charger
#: model:product.template,name:mrp.product_product_laptop_charger_product_template
@@ -2601,11 +2606,6 @@ msgstr ""
msgid "Settings"
msgstr ""
#. module: mrp
#: selection:mrp.bom,type:0
msgid "Ship this product as a set of components (kit)"
msgstr ""
#. module: mrp
#: code:addons/mrp/models/mrp_unbuild.py:89
#, python-format
@@ -3206,12 +3206,6 @@ msgstr ""
msgid "When a procurement has a ‘produce’ route with a operation type set, it will try to create a Manufacturing Order for that product using a BoM of the same operation type. That allows to define procurement rules which trigger different manufacturing orders with different BoMs."
msgstr ""
#. module: mrp
#: model:ir.ui.view,arch_db:mrp.mrp_bom_form_view
msgid "When processing a sales order for this product, the delivery order\n"
" will contain the raw materials, instead of the finished product."
msgstr ""
#. module: mrp
#: model:ir.model.fields,help:mrp.field_stock_warehouse_manufacture_to_resupply
msgid "When products are manufactured, they can be manufactured in this warehouse."
+2 -3
View File
@@ -24,9 +24,8 @@ class MrpBom(models.Model):
help="If the active field is set to False, it will allow you to hide the bills of material without removing it.")
type = fields.Selection([
('normal', 'Manufacture this product'),
('phantom', 'Ship this product as a set of components (kit)')], 'BoM Type',
default='normal', required=True,
help="Kit (Phantom): When processing a sales order for this product, the delivery order will contain the raw materials, instead of the finished product.")
('phantom', 'Kit')], 'BoM Type',
default='normal', required=True)
product_tmpl_id = fields.Many2one(
'product.template', 'Product',
domain="[('type', 'in', ['product', 'consu'])]", required=True)
+1 -1
View File
@@ -107,7 +107,7 @@ class MrpProduction(models.Model):
copy=False, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
domain=[('scrapped', '=', False)])
finished_move_line_ids = fields.One2many(
'stock.move.line', compute='_compute_lines', inverse='_inverse_lines',
'stock.move.line', compute='_compute_lines', inverse='_inverse_lines', string="Finished Product"
)
workorder_ids = fields.One2many(
'mrp.workorder', 'production_id', 'Work Orders',
+14 -3
View File
@@ -29,9 +29,20 @@
<group>
<field name="code"/>
<field name="type" widget="radio"/>
<p colspan="2" class="oe_grey" attrs="{'invisible': [('type','=','normal')]}">
When processing a sales order for this product, the delivery order
will contain the raw materials, instead of the finished product.
<p colspan="2" class="oe_grey oe_edit_only" attrs="{'invisible': [('type','=','normal')]}">
<ul>
<li>
If the product is a finished product: When processing a sales
order for this product, the delivery order will contain the raw
materials, instead of the finished product.
</li>
<li>
If the product is a semi-finished product: When processing a
manufacturing order that contains that product as component,
the raw materials of that product will be added to the
manufacturing order of the final product.
</li>
</ul>
</p>
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
</group>
+1 -1
View File
@@ -131,7 +131,7 @@
</field>
</page>
<page string="Finished Products">
<field name="finished_move_line_ids" attrs="{'readonly': [('is_locked', '=', True)], 'invisible': [('finished_move_line_ids', '=', [])]}">
<field name="finished_move_line_ids" context="{'form_view_ref': 'mrp.view_finisehd_move_line'}" attrs="{'readonly': [('is_locked', '=', True)], 'invisible': [('finished_move_line_ids', '=', [])]}">
<tree default_order="done_move" editable="bottom" create="0" delete="0" decoration-muted="state in ('done', 'cancel')">
<field name="product_id" readonly="1"/>
<field name="lot_id" groups="stock.group_production_lot" domain="[('product_id', '=', product_id)]" context="{'default_product_id': product_id}" attrs="{'invisible': [('lots_visible', '=', False)]}"/>
+3 -3
View File
@@ -140,13 +140,13 @@
<page string="Current Production">
<group>
<group>
<field name="qty_producing" string="Current Qty" attrs="{'readonly': ['|', ('product_tracking', '=', 'serial'), ('state', 'in', ('done', 'cancel'))]}" readonly="1"/>
<field name="final_lot_id" context="{'default_product_id': product_id}" domain="['|', '&amp;', ('product_id', '=', product_id), ('product_id.tracking', '!=', 'serial'), ('use_next_on_work_order_id', '=', id)]" attrs="{'invisible': [('product_tracking', '=', 'none')]}" groups="stock.group_production_lot" readonly="1"/>
<field name="qty_producing" string="Current Qty" attrs="{'readonly': ['|', ('product_tracking', '=', 'serial'), ('state', 'in', ('done', 'cancel'))]}"/>
<field name="final_lot_id" context="{'default_product_id': product_id}" domain="['|', '&amp;', ('product_id', '=', product_id), ('product_id.tracking', '!=', 'serial'), ('use_next_on_work_order_id', '=', id)]" attrs="{'invisible': [('product_tracking', '=', 'none')]}" groups="stock.group_production_lot"/>
<field name="product_tracking" invisible="1"/>
</group>
</group>
<field name="move_raw_ids" invisible="1"/>
<field name="active_move_line_ids" attrs="{'invisible': [('active_move_line_ids', '=', [])]}" readonly="1">
<field name="active_move_line_ids" attrs="{'invisible': [('active_move_line_ids', '=', [])]}">
<tree editable="bottom" create="0" delete="0">
<field name="product_id"/>
<field name="product_uom_qty" readonly="1" string="To Consume"/>
+22
View File
@@ -63,6 +63,28 @@
</field>
</record>
<record id="view_finisehd_move_line" model="ir.ui.view">
<field name="name">mrp.finished.move.line.form</field>
<field name="model">stock.move.line</field>
<field name="arch" type="xml">
<form string="Finished Product">
<group>
<group>
<field name="product_id" readonly="1"/>
<label for="qty_done" string ="Quantity"/>
<div class="o_row">
<span><field name="qty_done" readonly="1" nolabel="1"/></span>
<span>/</span>
<span><field name="product_uom_qty" readonly="1" nolabel="1"/></span>
<span><field name="product_uom_id" attrs="{'readonly': [('id', '!=', False)]}" nolabel="1"/></span>
</div>
<field name="lot_id" string="Lot/Serial number" groups="stock.group_production_lot" readonly="1" attrs="{'invisible': [('lots_visible', '=', False)]}"/>
</group>
</group>
</form>
</field>
</record>
<menuitem id="menu_mrp_traceability"
name="Traceability: Lots"
parent="menu_mrp_reporting"
+1 -1
View File
@@ -39,7 +39,7 @@ class ChangeProductionQty(models.TransientModel):
def change_prod_qty(self):
for wizard in self:
production = wizard.mo_id
produced = sum(production.move_finished_ids.mapped('quantity_done'))
produced = sum(production.move_finished_ids.filtered(lambda m: m.product_id == production.product_id).mapped('quantity_done'))
if wizard.product_qty < produced:
raise UserError(_("You have already processed %d. Please input a quantity higher than %d ")%(produced, produced))
production.write({'product_qty': wizard.product_qty})
@@ -3,6 +3,8 @@
from odoo import api, models
from odoo.tools import float_round
class MrpProduction(models.Model):
_description = 'Production'
@@ -42,3 +44,39 @@ class MrpProduction(models.Model):
for sub_product in production.bom_id.sub_products:
production._create_byproduct_move(sub_product)
return res
class MrpProductProduce(models.TransientModel):
_name = "mrp.product.produce"
_description = "Record Production"
_inherit = "mrp.product.produce"
@api.multi
def check_finished_move_lots(self):
""" Handle by product tracked """
by_product_moves = self.production_id.move_finished_ids.filtered(lambda m: m.product_id != self.product_id and m.product_id.tracking != 'none' and m.state not in ('done', 'cancel'))
for by_product_move in by_product_moves:
rounding = by_product_move.product_uom.rounding
quantity = float_round(self.product_qty * by_product_move.unit_factor, precision_rounding=rounding)
values = {
'move_id': by_product_move.id,
'product_id': by_product_move.product_id.id,
'production_id': self.production_id.id,
'product_uom_id': by_product_move.product_uom.id,
'location_id': by_product_move.location_id.id,
'location_dest_id': by_product_move.location_dest_id.id,
}
if by_product_move.product_id.tracking == 'lot':
values.update({
'product_uom_qty': quantity,
'qty_done': quantity,
})
self.env['stock.move.line'].create(values)
else:
values.update({
'product_uom_qty': 1.0,
'qty_done': 1.0,
})
for i in range(0, int(quantity)):
self.env['stock.move.line'].create(values)
return super(MrpProductProduce, self).check_finished_move_lots()
+10 -3
View File
@@ -53,9 +53,16 @@ class StripeController(http.Controller):
""" Create a payment transaction
Expects the result from the user input from checkout.js popup"""
tx = request.env['payment.transaction'].sudo().browse(
int(request.session.get('sale_transaction_id') or request.session.get('website_payment_tx_id', False))
)
TX = request.env['payment.transaction']
tx = None
if post.get('tx_ref'):
tx = TX.sudo().search([('reference', '=', post['tx_ref'])])
if not tx:
tx_id = (post.get('tx_id') or request.session.get('sale_transaction_id') or
request.session.get('website_payment_tx_id'))
tx = TX.sudo().browse(int(tx_id))
if not tx:
raise werkzeug.exceptions.NotFound()
response = tx._create_stripe_charge(tokenid=post['tokenid'], email=post['email'])
_logger.info('Stripe: entering form_feedback with post data %s', pprint.pformat(response))
if response:
+26 -15
View File
@@ -43,13 +43,13 @@ class PaymentAcquirerStripe(models.Model):
'amount': tx_values.get('amount'),
'currency': tx_values.get('currency') and tx_values.get('currency').name or '',
'currency_id': tx_values.get('currency') and tx_values.get('currency').id or '',
'address_line1': tx_values['partner_address'],
'address_city': tx_values['partner_city'],
'address_country': tx_values['partner_country'] and tx_values['partner_country'].name or '',
'email': tx_values['partner_email'],
'address_zip': tx_values['partner_zip'],
'name': tx_values['partner_name'],
'phone': tx_values['partner_phone'],
'address_line1': tx_values.get('partner_address'),
'address_city': tx_values.get('partner_city'),
'address_country': tx_values.get('partner_country') and tx_values['partner_country'].name or '',
'email': tx_values.get('partner_email'),
'address_zip': tx_values.get('partner_zip'),
'name': tx_values.get('partner_name'),
'phone': tx_values.get('partner_phone'),
}
temp_stripe_tx_values['returndata'] = stripe_tx_values.pop('return_url', '')
@@ -107,14 +107,15 @@ class PaymentTransactionStripe(models.Model):
charge_params = {
'amount': int(self.amount if self.currency_id.name in INT_CURRENCIES else self.amount*100),
'currency': self.currency_id.name,
'metadata[reference]': self.reference
'metadata[reference]': self.reference,
'description': self.reference,
}
if acquirer_ref:
charge_params['customer'] = acquirer_ref
if tokenid:
charge_params['card'] = str(tokenid)
if email:
charge_params['receipt_email'] = email
charge_params['receipt_email'] = email.strip()
r = requests.post(api_url_charge,
auth=(self.acquirer_id.stripe_secret_key, ''),
params=charge_params,
@@ -156,12 +157,17 @@ class PaymentTransactionStripe(models.Model):
transaction record. """
reference = data.get('metadata', {}).get('reference')
if not reference:
error_msg = _(
'Stripe: invalid reply received from provider, missing reference. Additional message: %s'
% data.get('error', {}).get('message', '')
)
_logger.error(error_msg)
stripe_error = data.get('error', {}).get('message', '')
_logger.error('Stripe: invalid reply received from stripe API, looks like '
'the transaction failed. (error: %s)', stripe_error or 'n/a')
error_msg = _("We're sorry to report that the transaction has failed.")
if stripe_error:
error_msg += " " + (_("Stripe gave us the following info about the problem: '%s'") %
stripe_error)
error_msg += " " + _("Perhaps the problem can be solved by double-checking your "
"credit card details, or contacting your bank?")
raise ValidationError(error_msg)
tx = self.search([('reference', '=', reference)])
if not tx:
error_msg = (_('Stripe: no order found for reference %s') % reference)
@@ -231,6 +237,7 @@ class PaymentTokenStripe(models.Model):
'card[exp_month]': str(values['cc_expiry'][:2]),
'card[exp_year]': str(values['cc_expiry'][-2:]),
'card[cvc]': values['cvc'],
'card[name]': values['cc_holder_name'],
}
r = requests.post(url_token,
auth=(payment_acquirer.stripe_secret_key, ''),
@@ -239,8 +246,12 @@ class PaymentTokenStripe(models.Model):
token = r.json()
if token.get('id'):
customer_params = {
'source': token['id']
'source': token['id'],
'description': values['cc_holder_name']
}
if values.get('partner_id'):
partner = self.env['res.partner'].browse(values['partner_id'])
customer_params['email'] = partner.email and partner.email.strip()
r = requests.post(url_customer,
auth=(payment_acquirer.stripe_secret_key, ''),
params=customer_params,
+101 -110
View File
@@ -14,120 +14,111 @@ odoo.define('payment_stripe.stripe', function(require) {
'RWF', 'KRW', 'VUV', 'VND', 'XOF'
];
function getStripeHandler(parentElement)
{
var handler = StripeCheckout.configure({
key: $("input[name='stripe_key']", parentElement).val(),
image: $("input[name='stripe_image']", parentElement).val(),
locale: 'auto',
token: function(token, args) {
handler.isTokenGenerate = true;
ajax.jsonRpc("/payment/stripe/create_charge", 'call', {
tokenid: token.id,
email: token.email,
amount: $("input[name='amount']", parentElement).val(),
acquirer_id: $("#acquirer_stripe", parentElement).val(),
currency: $("input[name='currency']", parentElement).val(),
invoice_num: $("input[name='invoice_num']", parentElement).val(),
return_url: $("input[name='return_url']", parentElement).val()
}).done(function(data){
handler.isTokenGenerate = false;
window.location.href = data;
}).fail(function(){
var msg = arguments && arguments[1] && arguments[1].data && arguments[1].data.message;
var wizard = $(qweb.render('stripe.error', {'msg': msg || _t('Payment error')}));
wizard.appendTo($('body')).modal({'keyboard': true});
});
},
});
return handler;
if ($.blockUI) {
// our message needs to appear above the modal dialog
$.blockUI.defaults.baseZ = 2147483647; //same z-index as StripeCheckout
$.blockUI.defaults.css.border = '0';
$.blockUI.defaults.css["background-color"] = '';
$.blockUI.defaults.overlayCSS["opacity"] = '0.9';
}
$(document).ready(function (){
if (!$('.o_payment_form').length) {
return $.Deferred().reject("DOM doesn't contain '.o_payment_form'");
}
$('#o_payment_form_pay').on('click', function(ev){
// we retrieve the payment form
var parent_form = ev.target.form;
// then the checked radio
var checked_radio = $('input[type="radio"]:checked', parent_form);
// check if there's one checked radio
if(checked_radio.length != 1) {
return;
}
// if there's a checked radio, we retrieve the usefull data
var acquirer_id = checked_radio.data('acquirerId');
var provider = checked_radio.data('provider');
var is_form_payment = checked_radio.data('form-payment') === "True";
// now we check if the user has clicked on stripe radio button and wants to pay via the checkout form
if(provider != "stripe" || is_form_payment !== true) {
return;
}
// from here we retrieve the inputs that contains the data we need
var provider_form = $("#o_payment_form_acq_" + acquirer_id, parent_form);
var so_id = $("input[name='return_url']", provider_form).val().match(/quote\/([0-9]+)/) || undefined;
var access_token = $("input[name='return_url']", provider_form).val().match(/quote\/([0-9]+)\/([0-9a-zA-Z\-]+)/) || undefined;
if (so_id) {
so_id = parseInt(so_id[1]);
}
if(access_token) {
access_token = access_token[2];
}
if ($('.o_website_payment').length !== 0) {
var currency = $("input[name='currency']", provider_form).val();
var amount = parseFloat($("input[name='amount']", provider_form).val() || '0.0');
if (!_.contains(int_currencies, currency)) {
amount = amount*100;
}
ajax.jsonRpc('/website_payment/transaction', 'call', {
reference: $("input[name='invoice_num']", provider_form).val(),
amount: amount,
currency_id: currency,
acquirer_id: acquirer_id
})
var handler = getStripeHandler(provider_form);
handler.open({
name: $("input[name='merchant']", provider_form).val(),
description: $("input[name='invoice_num']", provider_form).val(),
currency: currency,
amount: amount,
});
} else {
var currency = $("input[name='currency']", provider_form).val();
var amount = parseFloat($("input[name='amount']", provider_form).val() || '0.0');
if (!_.contains(int_currencies, currency)) {
amount = amount*100;
}
var url = '/shop/payment/transaction/';
if(so_id) {
url += so_id + '/';
if(access_token) {
url += access_token;
}
}
ajax.jsonRpc(url, 'call', {
acquirer_id: acquirer_id
}, {'async': false}).then(function (data) {
try {
provider_form.html(data);
}
catch(err) { } // here it will catch an error saying that payment_stripe.stripe is already started
var handler = getStripeHandler(provider_form);
handler.open({
name: $("input[name='merchant']", provider_form).val(),
description: $("input[name='invoice_num']", provider_form).val(),
currency: currency,
amount: amount,
});
var handler = StripeCheckout.configure({
key: $("input[name='stripe_key']").val(),
image: $("input[name='stripe_image']").val(),
locale: 'auto',
token: function(token, args) {
handler.isTokenGenerate = true;
if ($.blockUI) {
var msg = _t("Just one more second, confirming your payment...");
$.blockUI({
'message': '<h2 class="text-white"><img src="/web/static/src/img/spin.png" class="fa-pulse"/>' +
' <br />' + msg +
'</h2>'
});
}
ajax.jsonRpc("/payment/stripe/create_charge", 'call', {
tokenid: token.id,
email: token.email,
amount: $("input[name='amount']").val(),
acquirer_id: $("#acquirer_stripe").val(),
currency: $("input[name='currency']").val(),
invoice_num: $("input[name='invoice_num']").val(),
tx_ref: $("input[name='invoice_num']").val(),
return_url: $("input[name='return_url']").val()
}).always(function(){
if ($.blockUI) {
$.unblockUI();
}
}).done(function(data){
handler.isTokenGenerate = false;
window.location.href = data;
}).fail(function(){
var msg = arguments && arguments[1] && arguments[1].data && arguments[1].data.arguments && arguments[1].data.arguments[0];
var wizard = $(qweb.render('stripe.error', {'msg': msg || _t('Payment error')}));
wizard.appendTo($('body')).modal({'keyboard': true});
});
},
});
$('#pay_stripe').on('click', function(e) {
// Open Checkout with further options
if(!$(this).find('i').length)
$(this).append('<i class="fa fa-spinner fa-spin"/>');
$(this).attr('disabled','disabled');
var $form = $(e.currentTarget).parents('form');
var acquirer_id = $(e.currentTarget).closest('div.oe_sale_acquirer_button,div.oe_quote_acquirer_button,div.o_website_payment_new_payment');
acquirer_id = acquirer_id.data('id') || acquirer_id.data('acquirer_id');
if (! acquirer_id) {
return false;
}
var so_token = $("input[name='token']").val();
var so_id = $("input[name='return_url']").val().match(/quote\/([0-9]+)/) || undefined;
if (so_id) {
so_id = parseInt(so_id[1]);
}
e.preventDefault();
var currency = $("input[name='currency']").val();
var currency_id = $("input[name='currency_id']").val();
var amount = parseFloat($("input[name='amount']").val() || '0.0');
if ($('.o_website_payment').length !== 0) {
var create_tx = ajax.jsonRpc('/website_payment/transaction', 'call', {
reference: $("input[name='invoice_num']").val(),
amount: amount, // exact amount, not stripe cents
currency_id: currency_id,
acquirer_id: acquirer_id
});
}
else if ($('.o_website_quote').length !== 0) {
var url = _.str.sprintf("/quote/%s/transaction/%s/%s", so_id, acquirer_id, so_token);
var create_tx = ajax.jsonRpc(url, 'call', {}).then(function (data) {
try { $form.html(data); } catch (e) {};
});
}
else {
var create_tx = ajax.jsonRpc('/shop/payment/transaction/' + acquirer_id, 'call', {
so_id: so_id,
so_token: so_token
}).then(function (data) {
try { $form.html(data); } catch (e) {};
});
}
create_tx.done(function () {
if (!_.contains(int_currencies, currency)) {
amount = amount*100;
}
handler.open({
name: $("input[name='merchant']").val(),
description: $("input[name='invoice_num']").val(),
email: $("input[name='email']").val(),
currency: currency,
amount: amount,
});
});
});
});
+2 -2
View File
@@ -223,7 +223,7 @@ class PosOrder(models.Model):
# We convert a new id object back to a dictionary to write to
# bridge between old and new api
inv_line = invoice_line._convert_to_write({name: invoice_line[name] for name in invoice_line._cache})
inv_line.update(price_unit=line.price_unit, discount=line.discount)
inv_line.update(price_unit=line.price_unit, discount=line.discount, name=inv_name)
return InvoiceLine.sudo().create(inv_line)
def _create_account_move_line(self, session=None, move=None):
@@ -650,7 +650,7 @@ class PosOrder(models.Model):
return_picking = Picking.create(return_vals)
return_picking.message_post(body=message)
for line in order.lines.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not float_is_zero(l.qty, precision_digits=l.product_id.uom_id.rounding)):
for line in order.lines.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not float_is_zero(l.qty, precision_rounding=l.product_id.uom_id.rounding)):
moves |= Move.create({
'name': line.name,
'product_uom': line.product_id.uom_id.id,
+110 -45
View File
@@ -206,7 +206,7 @@ exports.PosModel = Backbone.Model.extend({
},
},{
model: 'account.tax',
fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids'],
fields: ['name','amount', 'price_include', 'include_base_amount', 'amount_type', 'children_tax_ids', 'sequence'],
domain: null,
loaded: function(self, taxes){
self.taxes = taxes;
@@ -1646,72 +1646,137 @@ exports.Orderline = Backbone.Model.extend({
return tax;
},
_compute_all: function(tax, base_amount, quantity) {
/**
* Mirror JS method of:
* _compute_amount in addons/account/models/account.py
*/
_compute_all: function(tax, base_amount, quantity, price_include) {
if(price_include === undefined)
price_include = tax.price_include;
if (tax.amount_type === 'fixed') {
var sign_base_amount = base_amount >= 0 ? 1 : -1;
return (Math.abs(tax.amount) * sign_base_amount) * quantity;
}
if ((tax.amount_type === 'percent' && !tax.price_include) || (tax.amount_type === 'division' && tax.price_include)){
if ((tax.amount_type === 'percent' && !price_include) || (tax.amount_type === 'division' && tax.price_include)){
return base_amount * tax.amount / 100;
}
if (tax.amount_type === 'percent' && tax.price_include){
if (tax.amount_type === 'percent' && price_include){
return base_amount - (base_amount / (1 + tax.amount / 100));
}
if (tax.amount_type === 'division' && !tax.price_include) {
if (tax.amount_type === 'division' && !price_include) {
return base_amount / (1 - tax.amount / 100) - base_amount;
}
return false;
},
/**
* Mirror JS method of:
* compute_all in addons/account/models/account.py
*
* Read comments in the python side method for more details about each sub-methods.
*/
compute_all: function(taxes, price_unit, quantity, currency_rounding, no_map_tax) {
var self = this;
var list_taxes = [];
// 1) Flatten the taxes.
var _collect_taxes = function(taxes, all_taxes){
taxes.sort(function (tax1, tax2) {
return tax1.sequence - tax2.sequence;
});
_(taxes).each(function(tax){
if(tax.amount_type === 'group')
all_taxes = _collect_taxes(tax.children_tax_ids, all_taxes);
else
all_taxes.push(tax);
});
return all_taxes;
}
var collect_taxes = function(taxes){
return _collect_taxes(taxes, []);
}
taxes = collect_taxes(taxes);
// 2) Avoid dealing with taxes mixing price_include=False && include_base_amount=True
// with price_include=True
var base_excluded_flag = false; // price_include=False && include_base_amount=True
var included_flag = false; // price_include=True
_(taxes).each(function(tax){
if(tax.price_include)
included_flag = true;
else if(tax.include_base_amount)
base_excluded_flag = true
if(base_excluded_flag && included_flag)
throw new Error('Unable to mix any taxes being price included with taxes affecting the base amount but not included in price.');
});
// 3) Deal with the rounding methods
var currency_rounding_bak = currency_rounding;
if (this.pos.company.tax_calculation_rounding_method == "round_globally"){
currency_rounding = currency_rounding * 0.00001;
}
var total_excluded = round_pr(price_unit * quantity, currency_rounding);
var total_included = total_excluded;
var base = total_excluded;
_(taxes).each(function(tax) {
if (!no_map_tax){
tax = self._map_tax_fiscal_position(tax);
}
if (!tax){
return;
}
if (tax.amount_type === 'group'){
var ret = self.compute_all(tax.children_tax_ids, price_unit, quantity, currency_rounding);
total_excluded = ret.total_excluded;
base = ret.total_excluded;
total_included = ret.total_included;
list_taxes = list_taxes.concat(ret.taxes);
}
else {
var tax_amount = self._compute_all(tax, base, quantity);
tax_amount = round_pr(tax_amount, currency_rounding);
if (tax_amount){
if (tax.price_include) {
total_excluded -= tax_amount;
base -= tax_amount;
}
else {
total_included += tax_amount;
}
if (tax.include_base_amount) {
base += tax_amount;
}
var data = {
id: tax.id,
amount: tax_amount,
name: tax.name,
};
list_taxes.push(data);
}
var recompute_base = function(base_amount, fixed_amount, percent_amount){
if(fixed_amount === 0.0 && percent_amount === 0.0)
return base_amount;
return (base_amount - fixed_amount) / (1.0 + percent_amount / 100.0);
}
// 4) Iterate the taxes in the reversed sequence order to retrieve the initial base of the computation.
var base = round_pr(price_unit * quantity, currency_rounding);
var sign = 1;
if(base < 0){
base = -base;
var sign = -1;
}
var incl_fixed_amount = 0.0;
var incl_percent_amount = 0.0;
_(taxes.reverse()).each(function(tax){
if(tax.include_base_amount){
base = recompute_base(base, incl_fixed_amount, incl_percent_amount);
incl_fixed_amount = 0.0;
incl_percent_amount = 0.0;
}
if(tax.price_include){
if(tax.amount_type === 'fixed')
incl_fixed_amount += tax.amount;
else if(tax.amount_type === 'percent')
incl_percent_amount += tax.amount;
}
});
base = recompute_base(base, incl_fixed_amount, incl_percent_amount);
var total_excluded = base;
var total_included = base;
// 5) Iterate the taxes in the sequence order to fill missing base/amount values.
var taxes_vals = [];
_(taxes.reverse()).each(function(tax){
var tax_amount = self._compute_all(tax, base, quantity, false);
tax_amount = round_pr(tax_amount, currency_rounding);
var tax_base = base;
if(tax.include_base_amount)
base += tax_amount;
total_included += tax_amount;
var tax_vals = {
id: tax.id,
amount: sign * tax_amount,
name: tax.name,
};
taxes_vals.push(tax_vals);
});
return {
taxes: list_taxes,
taxes: taxes_vals,
total_excluded: round_pr(total_excluded, currency_rounding_bak),
total_included: round_pr(total_included, currency_rounding_bak)
};
@@ -72,3 +72,7 @@
text-align: center;
}
}
.label-optional {
font-weight: normal;
}
+1 -1
View File
@@ -261,7 +261,7 @@
<div class="clearfix" />
<div t-attf-class="form-group #{error.get('street') and 'has-error' or ''} col-lg-6">
<label class="control-label label-optional" for="street">Street</label>
<label class="control-label" for="street">Street</label>
<input type="text" name="street" class="form-control" t-att-value="street or partner.street"/>
</div>
<div t-attf-class="form-group #{error.get('city') and 'has-error' or ''} col-lg-6">
+2 -1
View File
@@ -283,7 +283,8 @@ class ProductProduct(models.Model):
for value in product.attribute_value_ids:
if value.attribute_id in attributes:
raise ValidationError(_('Error! It is not allowed to choose more than one value for a given attribute.'))
attributes |= value.attribute_id
if value.attribute_id.create_variant:
attributes |= value.attribute_id
return True
@api.onchange('uom_id', 'uom_po_id')
+14 -1
View File
@@ -31,6 +31,19 @@ class StockMove(models.Model):
created_purchase_line_id = fields.Many2one('purchase.order.line',
'Created Purchase Order Line', ondelete='set null', readonly=True, copy=False)
@api.model
def _prepare_merge_moves_distinct_fields(self):
distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields()
distinct_fields.append('purchase_line_id')
return distinct_fields
@api.model
def _prepare_merge_move_sort_method(self, move):
move.ensure_one()
keys_sorted = super(StockMove, self)._prepare_merge_move_sort_method(move)
keys_sorted.append(move.purchase_line_id.id)
return keys_sorted
@api.multi
def _get_price_unit(self):
""" Returns the unit price for the move"""
@@ -148,4 +161,4 @@ class Orderpoint(models.Model):
res = super(Orderpoint, self)._quantity_in_progress()
for poline in self.env['purchase.order.line'].search([('state','in',('draft','sent','to approve')),('orderpoint_id','in',self.ids)]):
res[poline.orderpoint_id.id] += poline.product_uom._compute_quantity(poline.product_qty, poline.orderpoint_id.product_uom, round=False)
return res
return res
+1
View File
@@ -5,3 +5,4 @@ from . import test_onchange_product_id
from . import test_purchase_order
from . import test_create_picking
from . import test_purchase_lead_time
from . import test_stockvaluation
@@ -0,0 +1,200 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import time
from datetime import datetime
from odoo.tests.common import TransactionCase
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
class TestStockValuation(TransactionCase):
def setUp(self):
super(TestStockValuation, self).setUp()
self.supplier_location = self.env.ref('stock.stock_location_suppliers')
self.stock_location = self.env.ref('stock.stock_location_stock')
self.partner_id = self.env.ref('base.res_partner_1')
self.product1 = self.env.ref('product.product_product_8')
Account = self.env['account.account']
self.stock_input_account = Account.create({
'name': 'Stock Input',
'code': 'StockIn',
'user_type_id': self.env.ref('account.data_account_type_current_assets').id,
})
self.stock_output_account = Account.create({
'name': 'Stock Output',
'code': 'StockOut',
'user_type_id': self.env.ref('account.data_account_type_current_assets').id,
})
self.stock_valuation_account = Account.create({
'name': 'Stock Valuation',
'code': 'Stock Valuation',
'user_type_id': self.env.ref('account.data_account_type_current_assets').id,
})
self.stock_journal = self.env['account.journal'].create({
'name': 'Stock Journal',
'code': 'STJTEST',
'type': 'general',
})
self.product1.categ_id.write({
'property_stock_account_input_categ_id': self.stock_input_account.id,
'property_stock_account_output_categ_id': self.stock_output_account.id,
'property_stock_valuation_account_id': self.stock_valuation_account.id,
'property_stock_journal': self.stock_journal.id,
})
def test_change_unit_cost_average_1(self):
""" Confirm a purchase order and create the associated receipt, change the unit cost of the
purchase order before validating the receipt, the value of the received goods should be set
according to the last unit cost.
"""
self.product1.product_tmpl_id.cost_method = 'average'
po1 = self.env['purchase.order'].create({
'partner_id': self.partner_id.id,
'order_line': [
(0, 0, {
'name': self.product1.name,
'product_id': self.product1.id,
'product_qty': 10.0,
'product_uom': self.product1.uom_po_id.id,
'price_unit': 100.0,
'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
}),
],
})
po1.button_confirm()
picking1 = po1.picking_ids[0]
move1 = picking1.move_lines[0]
# the unit price of the purchase order line is copied to the in move
self.assertEquals(move1.price_unit, 100)
# update the unit price on the purchase order line
po1.order_line.price_unit = 200
# the unit price on the stock move is not directly updated
self.assertEquals(move1.price_unit, 100)
# validate the receipt
res_dict = picking1.button_validate()
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
wizard.process()
# the unit price of the stock move has been updated to the latest value
self.assertEquals(move1.price_unit, 200)
self.assertEquals(self.product1.stock_value, 2000)
def test_standard_price_change_1(self):
""" Confirm a purchase order and create the associated receipt, change the unit cost of the
purchase order and the standard price of the product before validating the receipt, the
value of the received goods should be set according to the last standard price.
"""
self.product1.product_tmpl_id.cost_method = 'standard'
# set a standard price
self.product1.product_tmpl_id.standard_price = 10
po1 = self.env['purchase.order'].create({
'partner_id': self.partner_id.id,
'order_line': [
(0, 0, {
'name': self.product1.name,
'product_id': self.product1.id,
'product_qty': 10.0,
'product_uom': self.product1.uom_po_id.id,
'price_unit': 11.0,
'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
}),
],
})
po1.button_confirm()
picking1 = po1.picking_ids[0]
move1 = picking1.move_lines[0]
# the move's unit price reflects the purchase order line's cost even if it's useless when
# the product's cost method is standard
self.assertEquals(move1.price_unit, 11)
# set a new standard price
self.product1.product_tmpl_id.standard_price = 12
# the unit price on the stock move is not directly updated
self.assertEquals(move1.price_unit, 11)
# validate the receipt
res_dict = picking1.button_validate()
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
wizard.process()
# the unit price of the stock move has been updated to the latest value
self.assertEquals(move1.price_unit, 12)
self.assertEquals(self.product1.stock_value, 120)
def test_change_currency_rate_average_1(self):
""" Confirm a purchase order in another currency and create the associated receipt, change
the currency rate, validate the receipt and then check that the value of the received goods
is set according to the last currency rate.
"""
usd_currency = self.env.ref('base.USD')
self.env.user.company_id.currency_id = usd_currency.id
eur_currency = self.env.ref('base.EUR')
self.product1.product_tmpl_id.cost_method = 'average'
# default currency is USD, create a purchase order in EUR
po1 = self.env['purchase.order'].create({
'partner_id': self.partner_id.id,
'currency_id': eur_currency.id,
'order_line': [
(0, 0, {
'name': self.product1.name,
'product_id': self.product1.id,
'product_qty': 10.0,
'product_uom': self.product1.uom_po_id.id,
'price_unit': 100.0,
'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT),
}),
],
})
po1.button_confirm()
picking1 = po1.picking_ids[0]
move1 = picking1.move_lines[0]
# convert the price unit in the company currency
price_unit_usd = po1.currency_id.compute(po1.order_line.price_unit, po1.company_id.currency_id, round=False)
# the unit price of the move is the unit price of the purchase order line converted in
# the company's currency
self.assertEqual(move1.price_unit, price_unit_usd)
# change the rate of the currency
self.env['res.currency.rate'].create({
'name': time.strftime('%Y-%m-%d'),
'rate': 2.0,
'currency_id': eur_currency.id,
'company_id': po1.company_id.id,
})
eur_currency._compute_current_rate()
price_unit_usd_new_rate = po1.currency_id.compute(po1.order_line.price_unit, po1.company_id.currency_id, round=False)
# the new price_unit is lower than th initial because of the rate's change
self.assertLess(price_unit_usd_new_rate, price_unit_usd)
# the unit price on the stock move is not directly updated
self.assertEquals(move1.price_unit, price_unit_usd)
# validate the receipt
res_dict = picking1.button_validate()
wizard = self.env[(res_dict.get('res_model'))].browse(res_dict.get('res_id'))
wizard.process()
# the unit price of the stock move has been updated to the latest value
self.assertEquals(move1.price_unit, price_unit_usd_new_rate)
self.assertAlmostEqual(self.product1.stock_value, price_unit_usd_new_rate * 10, delta=0.1)
+1 -1
View File
@@ -721,7 +721,7 @@ class ResourceCalendarLeaves(models.Model):
date_from = fields.Datetime('Start Date', required=True)
date_to = fields.Datetime('End Date', required=True)
tz = fields.Selection(
_tz_get, string='Timezone', default=lambda self: self._context.get('tz', self.env.user.tz),
_tz_get, string='Timezone', default=lambda self: self._context.get('tz', self.env.user.tz or 'UTC'),
help="Timezone used when encoding the leave. It is used to correctly"
"localize leave hours when computing time intervals.")
resource_id = fields.Many2one(
+2 -2
View File
@@ -20,6 +20,6 @@ def uninstall_hook(cr, registry):
if 'crm.team' in env:
recs = env['crm.team'].search([])
for rec in recs:
rec._onchange_team_type()
rec._onchange_team_type()
cr.after("commit", partial(update_dashboard_graph_model, cr.dbname))
cr.after("commit", partial(update_dashboard_graph_model, cr.dbname))
+4 -4
View File
@@ -72,13 +72,13 @@ from ${object.company_id.name}.
% set record = ctx.get('record')
% set company = record and record.company_id or user.company_id
<body style="margin: 0; padding: 0;">
<table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed" summary="o_mail_notification">
<table border="0" width="100%" cellpadding="0" bgcolor="#ededed" style="padding: 20px; background-color: #ededed; border-collapse:separate;" summary="o_mail_notification">
<tbody>
<!-- HEADER -->
<tr>
<td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;">
<tr>
<td valign="middle">
<span style="font-size:20px; color:white; font-weight: bold;">
@@ -96,7 +96,7 @@ from ${object.company_id.name}.
<!-- CONTENT -->
<tr>
<td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px;">
<table width="590" border="0" cellpadding="0" bgcolor="#ffffff" style="min-width: 590px; background-color: rgb(255, 255, 255); padding: 20px; border-collapse:separate;">
<tbody>
<td valign="top" style="font-family:Arial,Helvetica,sans-serif; color: #555; font-size: 14px;">
${object.body | safe}
@@ -109,7 +109,7 @@ from ${object.company_id.name}.
<!-- FOOTER -->
<tr>
<td align="center" style="min-width: 590px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px;">
<table width="590" border="0" cellpadding="0" bgcolor="#875A7B" style="min-width: 590px; background-color: rgb(135,90,123); padding: 20px; border-collapse:separate;">
<tr>
<td valign="middle" align="left" style="color: #fff; padding-top: 10px; padding-bottom: 10px; font-size: 12px;">
${company.name}<br/>
+11 -1
View File
@@ -693,6 +693,14 @@ class SaleOrderLine(models.Model):
'price_subtotal': taxes['total_excluded'],
})
@api.depends('product_id', 'order_id.state', 'qty_invoiced', 'qty_delivered')
def _compute_product_updatable(self):
for line in self:
if line.state in ['done', 'cancel'] or (line.state == 'sale' and (line.qty_invoiced > 0 or line.qty_delivered > 0)):
line.product_updatable = False
else:
line.product_updatable = True
@api.depends('product_id.invoice_policy', 'order_id.state')
def _compute_qty_delivered_updateable(self):
for line in self:
@@ -852,6 +860,7 @@ class SaleOrderLine(models.Model):
discount = fields.Float(string='Discount (%)', digits=dp.get_precision('Discount'), default=0.0)
product_id = fields.Many2one('product.product', string='Product', domain=[('sale_ok', '=', True)], change_default=True, ondelete='restrict', required=True)
product_updatable = fields.Boolean(compute='_compute_product_updatable', string='Can Edit Product', readonly=True, default=True)
product_uom_qty = fields.Float(string='Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True, default=1.0)
product_uom = fields.Many2one('product.uom', string='Unit of Measure', required=True)
# Non-stored related field to allow portal user to see the image of the product he has ordered
@@ -964,7 +973,8 @@ class SaleOrderLine(models.Model):
return price
else:
from_currency = self.order_id.company_id.currency_id
return from_currency.compute(product.lst_price, self.order_id.pricelist_id.currency_id)
product_price = product[pricelist_item.base] if (pricelist_item and pricelist_item.base != 'pricelist') else product.lst_price
return from_currency.compute(product_price, self.order_id.pricelist_id.currency_id)
@api.multi
@api.onchange('product_id')
+6 -2
View File
@@ -21,10 +21,13 @@ class TestSaleOrder(TestSale):
'pricelist_id': self.env.ref('product.list0').id,
})
self.assertEqual(so.amount_total, sum([2 * p.list_price for p in self.products.values()]), 'Sale: total amount is wrong')
so.order_line._compute_product_updatable()
self.assertTrue(so.order_line[0].product_updatable)
# send quotation
so.force_quotation_send()
self.assertTrue(so.state == 'sent', 'Sale: state after sending is wrong')
so.order_line._compute_product_updatable()
self.assertTrue(so.order_line[0].product_updatable)
# confirm quotation
so.action_confirm()
@@ -38,7 +41,8 @@ class TestSaleOrder(TestSale):
self.assertEqual(inv.amount_total, sum([2 * p.list_price if p.invoice_policy == 'order' else 0 for p in self.products.values()]), 'Sale: invoice total amount is wrong')
self.assertTrue(so.invoice_status == 'no', 'Sale: SO status after invoicing should be "nothing to invoice"')
self.assertTrue(len(so.invoice_ids) == 1, 'Sale: invoice is missing')
so.order_line._compute_product_updatable()
self.assertFalse(so.order_line[0].product_updatable)
# deliver lines except 'time and material' then invoice again
for line in so.order_line:
line.qty_delivered = 2 if line.product_id.expense_policy=='no' else 0
+4 -2
View File
@@ -239,9 +239,10 @@
<form string="Sales Order Lines">
<group>
<group>
<field name="product_updatable" invisible="1"/>
<field name="product_id"
context="{'partner_id':parent.partner_id, 'quantity':product_uom_qty, 'pricelist':parent.pricelist_id, 'uom':product_uom, 'company_id': parent.company_id}"
attrs="{'readonly': [('qty_invoiced', '&gt;', 0)]}"
attrs="{'readonly': [('product_updatable', '=', False)]}"
force_save="1"
/>
<field name="layout_category_id" groups="sale.group_sale_layout"/>
@@ -294,8 +295,9 @@
</form>
<tree string="Sales Order Lines" editable="bottom" decoration-info="invoice_status=='to invoice'">
<field name="sequence" widget="handle"/>
<field name="product_updatable" invisible="1"/>
<field name="product_id"
attrs="{'readonly': [('qty_invoiced', '&gt;', 0)]}"
attrs="{'readonly': [('product_updatable', '=', False)]}"
context="{'partner_id':parent.partner_id, 'quantity':product_uom_qty, 'pricelist':parent.pricelist_id, 'uom':product_uom, 'company_id': parent.company_id}"
/>
<field name="layout_category_id" groups="sale.group_sale_layout"/>
+1 -1
View File
@@ -51,7 +51,7 @@ class AccountInvoiceLine(models.Model):
qty_to_consider = invoiced_qty - qty_done
qty_to_consider = min(qty_to_consider, quantity - qty_delivered)
qty_delivered += qty_to_consider
average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + move.price_unit * qty_to_consider) / qty_delivered
average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + (-1 * move.price_unit) * qty_to_consider) / qty_delivered
if qty_delivered == quantity:
break
return average_price_unit
+16 -6
View File
@@ -127,14 +127,24 @@ class SaleOrderLine(models.Model):
and all(move.state in ['done', 'cancel'] for move in line.move_ids):
line.invoice_status = 'invoiced'
@api.depends('move_ids')
def _compute_product_updatable(self):
for line in self:
if not line.move_ids:
super(SaleOrderLine, line)._compute_product_updatable()
else:
line.product_updatable = False
@api.multi
@api.depends('product_id')
def _compute_qty_delivered_updateable(self):
for line in self:
if line.product_id.type not in ('consu', 'product'):
super(SaleOrderLine, line)._compute_qty_delivered_updateable()
else:
line.qty_delivered_updateable = False
# prefetch field before filtering
self.mapped('product_id')
# on consumable or stockable products, qty_delivered_updateable defaults
# to False; on other lines use the original computation
lines = self.filtered(lambda line: line.product_id.type not in ('consu', 'product'))
lines = lines.with_prefetch(self._prefetch)
super(SaleOrderLine, lines)._compute_qty_delivered_updateable()
@api.onchange('product_id')
def _onchange_product_id_set_customer_lead(self):
@@ -225,7 +235,7 @@ class SaleOrderLine(models.Model):
if line.state != 'sale' or not line.product_id.type in ('consu','product'):
continue
qty = 0.0
for move in line.move_ids:
for move in line.move_ids.filtered(lambda r: r.state != 'cancel'):
qty += move.product_qty
if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0:
continue
+13
View File
@@ -13,6 +13,19 @@ class StockMove(models.Model):
_inherit = "stock.move"
sale_line_id = fields.Many2one('sale.order.line', 'Sale Line')
@api.model
def _prepare_merge_moves_distinct_fields(self):
distinct_fields = super(StockMove, self)._prepare_merge_moves_distinct_fields()
distinct_fields.append('sale_line_id')
return distinct_fields
@api.model
def _prepare_merge_move_sort_method(self, move):
move.ensure_one()
keys_sorted = super(StockMove, self)._prepare_merge_move_sort_method(move)
keys_sorted.append(move.sale_line_id.id)
return keys_sorted
def _action_done(self):
result = super(StockMove, self)._action_done()
for line in self.mapped('sale_line_id'):
@@ -80,7 +80,11 @@ class TestSaleStock(TestSale):
for sol in self.so.order_line:
sol.product_id.invoice_policy = 'order'
# confirm our standard so, check the picking
self.so.order_line._compute_product_updatable()
self.assertTrue(self.so.order_line[0].product_updatable)
self.so.action_confirm()
self.so.order_line._compute_product_updatable()
self.assertFalse(self.so.order_line[0].product_updatable)
self.assertTrue(self.so.picking_ids, 'Sale Stock: no picking created for "invoice on order" stockable products')
# let's do an invoice for a deposit of 5%
adv_wiz = self.env['sale.advance.payment.inv'].with_context(active_ids=[self.so.id]).create({
+1
View File
@@ -16,6 +16,7 @@ have real delivered quantities in sales orders.
'website': 'https://www.odoo.com/page/warehouse',
'depends': ['sale_management', 'hr_timesheet'],
'data': [
'security/sale_timesheet_security.xml',
'views/account_invoice_views.xml',
'views/sale_order_views.xml',
'views/product_views.xml',
+2 -2
View File
@@ -55,8 +55,8 @@ class SaleTimesheetController(http.Controller):
dashboard_values['hours'][billable_type] = float_round(data.get('unit_amount'), precision_rounding=hour_rounding)
dashboard_values['hours']['total'] += float_round(data.get('unit_amount'), precision_rounding=hour_rounding)
# rates
dashboard_values['rates'][billable_type] = float_round(data.get('unit_amount') / dashboard_total_hours * 100, precision_rounding=hour_rounding)
dashboard_values['rates']['total'] += float_round(data.get('unit_amount') / dashboard_total_hours * 100, precision_rounding=hour_rounding)
dashboard_values['rates'][billable_type] = round(data.get('unit_amount') / dashboard_total_hours * 100, 2)
dashboard_values['rates']['total'] += round(data.get('unit_amount') / dashboard_total_hours * 100, 2)
# money_amount
so_lines = values['timesheet_lines'].mapped('so_line')
+9
View File
@@ -52,3 +52,12 @@ class ProductTemplate(models.Model):
def _onchange_service_tracking(self):
if self.service_tracking != 'task_global_project':
self.project_id = False
@api.onchange('type')
def _onchange_type(self):
if self.type == 'service':
self.invoice_policy = 'order'
self.service_type = 'timesheet'
elif self.type == 'consu':
self.invoice_policy = 'order'
self.service_type = 'manual'
@@ -144,6 +144,14 @@ class SaleOrderLine(models.Model):
for so_line in self:
so_line.is_service = so_line.product_id.type == 'service'
@api.depends('product_id.type')
def _compute_product_updatable(self):
for line in self:
if line.product_id.type == 'service' and line.state == 'sale':
line.product_updatable = False
else:
super(SaleOrderLine, line)._compute_product_updatable()
@api.model
def create(self, values):
line = super(SaleOrderLine, self).create(values)
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<record id="account_analytic_line_rule_billing_user" model="ir.rule">
<field name="name">account.analytic.line.billing.user</field>
<field name="model_id" ref="analytic.model_account_analytic_line"/>
<field name="domain_force">[('project_id', '=', False)]</field>
<field name="groups" eval="[(4, ref('account.group_account_invoice'))]"/>
<field name="perm_create" eval="1"/>
<field name="perm_write" eval="1"/>
<field name="perm_unlink" eval="1"/>
<field name="perm_read" eval="0"/>
</record>
</data>
</odoo>
@@ -25,7 +25,11 @@ class TestSaleService(CommonTest):
'pricelist_id': self.pricelist_usd.id,
}
sale_order = self.env['sale.order'].create(sale_order_vals)
sale_order.order_line._compute_product_updatable()
self.assertTrue(sale_order.order_line[0].product_updatable)
sale_order.action_confirm()
sale_order.order_line._compute_product_updatable()
self.assertFalse(sale_order.order_line[0].product_updatable)
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation')
# check task creation
+1 -1
View File
@@ -3,7 +3,7 @@
import logging
from odoo import api, fields, models
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.addons.iap.models import iap
+1 -1
View File
@@ -500,7 +500,7 @@ class ProductTemplate(models.Model):
done_moves = self.env['stock.move'].search([('product_id', 'in', updated.mapped('product_variant_ids').ids)], limit=1)
if done_moves:
raise UserError(_("You can not change the unit of measure of a product that has already been used in a done stock move. If you need to change the unit of measure, you may deactivate this product."))
if 'type' in vals:
if any('type' in vals and vals['type'] != prod_tmpl.type for prod_tmpl in self):
existing_move_lines = self.env['stock.move.line'].search([
('product_id', 'in', self.mapped('product_variant_ids').ids),
('state', 'in', ['partially_available', 'assigned']),
+19 -7
View File
@@ -514,25 +514,35 @@ class StockMove(models.Model):
'origin': origin,
}
@api.model
def _prepare_merge_moves_distinct_fields(self):
return [
'product_id', 'price_unit', 'product_packaging',
'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id'
]
@api.model
def _prepare_merge_move_sort_method(self, move):
move.ensure_one()
return [
move.product_id.id, move.price_unit, move.product_packaging.id, move.product_uom.id,
move.restrict_partner_id.id, move.scrapped, move.origin_returned_move_id.id
]
def _merge_moves(self):
""" This method will, for each move in `self`, go up in their linked picking and try to
find in their existing moves a candidate into which we can merge the move.
:return: Recordset of moves passed to this method. If some of the passed moves were merged
into another existing one, return this one and not the (now unlinked) original.
"""
distinct_fields = ['product_id', 'price_unit', 'product_packaging',
'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id']
def _keys_sorted(move):
return move.product_id.id, move.price_unit, move.product_packaging.id, move.product_uom.id,\
move.restrict_partner_id.id, move.scrapped, move.origin_returned_move_id.id
distinct_fields = self._prepare_merge_moves_distinct_fields()
# Move removed after merge
moves_to_unlink = self.env['stock.move']
moves_to_merge = []
for picking in self.mapped('picking_id'):
# First step find move to merge.
for k, g in groupby(sorted(picking.move_lines, key=_keys_sorted), key=itemgetter(*distinct_fields)):
for k, g in groupby(sorted(picking.move_lines, key=self._prepare_merge_move_sort_method), key=itemgetter(*distinct_fields)):
moves = self.env['stock.move'].concat(*g).filtered(lambda m: m.state not in ('done', 'cancel', 'draft'))
# If we have multiple records we will merge then in a single one.
if len(moves) > 1:
@@ -912,6 +922,8 @@ class StockMove(models.Model):
if any(move.state == 'done' for move in self):
raise UserError(_('You cannot cancel a stock move that has been set to \'Done\'.'))
for move in self:
if move.state == 'cancel':
continue
move._do_unreserve()
siblings_states = (move.move_dest_ids.mapped('move_orig_ids') - move).mapped('state')
if move.propagate:
+2 -2
View File
@@ -788,11 +788,11 @@ class Picking(models.Model):
if operations:
package = self.env['stock.quant.package'].create({})
for operation in operations:
if float_compare(operation.qty_done, operation.product_qty, precision_rounding=operation.product_uom_id.rounding) >= 0:
if float_compare(operation.qty_done, operation.product_uom_qty, precision_rounding=operation.product_uom_id.rounding) >= 0:
operation_ids |= operation
else:
quantity_left_todo = float_round(
operation.product_qty - operation.qty_done,
operation.product_uom_qty - operation.qty_done,
precision_rounding=operation.product_uom_id.rounding,
rounding_method='UP')
new_operation = operation.copy(
+1 -1
View File
@@ -244,7 +244,7 @@ class StockMove(models.Model):
self.ensure_one()
if self._is_in():
if self.product_id.cost_method in ['fifo', 'average']:
price_unit = self.price_unit or self._get_price_unit()
price_unit = self._get_price_unit()
value = price_unit * (quantity or self.product_qty)
vals = {
'price_unit': price_unit,
+4 -4
View File
@@ -24,10 +24,10 @@ This module allows you to easily add extra costs on pickings and decide the spli
'views/res_config_settings_views.xml',
],
'test': [
# '../account/test/account_minimal_test.xml',
# '../stock_account/test/stock_valuation_account.xml',
# 'test/stock_landed_costs.yml',
#'test/stock_landed_costs_rounding.yml',
'../account/test/account_minimal_test.xml',
'../stock_account/test/stock_valuation_account.xml',
'test/stock_landed_costs.yml',
'test/stock_landed_costs_rounding.yml',
],
'installable': True,
'auto_install': False,
@@ -93,10 +93,21 @@ class LandedCost(models.Model):
for line in cost.valuation_adjustment_lines.filtered(lambda line: line.move_id):
# Prorate the value at what's still in stock
cost_to_add = (line.move_id.remaining_qty / line.move_id.product_qty) * line.additional_landed_cost
line.move_id.landed_cost_value += cost_to_add
line.move_id.remaining_value += cost_to_add
line._create_accounting_entries(move, line.move_id.product_qty - line.move_id.remaining_qty)
new_landed_cost_value = line.move_id.landed_cost_value + line.additional_landed_cost
line.move_id.write({
'landed_cost_value': new_landed_cost_value,
'remaining_value': line.move_id.remaining_value + cost_to_add,
'price_unit': (line.move_id.value + new_landed_cost_value) / line.move_id.product_qty,
})
# `remaining_qty` is negative if the move is out and delivered proudcts that were not
# in stock.
qty_out = 0
if line.move_id._is_in():
qty_out = line.move_id.product_qty - line.move_id.remaining_qty
elif line.move_id._is_out():
qty_out = line.move_id.product_qty
line._create_accounting_entries(move, qty_out)
move.assert_balanced()
cost.write({'state': 'done', 'account_move_id': move.id})
@@ -1,7 +1,7 @@
-
In order to test the landed costs feature of stock, I create a landed cost, confirm it and check its account move created
-
I create 2 products with different volume and gross weight and configure them for real_time valuation and real price costing method
I create 2 products with different volume and gross weight and configure them for real_time valuation and fifo costing method
-
!record {model: product.product, id: product_landed_cost_1}:
name: "LC product 1"
@@ -33,6 +33,15 @@
product_uom: product.product_uom_unit
location_id: stock.stock_location_stock
location_dest_id: stock.stock_location_customers
-
Confirm and assign picking
-
!python {model: stock.picking, id: picking_landed_cost_1}: |
self.env.user.company_id.anglo_saxon_accounting = True
self.action_confirm()
self.action_assign()
self.move_lines.quantity_done = 5
self.button_validate()
-
!record {model: stock.picking, id: picking_landed_cost_2}:
name: 'LC_pick_2'
@@ -44,6 +53,14 @@
product_uom: product.product_uom_unit
location_id: stock.stock_location_stock
location_dest_id: stock.stock_location_customers
-
Confirm and assign picking
-
!python {model: stock.picking, id: picking_landed_cost_2}: |
self.action_confirm()
self.action_assign()
self.move_lines.quantity_done = 10
self.button_validate()
-
I create a landed cost for those 2 pickings
-
@@ -73,7 +90,6 @@
-
!python {model: stock.landed.cost, id: stock_landed_cost_1}: |
self.compute_landed_cost()
-
I check the valuation adjustment lines
-
@@ -106,4 +122,4 @@
!assert {model: stock.landed.cost, id: stock_landed_cost_1}:
- state == 'done'
- account_move_id
- len(account_move_id.line_ids) == 16
- len(account_move_id.line_ids) == 48
+20 -16
View File
@@ -18,13 +18,14 @@ import os
import re
import sys
import time
import zlib
import werkzeug
import werkzeug.utils
import werkzeug.wrappers
import zlib
import werkzeug.wsgi
from werkzeug.urls import url_decode, iri_to_uri
from xml.etree import ElementTree
from werkzeug import url_decode
from werkzeug import iri_to_uri
import odoo
@@ -33,11 +34,11 @@ from odoo.api import call_kw, Environment
from odoo.modules import get_resource_path
from odoo.tools import crop_image, topological_sort, html_escape, pycompat
from odoo.tools.translate import _
from odoo.tools.misc import str2bool, xlwt
from odoo.tools.misc import str2bool, xlwt, file_open
from odoo.tools.safe_eval import safe_eval
from odoo import http
from odoo.http import content_disposition, dispatch_rpc, request, \
serialize_exception as _serialize_exception
serialize_exception as _serialize_exception, Response
from odoo.exceptions import AccessError, UserError
from odoo.models import check_method_name
@@ -513,21 +514,24 @@ class WebClient(http.Controller):
@http.route('/web/webclient/locale/<string:lang>', type='http', auth="none")
def load_locale(self, lang):
magic_file_finding = [lang.replace("_", '-').lower(), lang.split('_')[0]]
addons_path = http.addons_manifest['web']['addons_path']
# load momentjs locale
momentjs_locale = ""
for code in magic_file_finding:
try:
with open(os.path.join(addons_path, 'web', 'static', 'lib', 'moment', 'locale', code + '.js'), 'r') as f:
momentjs_locale = f.read()
# we found a locale matching so we can exit
break
return http.Response(
werkzeug.wsgi.wrap_file(
request.httprequest.environ,
file_open('web/static/lib/moment/locale/%s.js' % code, 'rb')
),
content_type='application/javascript; charset=utf-8',
headers=[('Cache-Control', 'max-age=36000')],
direct_passthrough=True,
)
except IOError:
continue
_logger.debug("No moment locale for code %s", code)
# return the content of the locale
headers = [('Content-Type', 'application/javascript'), ('Cache-Control', 'max-age=%s' % (36000))]
return request.make_response(momentjs_locale, headers)
return request.make_response("", headers=[
('Content-Type', 'application/javascript'),
('Cache-Control', 'max-age=36000'),
])
@http.route('/web/webclient/qweb', type='http', auth="none", cors="*")
def qweb(self, mods=None, db=None):
+10 -1
View File
@@ -212,6 +212,9 @@ var loadJS = (function () {
* @param {Function} [options.success] callback in case of download success
* @param {Function} [options.error] callback in case of request error, provided with the error body
* @param {Function} [options.complete] called after both ``success`` and ``error`` callbacks have executed
* @returns {boolean} a false value means that a popup window was blocked. This
* mean that we probably need to inform the user that something needs to be
* changed to make it work.
*/
function get_file(options) {
// need to detect when the file is done downloading (not used
@@ -232,7 +235,12 @@ function get_file(options) {
var url = options.session.url(options.url, params);
if (options.complete) { options.complete(); }
return window.open(url);
var w = window.open(url);
if (!w || w.closed || typeof w.closed === 'undefined') {
// popup was blocked
return false;
}
return true;
}
var $form, $form_data = $('<div>');
@@ -309,6 +317,7 @@ function get_file(options) {
}
};
timer = setTimeout(waitLoop, CHECK_INTERVAL);
return true;
};
function post (controller_url, data) {
+17
View File
@@ -173,6 +173,23 @@ function ReloadContext (parent, action) {
}
core.action_registry.add("reload_context", ReloadContext);
// In Internet Explorer, document doesn't have the contains method, so we make a
// polyfill for the method in order to be compatible.
if (!document.contains) {
document.contains = function contains (node) {
if (!(0 in arguments)) {
throw new TypeError('1 argument is required');
}
do {
if (this === node) {
return true;
}
} while (node = node && node.parentNode);
return false;
};
}
return {
blockUI: blockUI,
+1 -1
View File
@@ -278,7 +278,7 @@ var Session = core.Class.extend(mixins.EventDispatcherMixin, {
options.data.session_id = this.session_id;
}
options.session = this;
ajax.get_file(options);
return ajax.get_file(options);
},
/**
* (re)loads the content of a session: db name, username, user id, session
+24 -10
View File
@@ -394,7 +394,7 @@ var LinkButton = AbstractField.extend({
var FieldDate = InputField.extend({
className: "o_field_date",
tagName: "span",
supportedFieldTypes: ['date'],
supportedFieldTypes: ['date', 'datetime'],
/**
* @override
@@ -453,12 +453,13 @@ var FieldDate = InputField.extend({
* @override
* @private
* @param {Moment|false} value
* @returns {boolean}
*/
_isSameValue: function (value) {
if (value === false) {
return this.value === false;
}
return value.isSame(this.value);
return value.isSame(this.value, 'day');
},
/**
* Instantiates a new DateWidget datepicker.
@@ -497,7 +498,16 @@ var FieldDateTime = FieldDate.extend({
var value = this.datewidget.getValue();
return value && value.add(-this.getSession().getTZOffset(value), 'minutes');
},
/**
* @override
* @private
*/
_isSameValue: function (value) {
if (value === false) {
return this.value === false;
}
return value.isSame(this.value);
},
/**
* Instantiates a new DateTimeWidget datepicker rather than DateWidget.
*
@@ -966,7 +976,7 @@ var FieldPhone = FieldEmail.extend({
* @override
*/
getFocusableElement: function () {
if (this._canCall()) {
if (this.mode !== 'readonly' || this._canCall()) {
return this._super.apply(this, arguments);
}
return $();
@@ -1171,7 +1181,6 @@ var FieldBinaryImage = AbstractFieldBinary.extend({
supportedFieldTypes: ['binary'],
_render: function () {
var self = this;
var attrs = this.attrs;
var url = this.placeholder;
if (this.value) {
if (!utils.is_bin_size(this.value)) {
@@ -1186,11 +1195,7 @@ var FieldBinaryImage = AbstractFieldBinary.extend({
});
}
}
var $img = $('<img>').attr('src', url);
$img.css({
width: this.nodeOptions.size ? this.nodeOptions.size[0] : attrs.img_width || attrs.width,
height: this.nodeOptions.size ? this.nodeOptions.size[1] : attrs.img_height || attrs.height,
});
var $img = $(qweb.render("FieldBinaryImage-img", {widget: this, url: url}));
this.$('> img').remove();
this.$el.prepend($img);
$img.on('error', function () {
@@ -2174,6 +2179,15 @@ var FieldDomain = AbstractField.extend({
// Public
//--------------------------------------------------------------------------
/**
* A domain field is always set since the false value is considered to be
* equal to "[]" (match all records).
*
* @override
*/
isSet: function () {
return true;
},
/**
* @override isValid from AbstractField.isValid
* Parsing the char value is not enough for this field. It is considered
@@ -21,7 +21,7 @@ var WKHTMLTOPDF_MESSAGES = {
};
var trigger_download = function (session, response, c, action, options) {
session.get_file({
return session.get_file({
url: '/report/download',
data: {data: JSON.stringify(response)},
complete: framework.unblockUI,
@@ -107,7 +107,10 @@ ActionManager.include({
report_urls['qweb-pdf'],
action.report_type, //The 'root' report is considered the maine one, so we use its type for all the others.
];
trigger_download(self.getSession(), response, c, current_action, options);
var success = trigger_download(self.getSession(), response, c, current_action, options);
if (!success) {
self.do_warn(_t('Warning'), _t('A popup window with your report was blocked. You may need to change your browser settings to allow popup windows for this page.'), true);
}
treated_actions.push(current_action);
current_action = current_action.next_report_to_generate;
@@ -875,7 +875,10 @@ var BasicModel = AbstractModel.extend({
// id never changes, and should not be written
delete record._changes.id;
}
var changes = self._generateChanges(record, {viewType: options.viewType});
var changes = self._generateChanges(record, {viewType: options.viewType, changesOnly: method !== 'create'});
// id field should never be written/changed
delete changes.id;
if (method === 'create') {
var fieldNames = record.getFieldNames();
@@ -1551,6 +1554,9 @@ var BasicModel = AbstractModel.extend({
removedDef = this._applyX2ManyChange(record, fieldName, {
operation: 'DELETE',
ids: _.map(removedIds, function (resID) {
if (resID in list._cache) {
return list._cache[resID];
}
return _.findWhere(listData, {res_id: resID}).id;
}),
});
@@ -2455,7 +2461,7 @@ var BasicModel = AbstractModel.extend({
changes = _.extend({}, record.data, record._changes);
} else {
changes = _.extend({}, record._changes);
};
}
var withReadonly = options.withReadonly || false;
var commands = this._generateX2ManyCommands(record, {
changesOnly: 'changesOnly' in options ? options.changesOnly : true,
@@ -2492,6 +2498,7 @@ var BasicModel = AbstractModel.extend({
changes[fieldName] = false;
}
}
return changes;
},
/**
@@ -2608,6 +2615,7 @@ var BasicModel = AbstractModel.extend({
_.each(relRecordUpdated, function (relRecord) {
var changes = self._generateChanges(relRecord, options);
if (!_.isEmpty(changes)) {
delete changes.id;
var command = x2ManyCommands.update(relRecord.res_id, changes);
commands[fieldName].push(command);
}
@@ -2638,7 +2646,12 @@ var BasicModel = AbstractModel.extend({
// this is a new id
relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]});
changes = this._generateChanges(relRecord, options);
commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes));
if ('id' in changes) {
delete changes.id;
commands[fieldName].push(x2ManyCommands.update(relRecord.res_id, changes));
} else {
commands[fieldName].push(x2ManyCommands.create(relRecord.ref, changes));
}
}
}
if (options.changesOnly && !didChange && addedIds.length === 0 && removedIds.length === 0) {
@@ -3294,11 +3307,17 @@ var BasicModel = AbstractModel.extend({
* This method is quite important: it is supposed to perform the /onchange
* rpc and apply the result.
*
* The changes that triggered the onchange are assumed to have already been
* applied to the record.
*
* @param {Object} record
* @param {string[]} fields changed fields
* @param {string} [viewType] current viewType. If not set, we will assume
* main viewType from the record
* @returns {Deferred}
* @returns {Deferred} Note: this deferred cannot fail. It is either in the
* 'success' or 'pending' state. If the onchange rpc fails, it will be
* resolved with an empty dictionary. Note that the initial change was
* already applied.
*/
_performOnChange: function (record, fields, viewType) {
var self = this;
@@ -3318,7 +3337,9 @@ var BasicModel = AbstractModel.extend({
var context = this._getContext(record, options);
var currentData = this._generateOnChangeData(record, {changesOnly: false});
return self._rpc({
var def = $.Deferred();
self._rpc({
model: record.model,
method: 'onchange',
args: [idList, currentData, fields, onchangeSpec, context],
@@ -3343,9 +3364,12 @@ var BasicModel = AbstractModel.extend({
record._domains = _.extend(record._domains, result.domain);
}
return self._applyOnChange(result.value, record).then(function () {
return result;
def.resolve(result);
});
}).fail(function () {
def.resolve({});
});
return def;
},
/**
* Once a record is created and some data has been fetched, we need to do
@@ -257,13 +257,20 @@ var CalendarController = AbstractController.extend({
id = id && parseInt(id).toString() === id ? parseInt(id) : id;
if (!this.eventOpenPopup) {
this.do_action({
type: 'ir.actions.act_window',
res_id: id,
res_model: this.modelName,
views: [[this.formViewId || false, 'form']],
target: 'current',
context: event.context || self.context,
this._rpc({
model: self.modelName,
method: 'get_formview_id',
//The event can be called by a view that can have another context than the default one.
args: [[id], event.context || self.context],
}).then(function (viewId) {
self.do_action({
type:'ir.actions.act_window',
res_id: id,
res_model: self.modelName,
views: [[viewId || false, 'form']],
target: 'current',
context: event.context || self.context,
});
});
return;
}
@@ -807,7 +807,15 @@ var FormRenderer = BasicRenderer.extend({
// Attach the tooltips on the fields' label
_.each(this.allFieldWidgets[this.state.id], function (widget) {
var idForLabel = self.idsForLabels[widget.name];
// We usually don't support multiple widgets for the same field on the
// same view but it is the case with the new settings view on V11.0.
// Therefore, we need to retrieve the correct label since it could be
// displayed multiple times on the view, otherwise, for example the
// enterprise label will be displayed as many times as the field
// exists on settings.
var $widgets = self.$('.o_field_widget[name=' + widget.name + ']');
var $label = idForLabel ? self.$('label[for=' + idForLabel + ']') : $();
$label = $label.eq($widgets.index(widget.$el));
if (config.debug || widget.attrs.help || widget.field.help) {
self._addFieldTooltip(widget, $label);
}
@@ -136,7 +136,9 @@ ListRenderer.include({
currentRowID = this.state.data[this.currentRow].id;
currentWidget = this.allFieldWidgets[currentRowID][this.currentFieldIndex];
focusedElement = currentWidget.getFocusableElement().get(0);
selectionRange = dom.getSelectionRange(focusedElement);
if (currentWidget.formatType !== 'boolean') {
selectionRange = dom.getSelectionRange(focusedElement);
}
}
var oldData = this.state.data;
@@ -179,7 +181,9 @@ ListRenderer.include({
currentRowID = self.state.data[newRowIndex].id;
currentWidget = self.allFieldWidgets[currentRowID][self.currentFieldIndex];
focusedElement = currentWidget.getFocusableElement().get(0);
dom.setSelectionRange(focusedElement, selectionRange);
if (selectionRange) {
dom.setSelectionRange(focusedElement, selectionRange);
}
});
}
});
@@ -585,12 +589,18 @@ ListRenderer.include({
if (fieldIndex >= (self.allFieldWidgets[record.id] || []).length) {
return $.Deferred().reject();
}
// _activateFieldWidget might trigger an onchange,
// which requires currentFieldIndex to be set
// so that the cursor can be restored
var oldFieldIndex = self.currentFieldIndex;
self.currentFieldIndex = fieldIndex;
fieldIndex = self._activateFieldWidget(record, fieldIndex, {
inc: 1,
wrap: wrap,
event: options && options.event,
});
if (fieldIndex < 0) {
self.currentFieldIndex = oldFieldIndex;
return $.Deferred().reject();
}
self.currentFieldIndex = fieldIndex;
+2
View File
@@ -52,6 +52,8 @@
.modal-body {
overflow: auto;
// fix iOS issue https://github.com/scottjehl/Device-Bugs/issues/8
-webkit-transform: translate3d(0, 0, 0);
min-height: 0;
&.o_dialog_error {
+4 -4
View File
@@ -335,12 +335,12 @@
<t t-if="item.create_uid and item.create_uid[0]" t-set="item.title">
<t t-raw="item.title"/><br/>
<b>Created by : </b><br/>
<t t-raw="item.create_uid[1] + ' ' + item.create_date"/>
<t t-raw="item.create_uid[1]"/> <t t-esc="item.create_date_string" />
</t>
<t t-if="item.create_uid and item.write_uid and item.create_uid[0] != item.write_uid[0]" t-set="item.title">
<t t-raw="item.title"/><br/>
<b>Modified by : </b><br/>
<t t-raw="item.write_uid[1] + ' ' + item.write_date"/>
<t t-raw="item.write_uid[1]"/> <t t-esc="item.write_date_string"/>
</t>
</t>
<a t-att-title="item.title or None" t-att-data-section="section.name" t-att-data-index="item_index" t-att-href="item.url">
@@ -861,8 +861,8 @@
t-att-src='url'
t-att-border="widget.readonly ? 0 : 1"
t-att-name="widget.name"
t-att-width="widget.node.attrs.img_width || widget.node.attrs.width"
t-att-height="widget.node.attrs.img_height || widget.node.attrs.height"/>
t-att-width="widget.nodeOptions.size ? widget.nodeOptions.size[0] : widget.attrs.img_width || widget.attrs.width"
t-att-height="widget.nodeOptions.size ? widget.nodeOptions.size[1] : widget.attrs.img_height || widget.attrs.height"/>
</t>
<t t-name="FieldBinaryFile">
<a t-if="widget.mode === 'readonly'" href="javascript:void(0)" class="o_form_uri"/>
@@ -1462,6 +1462,42 @@ QUnit.module('basic_fields', {
}
});
QUnit.module('FieldImage');
QUnit.test('image fields are correctly rendered', function (assert) {
assert.expect(5);
this.data.partner.records[0].document = 'myimage';
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="document" widget="image" options="{\'size\': [90, 90]}"/> ' +
'</form>',
res_id: 1,
mockRPC: function (route) {
if (route === 'data:image/png;base64,myimage') {
assert.ok(true, "should called the correct route");
return $.when('wow');
}
return this._super.apply(this, arguments);
},
});
assert.ok(form.$('div[name="document"]').hasClass('o_field_image'),
"the widget should have the correct class");
assert.strictEqual(form.$('div[name="document"] > img').length, 1,
"the widget should contain an image");
assert.ok(form.$('div[name="document"] > img').hasClass('img-responsive'),
"the image should have the correct class");
assert.strictEqual(form.$('div[name="document"] > img').attr('width'), "90",
"the image should correctly set its attributes");
form.destroy();
});
QUnit.module('JournalDashboardGraph');
QUnit.test('graph dashboard widget is rendered correctly', function (assert) {
@@ -1844,6 +1880,39 @@ QUnit.module('basic_fields', {
form.destroy();
});
QUnit.test('do not trigger a field_changed for datetime field with date widget', function (assert) {
assert.expect(3);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners"><field name="datetime" widget="date"/></form>',
translateParameters: { // Avoid issues due to localization formats
date_format: '%m/%d/%Y',
time_format: '%H:%M:%S',
},
res_id: 1,
viewOptions: {
mode: 'edit',
},
mockRPC: function (route, args) {
assert.step(args.method);
return this._super.apply(this, arguments);
},
});
assert.strictEqual(form.$('.o_datepicker_input').val(), '02/08/2017',
'the date should be correct');
form.$('input[name="datetime"]').val('02/08/2017').trigger('input').trigger('change');
form.$buttons.find('.o_form_button_save').click();
assert.verifySteps(['read']); // should not have save as nothing changed
form.destroy();
});
QUnit.module('FieldDatetime');
QUnit.test('datetime field in form view', function (assert) {
@@ -2828,6 +2897,33 @@ QUnit.module('basic_fields', {
form.destroy();
});
QUnit.test('use TAB to navigate to a phone field', function (assert) {
assert.expect(2);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<sheet>' +
'<group>' +
'<field name="display_name"/>' +
'<field name="foo" widget="phone"/>' +
'</group>' +
'</sheet>' +
'</form>',
});
form.$('input[name=display_name]').click();
assert.strictEqual(form.$('input[name="display_name"]')[0], document.activeElement,
"display_name should be focused");
form.$('input[name="display_name"]').trigger($.Event('keydown', {which: $.ui.keyCode.TAB}));
assert.strictEqual(form.$('input[name="foo"]')[0], document.activeElement,
"foo should be focused");
form.destroy();
});
QUnit.module('PriorityWidget');
QUnit.test('priority widget when not set', function (assert) {
@@ -3939,7 +4035,7 @@ QUnit.module('basic_fields', {
});
QUnit.test('domain field: handle false domain as []', function (assert) {
assert.expect(2);
assert.expect(3);
this.data.partner.records[0].foo = false;
this.data.partner.fields.bar.type = "char";
@@ -3965,11 +4061,13 @@ QUnit.module('basic_fields', {
return this._super.apply(this, arguments);
},
res_id: 1,
viewOptions: {
mode: 'edit',
},
});
assert.strictEqual(form.$('.o_field_widget[name=foo]:not(.o_field_empty)').length, 1,
"there should be a domain field, not considered empty");
form.$buttons.find('.o_form_button_edit').click();
var $warning = form.$('.o_field_widget[name=foo] .text-warning');
assert.strictEqual($warning.length, 0, "should not display that the domain is invalid");

Some files were not shown because too many files have changed in this diff Show More