[MERGE] forward port branch 11.0 up to d70518d04f
This commit is contained in:
@@ -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/>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 > 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 < 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 < 0"><t t-raw="state.balance.amount_str"/></t></td>
|
||||
<td class="cell_right"><t t-if="state.balance.amount > 0"><t t-raw="state.balance.amount_str"/></t></td>
|
||||
<td class="cell_left"><t t-if="state.balance.amount < 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 > 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'));
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,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,))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -385,7 +385,7 @@
|
||||
domain="[('activity_ids.user_id', '=', uid)]"/>
|
||||
<separator/>
|
||||
<filter string="Late Activities" name="activities_overdue"
|
||||
domain="[('activity_ids.date_deadline', '<', context_today().strftime('%Y-%m-%d'))]"
|
||||
domain="[('activity_ids.date_deadline', '<', 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', '<', context_today().strftime('%Y-%m-%d'))]"
|
||||
domain="[('activity_ids.date_deadline', '<', 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', '<', context_today().strftime('%Y-%m-%d'))]"
|
||||
domain="[('date_deadline', '<', 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;
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"><img src='/mail/static/src/img/smiley/angry.png' alt='😈'/></field>
|
||||
<field name="unicode_source">😠</field>
|
||||
<field name="substitution"><img src='/mail/static/src/img/smiley/angry.png' alt='😠'/></field>
|
||||
<field name="description">Angry</field>
|
||||
<field name="shortcode_type">image</field>
|
||||
</record>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)]}"/>
|
||||
|
||||
@@ -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="['|', '&', ('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="['|', '&', ('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"/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', '>', 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', '>', 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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user