diff --git a/addons/account/models/account.py b/addons/account/models/account.py index f55eb751b20..85896ad39d1 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -954,7 +954,7 @@ class AccountJournal(models.Model): @api.depends('company_id') def _belong_to_company(self): for journal in self: - journal.belong_to_company = (journal.company_id.id == self.env.company.id) + journal.belongs_to_company = (journal.company_id.id == self.env.company.id) @api.multi def _search_company_journals(self, operator, value): @@ -1361,20 +1361,27 @@ class AccountTax(models.Model): cached_tax_amounts = {} if handle_price_include: for tax in reversed(taxes): + tax_repartition_lines = ( + is_refund + and tax.refund_repartition_line_ids + or tax.invoice_repartition_line_ids + ).filtered(lambda x: x.repartition_type == "tax") + sum_repartition_factor = sum(tax_repartition_lines.mapped("factor")) + if tax.include_base_amount: base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount) incl_fixed_amount = incl_percent_amount = incl_division_amount = 0 store_included_tax_total = True if tax.price_include or self._context.get('force_price_include'): if tax.amount_type == 'percent': - incl_percent_amount += tax.amount + incl_percent_amount += tax.amount * sum_repartition_factor elif tax.amount_type == 'division': - incl_division_amount += tax.amount + incl_division_amount += tax.amount * sum_repartition_factor elif tax.amount_type == 'fixed': - incl_fixed_amount += quantity * tax.amount + incl_fixed_amount += quantity * tax.amount * sum_repartition_factor else: # tax.amount_type == other (python) - tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) + tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) * sum_repartition_factor incl_fixed_amount += tax_amount # Avoid unecessary re-computation cached_tax_amounts[i] = tax_amount @@ -1393,6 +1400,9 @@ class AccountTax(models.Model): i = 0 cumulated_tax_included_amount = 0 for tax in taxes: + tax_repartition_lines = (is_refund and tax.refund_repartition_line_ids or tax.invoice_repartition_line_ids).filtered(lambda x: x.repartition_type == 'tax') + sum_repartition_factor = sum(tax_repartition_lines.mapped('factor')) + #compute the tax_amount if (self._context.get('force_price_include') or tax.price_include) and total_included_checkpoints.get(i): # We know the total to reach for that tax, so we make a substraction to avoid any rounding issues @@ -1402,11 +1412,12 @@ class AccountTax(models.Model): tax_amount = tax.with_context(force_price_include=False)._compute_amount( base, sign * price_unit, quantity, product, partner) - # Round the tax_amount + # Round the tax_amount multiplied by the computed repartition lines factor. tax_amount = round(tax_amount, prec) + factorized_tax_amount = round(tax_amount * sum_repartition_factor, prec) if tax.price_include and not total_included_checkpoints.get(i): - cumulated_tax_included_amount += tax_amount + cumulated_tax_included_amount += factorized_tax_amount # If the tax affects the base of subsequent taxes, its tax move lines must # receive the base tags and tag_ids of these taxes, so that the tax report computes @@ -1417,18 +1428,24 @@ class AccountTax(models.Model): subsequent_taxes = taxes[i+1:] subsequent_tags = subsequent_taxes.get_tax_tags(is_refund, 'base') - # Compute the tax lines - tax_repartition_lines = (is_refund and tax.refund_repartition_line_ids or tax.invoice_repartition_line_ids).filtered(lambda x: x.repartition_type == 'tax') - sum_factor = sum(tax_repartition_lines.mapped('factor')) - repartition_lines_to_treat = len(tax_repartition_lines) - total_amount = 0 - for repartition_line in tax_repartition_lines: - if repartition_lines_to_treat != 1 or sum_factor != 1.0: - line_amount = round(tax_amount * repartition_line.factor, prec) - else: - # When the sum of the repartition lines factor is 100%, we need to ensure the whole tax amount has - # been spread into the repartition lines. - line_amount = round(tax_amount - total_amount, prec) + # Compute the tax line amounts by multiplying each factor with the tax amount. + # Then, spread the tax rounding to ensure the consistency of each line independently with the factorized + # amount. E.g: + # + # Suppose a tax having 4 x 50% repartition line applied on a tax amount of 0.03 with 2 decimal places. + # The factorized_tax_amount will be 0.06 (200% x 0.03). However, each line taken independently will compute + # 50% * 0.03 = 0.01 with rounding. It means there is 0.06 - 0.04 = 0.02 as total_rounding_error to dispatch + # in lines as 2 x 0.01. + repartition_line_amounts = [round(tax_amount * line.factor, prec) for line in tax_repartition_lines] + total_rounding_error = round(factorized_tax_amount - sum(repartition_line_amounts), prec) + nber_rounding_steps = int(abs(total_rounding_error / currency.rounding)) + rounding_error = round(nber_rounding_steps and total_rounding_error / nber_rounding_steps or 0.0, prec) + + for repartition_line, line_amount in zip(tax_repartition_lines, repartition_line_amounts): + + if nber_rounding_steps: + line_amount += rounding_error + nber_rounding_steps -= 1 taxes_vals.append({ 'id': tax.id, @@ -1445,16 +1462,14 @@ class AccountTax(models.Model): 'tax_ids': subsequent_taxes.ids, }) - total_amount += line_amount if not repartition_line.account_id: total_void += line_amount - repartition_lines_to_treat -= 1 # Affect subsequent taxes if tax.include_base_amount: - base += tax_amount + base += factorized_tax_amount - total_included += tax_amount + total_included += factorized_tax_amount i += 1 return { diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index de134656e7e..2b50e0ed318 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -1334,6 +1334,24 @@ class AccountMove(models.Model): raise UserError(message) return True + def _check_tax_lock_date(self): + for move in self: + if ( + move.company_id.tax_lock_date + and move.date <= move.company_id.tax_lock_date + and any( + line.tax_ids + or line.tax_line_id + or line.tag_ids.filtered(lambda x: x.applicability == "taxes") + for line in move.line_ids + ) + ): + raise UserError( + _( + "The operation is refused as it would impact an already issued tax statement. Please change the journal entry date or the tax lock date set in the settings ({}) to proceed" + ).format(move.company_id.tax_lock_date or date.min) + ) + @api.multi def _check_move_consistency(self): for move in self: @@ -1342,6 +1360,7 @@ class AccountMove(models.Model): raise UserError(_("Cannot create moves for different companies.")) self._check_balanced() + self._check_tax_lock_date() self._check_fiscalyear_lock_date() # ------------------------------------------------------------------------- @@ -1971,6 +1990,7 @@ class AccountMove(models.Model): @api.multi def button_cancel(self): + self._check_tax_lock_date() AccountMoveLine = self.env['account.move.line'] excluded_move_ids = [] @@ -2033,6 +2053,7 @@ class AccountMove(models.Model): """ if any(not move.is_invoice(include_receipts=True) for move in self): raise UserError(_("Only invoices could be printed.")) + self._check_tax_lock_date() self.filtered(lambda inv: not inv.invoice_sent).write({'invoice_sent': True}) if self.user_has_groups('account.group_account_invoice'): @@ -2715,7 +2736,7 @@ class AccountMoveLine(models.Model): else: date = partial_line.credit_move_id.date if partial_line.debit_move_id == line else partial_line.debit_move_id.date rate = line.currency_id.with_context(date=date).rate - amount_residual_currency += sign_partial_line * partial_line.amount * rate + amount_residual_currency += sign_partial_line * line.currency_id.round(partial_line.amount * rate) #computing the `reconciled` field. reconciled = False @@ -2787,24 +2808,6 @@ class AccountMoveLine(models.Model): if control_type_failed or control_account_failed: raise UserError(_('You cannot use this general account in this journal, check the tab \'Entry Controls\' on the related journal.')) - @api.constrains('tax_ids', 'tax_line_id') - def _check_tax_lock_date1(self): - for line in self: - if line.date <= (line.company_id.tax_lock_date or date.min): - raise ValidationError( - _("The operation is refused as it would impact an already issued tax statement. " + - "Please change the journal entry date or the tax lock date set in the settings ({}) to proceed").format( - line.company_id.tax_lock_date or date.min)) - - @api.constrains('credit', 'debit', 'date') - def _check_tax_lock_date2(self): - for line in self: - if (line.tax_ids or line.tax_line_id) and line.date <= (line.company_id.tax_lock_date or date.min): - raise ValidationError( - _("The operation is refused as it would impact an already issued tax statement. " + - "Please change the journal entry date or the tax lock date set in the settings ({}) to proceed").format( - line.company_id.tax_lock_date or date.min)) - @api.multi def _update_check(self): """ Raise Warning to cause rollback if the move is posted, some entries are reconciled or the move is older than the lock date""" @@ -2817,7 +2820,10 @@ class AccountMoveLine(models.Model): raise UserError(_('You cannot do this modification on a reconciled entry. You can just change some non legal fields or you must unreconcile first.\n%s.') % err_msg) if line.move_id.id not in move_ids: move_ids.add(line.move_id.id) - self.env['account.move'].browse(list(move_ids))._check_fiscalyear_lock_date() + if move_ids: + moves = self.env['account.move'].browse(list(move_ids)) + moves._check_fiscalyear_lock_date() + moves._check_tax_lock_date() return True # ------------------------------------------------------------------------- @@ -2976,7 +2982,6 @@ class AccountMoveLine(models.Model): @api.multi def unlink(self): self._update_check() - self._check_tax_lock_date2() move_ids = set() for line in self: if line.move_id.id not in move_ids: @@ -3010,6 +3015,11 @@ class AccountMoveLine(models.Model): # Suggest default value for debit / credit to balance the journal entry. balance = sum(line['debit'] - line['credit'] for line in move.line_ids) + # if we are here, line_ids is in context, so journal_id should also be. + journal = self.env['account.journal'].browse(self._context.get('default_journal_id') or self._context['journal_id']) + currency = journal.exists() and journal.company_id.currency_id + if currency: + balance = currency.round(balance) if balance < 0.0: values.update({'debit': -balance}) if balance > 0.0: diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index d04a4b05520..280869974e9 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -452,7 +452,8 @@ class account_payment(models.Model): for move in rec.move_line_ids.mapped('move_id'): if rec.reconciled_invoice_ids: move.line_ids.remove_move_reconcile() - move.button_cancel() + if move.state != 'draft': + move.button_cancel() move.unlink() rec.write({ 'state': 'cancelled', diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py index 94e7407521c..6c7b28d89c7 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -361,7 +361,7 @@ class AccountReconcileModel(models.Model): query += ' AND st_line.name NOT ILIKE %s' params += ['%%%s%%' % rule.match_label_param] elif rule.match_label == 'match_regex': - query += ' AND st_line.name ~ %s' + query += ' AND st_line.name ~* %s' params += [rule.match_label_param] # Filter on partners. @@ -621,8 +621,10 @@ class AccountReconcileModel(models.Model): if line_residual > total_residual: amount_percentage = (total_residual / line_residual) * 100 - else: + elif total_residual: amount_percentage = (line_residual / total_residual) * 100 if total_residual else 0 + else: + return False return amount_percentage >= self.match_total_amount_param @api.multi diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py index 5e49e94e272..8e503f07b80 100644 --- a/addons/account/models/reconciliation_widget.py +++ b/addons/account/models/reconciliation_widget.py @@ -116,7 +116,8 @@ class AccountReconciliation(models.AbstractModel): query += 'LEFT JOIN res_partner_bank bank ON bank.id = st_line.bank_account_id OR bank.acc_number = st_line.account_number %s\n' % (where_bank) query += 'LEFT JOIN res_partner p1 ON st_line.partner_id=p1.id \n' query += 'LEFT JOIN res_partner p2 ON bank.partner_id=p2.id \n' - query += 'LEFT JOIN res_partner p3 ON p3.name ILIKE st_line.partner_name %s\n' % (where_partner) + # By definition the commercial partner_id doesn't have a parent_id set + query += 'LEFT JOIN res_partner p3 ON p3.name ILIKE st_line.partner_name %s AND p3.parent_id is NULL \n' % (where_partner) query += 'WHERE st_line.id IN %s' params += [tuple(st_lines.ids)] @@ -520,17 +521,22 @@ class AccountReconciliation(models.AbstractModel): ]) domain = expression.OR([domain_reconciliation, domain_matching]) + partner_domain = [] if partner_id: - domain = expression.AND([domain, [('partner_id', '=', partner_id)]]) + partner_domain = [('partner_id', '=', partner_id)] + domain = expression.AND([domain, partner_domain]) # Domain factorized for all reconciliation use cases if search_str: str_domain = self._domain_move_lines(search_str=search_str) if not partner_id: - str_domain = expression.OR([ - str_domain, - [('partner_id.name', 'ilike', search_str)] - ]) + partner_domain = [('partner_id.name', 'ilike', search_str)] + + str_domain = expression.OR([ + str_domain, + partner_domain, + ]) + domain = expression.AND([ domain, str_domain diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index c2612d50551..ff751752fcf 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -19,3 +19,4 @@ from . import test_templates_consistency from . import test_account_fiscal_year from . import test_account_all_l10n from . import test_reconciliation_matching_rules +from . import test_account_move_tax_lock_date diff --git a/addons/account/tests/test_account_move_entry.py b/addons/account/tests/test_account_move_entry.py index 98f0f70a6e5..b8e8abb4001 100644 --- a/addons/account/tests/test_account_move_entry.py +++ b/addons/account/tests/test_account_move_entry.py @@ -69,33 +69,26 @@ class TestAccountMove(InvoiceTestCommon): # lines[3] = 'revenue line 2' lines = self.test_move.line_ids.sorted('debit') - # Writing not affecting a tax is allowed. - self.test_move.write({ - 'line_ids': [ - (1, lines[0].id, {'credit': 1750.0}), # counterpart line - (1, lines[2].id, {'debit': 600.0}), # revenue line 1 - ], - }) self.cr.execute('SAVEPOINT test_misc_tax_lock_date_1') # Writing something affecting a tax is not allowed. - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.write({ 'line_ids': [ (1, lines[0].id, {'credit': 2750.0}), - (1, lines[3].id, {'debit': 2000.0}), + (1, lines[3].id, {'debit': 2100.0}), ], }) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.write({ 'line_ids': [ (1, lines[3].id, {'tax_ids': [(6, 0, self.company_data['default_tax_purchase'].ids)]}), ], }) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.write({ 'line_ids': [ (1, lines[0].id, {'credit': 1900.0}), @@ -103,7 +96,7 @@ class TestAccountMove(InvoiceTestCommon): ], }) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.unlink() self.cr.execute('ROLLBACK TO SAVEPOINT test_misc_tax_lock_date_1') diff --git a/addons/account/tests/test_account_move_tax_lock_date.py b/addons/account/tests/test_account_move_tax_lock_date.py new file mode 100644 index 00000000000..280536e6b46 --- /dev/null +++ b/addons/account/tests/test_account_move_tax_lock_date.py @@ -0,0 +1,98 @@ +from odoo.addons.account.tests.account_test_classes import AccountingTestCase +from odoo.exceptions import UserError +from datetime import date +from calendar import monthrange +# from odoo.tools import DEFAULT_SERVER_DATE_FORMAT +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestTaxBlockDate(AccountingTestCase): + """ + Forbid creation, edition and deletion of Journal Items related to taxes with + a date prior to the Tax Block Date. + """ + + def setUp(self): + super(TestTaxBlockDate, self).setUp() + self.user_id = self.env.user + self.company = self.env.company + company_id = self.company.id + + last_day_month = date.today() + self.last_day_month = last_day_month.replace(day=monthrange(last_day_month.year, last_day_month.month)[1]) + first_day_month = date.today() + self.first_day_month = first_day_month.replace(day=1) + middle_day_month = date.today() + middle_day_month = middle_day_month.replace(day=15) + + self.sale_journal_id = self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', company_id)], limit=1)[0] + self.account_id = self.env['account.account'].search([('internal_type', '=', 'receivable'), ('company_id', '=', company_id)], limit=1)[0] + self.other_account_id = self.env['account.account'].search([('internal_type', '!=', 'receivable'), ('company_id', '=', company_id)], limit=1)[0] + self.tax_id = self.env['account.tax'].search([('company_id', '=', company_id)], limit=1)[0] + + self.move = { + 'name': '/', + 'journal_id': self.sale_journal_id.id, + 'date': middle_day_month, + 'line_ids': [(0, 0, { + 'name': 'foo', + 'debit': 10, + 'account_id': self.account_id.id, + 'tax_ids': [(6, False, [self.tax_id.id])] + }), (0, 0, { + 'name': 'bar', + 'credit': 10, + 'account_id': self.account_id.id, + })] + } + + self.move_no_tax = { + 'name': '/', + 'journal_id': self.sale_journal_id.id, + 'date': middle_day_month, + 'line_ids': [(0, 0, { + 'name': 'foo', + 'debit': 10, + 'account_id': self.account_id.id, + }), (0, 0, { + 'name': 'bar', + 'credit': 10, + 'account_id': self.account_id.id, + })] + } + + def test_tax_lock_date_post(self): + """ Checks that it is impossible to post an entry + before the tax lock date. + """ + # Posting after the lock date is always allowed + self.company.tax_lock_date = self.first_day_month + # copy() because mail.thread modifies the dictionary given to create + move_after_lock = self.env['account.move'].create(self.move.copy()) + move_after_lock.post() + + # Posting before the tax lock date is allowed only if the move doesn't contain any tax + self.user_id.company_id.tax_lock_date = self.last_day_month + move_before_lock_no_tax = self.env['account.move'].create(self.move_no_tax) + move_before_lock_no_tax.post() + + # Posting before the tax lock date is not allowed for moves impacting the tax report + self.user_id.company_id.tax_lock_date = self.last_day_month + with self.assertRaises(UserError): + self.env['account.move'].create(self.move.copy()) + + def test_tax_lock_date_cancel(self): + """ Checks that it is not possible to cancel an entry posted before the + tax lock date if it impacts the tax report. """ + self.sale_journal_id.update_posted = True + move = self.env['account.move'].create(self.move) + move.post() + move_no_tax = self.env['account.move'].create(self.move_no_tax) + move_no_tax.post() + + self.user_id.company_id.tax_lock_date = self.last_day_month + + move_no_tax.button_cancel() + with self.assertRaises(UserError): + move.button_cancel() diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index 641ff761fa8..c8ffdb57365 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -149,6 +149,24 @@ class TestReconciliation(AccountingTestCase): invoice.post() return invoice + def create_invoice_partner(self, type='out_invoice', invoice_amount=50, currency_id=None, partner_id=False, payment_term_id=False): + move = self.env['account.move'].with_context(default_type=type).create({ + 'type': type, + 'partner_id': partner_id, + 'invoice_date': time.strftime('%Y') + '-07-01', + 'date': time.strftime('%Y') + '-07-01', + 'currency_id': currency_id, + 'invoice_payment_term_id': payment_term_id, + 'invoice_line_ids': [(0, 0, { + 'quantity': 1, + 'price_unit': invoice_amount, + + 'name': 'product that cost ' + str(invoice_amount), + })], + }) + move.post() + return move + def make_payment(self, invoice_record, bank_journal, amount=0.0, amount_currency=0.0, currency_id=None): bank_stmt = self.acc_bank_stmt_model.create({ 'journal_id': bank_journal.id, @@ -895,22 +913,6 @@ class TestReconciliationExec(TestReconciliation): self.assertEqual(reversed_bank_line.full_reconcile_id.id, bank_line.full_reconcile_id.id) self.assertEqual(reversed_customer_line.full_reconcile_id.id, customer_line.full_reconcile_id.id) - def create_invoice_partner(self, type='out_invoice', invoice_amount=50, currency_id=None, partner_id=False, payment_term_id=False): - move = self.env['account.move'].with_context(default_type=type).create({ - 'type': type, - 'partner_id': partner_id, - 'invoice_date': time.strftime('%Y') + '-07-01', - 'date': time.strftime('%Y') + '-07-01', - 'currency_id': currency_id, - 'invoice_payment_term_id': payment_term_id, - 'invoice_line_ids': [(0, 0, { - 'quantity': 1, - 'price_unit': invoice_amount, - 'name': 'product that cost ' + str(invoice_amount), - })], - }) - move.post() - return move def test_aged_report(self): AgedReport = self.env['report.account.report_agedpartnerbalance'].with_context(include_nullified_amount=True) @@ -1168,6 +1170,68 @@ class TestReconciliationExec(TestReconciliation): # because they owe us still 50 CC. self.assertEqual(invoice_cust_1.invoice_payment_state, 'not_paid', 'Invoice is in status %s' % invoice_cust_1.state) + def test_inv_refund_foreign_payment_writeoff_domestic(self): + company = self.env.ref('base.main_company') + self.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_euro_id, + 'company_id': company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.113900, # Don't change this ! + 'currency_id': self.currency_usd_id, + 'company_id': self.env.ref('base.main_company').id + }) + inv1 = self.create_invoice(invoice_amount=480, currency_id=self.currency_usd_id) + inv2 = self.create_invoice(type="out_refund", invoice_amount=140, currency_id=self.currency_usd_id) + + payment = self.env['account.payment'].create({ + 'payment_method_id': self.inbound_payment_method.id, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': inv1.partner_id.id, + 'amount': 287.20, + 'journal_id': self.bank_journal_euro.id, + 'company_id': company.id, + }) + payment.post() + + inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + pay_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + data_for_reconciliation = [ + { + 'type': 'partner', + 'id': inv1.partner_id.id, + 'mv_line_ids': (inv1_receivable + inv2_receivable + pay_receivable).ids, + 'new_mv_line_dicts': [ + { + 'credit': 18.04, + 'debit': 0.00, + 'journal_id': self.bank_journal_euro.id, + 'name': 'Total WriteOff (Fees)', + 'account_id': self.diff_expense_account.id + } + ] + } + ] + + self.env["account.reconciliation.widget"].process_move_lines(data_for_reconciliation) + + self.assertTrue(inv1_receivable.full_reconcile_id.exists()) + self.assertEquals(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id) + self.assertEquals(inv1_receivable.full_reconcile_id, pay_receivable.full_reconcile_id) + + self.assertTrue(all(l.reconciled for l in inv1_receivable)) + self.assertTrue(all(l.reconciled for l in inv2_receivable)) + + self.assertEquals(inv1.invoice_payment_state, 'paid') + self.assertEquals(inv2.invoice_payment_state, 'paid') + def test_multiple_term_reconciliation_opw_1906665(self): '''Test that when registering a payment to an invoice with multiple payment term lines the reconciliation happens against the line diff --git a/addons/account/tests/test_reconciliation_widget.py b/addons/account/tests/test_reconciliation_widget.py index 46dbaceca05..a588fccd42c 100644 --- a/addons/account/tests/test_reconciliation_widget.py +++ b/addons/account/tests/test_reconciliation_widget.py @@ -53,3 +53,80 @@ class TestReconciliationWidget(TestReconciliation): result = self.env['account.reconciliation.widget'].get_bank_statement_line_data(bank_stmt_line.ids) self.assertEqual(result['lines'][0]['reconciliation_proposition'][0]['amount_str'], '$ 50.00') + + def test_filter_partner1(self): + inv1 = self.create_invoice(currency_id=self.currency_euro_id) + inv2 = self.create_invoice(currency_id=self.currency_euro_id) + + receivable1 = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + receivable2 = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + bank_stmt = self.acc_bank_stmt_model.create({ + 'company_id': self.env.ref('base.main_company').id, + 'journal_id': self.bank_journal_euro.id, + 'date': time.strftime('%Y-07-15'), + 'name': 'test', + }) + + bank_stmt_line = self.acc_bank_stmt_line_model.create({ + 'name': 'testLine', + 'statement_id': bank_stmt.id, + 'amount': 100, + 'date': time.strftime('%Y-07-15'), + }) + + # This is like opening the widget, and type "deco" in the filter + mv_lines_rec = self.env['account.reconciliation.widget'].get_move_lines_for_bank_statement_line( + bank_stmt_line.id, + partner_id=False, + excluded_ids=[], + search_str='deco' + ) + mv_lines_ids = [l['id'] for l in mv_lines_rec] + + self.assertTrue(receivable1.id in mv_lines_ids) + self.assertTrue(receivable2.id in mv_lines_ids) + + # This is like clicking on the first receivable + partner = inv1.partner_id + mv_lines_rec = self.env['account.reconciliation.widget'].get_move_lines_for_bank_statement_line( + bank_stmt_line.id, + partner_id=partner.id, + excluded_ids=[receivable1.id], + search_str='deco' + ) + mv_lines_ids = [l['id'] for l in mv_lines_rec] + + self.assertFalse(receivable1.id in mv_lines_ids) + self.assertTrue(receivable2.id in mv_lines_ids) + + def test_partner_name_with_parent(self): + parent_partner = self.env['res.partner'].create({ + 'name': 'test', + }) + child_partner = self.env['res.partner'].create({ + 'name': 'test', + 'parent_id': parent_partner.id, + 'type': 'delivery', + }) + self.create_invoice_partner(currency_id=self.currency_euro_id, partner_id=child_partner.id) + + bank_stmt = self.acc_bank_stmt_model.create({ + 'company_id': self.env.ref('base.main_company').id, + 'journal_id': self.bank_journal_euro.id, + 'date': time.strftime('%Y-07-15'), + 'name': 'test', + }) + + bank_stmt_line = self.acc_bank_stmt_line_model.create({ + 'name': 'testLine', + 'statement_id': bank_stmt.id, + 'amount': 100, + 'date': time.strftime('%Y-07-15'), + 'partner_name': 'test', + }) + + bkstmt_data = self.env['account.reconciliation.widget'].get_bank_statement_line_data(bank_stmt_line.ids) + + self.assertEqual(len(bkstmt_data['lines']), 1) + self.assertEqual(bkstmt_data['lines'][0]['partner_id'], parent_partner.id) diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py index 903699dc4d9..48119a58cba 100644 --- a/addons/account/tests/test_tax.py +++ b/addons/account/tests/test_tax.py @@ -348,12 +348,32 @@ class TestTax(AccountTestUsers): 'price_include': True, 'include_base_amount': True, 'sequence': 1, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) tax_2 = self.env['account.tax'].create({ 'name': 'test_advanced_taxes_computation_0_2', 'amount_type': 'percent', 'amount': 10, 'sequence': 2, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) tax_3 = self.env['account.tax'].create({ 'name': 'test_advanced_taxes_computation_0_3', @@ -361,12 +381,32 @@ class TestTax(AccountTestUsers): 'amount': 10, 'price_include': True, 'sequence': 3, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) tax_4 = self.env['account.tax'].create({ 'name': 'test_advanced_taxes_computation_0_4', 'amount_type': 'percent', 'amount': 10, 'sequence': 4, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) tax_5 = self.env['account.tax'].create({ 'name': 'test_advanced_taxes_computation_0_5', @@ -374,21 +414,229 @@ class TestTax(AccountTestUsers): 'amount': 10, 'price_include': True, 'sequence': 5, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) taxes = tax_1 + tax_2 + tax_3 + tax_4 + tax_5 - res = taxes.compute_all(132.0) + + # Test with positive amount. self._check_compute_all_results( 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), # | 2 | 10% | | - (110.0, 11.0), # | 3 | 10% | t | - (110.0, 11.0), # | 4 | 10% | | - (110.0, 11.0), # | 5 | 10% | t | + (100.0, 5.0), # | 1 | 10% | t | t + (100.0, 5.0), # | 1 | 10% | t | t + (110.0, 5.5), # | 2 | 10% | | + (110.0, 5.5), # | 2 | 10% | | + (110.0, 5.5), # | 3 | 10% | t | + (110.0, 5.5), # | 3 | 10% | t | + (110.0, 5.5), # | 4 | 10% | | + (110.0, 5.5), # | 4 | 10% | | + (110.0, 5.5), # | 5 | 10% | t | + (110.0, 5.5), # | 5 | 10% | t | # --------------------------------------------------- ], - res + taxes.compute_all(132.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -154, # 'total_included' + -100, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (-100.0, -5.0), # | 1 | 10% | t | t + (-100.0, -5.0), # | 1 | 10% | t | t + (-110.0, -5.5), # | 2 | 10% | | + (-110.0, -5.5), # | 2 | 10% | | + (-110.0, -5.5), # | 3 | 10% | t | + (-110.0, -5.5), # | 3 | 10% | t | + (-110.0, -5.5), # | 4 | 10% | | + (-110.0, -5.5), # | 4 | 10% | | + (-110.0, -5.5), # | 5 | 10% | t | + (-110.0, -5.5), # | 5 | 10% | t | + # --------------------------------------------------- + ], + taxes.compute_all(-132.0) + ) + + def test_intracomm_taxes_computation_0(self): + ''' Test usage of intracomm taxes having e.g.+100%, -100% as repartition lines. ''' + intracomm_tax = self.env['account.tax'].create({ + 'name': 'test_intracomm_taxes_computation_0_1', + 'amount_type': 'percent', + 'amount': 21, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -100.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -100.0}), + ], + }) + + # Test with positive amount. + self._check_compute_all_results( + 100, # 'total_included' + 100, # 'total_excluded' + [ + # base , amount + # --------------- + (100.0, 21.0), + (100.0, -21.0), + # --------------- + ], + intracomm_tax.compute_all(100.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -100, # 'total_included' + -100, # 'total_excluded' + [ + # base , amount + # --------------- + (-100.0, -21.0), + (-100.0, 21.0), + # --------------- + ], + intracomm_tax.compute_all(-100.0) + ) + + def test_rounding_issues_0(self): + ''' Test taxes having a complex setup of repartition lines. ''' + tax = self.env['account.tax'].create({ + 'name': 'test_rounding_issues_0', + 'amount_type': 'percent', + 'amount': 3, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + }) + + # Test with positive amount. + self._check_compute_all_results( + 1.09, # 'total_included' + 1, # 'total_excluded' + [ + # base , amount + # --------------- + (1.0, 0.01), + (1.0, 0.01), + (1.0, 0.01), + (1.0, 0.02), + (1.0, 0.02), + (1.0, 0.02), + # --------------- + ], + tax.compute_all(1.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -1.09, # 'total_included' + -1, # 'total_excluded' + [ + # base , amount + # --------------- + (-1.0, -0.01), + (-1.0, -0.01), + (-1.0, -0.01), + (-1.0, -0.02), + (-1.0, -0.02), + (-1.0, -0.02), + # --------------- + ], + tax.compute_all(-1.0) + ) + + def test_rounding_issues_1(self): + ''' Test taxes having a complex setup of repartition lines. ''' + tax = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_repartition_lines_computation_1', + 'amount_type': 'percent', + 'amount': 3, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + ], + }) + + # Test with positive amount. + self._check_compute_all_results( + 1, # 'total_included' + 1, # 'total_excluded' + [ + # base , amount + # --------------- + (1.0, 0.02), + (1.0, -0.02), + (1.0, 0.01), + (1.0, 0.01), + (1.0, -0.01), + (1.0, -0.01), + # --------------- + ], + tax.compute_all(1.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -1, # 'total_included' + -1, # 'total_excluded' + [ + # base , amount + # --------------- + (-1.0, -0.02), + (-1.0, 0.02), + (-1.0, -0.01), + (-1.0, -0.01), + (-1.0, 0.01), + (-1.0, 0.01), + # --------------- + ], + tax.compute_all(-1.0) ) diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml index 4a31cff037c..943f0d0edb9 100644 --- a/addons/account/views/report_invoice.xml +++ b/addons/account/views/report_invoice.xml @@ -176,7 +176,7 @@

-
+

Scan me with your banking app.

diff --git a/addons/account_cancel/views/account_views.xml b/addons/account_cancel/views/account_views.xml index 7d8591f1146..2a6162a64f9 100644 --- a/addons/account_cancel/views/account_views.xml +++ b/addons/account_cancel/views/account_views.xml @@ -5,6 +5,9 @@ account.journal + + {'invisible': 0} + diff --git a/addons/account_check_printing/models/account_payment.py b/addons/account_check_printing/models/account_payment.py index 98bb3c25366..3f56070c2bc 100644 --- a/addons/account_check_printing/models/account_payment.py +++ b/addons/account_check_printing/models/account_payment.py @@ -104,6 +104,11 @@ class AccountPayment(models.Model): """ This method is a hook for l10n_xx_check_printing modules to implement actual check printing capabilities """ raise UserError(_("You have to choose a check layout. For this, go in Apps, search for 'Checks layout' and install one.")) + + def set_check_amount_in_words(self): + for payment in self: + payment.check_amount_in_words = payment.currency_id.amount_to_text(payment.amount) if payment.currency_id else '' + ####################### #CHECK PRINTING METHODS ####################### diff --git a/addons/account_check_printing/views/account_payment_views.xml b/addons/account_check_printing/views/account_payment_views.xml index 82a97e89edc..78429cd7bf5 100644 --- a/addons/account_check_printing/views/account_payment_views.xml +++ b/addons/account_check_printing/views/account_payment_views.xml @@ -8,6 +8,7 @@

+
+ + + diff --git a/addons/crm_iap_lead_website/models/crm_reveal_rule.py b/addons/crm_iap_lead_website/models/crm_reveal_rule.py index b1d6fd23cfb..ea541e0ce3d 100644 --- a/addons/crm_iap_lead_website/models/crm_reveal_rule.py +++ b/addons/crm_iap_lead_website/models/crm_reveal_rule.py @@ -7,7 +7,8 @@ import logging import re from dateutil.relativedelta import relativedelta -from odoo import api, fields, models, tools, _, _geoip_resolver +import odoo +from odoo import api, fields, models, tools, _ from odoo.addons.iap import jsonrpc from odoo.addons.crm.models import crm_stage from odoo.exceptions import ValidationError @@ -75,7 +76,7 @@ class CRMRevealRule(models.Model): @api.model def _assert_geoip(self): - if not _geoip_resolver: + if not odoo._geoip_resolver: message = _('Lead Generation requires a GeoIP resolver which could not be found on your system. Please consult https://pypi.org/project/GeoIP/.') self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), diff --git a/addons/delivery/models/sale_order.py b/addons/delivery/models/sale_order.py index 4310985c54a..43de9e86c03 100644 --- a/addons/delivery/models/sale_order.py +++ b/addons/delivery/models/sale_order.py @@ -49,6 +49,7 @@ class SaleOrder(models.Model): self._remove_delivery_line() for order in self: + order.carrier_id = carrier.id order._create_delivery_line(carrier, amount, price_unit_in_description=self.carrier_id.invoice_policy == 'real') return True diff --git a/addons/delivery/wizard/choose_delivery_carrier.py b/addons/delivery/wizard/choose_delivery_carrier.py index c7bac396c61..9953d15912e 100644 --- a/addons/delivery/wizard/choose_delivery_carrier.py +++ b/addons/delivery/wizard/choose_delivery_carrier.py @@ -74,6 +74,5 @@ class ChooseDeliveryCarrier(models.TransientModel): } def button_confirm(self): - self.order_id.carrier_id = self.carrier_id self.order_id.delivery_message = self.delivery_message self.order_id.set_delivery_line(self.carrier_id, self.delivery_price) diff --git a/addons/gamification/models/challenge.py b/addons/gamification/models/challenge.py index 5e2515e797c..519a86d5b2f 100644 --- a/addons/gamification/models/challenge.py +++ b/addons/gamification/models/challenge.py @@ -383,9 +383,9 @@ class Challenge(models.Model): # the goal is initialised over the limit to make sure we will compute it at least once if line.condition == 'higher': - values['current'] = line.target_goal - 1 + values['current'] = min(line.target_goal - 1, 0) else: - values['current'] = line.target_goal + 1 + values['current'] = max(line.target_goal + 1, 0) if challenge.remind_update_delay: values['remind_update_delay'] = challenge.remind_update_delay @@ -743,7 +743,7 @@ class Challenge(models.Model): all_reached = False if goal.definition_condition == 'higher': # can be over 100 - total_completeness += 100.0 * goal.current / goal.target_goal + total_completeness += (100.0 * goal.current / goal.target_goal) if goal.target_goal else 0 elif goal.state == 'reached': # for lower goals, can not get percentage so 0 or 100 total_completeness += 100 diff --git a/addons/gamification/models/goal.py b/addons/gamification/models/goal.py index c5a280f13f2..736ed2da71b 100644 --- a/addons/gamification/models/goal.py +++ b/addons/gamification/models/goal.py @@ -196,7 +196,7 @@ class Goal(models.Model): if goal.current >= goal.target_goal: goal.completeness = 100.0 else: - goal.completeness = round(100.0 * goal.current / goal.target_goal, 2) + goal.completeness = round(100.0 * goal.current / goal.target_goal, 2) if goal.target_goal else 0 elif goal.current < goal.target_goal: # a goal 'lower than' has only two values possible: 0 or 100% goal.completeness = 100.0 diff --git a/addons/gamification/views/challenge.xml b/addons/gamification/views/challenge.xml index a5f9f4fe2ee..7621bcbb6ad 100644 --- a/addons/gamification/views/challenge.xml +++ b/addons/gamification/views/challenge.xml @@ -15,7 +15,7 @@ gamification.goal Related Goals - kanban,tree + kanban,tree,form {'search_default_group_by_definition': True, 'search_default_inprogress': True, 'search_default_challenge_id': active_id, 'default_challenge_id': active_id}

@@ -61,7 +61,7 @@

- +
@@ -171,7 +171,7 @@ Challenges gamification.challenge - kanban,tree,form + kanban,tree {'search_default_inprogress':True, 'default_inprogress':True}

diff --git a/addons/gamification/views/goal.xml b/addons/gamification/views/goal.xml index 9af36db1948..cd17738869d 100644 --- a/addons/gamification/views/goal.xml +++ b/addons/gamification/views/goal.xml @@ -135,6 +135,7 @@ + @@ -143,10 +144,12 @@ - +

    +
  • +
-
+

diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index d2d68f018b0..8d456be3e0f 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -30,14 +30,19 @@ class HrExpense(models.Model): @api.model def _get_employee_id_domain(self): - res= [('id', '=', 0)] # Nothing accepted by domain, by default if self.user_has_groups('hr_expense.group_hr_expense_user') or self.user_has_groups('account.group_account_user'): res = [] # Then, domain accepts everything elif self.user_has_groups('hr_expense.group_hr_expense_team_approver') and self.env.user.employee_ids: - employee = self.env.user.employee_ids[0] - res = ['|', '|', '|', ('department_id.manager_id.id', '=', employee.id), - ('parent_id.id', '=', employee.id), ('expense_manager_id.id', '=', employee.user_id.id), ('id', '=', employee.id)] + user = self.env.user + employee = user.employee_ids[0] + res = [ + '|', '|', '|', + ('department_id.manager_id', '=', employee.id), + ('parent_id', '=', employee.id), + ('id', '=', employee.id), + ('expense_manager_id', '=', user.id), + ] elif self.env.user.employee_ids: employee = self.env.user.employee_ids[0] res = [('id', '=', employee.id)] diff --git a/addons/hr_expense/tests/test_expenses.py b/addons/hr_expense/tests/test_expenses.py index ab31f29c804..3d76478c83c 100644 --- a/addons/hr_expense/tests/test_expenses.py +++ b/addons/hr_expense/tests/test_expenses.py @@ -187,6 +187,70 @@ class TestAccountEntry(TestExpenseCommon): self.assertEquals(expense.total_amount, 9876.0) self.assertTrue(expense.employee_id in user_demo.employee_ids) + def test_partial_payment_multiexpense(self): + bank_journal = self.env['account.journal'].create({ + 'name': 'Payment Journal', + 'code': 'PAY', + 'type': 'bank', + 'company_id': self.env.company.id, + }) + + outbound_pay_method = self.env['account.payment.method'].create({ + 'name': 'outbound', + 'code': 'out', + 'payment_type': 'outbound', + }) + + expense = self.env['hr.expense.sheet'].create({ + 'name': 'Expense for John Smith', + 'employee_id': self.employee.id, + }) + expense_line = self.env['hr.expense'].create({ + 'name': 'Car Travel Expenses', + 'employee_id': self.employee.id, + 'product_id': self.product_expense.id, + 'unit_amount': 200.00, + 'tax_ids': [(6, 0, [self.tax.id])], + 'sheet_id': expense.id, + 'analytic_account_id': self.analytic_account.id, + }) + expense_line.copy({ + 'sheet_id': expense.id + }) + expense.approve_expense_sheets() + expense.action_sheet_move_create() + + exp_move_lines = expense.account_move_id.line_ids + payable_move_lines = exp_move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEquals(len(payable_move_lines), 2) + + WizardRegister = self.env['hr.expense.sheet.register.payment.wizard'].with_context(active_ids=expense.ids) + + register_pay1 = WizardRegister.create({ + 'journal_id': bank_journal.id, + 'payment_method_id': outbound_pay_method.id, + 'amount': 300, + }) + register_pay1.expense_post_payment() + + exp_move_lines = expense.account_move_id.line_ids + payable_move_lines = exp_move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEquals(len(payable_move_lines.filtered(lambda l: l.reconciled)), 1) + + register_pay2 = WizardRegister.create({ + 'journal_id': bank_journal.id, + 'payment_method_id': outbound_pay_method.id, + 'amount': 100, + }) + register_pay2.expense_post_payment() + exp_move_lines = expense.account_move_id.line_ids + payable_move_lines = exp_move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEquals(len(payable_move_lines.filtered(lambda l: l.reconciled)), 2) + + full_reconcile = payable_move_lines.mapped('full_reconcile_id') + self.assertEquals(len(full_reconcile), 1) + + class TestExpenseRights(TestExpenseCommon): @classmethod diff --git a/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py b/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py index 9a98f40dc59..bf07f7926b1 100644 --- a/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py +++ b/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py @@ -109,7 +109,7 @@ class HrExpenseSheetRegisterPaymentWizard(models.TransientModel): # Reconcile the payment and the expense, i.e. lookup on the payable account move lines account_move_lines_to_reconcile = self.env['account.move.line'] for line in payment.move_line_ids + expense_sheet.account_move_id.line_ids: - if line.account_id.internal_type == 'payable': + if line.account_id.internal_type == 'payable' and not line.reconciled: account_move_lines_to_reconcile |= line account_move_lines_to_reconcile.reconcile() diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index d954e2e7dd9..c855e9bf6a0 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -6,6 +6,8 @@ import logging import math +from collections import namedtuple + from datetime import datetime, time from pytz import timezone, UTC @@ -18,6 +20,9 @@ from odoo.tools.translate import _ _logger = logging.getLogger(__name__) +# Used to agglomerate the attendances in order to find the hour_from and hour_to +# See _onchange_request_parameters +DummyAttendance = namedtuple('DummyAttendance', 'hour_from, hour_to, dayofweek, day_period') class HolidaysRequest(models.Model): """ Leave Requests Access specifications @@ -286,12 +291,17 @@ class HolidaysRequest(models.Model): return domain = [('calendar_id', '=', self.employee_id.resource_calendar_id.id or self.env.company.resource_calendar_id.id)] - attendances = self.env['resource.calendar.attendance'].search(domain, order='dayofweek, day_period DESC') + attendances = self.env['resource.calendar.attendance'].read_group(domain, ['ids:array_agg(id)', 'hour_from:min(hour_from)', 'hour_to:max(hour_to)', 'dayofweek', 'day_period'], ['dayofweek', 'day_period'], lazy=False) + + # Must be sorted by dayofweek ASC and day_period DESC + attendances = sorted([DummyAttendance(group['hour_from'], group['hour_to'], group['dayofweek'], group['day_period']) for group in attendances], key=lambda att: (att.dayofweek, att.day_period != 'morning')) + + default_value = DummyAttendance(0, 0, 0, 'morning') # find first attendance coming after first_day - attendance_from = next((att for att in attendances if int(att.dayofweek) >= self.request_date_from.weekday()), attendances[0]) + attendance_from = next((att for att in attendances if int(att.dayofweek) >= self.request_date_from.weekday()), attendances[0] if attendances else default_value) # find last attendance coming before last_day - attendance_to = next((att for att in reversed(attendances) if int(att.dayofweek) <= self.request_date_to.weekday()), attendances[-1]) + attendance_to = next((att for att in reversed(attendances) if int(att.dayofweek) <= self.request_date_to.weekday()), attendances[-1] if attendances else default_value) if self.request_unit_half: if self.request_date_from_period == 'am': diff --git a/addons/hr_holidays/tests/__init__.py b/addons/hr_holidays/tests/__init__.py index 67c8327e999..3c15cd779a1 100644 --- a/addons/hr_holidays/tests/__init__.py +++ b/addons/hr_holidays/tests/__init__.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_access_rights +from . import test_automatic_leave_dates from . import test_holidays_flow from . import test_hr_leave_type from . import test_accrual_allocations diff --git a/addons/hr_holidays/tests/test_automatic_leave_dates.py b/addons/hr_holidays/tests/test_automatic_leave_dates.py new file mode 100644 index 00000000000..5b39259d053 --- /dev/null +++ b/addons/hr_holidays/tests/test_automatic_leave_dates.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +from datetime import date, datetime + +from odoo.tests.common import Form, tagged + +from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase + + +@tagged('prout') +class TestAutomaticLeaveDates(TestHrHolidaysBase): + def setUp(self): + super(TestAutomaticLeaveDates, self).setUp() + + self.leave_type = self.env['hr.leave.type'].create({ + 'name': 'Automatic Test', + 'time_type': 'leave', + 'allocation_type': 'no', + 'validity_start': False, + }) + + def test_no_attendances(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'No Attendances', + 'attendance_ids': [(5, 0, 0)], + }) + employee = self.employee_emp + employee.resource_calendar_id = calendar + + with Form(self.env['hr.leave']) as leave_form: + leave_form.employee_id = employee + leave_form.holiday_status_id = self.leave_type + leave_form.request_date_from = date(2019, 9, 2) + leave_form.request_date_to = date(2019, 9, 2) + leave_form.request_unit_half = True + leave_form.request_date_from_period = 'am' + + self.assertEqual(leave_form.number_of_days_display, 0) + self.assertEqual(leave_form.number_of_hours_display, 0) + + def test_single_attendance_on_morning_and_afternoon(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'simple morning + afternoon', + 'attendance_ids': [(5, 0, 0), + (0, 0, { + 'name': 'monday morning', + 'hour_from': 8, + 'hour_to': 12, + 'day_period': 'morning', + 'dayofweek': '0', + }), + (0, 0, { + 'name': 'monday afternoon', + 'hour_from': 13, + 'hour_to': 17, + 'day_period': 'afternoon', + 'dayofweek': '0', + })] + }) + + employee = self.employee_emp + employee.resource_calendar_id = calendar + + with Form(self.env['hr.leave']) as leave_form: + leave_form.employee_id = employee + leave_form.holiday_status_id = self.leave_type + leave_form.request_date_from = date(2019, 9, 2) + leave_form.request_date_to = date(2019, 9, 2) + leave_form.request_unit_half = True + leave_form.request_date_from_period = 'am' + + self.assertEqual(leave_form.number_of_days_display, .5) + self.assertEqual(leave_form.number_of_hours_display, 4) + + leave_form.request_date_from_period = 'pm' + + self.assertEqual(leave_form.number_of_days_display, .5) + self.assertEqual(leave_form.number_of_hours_display, 4) + + def test_multiple_attendance_on_morning(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'multi morning', + 'attendance_ids': [(5, 0, 0), + (0, 0, { + 'name': 'monday morning 1', + 'hour_from': 8, + 'hour_to': 10, + 'day_period': 'morning', + 'dayofweek': '0', + }), + (0, 0, { + 'name': 'monday morning 2', + 'hour_from': 10.25, + 'hour_to': 12.25, + 'day_period': 'morning', + 'dayofweek': '0', + }), + (0, 0, { + 'name': 'monday afternoon', + 'hour_from': 13, + 'hour_to': 17, + 'day_period': 'afternoon', + 'dayofweek': '0', + })] + }) + employee = self.employee_emp + employee.resource_calendar_id = calendar + + with Form(self.env['hr.leave']) as leave_form: + leave_form.employee_id = employee + leave_form.holiday_status_id = self.leave_type + leave_form.request_date_from = date(2019, 9, 2) + leave_form.request_date_to = date(2019, 9, 2) + leave_form.request_unit_half = True + leave_form.request_date_from_period = 'am' + + self.assertEqual(leave_form.number_of_days_display, .5) + self.assertEqual(leave_form.number_of_hours_display, 4) + + leave_form.request_date_from_period = 'pm' + + self.assertEqual(leave_form.number_of_days_display, .5) + self.assertEqual(leave_form.number_of_hours_display, 4) + + def test_attendance_on_morning(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'Morning only', + 'attendance_ids': [(5, 0, 0), + (0, 0, { + 'name': 'Monday All day', + 'hour_from': 8, + 'hour_to': 16, + 'day_period': 'morning', + 'dayofweek': '0', + })], + }) + employee = self.employee_emp + employee.resource_calendar_id = calendar + with Form(self.env['hr.leave']) as leave_form: + leave_form.employee_id = employee + leave_form.holiday_status_id = self.leave_type + leave_form.request_date_from = date(2019, 9, 2) + leave_form.request_date_to = date(2019, 9, 2) + leave_form.request_unit_half = True + # Ask for morning + leave_form.request_date_from_period = 'am' + + self.assertEqual(leave_form.number_of_days_display, 1) + self.assertEqual(leave_form.number_of_hours_display, 8) + + # Ask for afternoon + leave_form.request_date_from_period = 'pm' + + self.assertEqual(leave_form.number_of_days_display, 1) + self.assertEqual(leave_form.number_of_hours_display, 8) + + def test_attendance_next_day(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'auto next day', + 'attendance_ids': [(5, 0, 0), + (0, 0, { + 'name': 'tuesday morning', + 'hour_from': 8, + 'hour_to': 12, + 'day_period': 'morning', + 'dayofweek': '1', + })] + }) + employee = self.employee_emp + employee.resource_calendar_id = calendar + + with Form(self.env['hr.leave']) as leave_form: + leave_form.employee_id = employee + leave_form.holiday_status_id = self.leave_type + leave_form.request_date_from = date(2019, 9, 2) + leave_form.request_date_to = date(2019, 9, 2) + leave_form.request_unit_half = True + leave_form.request_date_from_period = 'am' + + + self.assertEqual(leave_form.number_of_days_display, 0) + self.assertEqual(leave_form.number_of_hours_display, 0) + self.assertEqual(leave_form.date_from, datetime(2019, 9, 2, 6, 0, 0)) + self.assertEqual(leave_form.date_to, datetime(2019, 9, 2, 10, 0, 0)) + + def test_attendance_previous_day(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'auto next day', + 'attendance_ids': [(5, 0, 0), + (0, 0, { + 'name': 'monday morning', + 'hour_from': 8, + 'hour_to': 12, + 'day_period': 'morning', + 'dayofweek': '0', + })] + }) + employee = self.employee_emp + employee.resource_calendar_id = calendar + + with Form(self.env['hr.leave']) as leave_form: + leave_form.employee_id = employee + leave_form.holiday_status_id = self.leave_type + leave_form.request_date_from = date(2019, 9, 3) + leave_form.request_date_to = date(2019, 9, 3) + leave_form.request_unit_half = True + leave_form.request_date_from_period = 'am' + + + self.assertEqual(leave_form.number_of_days_display, 0) + self.assertEqual(leave_form.number_of_hours_display, 0) + self.assertEqual(leave_form.date_from, datetime(2019, 9, 3, 6, 0, 0)) + self.assertEqual(leave_form.date_to, datetime(2019, 9, 3, 10, 0, 0)) diff --git a/addons/hr_recruitment/views/hr_recruitment_views.xml b/addons/hr_recruitment/views/hr_recruitment_views.xml index 5384d477bc4..d076fba8e71 100644 --- a/addons/hr_recruitment/views/hr_recruitment_views.xml +++ b/addons/hr_recruitment/views/hr_recruitment_views.xml @@ -286,6 +286,7 @@ + diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py index 5703563e95d..1dc620edd49 100644 --- a/addons/im_livechat/controllers/main.py +++ b/addons/im_livechat/controllers/main.py @@ -103,7 +103,6 @@ class LivechatController(http.Controller): @http.route('/im_livechat/feedback', type='json', auth='public', cors="*") def feedback(self, uuid, rate, reason=None, **kwargs): Channel = request.env['mail.channel'] - Rating = request.env['rating.rating'] channel = Channel.sudo().search([('uuid', '=', uuid)], limit=1) if channel: # limit the creation : only ONE rating per session @@ -124,7 +123,11 @@ class LivechatController(http.Controller): # if logged in user, set its partner on rating values['partner_id'] = request.env.user.partner_id.id if request.session.uid else False # create the rating - rating = Rating.sudo().create(values) + + channel.write({ + 'rating_ids': [(0, False, values)] + }) + rating = channel.rating_ids[0] else: rating = channel.rating_ids[0] rating.write(values) diff --git a/addons/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js index 086f02daa32..c6b59683316 100644 --- a/addons/im_livechat/static/src/js/im_livechat.js +++ b/addons/im_livechat/static/src/js/im_livechat.js @@ -123,6 +123,14 @@ var LivechatButton = Widget.extend({ serverURL: this._serverURL, }); var message = new WebsiteLivechatMessage(this, data, options); + + var hasAlreadyMessage = _.some(this._messages, function (msg) { + return message.id === msg.id; + }); + if (hasAlreadyMessage) { + return; + } + if (this._livechat) { this._livechat.addMessage(message); } diff --git a/addons/l10n_ae/data/account_tax_template_data.xml b/addons/l10n_ae/data/account_tax_template_data.xml index b3cec92c1c5..2993be745de 100644 --- a/addons/l10n_ae/data/account_tax_template_data.xml +++ b/addons/l10n_ae/data/account_tax_template_data.xml @@ -1,11 +1,11 @@ - TVA 5% (Dubai) + VAT 5% (Dubai) sale 5 percent - TVA 5% + VAT 5% - TVA 5% (Abu Dhabi) + VAT 5% (Abu Dhabi) sale 5 percent - TVA 5% + VAT 5% - TVA 5% (Sharjah) + VAT 5% (Sharjah) sale 5 percent - TVA 5% + VAT 5% - TVA 5% (Ajman) + VAT 5% (Ajman) sale 5 percent - TVA 5% + VAT 5% - TVA 5% (Umm Al Quwain) + VAT 5% (Umm Al Quwain) sale 5 percent - TVA 5% + VAT 5% - TVA 5% (Ras Al-Khaima) + VAT 5% (Ras Al-Khaima) sale 5 percent - TVA 5% + VAT 5% - TVA 5% (Fujairah) + VAT 5% (Fujairah) sale 5 percent - TVA 5% + VAT 5% - TVA 0% + VAT 0% sale 0 percent - TVA 0% + VAT 0% - TVA 5% + VAT 5% purchase 5 percent - TVA 5% + VAT 5% - TVA 0% + VAT 0% purchase 0 percent - TVA 0% + VAT 0% - - TVA 5% - none - 5 - percent - TVA 5% - - - - - - - Reverse Charge TVA +5%(Purchase) - none - 5 - percent - TVA 5% - - - - - Reverse Charge Provision purchase diff --git a/addons/l10n_ch/models/account_invoice.py b/addons/l10n_ch/models/account_invoice.py index 02af23c0f7c..662dae71a6b 100644 --- a/addons/l10n_ch/models/account_invoice.py +++ b/addons/l10n_ch/models/account_invoice.py @@ -191,3 +191,17 @@ class AccountMove(models.Model): if self.env.context.get('l10n_ch_mark_isr_as_sent'): self.filtered(lambda inv: not inv.l10n_ch_isr_sent).write({'l10n_ch_isr_sent': True}) return super(AccountMove, self.with_context(mail_post_autofollow=True)).message_post(**kwargs) + + def _get_reference_ch_invoice(self): + """ This sets ISR reference number which is generated based on customer's `Bank Account` and set it as + `Payment Reference` of the invoice when invoice's journal is using Switzerland's communication standard + """ + self.ensure_one() + return self.l10n_ch_isr_number_spaced + + def _get_reference_ch_partner(self): + """ This sets ISR reference number which is generated based on customer's `Bank Account` and set it as + `Payment Reference` of the invoice when invoice's journal is using Switzerland's communication standard + """ + self.ensure_one() + return self.l10n_ch_isr_number_spaced diff --git a/addons/l10n_ch/models/account_journal.py b/addons/l10n_ch/models/account_journal.py index a42e163a299..85646dfa396 100644 --- a/addons/l10n_ch/models/account_journal.py +++ b/addons/l10n_ch/models/account_journal.py @@ -14,6 +14,7 @@ class AccountJournal(models.Model): # creation of bank journals by giving the account number, allow craetion of the l10n_ch_postal = fields.Char(related='bank_account_id.l10n_ch_postal', readonly=False) + invoice_reference_model = fields.Selection(selection_add=[('ch', 'Switzerland')]) @api.model def create(self, vals): diff --git a/addons/l10n_es/data/account_tax_data.xml b/addons/l10n_es/data/account_tax_data.xml index dca71560db1..9461c865a72 100644 --- a/addons/l10n_es/data/account_tax_data.xml +++ b/addons/l10n_es/data/account_tax_data.xml @@ -445,7 +445,6 @@ P_IVA21_SP_IN purchase - percent @@ -551,7 +550,6 @@ purchase percent - percent purchase - IVA 4% Adquisición de servicios extracomunitarios P_IVA10_SP_EX percent - purchase IVA 10% Adquisición de servicios extracomunitarios @@ -824,7 +820,6 @@ purchase - percent P_IVA4_IC_BC - percent purchase IVA 4% Adquisición Intracomunitario. Bienes corrientes @@ -930,7 +924,6 @@ percent purchase IVA 4% Adquisición Intracomunitario. Bienes de inversión - P_IVA10_IC_BC - percent purchase IVA 10% Adquisición Intracomunitario. Bienes corrientes @@ -1034,7 +1026,6 @@ percent purchase IVA 10% Adquisición Intracomunitario. Bienes de inversión - percent purchase IVA 10% Adquisición de servicios intracomunitarios - percent purchase IVA 4% Adquisición de servicios intracomunitarios - P_IVA4_ISP IVA 4% Compra con Inversión del Sujeto Pasivo Nacional purchase - percent @@ -3455,7 +3443,6 @@ P_IVA10_ISP IVA 10% Compra con Inversión del Sujeto Pasivo Nacional purchase - percent @@ -3509,7 +3496,6 @@ purchase percent - - - - - - + + + + + @@ -43,21 +43,21 @@ - + - + - + @@ -65,7 +65,7 @@ - + diff --git a/addons/l10n_it_edi/models/ir_mail_server.py b/addons/l10n_it_edi/models/ir_mail_server.py index bafdf1cb464..581274ac404 100644 --- a/addons/l10n_it_edi/models/ir_mail_server.py +++ b/addons/l10n_it_edi/models/ir_mail_server.py @@ -31,10 +31,10 @@ class FetchmailServer(models.Model): l10n_it_is_pec = fields.Boolean('PEC server', help="If PEC Server, only mail from '...@pec.fatturapa.it' will be processed.") l10n_it_last_uid = fields.Integer(string='Last message UID', default=1) - @api.constrains('l10n_it_is_pec', 'type') + @api.constrains('l10n_it_is_pec', 'server_type') def _check_pec(self): for record in self: - if record.l10n_it_is_pec and record.type != 'imap': + if record.l10n_it_is_pec and record.server_type != 'imap': raise ValidationError(_("PEC mail server must be of type IMAP.")) @api.multi @@ -43,7 +43,7 @@ class FetchmailServer(models.Model): MailThread = self.env['mail.thread'] for server in self.filtered(lambda s: s.l10n_it_is_pec): - _logger.info('start checking for new emails on %s PEC server %s', server.type, server.name) + _logger.info('start checking for new emails on %s PEC server %s', server.server_type, server.name) count, failed = 0, 0 imap_server = None @@ -81,14 +81,14 @@ class FetchmailServer(models.Model): self._attachment_invoice(msg_txt) new_max_uid = max(new_max_uid, int(uid)) except Exception: - _logger.info('Failed to process mail from %s server %s.', server.type, server.name, exc_info=True) + _logger.info('Failed to process mail from %s server %s.', server.server_type, server.name, exc_info=True) failed += 1 self._cr.commit() count += 1 server.write({'l10n_it_last_uid': new_max_uid}) - _logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", count, server.type, server.name, (count - failed), failed) + _logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", count, server.server_type, server.name, (count - failed), failed) except Exception: - _logger.info("General failure when trying to fetch mail from %s server %s.", server.type, server.name, exc_info=True) + _logger.info("General failure when trying to fetch mail from %s server %s.", server.server_type, server.name, exc_info=True) finally: if imap_server: imap_server.close() diff --git a/addons/l10n_lu/__init__.py b/addons/l10n_lu/__init__.py index 1947891c8f5..79508eb4f80 100644 --- a/addons/l10n_lu/__init__.py +++ b/addons/l10n_lu/__init__.py @@ -1,8 +1,15 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import api, SUPERUSER_ID + from . import models +def _post_init_hook(cr, registry): + _preserve_tag_on_taxes(cr, registry) + env = api.Environment(cr, SUPERUSER_ID, {}) + env.ref('l10n_lu.lu_2011_chart_1').process_coa_translations() + def _preserve_tag_on_taxes(cr, registry): from odoo.addons.account.models.chart_template import preserve_existing_tags_on_taxes preserve_existing_tags_on_taxes(cr, registry, 'l10n_lu') diff --git a/addons/l10n_lu/__manifest__.py b/addons/l10n_lu/__manifest__.py index c5457fd82d4..9eabf73f08d 100644 --- a/addons/l10n_lu/__manifest__.py +++ b/addons/l10n_lu/__manifest__.py @@ -43,5 +43,5 @@ Notes: 'data/account.chart.template.csv', 'data/account_chart_template_data.xml', ], - 'post_init_hook': '_preserve_tag_on_taxes', + 'post_init_hook': '_post_init_hook', } diff --git a/addons/l10n_ma/data/account_tax_data.xml b/addons/l10n_ma/data/account_tax_data.xml index c112839f15b..cb134570086 100644 --- a/addons/l10n_ma/data/account_tax_data.xml +++ b/addons/l10n_ma/data/account_tax_data.xml @@ -2,10 +2,10 @@ - - + + - + diff --git a/addons/l10n_ma/data/l10n_ma_chart_data.xml b/addons/l10n_ma/data/l10n_ma_chart_data.xml index d72021c2ae4..cec91faca6f 100644 --- a/addons/l10n_ma/data/l10n_ma_chart_data.xml +++ b/addons/l10n_ma/data/l10n_ma_chart_data.xml @@ -1190,13 +1190,15 @@ Clients - catégorie A 34211 - + + Clients - catégorie B 34212 - + + @@ -1370,7 +1372,7 @@ Divers débiteurs 3488 - + @@ -1515,7 +1517,8 @@ Fournisseurs 4411 - + + @@ -1761,7 +1764,7 @@ Divers créanciers 4488 - + @@ -2065,6 +2068,12 @@ + + Achats des prestations de service + 61263 + + + Achats de matières et de fournitures des exercices antérieurs 6128 diff --git a/addons/l10n_multilang/models/l10n_multilang.py b/addons/l10n_multilang/models/l10n_multilang.py index 2f6e3731faa..51ddb70c94c 100644 --- a/addons/l10n_multilang/models/l10n_multilang.py +++ b/addons/l10n_multilang/models/l10n_multilang.py @@ -12,6 +12,19 @@ _logger = logging.getLogger(__name__) class AccountChartTemplate(models.Model): _inherit = 'account.chart.template' + def load_for_current_company(self, sale_tax_rate, purchase_tax_rate): + res = super(AccountChartTemplate, self).load_for_current_company(sale_tax_rate, purchase_tax_rate) + # Copy chart of account translations when loading chart of account + for chart_template in self.filtered('spoken_languages'): + external_id = self.env['ir.model.data'].search([ + ('model', '=', 'account.chart.template'), + ('res_id', '=', chart_template.id), + ], order='id', limit=1) + module = external_id and self.env.ref('base.module_' + external_id.module) + if module and module.state == 'installed': + chart_template.process_coa_translations() + return res + @api.multi def process_translations(self, langs, in_field, in_ids, out_ids): """ diff --git a/addons/l10n_uk/__manifest__.py b/addons/l10n_uk/__manifest__.py index 55168a9887c..fdd0323faf4 100644 --- a/addons/l10n_uk/__manifest__.py +++ b/addons/l10n_uk/__manifest__.py @@ -15,7 +15,7 @@ This is the latest UK Odoo localisation necessary to run Odoo accounting for UK - InfoLogic UK counties listing - a few other adaptations""", 'author': 'SmartMode LTD', - 'website': 'http://www.smartmode.co.uk', + 'website': 'https://www.odoo.com/page/accounting', 'depends': [ 'account', 'base_iban', diff --git a/addons/lunch/static/src/js/lunch_kanban_controller.js b/addons/lunch/static/src/js/lunch_kanban_controller.js index f1acf81b301..c7e49ca38b6 100644 --- a/addons/lunch/static/src/js/lunch_kanban_controller.js +++ b/addons/lunch/static/src/js/lunch_kanban_controller.js @@ -117,9 +117,11 @@ var LunchKanbanController = KanbanController.extend({ * @private */ _updateSearchPanel: function () { - var locationId = this.model.getCurrentLocationId(); - var domain = this.controlPanelDomain.concat([['is_available_at', 'in', [locationId]]]); - return this._searchPanel.update({searchDomain: domain}); + var self = this; + this.model.getLocationDomain().then(function (locationDomain) { + var domain = self.controlPanelDomain.concat(locationDomain); + return self._searchPanel.update({searchDomain: domain}); + }); }, //-------------------------------------------------------------------------- diff --git a/addons/lunch/static/src/js/lunch_kanban_model.js b/addons/lunch/static/src/js/lunch_kanban_model.js index c2386c7ace8..d94812a6c3c 100644 --- a/addons/lunch/static/src/js/lunch_kanban_model.js +++ b/addons/lunch/static/src/js/lunch_kanban_model.js @@ -12,6 +12,7 @@ var LunchKanbanModel = KanbanModel.extend({ init: function () { this.locationId = false; this.userId = false; + this._promInitLocation = null; this._super.apply(this, arguments); }, @@ -26,15 +27,23 @@ var LunchKanbanModel = KanbanModel.extend({ getCurrentLocationId: function () { return this.locationId; }, + /** + * @return {Promise} resolved with the location domain + */ + getLocationDomain: function () { + var self = this; + return this._initUserLocation().then(function () { + return self._buildLocationDomainLeaf() ? [self._buildLocationDomainLeaf()]: []; + }); + }, load: function () { var self = this; var args = arguments; var _super = this._super; - return this._getUserLocation().then(function (locationId) { + return this._initUserLocation().then(function () { var params = args[0]; - self.locationId = locationId; - self._addOrUpdate(params.domain, ['is_available_at', 'in', [locationId]]); + self._addOrUpdate(params.domain, self._buildLocationDomainLeaf()); return _super.apply(self, args); }); @@ -42,7 +51,7 @@ var LunchKanbanModel = KanbanModel.extend({ reload: function (id, options) { var domain = options && options.domain || this.localData[id].domain; - this._addOrUpdate(domain, ['is_available_at', 'in', [this.locationId]]); + this._addOrUpdate(domain, this._buildLocationDomainLeaf()); options = _.extend(options, {domain: domain}); return this._super.apply(this, arguments); @@ -53,19 +62,35 @@ var LunchKanbanModel = KanbanModel.extend({ //-------------------------------------------------------------------------- _addOrUpdate: function (domain, subDomain) { - var key = subDomain[0]; - var index = _.findIndex(domain, function (val) { - return val[0] === key; - }); + if (subDomain && subDomain.length) { + var key = subDomain[0]; + var index = _.findIndex(domain, function (val) { + return val[0] === key; + }); - if (index < 0) { - domain.push(subDomain); - } else { - domain[index] = subDomain; + if (index < 0) { + domain.push(subDomain); + } else { + domain[index] = subDomain; + } + + return domain; } return domain; }, + /** + * Builds the domain leaf corresponding to the current user's location + * + * @private + * @return {Array} + */ + _buildLocationDomainLeaf: function () { + var locationId = this.getCurrentLocationId(); + if (locationId) { + return ['is_available_at', 'in', [locationId]]; + } + }, _getUserLocation: function () { return this._rpc({ route: '/lunch/user_location_get', @@ -74,18 +99,34 @@ var LunchKanbanModel = KanbanModel.extend({ }, }); }, + /** + * Gets the user location once. + * Can be triggered from anywhere + * Useful to inject the location domain in the search panel + * + * @private + * @return {Promise} + */ + _initUserLocation: function () { + var self = this; + if (!this._promInitLocation) { + this._promInitLocation = new Promise(function (resolve) { + self._getUserLocation().then(function (locationId) { + self.locationId = locationId; + resolve(); + }); + }); + } + return this._promInitLocation; + }, _updateLocation: function (locationId) { this.locationId = locationId; return Promise.resolve(); }, _updateUser: function (userId) { - var self = this; - this.userId = userId; - - return this._getUserLocation().then(function (locationId) { - self.locationId = locationId; - }); + this._promInitLocation = null; + return this._initUserLocation(); } }); diff --git a/addons/lunch/static/src/js/lunch_kanban_view.js b/addons/lunch/static/src/js/lunch_kanban_view.js index a0cd6d3f3e0..6be5e7f2c7b 100644 --- a/addons/lunch/static/src/js/lunch_kanban_view.js +++ b/addons/lunch/static/src/js/lunch_kanban_view.js @@ -18,6 +18,26 @@ var LunchKanbanView = KanbanView.extend({ Renderer: LunchKanbanRenderer, }), display_name: _lt('Lunch Kanban'), + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Injects an additional domain in the search panel + * + * @private + * @override + */ + _createSearchPanel: function (parent) { + var self = this; + var model = this.getModel(parent); + var _super = this._super.bind(this); + return model.getLocationDomain().then(function (domain) { + self.loadParams.domain = self.loadParams.domain.concat(domain); + return _super(parent); + }); + }, }); view_registry.add('lunch_kanban', LunchKanbanView); diff --git a/addons/lunch/static/tests/lunch_kanban_tests.js b/addons/lunch/static/tests/lunch_kanban_tests.js index 8bc4879f9e5..8f64e7b0481 100644 --- a/addons/lunch/static/tests/lunch_kanban_tests.js +++ b/addons/lunch/static/tests/lunch_kanban_tests.js @@ -211,6 +211,56 @@ QUnit.module('LunchKanbanView', { kanban.destroy(); }); + QUnit.test('search panel domain location', async function (assert) { + assert.expect(10); + const locationId = this.data['lunch.location'].records[0].id; + const regularInfos = _.extend({}, this.regularInfos); + + const kanban = await createLunchKanbanView({ + View: LunchKanbanView, + model: 'product', + data: this.data, + arch: ` + + + +
+
+
+
+ `, + mockRPC: function (route, args) { + assert.step(route); + + if (route.startsWith('/lunch')) { + return mockLunchRPC({ + infos: regularInfos, + userLocation: locationId, + }).apply(this, arguments); + } + if (args.method === 'search_panel_select_multi_range') { + assert.deepEqual(args.kwargs.search_domain, [["is_available_at", "in", [locationId]]], + 'The initial domain of the search panel must contain the user location'); + } + if (route === '/web/dataset/search_read') { + assert.deepEqual(args.domain, [["is_available_at", "in", [locationId]]], + 'The domain for fetching actual data should be correct'); + } + return this._super.apply(this, arguments); + } + }); + assert.verifySteps([ + '/lunch/user_location_get', + '/web/dataset/call_kw/product/search_panel_select_multi_range', + '/web/dataset/call_kw/product/search_panel_select_multi_range', + '/web/dataset/search_read', + '/lunch/infos', + '/web/dataset/call_kw/ir.model.data/xmlid_to_res_id', + ]) + + kanban.destroy(); + }); + QUnit.test('non-empty cart', async function (assert) { assert.expect(17); diff --git a/addons/mail/controllers/main.py b/addons/mail/controllers/main.py index 39b7c53ddf5..0e786eb4c4c 100644 --- a/addons/mail/controllers/main.py +++ b/addons/mail/controllers/main.py @@ -144,7 +144,7 @@ class MailController(http.Controller): @http.route('/mail/read_followers', type='json', auth='user') def read_followers(self, follower_ids, res_model): followers = [] - is_editable = request.env.user.has_group('base.group_no_one') + is_editable = request.env['mail.followers'].user_has_groups('base.group_no_one') partner_id = request.env.user.partner_id follower_id = None follower_recs = request.env['mail.followers'].sudo().browse(follower_ids) diff --git a/addons/mail/static/src/js/models/threads/document_thread.js b/addons/mail/static/src/js/models/threads/document_thread.js index 0ef02e5f57a..cb860b70beb 100644 --- a/addons/mail/static/src/js/models/threads/document_thread.js +++ b/addons/mail/static/src/js/models/threads/document_thread.js @@ -62,9 +62,16 @@ var DocumentThread = Thread.extend({ * shared between tabs, and restored on F5. * * @override + * @param {Object} [options] + * @param {boolean} [options.skipCrossTabSync=false] if set, it should + * not notify other tabs from updating document thread state. This happens + * in case the `close` operation comes from local storage event. */ - close: function () { + close: function (options) { this._super.apply(this, arguments); + if (options && options.skipCrossTabSync) { + return; + } this.call('mail_service', 'updateDocumentThreadState', this._id, { name: this.getName(), windowState: 'closed', @@ -75,9 +82,16 @@ var DocumentThread = Thread.extend({ * shared between tabs, and restored on F5. * * @override + * @param {Object} [options] + * @param {boolean} [options.skipCrossTabSync=false] if set, it should + * not notify other tabs from updating document thread state. This happens + * in case the `detach` operation comes from local storage event. */ - detach: function () { + detach: function (options) { this._super.apply(this, arguments); + if (options && options.skipCrossTabSync) { + return; + } var windowState = this._folded ? 'folded' : 'open'; this.call('mail_service', 'updateDocumentThreadState', this._id, { name: this.getName(), @@ -105,9 +119,16 @@ var DocumentThread = Thread.extend({ * shared between tabs, and restored on F5. * * @override + * @param {Object} [options] + * @param {boolean} [options.skipCrossTabSync=false] if set, it should + * not notify other tabs from updating document thread state. This happens + * in case the `detach` operation comes from local storage event. */ - fold: function () { + fold: function (folded, options) { this._super.apply(this, arguments); + if (options && options.skipCrossTabSync) { + return; + } var windowState = this._folded ? 'folded' : 'open'; this.call('mail_service', 'updateDocumentThreadState', this._id, { name: this.getName(), diff --git a/addons/mail/static/src/js/models/threads/searchable_thread.js b/addons/mail/static/src/js/models/threads/searchable_thread.js index 511bc6348c8..2b9d64b2597 100644 --- a/addons/mail/static/src/js/models/threads/searchable_thread.js +++ b/addons/mail/static/src/js/models/threads/searchable_thread.js @@ -148,7 +148,11 @@ var SearchableThread = Thread.extend({ if (cache.messages[index] !== message) { cache.messages.splice(index, 0, message); } - if (!message.isMyselfAuthor() && options.incrementUnread) { + if ( + !message.isMyselfAuthor() && + options.incrementUnread && + message.getType() !== 'notification' + ) { this._incrementUnreadCounter(); } }, diff --git a/addons/mail/static/src/js/services/mail_document_thread_manager.js b/addons/mail/static/src/js/services/mail_document_thread_manager.js index acf06461a04..6bb66b1b439 100644 --- a/addons/mail/static/src/js/services/mail_document_thread_manager.js +++ b/addons/mail/static/src/js/services/mail_document_thread_manager.js @@ -21,17 +21,23 @@ MailManager.include({ start: function () { this._super.apply(this, arguments); - + this._documentThreadWindowStates = {}; + this._tabId = this.call('bus_service', 'getTabId'); // retrieve the open DocumentThreads from the localStorage - var state = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY); - if (!state) { - this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY, {}); - } else { - this.isReady().then(this._updateDocumentThreadWindows.bind(this, state)); + var localStorageLength = this.call('local_storage', 'length'); + for (var i = 0; i < localStorageLength; i++) { + var key = this.call('local_storage', 'key', i); + if (key.indexOf(this.DOCUMENT_THREAD_STATE_KEY) === 0) { + // key format: DOCUMENT_THREAD_STATE_KEY + '/' + threadId + var documentThreadId = key.substring(this.DOCUMENT_THREAD_STATE_KEY.length+1); + this._documentThreadWindowStates[documentThreadId] = + this.call('local_storage', 'getItem', key); + } } + this.isReady().then(this._updateDocumentThreadWindows.bind(this, this._documentThreadWindowStates)); // listen to localStorage changes to synchronize DocumentThread's // windows between tabs - window.addEventListener('storage', this._onStorage.bind(this)); + this.call('local_storage', 'onStorage', this, this._onStorage); }, //-------------------------------------------------------------------------- @@ -60,7 +66,10 @@ MailManager.include({ options.postedFromDocumentThread ) { var key = this.DOCUMENT_THREAD_MESSAGE_KEY; - this.call('local_storage', 'setItem', key, data); + this.call('local_storage', 'setItem', key, { + messageData: data, + tabId: this._tabId, + }); } return this._super.apply(this, arguments); }, @@ -144,12 +153,11 @@ MailManager.include({ * @param {string} state.windowState ('closed', 'folded' or 'open') */ updateDocumentThreadState: function (threadID, state) { - var states = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY); - states = _.omit(states, function (state) { - return state.windowState === 'closed'; + this._documentThreadWindowStates[threadID] = state; + this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY + '/' + threadID, { + state: state, + tabId: this._tabId, }); - states[threadID] = state; - this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY, states); }, //-------------------------------------------------------------------------- @@ -194,10 +202,17 @@ MailManager.include({ resModel: info[0], }); if (state.windowState === 'closed') { - documentThread.close(); + documentThread.close({ + skipCrossTabSync: true, + }); } else { - documentThread.fold(state.windowState === 'folded'); - self.openThreadWindow(documentThread.getID(), { keepFoldState: true }); + documentThread.fold(state.windowState === 'folded', { + skipCrossTabSync: true, + }); + self.openThreadWindow(documentThread.getID(), { + keepFoldState: true, + skipCrossTabSync: true, + }); } }); }, @@ -213,13 +228,27 @@ MailManager.include({ * @param {StorageEvent} ev */ _onStorage: function (ev) { - if (ev.key === this.DOCUMENT_THREAD_STATE_KEY) { - var state = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY); - this._updateDocumentThreadWindows(state); + var value; + try { + value = JSON.parse(ev.newValue); + } catch (err) { + return; + } + if (ev.key.indexOf(this.DOCUMENT_THREAD_STATE_KEY) === 0) { + if (value.tabId === this._tabId) { + return; + } + // key format: DOCUMENT_THREAD_STATE_KEY + '/' + threadId + var documentThreadId = ev.key.substring(this.DOCUMENT_THREAD_STATE_KEY.length+1); + var param = {}; + param[documentThreadId] = value.state; + this._updateDocumentThreadWindows(param); } else if (ev.key === this.DOCUMENT_THREAD_MESSAGE_KEY) { - var message = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_MESSAGE_KEY); - if (message) { - this.addMessage(message); + if (value.tabId === this._tabId) { + return; + } + if (value.messageData) { + this.addMessage(value.messageData); } } }, diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js index a832b0b0793..d8174da5a73 100644 --- a/addons/mail/static/src/js/services/mail_window_manager.js +++ b/addons/mail/static/src/js/services/mail_window_manager.js @@ -94,6 +94,8 @@ MailManager.include({ * is not open yet, and do nothing otherwise. * @param {boolean} [options.keepFoldState=false] if set to true, keep the * fold state of the thread + * @param {boolean} [options.skipCrossTabSync=false] if set, thread + * should not notify other tabs from new document thread chat window state. * @returns {Promise} */ openThreadWindow: function (threadID, options) { @@ -132,7 +134,9 @@ MailManager.include({ // thread window could not be open, which may happen due to // access error while fetching messages to the document. // abort opening the thread window in this case. - thread.close(); + thread.close({ + skipCrossTabSync: true, + }); thread.isCreatingWindow = false; }); } diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js index 16d5cd31c86..08671354a3e 100644 --- a/addons/mail/static/src/js/thread_windows/thread_window.js +++ b/addons/mail/static/src/js/thread_windows/thread_window.js @@ -3,7 +3,9 @@ odoo.define('mail.ThreadWindow', function (require) { var AbstractThreadWindow = require('mail.AbstractThreadWindow'); var BasicComposer = require('mail.composer.Basic'); +var ExtendedComposer = require('mail.composer.Extended'); +var config = require('web.config'); var core = require('web.core'); var QWeb = core.qweb; @@ -65,19 +67,27 @@ var ThreadWindow = AbstractThreadWindow.extend({ if (!this.hasThread()) { this._startWithoutThread(); } else if (this.needsComposer()) { - var basicComposer = new BasicComposer(this, { + var composer; + var composerParams = { mentionPartnersRestricted: this._thread.getType() !== 'document_thread', - isMini: true, thread: this._thread, - }); - basicComposer.on('post_message', this, this._postMessage); - basicComposer.once('input_focused', this, function () { + }; + if ( + config.device.isMobile && + this._thread.isMassMailing() + ) { + composer = new ExtendedComposer(this, composerParams); + } else { + composer = new BasicComposer(this, _.extend({ isMini: true }, composerParams)); + } + composer.on('post_message', this, this._postMessage); + composer.once('input_focused', this, function () { var commands = this._thread.getCommands(); var partners = this._thread.getMentionPartnerSuggestions(); - basicComposer.mentionSetCommands(commands); - basicComposer.mentionSetPrefetchedPartners(partners); + composer.mentionSetCommands(commands); + composer.mentionSetPrefetchedPartners(partners); }); - composerDef = basicComposer.replace(this.$('.o_thread_composer')); + composerDef = composer.replace(this.$('.o_thread_composer')); composerDef.then(function () { self.$input = self.$('.o_composer_text_field'); }); @@ -129,24 +139,6 @@ var ThreadWindow = AbstractThreadWindow.extend({ isPassive: function () { return this._passive; }, - /** - * States whether the input of the thread window should be displayed or not. - * This is based on the type of the thread: - * - * Do not display the input in the following cases: - * - * - no thread related to this window - * - window of a mailbox (temp: let us have mailboxes in window mode) - * - window of a thread with mass mailing - * - * Any other threads show the input in the window. - * - * @override - * @returns {boolean} - */ - needsComposer: function () { - return this._super() && !this._thread.isMassMailing(); - }, /** * Turn the thread window in active mode, so that when the bottom of the * thread is visible, it is automatically marked as read. diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js index 14ac97def76..25e327b0428 100644 --- a/addons/mail/static/src/js/utils.js +++ b/addons/mail/static/src/js/utils.js @@ -8,7 +8,12 @@ var _t = core._t; function parseAndTransform(htmlString, transformFunction) { var openToken = "OPEN" + Date.now(); var string = htmlString.replace(/</g, openToken); - var children = $('
').html(string).contents(); + var children; + try { + children = $('
').html(string).contents(); + } catch (e) { + children = $('
').html('
' + string + '
').contents(); + } return _parseAndTransform(children, transformFunction) .replace(new RegExp(openToken, "g"), "<"); } diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss index 90793c91e95..5a9bad8e2dc 100644 --- a/addons/mail/static/src/scss/composer.scss +++ b/addons/mail/static/src/scss/composer.scss @@ -251,6 +251,9 @@ .o_composer_send .o_composer_button_send, .o_chatter_avatar { display: none !important; } + .o_attachment { + width: 50%; + } } .o_file_drop_zone_container { @@ -292,6 +295,10 @@ flex-flow: row wrap; } + .o_attachment_wrap { + padding-right: 0px; + } + .o_attachments_previews { > .o_attachment { width: 50%; diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss index 89e5f220ea2..8adf2113b47 100644 --- a/addons/mail/static/src/scss/thread.scss +++ b/addons/mail/static/src/scss/thread.scss @@ -183,6 +183,7 @@ } img { max-width: 100%; + height: auto; } .o_mail_body_long { diff --git a/addons/mail/static/tests/discuss_mobile_tests.js b/addons/mail/static/tests/discuss_mobile_tests.js index 84fd155522c..8f14f703b5d 100644 --- a/addons/mail/static/tests/discuss_mobile_tests.js +++ b/addons/mail/static/tests/discuss_mobile_tests.js @@ -13,6 +13,9 @@ QUnit.module('Discuss in mobile', { beforeEach: function () { this.services = mailTestUtils.getMailServices(); this.data = { + 'mail.channel': { + fields: {}, + }, 'mail.message': { fields: {}, }, @@ -80,27 +83,65 @@ QUnit.test('mobile basic rendering', async function (assert) { discuss.destroy(); }); -QUnit.test('on_{attach/detach}_callback', function (assert) { +QUnit.test('on_{attach/detach}_callback', async function (assert) { assert.expect(2); - var done = assert.async(); - createDiscuss({ + var discuss = await createDiscuss({ id: 1, context: {}, params: {}, data: this.data, services: this.services, - }).then(function (discuss) { - try { - discuss.on_attach_callback(); - assert.ok(true, 'should not crash on attach callback'); - discuss.on_detach_callback(); - assert.ok(true, 'should not crash on detach callback'); - } finally { - discuss.destroy(); - done(); - } }); + + discuss.on_attach_callback(); + assert.ok(true, 'should not crash on attach callback'); + discuss.on_detach_callback(); + assert.ok(true, 'should not crash on detach callback'); + discuss.destroy(); +}); + +QUnit.test('extended composer in mass mailing channel', async function (assert) { + assert.expect(4); + + this.data.initMessaging = { + channel_slots: { + channel_channel: [{ + id: 1, + channel_type: "channel", + name: "General", + mass_mailing: true, + }], + }, + }; + + var discuss = await createDiscuss({ + id: 1, + context: {}, + params: {}, + data: this.data, + services: this.services, + }); + + await testUtils.dom.click(discuss.$('.o_mail_mobile_tab[data-type="multi_user_channel"]')); + await testUtils.dom.click(discuss.$('.o_mail_preview[data-preview-id="1"]')); + assert.containsOnce( + $, + '.o_thread_window', + "should display a chat window"); + assert.strictEqual( + $('.o_thread_window').data('thread-id'), + 1, + "chat window should be from channel General"); + assert.containsOnce( + $('.o_thread_window'), + '.o_thread_composer', + "chat window should have a composer"); + assert.ok( + $('.o_thread_window .o_thread_composer').hasClass('o_thread_composer_extended'), + "chat window composer should be extended composer"); + + discuss.destroy(); }); }); diff --git a/addons/mail/static/tests/document_thread_window_tests.js b/addons/mail/static/tests/document_thread_window_tests.js index 86d06ba36c4..0748168a0cb 100644 --- a/addons/mail/static/tests/document_thread_window_tests.js +++ b/addons/mail/static/tests/document_thread_window_tests.js @@ -201,7 +201,7 @@ QUnit.test('post messages in a document thread window', async function (assert) testUtils.mock.intercept(messagingMenu, 'call_service', function (ev) { if (ev.data.service === 'local_storage' && ev.data.method === 'setItem' && ev.data.args[0] === 'mail.document_threads_last_message') { - assert.deepEqual(ev.data.args[1], newMessage, + assert.deepEqual(ev.data.args[1].messageData, newMessage, "should write sent message in local storage, to share info with other tabs"); } }, true); @@ -227,7 +227,7 @@ QUnit.test('post messages in a document thread window', async function (assert) }); QUnit.test('open, fold, unfold and close a document thread window', async function (assert) { - assert.expect(8); + assert.expect(24); var messagingMenu = new MessagingMenu(); testUtils.mock.addMockEnvironment(messagingMenu, { services: this.services, @@ -237,7 +237,13 @@ QUnit.test('open, fold, unfold and close a document thread window', async functi await testUtils.nextTick(); testUtils.mock.intercept(messagingMenu, 'call_service', function (ev) { if (ev.data.service === 'local_storage' && ev.data.method === 'setItem') { - assert.step(JSON.stringify(ev.data.args)); + const key = ev.data.args[0]; + const state = ev.data.args[1].state; + assert.strictEqual(key, 'mail.document_threads_state/some.res.model_1'); + assert.ok(state); + assert.ok(state.name); + assert.ok(state.windowState); + assert.step(`${key}: { name: "${state.name}", windowState: '${state.windowState}' }`); } }, true); await messagingMenu.appendTo($('#qunit-fixture')); @@ -263,10 +269,10 @@ QUnit.test('open, fold, unfold and close a document thread window', async functi await testUtils.nextTick(); assert.verifySteps([ - '["mail.document_threads_state",{"some.res.model_1":{"name":"Some Record","windowState":"open"}}]', - '["mail.document_threads_state",{"some.res.model_1":{"name":"Some Record","windowState":"folded"}}]', - '["mail.document_threads_state",{"some.res.model_1":{"name":"Some Record","windowState":"open"}}]', - '["mail.document_threads_state",{"some.res.model_1":{"name":"Some Record","windowState":"closed"}}]', + `mail.document_threads_state/some.res.model_1: { name: "Some Record", windowState: 'open' }`, + `mail.document_threads_state/some.res.model_1: { name: "Some Record", windowState: 'folded' }`, + `mail.document_threads_state/some.res.model_1: { name: "Some Record", windowState: 'open' }`, + `mail.document_threads_state/some.res.model_1: { name: "Some Record", windowState: 'closed' }`, ]); messagingMenu.destroy(); diff --git a/addons/mail/static/tests/systray/systray_messaging_menu_tests.js b/addons/mail/static/tests/systray/systray_messaging_menu_tests.js index 610e689ae4a..047d723a425 100644 --- a/addons/mail/static/tests/systray/systray_messaging_menu_tests.js +++ b/addons/mail/static/tests/systray/systray_messaging_menu_tests.js @@ -427,6 +427,88 @@ QUnit.test('update messaging preview on receiving a new message in channel previ messagingMenu.destroy(); }); +QUnit.test('new message of type "notification" are not considered as unread messages', async function (assert) { + assert.expect(8); + + const messagingMenu = new MessagingMenu(); + testUtils.mock.addMockEnvironment(messagingMenu, { + data: this.data, + services: this.services, + }); + await messagingMenu.appendTo($('#qunit-fixture')); + // open messaging menu + await testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + assert.containsOnce( + messagingMenu, + '.o_mail_preview', + "should display a single channel preview"); + assert.strictEqual( + messagingMenu + .$('.o_preview_name') + .text() + .trim(), + "general", + "should display channel preview of 'general' channel"); + assert.strictEqual( + messagingMenu + .$('.o_preview_counter') + .text() + .trim(), + "", + "should have unread counter of 0 for 'general' channel (no unread messages)"); + assert.strictEqual( + messagingMenu + .$('.o_last_message_preview') + .text() + .replace(/\s/g, ""), + "Me:test", + "should display author name and inline body of currently last message in the channel"); + // close messaging menu + await testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + + // simulate receiving a new message on the channel 'general' (id 1) + // of type 'notification' + const data = { + id: 100, + author_id: [42, "Someone"], + body: "

Left #general

", + channel_ids: [1], + message_type: 'notification', + }; + const notification = [[false, 'mail.channel', 1], data]; + messagingMenu.call('bus_service', 'trigger', 'notification', [notification]); + await testUtils.nextTick(); + // open messaging menu + await testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + assert.containsOnce( + messagingMenu, + '.o_mail_preview', + "should still display a single channel preview"); + assert.strictEqual( + messagingMenu + .$('.o_preview_name') + .text() + .trim(), + "general", + "should still display channel preview of 'general' channel"); + assert.strictEqual( + messagingMenu + .$('.o_preview_counter') + .text() + .trim(), + "", + "should still have unread counter of 0 for 'general' channel (no unread messages)"); + assert.strictEqual( + messagingMenu + .$('.o_last_message_preview') + .text() + .replace(/\s/g, ""), + "Someone:Left#general", + "should display author name and inline body of notification message (last message of channel)"); + + messagingMenu.destroy(); +}); + QUnit.test('preview of inbox message not linked to document + mark as read', async function (assert) { assert.expect(17); diff --git a/addons/maintenance/views/maintenance_views.xml b/addons/maintenance/views/maintenance_views.xml index 14f17c71c3e..f652e0b6575 100644 --- a/addons/maintenance/views/maintenance_views.xml +++ b/addons/maintenance/views/maintenance_views.xml @@ -128,8 +128,8 @@
    -
  • Category:
  • -
  • Request to:
  • +
  • Category:
  • +
  • Request to:
@@ -427,8 +427,8 @@
    -
  • Serial Number:
  • -
  • Model Number:
  • +
  • Serial Number:
  • +
  • Model Number:
diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 93db2240207..76e547c6916 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -338,6 +338,7 @@ class MrpProduction(models.Model): order.post_visible = order.is_locked and any((x.quantity_done > 0 and x.state not in ['done', 'cancel']) for x in order.move_raw_ids | order.move_finished_ids) else: order.post_visible = order.is_locked and any((x.quantity_done > 0 and x.state not in ['done', 'cancel']) for x in order.move_finished_ids) + order.post_visible &= all(wo.state in ['done', 'cancel'] for wo in order.workorder_ids) or all(m.product_id.tracking == 'none' for m in order.move_raw_ids) @api.multi @api.depends('workorder_ids.state', 'move_finished_ids', 'move_finished_ids.quantity_done', 'is_locked') @@ -619,6 +620,8 @@ class MrpProduction(models.Model): @api.multi def open_produce_product(self): self.ensure_one() + if self.bom_id.type == 'phantom': + raise UserError(_('You cannot produce a MO with a bom kit product.')) action = self.env.ref('mrp.act_mrp_product_produce').read()[0] return action diff --git a/addons/mrp/models/res_config_settings.py b/addons/mrp/models/res_config_settings.py index 087d8050b40..41cf3015089 100644 --- a/addons/mrp/models/res_config_settings.py +++ b/addons/mrp/models/res_config_settings.py @@ -15,7 +15,6 @@ class ResConfigSettings(models.TransientModel): module_mrp_plm = fields.Boolean("Product Lifecycle Management (PLM)") module_mrp_workorder = fields.Boolean("Work Orders") module_quality_control = fields.Boolean("Quality") - module_mrp_subcontracting = fields.Boolean("Subcontracting") group_mrp_routings = fields.Boolean("MRP Work Orders", implied_group='mrp.group_mrp_routings') diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index f2058420815..a65cf3ca9fb 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -233,6 +233,11 @@ class StockMove(models.Model): res = super(StockMove, self)._should_be_assigned() return bool(res and not (self.production_id or self.raw_material_production_id)) + def _key_assign_picking(self): + keys = super(StockMove, self)._key_assign_picking() + return keys + (self.created_production_id,) + + def _compute_kit_quantities(self, product_id, kit_qty, kit_bom, filters): """ Computes the quantity delivered or received when a kit is sold or purchased. A ratio 'qty_processed/qty_needed' is computed for each component, and the lowest one is kept @@ -266,4 +271,4 @@ class StockMove(models.Model): # doesn't make sense. return min(qty_ratios) // 1 else: - return 0.0 + return 0.0 \ No newline at end of file diff --git a/addons/mrp/views/res_config_settings_views.xml b/addons/mrp/views/res_config_settings_views.xml index a40a1d150ee..96378ad693b 100644 --- a/addons/mrp/views/res_config_settings_views.xml +++ b/addons/mrp/views/res_config_settings_views.xml @@ -44,17 +44,6 @@
-
-
- -
-
-
-

Products

diff --git a/addons/mrp_subcontracting/__init__.py b/addons/mrp_subcontracting/__init__.py deleted file mode 100644 index be29e5c5628..00000000000 --- a/addons/mrp_subcontracting/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import models -from odoo import api, SUPERUSER_ID - -def _create_subcontracting_rules(cr, registry): - """ This hook is used to add the default subcontracting rules on every warehouse/company. It is - necessary if the mrp_subcontracting module is installed after some warehouses - were already created. - """ - env = api.Environment(cr, SUPERUSER_ID, {}) - warehouse_ids = env['stock.warehouse'].search(['|', ('subcontracting_mto_pull_id', '=', False), ('subcontracting_pull_id', '=', False)]) - for warehouse_id in warehouse_ids: - warehouse_id.write({'subcontracting_to_resupply': warehouse_id.subcontracting_to_resupply}) - company_ids = env['res.company'].search([('subcontracting_location_id', '!=', False)]) - for company_id in company_ids: - wh = env['stock.warehouse'].search([('company_id', '=', company_id.id), ('active', '=', True)], limit=1) - production_location = wh._get_production_location() - rsoo_rule = env['stock.rule'].search([ - ('location_src_id', '=', company_id.subcontracting_location_id.id), - ('location_id', '=', production_location.id), - ('picking_type_id', '=', wh.manu_type_id.id) - ]) - if not rsoo_rule: - company_id._create_resupply_subcontractor_rules() diff --git a/addons/mrp_subcontracting/__manifest__.py b/addons/mrp_subcontracting/__manifest__.py deleted file mode 100644 index d6fbd927bdb..00000000000 --- a/addons/mrp_subcontracting/__manifest__.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -{ - 'name': "mrp_subcontracting", - 'version': '0.1', - 'summary': "Subcontract Productions", - 'description': "", - 'website': 'https://www.odoo.com/page/manufacturing', - 'category': 'Manufacturing/Manufacturing', - 'depends': ['mrp', 'contacts'], - 'data': [ - 'data/mrp_subcontracting_data.xml', - 'views/stock_warehouse_views.xml', - 'views/stock_picking_views.xml', - 'views/mrp_bom_views.xml', - ], - 'post_init_hook': '_create_subcontracting_rules', -} diff --git a/addons/mrp_subcontracting/data/mrp_subcontracting_data.xml b/addons/mrp_subcontracting/data/mrp_subcontracting_data.xml deleted file mode 100644 index 3966da5b9f2..00000000000 --- a/addons/mrp_subcontracting/data/mrp_subcontracting_data.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - Resupply Subcontractor on Order - - 5 - - - - - diff --git a/addons/mrp_subcontracting/models/__init__.py b/addons/mrp_subcontracting/models/__init__.py deleted file mode 100644 index 98238bd9a1b..00000000000 --- a/addons/mrp_subcontracting/models/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import mrp_bom -from . import res_company -from . import res_partner -from . import stock_move -from . import stock_picking -from . import stock_warehouse - diff --git a/addons/mrp_subcontracting/models/mrp_bom.py b/addons/mrp_subcontracting/models/mrp_bom.py deleted file mode 100644 index 8f0d87d0b73..00000000000 --- a/addons/mrp_subcontracting/models/mrp_bom.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import fields, models - - -class MrpBom(models.Model): - _inherit = 'mrp.bom' - - type = fields.Selection(selection_add=[('subcontract', 'Subcontracting')]) - subcontractor_ids = fields.One2many('res.partner', 'bom_id', domain=[('type', '=', 'subcontractor')], string='Subcontractors') - - def _bom_subcontract_find(self, product_tmpl=None, product=None, picking_type=None, company_id=False, bom_type='subcontract', subcontractor=False): - domain = self._bom_find_domain(product_tmpl=product_tmpl, product=product, picking_type=picking_type, company_id=company_id, bom_type=bom_type) - if subcontractor: - domain += ['|', ('subcontractor_ids', 'in', subcontractor.id), ('subcontractor_ids', '=', False)] - else: - domain += [('subcontractor_ids', '=', False)] - return self.search(domain, order='subcontractor_ids, sequence, product_id', limit=1) - diff --git a/addons/mrp_subcontracting/models/res_company.py b/addons/mrp_subcontracting/models/res_company.py deleted file mode 100644 index 93687fa4d27..00000000000 --- a/addons/mrp_subcontracting/models/res_company.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ - - -class ResCompany(models.Model): - _inherit = 'res.company' - - subcontracting_location_id = fields.Many2one('stock.location') - - def _create_per_company_locations(self): - super(ResCompany, self)._create_per_company_locations() - self._create_subcontracting_location() - - def _create_subcontracting_location(self): - for company in self: - parent_location = self.env.ref('stock.stock_location_locations_partner', raise_if_not_found=False) - subcontracting_location = self.env['stock.location'].create({ - 'name': _('%s: Subcontracting Location') % company.name, - 'usage': 'internal', - 'location_id': parent_location.id, - 'company_id': company.id, - }) - company.write({'subcontracting_location_id': subcontracting_location.id}) - - @api.model - def create_missing_subcontracting_location(self): - company_without_subcontracting_loc = self.env['res.company'].search( - [('subcontracting_location_id', '=', False)]) - for company in company_without_subcontracting_loc: - company._create_subcontracting_location() - - def _create_resupply_subcontractor_rules(self): - self.ensure_one() - wh = self.env['stock.warehouse'].search([('company_id', '=', self.id), ('active', '=', True)], limit=1) - subcontracting_location = self.subcontracting_location_id - production_location = wh._get_production_location() - resupply_route = wh._find_global_route('mrp_subcontracting.route_resupply_subcontractor_mto', _('Resupply Subcontractor on Order')) - resupply_route.rule_ids = [(0, 0, { - 'name': '%s → %s' % (subcontracting_location.name, production_location.name), - 'action': 'pull', - 'picking_type_id': wh.subcontracting_type_id, - 'location_src_id': subcontracting_location, - 'location_id': production_location, - })], - diff --git a/addons/mrp_subcontracting/models/res_partner.py b/addons/mrp_subcontracting/models/res_partner.py deleted file mode 100644 index 925296fee0c..00000000000 --- a/addons/mrp_subcontracting/models/res_partner.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ - - -class ResPartner(models.Model): - _inherit = 'res.partner' - - type = fields.Selection(selection_add=[('subcontractor', 'Subcontractor')]) - bom_id = fields.Many2one('mrp.bom', domain=[('type', '=', 'subcontract')]) - - def _get_name(self): - name = super(ResPartner, self)._get_name() - if self.type == 'subcontractor': - subcontractor = _('Subcontractor') - if name.endswith(', '): - name = "%s%s" % (name, subcontractor) - else: - name = '%s, %s' % (name, subcontractor) - return name - - @api.onchange('type') - def _onchange_partner_type(self): - if self.type == 'subcontractor': - company = self.company_id or self.parent_id.company_id - subcontracting_location = company.subcontracting_location_id - self.update({ - 'property_stock_customer': subcontracting_location, - 'property_stock_supplier': subcontracting_location - }) - else: - self.update({ - 'property_stock_customer': self.env.ref('stock.stock_location_customers'), - 'property_stock_supplier': self.env.ref('stock.stock_location_suppliers') - }) - - @api.model_create_multi - def create(self, vals_list): - for vals in vals_list: - if vals.get('type') == 'subcontractor': - if not vals.get('property_stock_supplier') and not vals.get('property_stock_customer'): - company = vals.get('company_id') and self.env['res.company'].browse(vals['company_id']) or \ - self.browse(vals.get('parent_id')).company_id - subcontracting_location = company.subcontracting_location_id - vals['property_stock_supplier'] = subcontracting_location - vals['property_stock_customer'] = subcontracting_location - return super(ResPartner, self).create(vals_list) - - def write(self, vals): - if vals.get('type') == 'subcontractor': - subcontracting_location = self.env.company.subcontracting_location_id - if not vals.get('property_stock_supplier') and not vals.get('property_stock_customer'): - vals['property_stock_supplier'] = subcontracting_location.id - vals['property_stock_customer'] = subcontracting_location.id - return super(ResPartner, self).write(vals) diff --git a/addons/mrp_subcontracting/models/stock_move.py b/addons/mrp_subcontracting/models/stock_move.py deleted file mode 100644 index daf847891bf..00000000000 --- a/addons/mrp_subcontracting/models/stock_move.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from collections import defaultdict - -from odoo import models, _ -from odoo.exceptions import UserError - - -class StockMove(models.Model): - _inherit = 'stock.move' - - def _get_subcontract_bom(self): - self.ensure_one() - bom = self.env['mrp.bom']._bom_subcontract_find( - product=self.product_id, - picking_type=self.picking_type_id, - company_id=self.company_id.id, - bom_type='subcontract', - subcontractor=self.picking_id.partner_id, - ) - return bom - - def _action_confirm(self, merge=True, merge_into=False): - res = super(StockMove, self)._action_confirm(merge=merge, merge_into=merge_into) - subcontract_details_per_picking = defaultdict(list) - for move in res: - if not move.picking_id: - continue - if not move.picking_id._is_subcontract(): - continue - bom = move._get_subcontract_bom() - if not bom: - error_message = _('Please define a BoM of type subcontracting for the product "%s". If you don\'t want to subcontract the product "%s", do not select a partner of type subcontractor.') - error_message += '\n\n' - error_message += _('If there is well a BoM of type subcontracting defined, check if you have set the correct subcontractors on it.') - raise UserError(error_message % (move.product_id.name, move.product_id.name)) - subcontract_details_per_picking[move.picking_id].append((move, bom)) - for picking, subcontract_details in subcontract_details_per_picking.items(): - picking._subcontracted_produce(subcontract_details) - return res diff --git a/addons/mrp_subcontracting/models/stock_picking.py b/addons/mrp_subcontracting/models/stock_picking.py deleted file mode 100644 index b3c93fb8b7e..00000000000 --- a/addons/mrp_subcontracting/models/stock_picking.py +++ /dev/null @@ -1,133 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models - - -class StockPicking(models.Model): - _inherit = 'stock.picking' - - display_action_record_components = fields.Boolean(compute='_compute_display_action_record_components') - display_view_subcontracted_move_lines = fields.Boolean(compute='_compute_display_view_subcontracted_move_lines') - - @api.depends('state') - def _compute_display_action_record_components(self): - for picking in self: - # Hide if not encoding state - if picking.state in ('draft', 'cancel', 'done'): - continue - if not picking._is_subcontract(): - continue - # Hide if no move is tracked - subcontracted_productions = picking._get_subcontracted_productions() - subcontracted_moves = subcontracted_productions.mapped('move_raw_ids') - subcontracted_moves |= subcontracted_productions.mapped('move_finished_ids') - if all(subcontracted_move.has_tracking == 'none' for subcontracted_move in subcontracted_moves): - continue - # Hide if the production is to close - if not subcontracted_productions.filtered(lambda mo: mo.state not in ('to_close', 'done')): - continue - picking.display_action_record_components = True - - @api.depends('state') - def _compute_display_view_subcontracted_move_lines(self): - for picking in self: - # Hide if not encoding state - if picking.state in ('draft', 'cancel'): - continue - if not picking._is_subcontract(): - continue - # Hide until state done if no move is tracked, if tracked until something was produced - subcontracted_productions = picking._get_subcontracted_productions() - subcontracted_moves = subcontracted_productions.mapped('move_raw_ids') - subcontracted_moves |= subcontracted_productions.mapped('move_finished_ids') - if all(subcontracted_move.has_tracking == 'none' for subcontracted_move in subcontracted_moves): - if picking.state != 'done': - continue - # Hide if nothing was produced - if all(subcontracted_move.quantity_done == 0 for subcontracted_move in subcontracted_moves): - continue - picking.display_view_subcontracted_move_lines = True - - # ------------------------------------------------------------------------- - # Action methods - # ------------------------------------------------------------------------- - def action_done(self): - for picking in self: - subcontracted_productions = picking._get_subcontracted_productions() - for subcontracted_production in subcontracted_productions: - subcontracted_production.button_mark_done() - return super(StockPicking, self).action_done() - - def action_view_subcontracted_move_lines(self): - """ Returns a list view with the move lines of the subcontracted products. To find them, we - look on the origin moves of the move lines of the picking if there is a manufacturing order. - """ - self.ensure_one() - subcontracted_productions = self._get_subcontracted_productions() - subcontracted_move_lines = self.env['stock.move.line'] - for subcontracted_production in subcontracted_productions: - subcontracted_move_lines |= subcontracted_production.move_raw_ids.mapped('move_line_ids') - subcontracted_move_lines |= subcontracted_production.move_finished_ids.mapped('move_line_ids') - action = self.env.ref('stock.stock_move_line_action').read()[0] - action['context'] = {} - action['domain'] = [('id', 'in', subcontracted_move_lines.ids)] - return action - - def action_record_components(self): - self.ensure_one() - subcontracted_productions = self._get_subcontracted_productions() - to_register = subcontracted_productions.filtered(lambda mo: mo.state not in ('to_close', 'done')) - if to_register: - mo = to_register[0] - action = self.env.ref('mrp.act_mrp_product_produce').read()[0] - action['context'] = dict(self.env.context, active_id=mo.id) - return action - - # ------------------------------------------------------------------------- - # Subcontract helpers - # ------------------------------------------------------------------------- - def _is_subcontract(self): - self.ensure_one() - if self.partner_id.type == 'subcontractor' and \ - self.picking_type_id.code == 'incoming': - return True - return False - - def _get_subcontracted_productions(self): - self.ensure_one() - return self.move_lines.mapped('move_orig_ids.production_id') - - def _prepare_subcontract_mo_vals(self, subcontract_move, bom): - subcontract_move.ensure_one() - product = subcontract_move.product_id - warehouse = subcontract_move.warehouse_id or self.picking_type_id.warehouse_id - vals = { - 'product_id': product.id, - 'product_uom_id': subcontract_move.product_uom.id, - 'bom_id': bom.id, - 'location_src_id': subcontract_move.picking_id.partner_id.property_stock_supplier.id, - 'location_dest_id': subcontract_move.location_id.id, - 'product_qty': subcontract_move.product_uom_qty, - 'picking_type_id': warehouse.subcontracting_type_id.id - } - return vals - - def _subcontracted_produce(self, subcontract_details): - self.ensure_one() - for move, bom in subcontract_details: - mo = self.env['mrp.production'].create(self._prepare_subcontract_mo_vals(move, bom)) - self.env['stock.move'].create(mo._get_moves_raw_values()) - mo.action_confirm() - - # Link the finished to the receipt move. - finished_move = mo.move_finished_ids.filtered(lambda m: m.product_id == move.product_id) - finished_move.write({'move_dest_ids': [(4, move.id, False)]}) - mo.action_assign() - - # Only skip the produce wizard if no moves is tracked - moves = mo.mapped('move_raw_ids') + mo.mapped('move_finished_ids') - if any(move.has_tracking != 'none' for move in moves): - continue - for m in moves: - m.quantity_done = m.product_uom_qty diff --git a/addons/mrp_subcontracting/models/stock_warehouse.py b/addons/mrp_subcontracting/models/stock_warehouse.py deleted file mode 100644 index a9d0cd1713b..00000000000 --- a/addons/mrp_subcontracting/models/stock_warehouse.py +++ /dev/null @@ -1,139 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ - - -class StockWarehouse(models.Model): - _inherit = 'stock.warehouse' - - subcontracting_to_resupply = fields.Boolean( - 'Resupply Subcontractors', default=True, - help="Resupply subcontractors with components") - - subcontracting_mto_pull_id = fields.Many2one( - 'stock.rule', 'Subcontracting MTO Rule') - subcontracting_pull_id = fields.Many2one( - 'stock.rule', 'Subcontracting MTS Rule' - ) - - subcontracting_route_id = fields.Many2one('stock.location.route', 'Resupply Subcontractor', ondelete='restrict') - - subcontracting_type_id = fields.Many2one( - 'stock.picking.type', 'Subcontracting Operation Type', - domain=[('code', '=', 'mrp_operation')]) - - def get_rules_dict(self): - result = super(StockWarehouse, self).get_rules_dict() - subcontract_location_id = self._get_subcontracting_location() - for warehouse in self: - result[warehouse.id].update({ - 'subcontract': [ - self.Routing(warehouse.lot_stock_id, subcontract_location_id, warehouse.out_type_id, 'pull'), - ] - }) - return result - - def _get_routes_values(self): - routes = super(StockWarehouse, self)._get_routes_values() - routes.update({ - 'subcontracting_route_id': { - 'routing_key': 'subcontract', - 'depends': ['subcontracting_to_resupply'], - 'route_create_values': { - 'product_categ_selectable': False, - 'warehouse_selectable': True, - 'product_selectable': False, - 'company_id': self.company_id.id, - 'sequence': 10, - 'name': self._format_routename(name=_('Resupply Subcontractor')) - }, - 'route_update_values': { - 'active': self.subcontracting_to_resupply, - }, - 'rules_values': { - 'active': self.subcontracting_to_resupply, - } - } - }) - return routes - - def _get_global_route_rules_values(self): - rules = super(StockWarehouse, self)._get_global_route_rules_values() - subcontract_location_id = self._get_subcontracting_location() - production_location_id = self._get_production_location() - rules.update({ - 'subcontracting_mto_pull_id': { - 'depends': ['subcontracting_to_resupply'], - 'create_values': { - 'procure_method': 'make_to_order', - 'company_id': self.company_id.id, - 'action': 'pull', - 'auto': 'manual', - 'route_id': self._find_global_route('stock.route_warehouse0_mto', _('Make To Order')).id, - 'name': self._format_rulename(self.lot_stock_id, subcontract_location_id, 'MTO'), - 'location_id': subcontract_location_id.id, - 'location_src_id': self.lot_stock_id.id, - 'picking_type_id': self.out_type_id.id - }, - 'update_values': { - 'active': self.subcontracting_to_resupply - } - }, - 'subcontracting_pull_id': { - 'depends': ['subcontracting_to_resupply'], - 'create_values': { - 'procure_method': 'make_to_order', - 'company_id': self.company_id.id, - 'action': 'pull', - 'auto': 'manual', - 'route_id': self._find_global_route('mrp_subcontracting.route_resupply_subcontractor_mto', - _('Resupply Subcontractor on Order')).id, - 'name': self._format_rulename(self.lot_stock_id, subcontract_location_id, False), - 'location_id': production_location_id.id, - 'location_src_id': subcontract_location_id.id, - 'picking_type_id': self.out_type_id.id - }, - 'update_values': { - 'active': self.subcontracting_to_resupply - } - }, - }) - return rules - - def _get_picking_type_create_values(self, max_sequence): - data, next_sequence = super(StockWarehouse, self)._get_picking_type_create_values(max_sequence) - data.update({ - 'subcontracting_type_id': { - 'name': _('Subcontracting'), - 'code': 'mrp_operation', - 'use_create_lots': True, - 'use_existing_lots': True, - 'sequence': next_sequence + 2 - }, - }) - return data, max_sequence + 4 - - def _get_sequence_values(self): - values = super(StockWarehouse, self)._get_sequence_values() - values.update({ - 'subcontracting_type_id': {'name': self.name + ' ' + _('Sequence subcontracting'), 'prefix': self.code + '/SBC/', 'padding': 5}, - }) - return values - - def _get_picking_type_update_values(self): - data = super(StockWarehouse, self)._get_picking_type_update_values() - subcontract_location_id = self._get_subcontracting_location() - production_location_id = self._get_production_location() - data.update({ - 'subcontracting_type_id': { - 'active': False, - 'default_location_src_id': subcontract_location_id.id, - 'default_location_dest_id': production_location_id.id, - }, - }) - return data - - def _get_subcontracting_location(self): - return self.company_id.subcontracting_location_id - diff --git a/addons/mrp_subcontracting/tests/__init__.py b/addons/mrp_subcontracting/tests/__init__.py deleted file mode 100644 index c8d5649e39d..00000000000 --- a/addons/mrp_subcontracting/tests/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import test_subcontracting - diff --git a/addons/mrp_subcontracting/tests/test_subcontracting.py b/addons/mrp_subcontracting/tests/test_subcontracting.py deleted file mode 100644 index 032f319284a..00000000000 --- a/addons/mrp_subcontracting/tests/test_subcontracting.py +++ /dev/null @@ -1,526 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo.tests import Form -from odoo.tests.common import SavepointCase, TransactionCase - -from odoo.tests import tagged - -@tagged('post_install', '-at_install') -class TestSubcontractingBasic(TransactionCase): - def test_partner_1(self): - """ Checks the `display_name` of subcontracted partners. """ - main_partner = self.env['res.partner'].create({'name': 'main_partner'}) - - # When a name is set to the subcontracted partner. - subcontractor_partner1 = self.env['res.partner'].create({ - 'name': 'subcontractor_partner', - 'type': 'subcontractor', - 'parent_id': main_partner.id, - }) - self.assertEqual(subcontractor_partner1.display_name, "main_partner, subcontractor_partner, Subcontractor") - - # When a name is not set to the subcontracted partner. - subcontractor_partner1 = self.env['res.partner'].create({ - 'name': '', - 'type': 'subcontractor', - 'parent_id': main_partner.id, - }) - self.assertEqual(subcontractor_partner1.display_name, "main_partner, Subcontractor") - - def test_partner_2(self): - """ Open an existing partner and add a contact of type subcontractor (simple form view). - Checks the customer and supplier locations are set to the subcontracting one onces saved. - """ - that_company = self.env['res.partner'].create({ - 'is_company': True, - 'name': "That Company", - }) - partner_form = Form(that_company) - with partner_form.child_ids.new() as partner: - partner.name = 'That Guy' - partner.type = 'subcontractor' - partner_form.save() - subcontractor = that_company.child_ids - self.assertEquals(subcontractor.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) - self.assertEquals(subcontractor.property_stock_customer, self.env.user.company_id.subcontracting_location_id) - - def test_partner_3(self): - """ Create a new partner, select a parent company, and choose type subcontractor (debug - active). Checks the onchange correctly set the customer and supplier locations to the - subcontracting ones. Change them to another one, save, checks they are rightly applied. - """ - # FIXME sle: this field should not be writeable without the debug mode but it is. ask xmo - group_no_one = self.env.ref('base.group_no_one') - self.env.user.write({'groups_id': [(3, group_no_one.id)]}) - partner_form = Form(self.env['res.partner']) - partner_form.name = 'subcontractor partner' - self.assertNotEqual(partner_form.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) - self.assertNotEqual(partner_form.property_stock_customer, self.env.user.company_id.subcontracting_location_id) - partner_form.type = 'subcontractor' - self.assertEqual(partner_form.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) - self.assertEqual(partner_form.property_stock_customer, self.env.user.company_id.subcontracting_location_id) - subcontracting_location2 = self.env.user.company_id.subcontracting_location_id.copy() - partner_form.property_stock_supplier = subcontracting_location2 - partner_form.property_stock_customer = subcontracting_location2 - partner = partner_form.save() - self.assertEqual(partner.property_stock_supplier, subcontracting_location2) - self.assertEqual(partner.property_stock_customer, subcontracting_location2) - - def test_partner_4(self): - """ Create a new partner, select a parent company, and choose type subcontractor (debug - not active). Checks the customer and supplier locations are set to the subcontracting one - onces saved. - """ - that_company = self.env['res.partner'].create({ - 'is_company': True, - 'name': "That Company", - }) - partner_form = Form(self.env['res.partner']) - partner_form.name = 'That Guy' - partner_form.parent_id = that_company - partner_form.type = 'subcontractor' - subcontractor = partner_form.save() - self.assertEquals(subcontractor.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) - self.assertEquals(subcontractor.property_stock_customer, self.env.user.company_id.subcontracting_location_id) - - def test_partner_5(self): - """ Create a new partner with a child partner of type contact, - then write the type 'subcontractor' on it - """ - that_company = self.env['res.partner'].create({ - 'is_company': True, - 'name': "That Company", - }) - partner_form = Form(that_company) - with partner_form.child_ids.new() as partner: - partner.name = 'That Guy' - partner.type = 'contact' - partner_form.save() - - # We write a new type on the created child partner - subcontractor = that_company.child_ids - self.assertEquals(len(that_company.child_ids), 1) - subcontractor.type = 'subcontractor' - - self.assertEquals(subcontractor.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) - self.assertEquals(subcontractor.property_stock_customer, self.env.user.company_id.subcontracting_location_id) - - def test_subcontracting_location_1(self): - """ Checks the creation and presence of the subcontracting location. """ - self.assertTrue(self.env.user.company_id.subcontracting_location_id) - self.assertTrue(self.env.user.company_id.subcontracting_location_id.active) - company2 = self.env['res.company'].create({'name': 'Test Company'}) - self.assertTrue(company2.subcontracting_location_id) - - -class TestSubcontractingFlows(SavepointCase): - @classmethod - def setUpClass(cls): - super(TestSubcontractingFlows, cls).setUpClass() - # 1: Create a subcontracting partner - main_partner = cls.env['res.partner'].create({'name': 'main_partner'}) - cls.subcontractor_partner1 = cls.env['res.partner'].create({ - 'name': 'subcontractor_partner', - 'type': 'subcontractor', - 'parent_id': main_partner.id, - }) - - # 2. Create a BOM of subcontracting type - cls.comp1 = cls.env['product.product'].create({ - 'name': 'Component1', - 'type': 'product', - 'categ_id': cls.env.ref('product.product_category_all').id, - }) - cls.comp2 = cls.env['product.product'].create({ - 'name': 'Component2', - 'type': 'product', - 'categ_id': cls.env.ref('product.product_category_all').id, - }) - cls.finished = cls.env['product.product'].create({ - 'name': 'finished', - 'type': 'product', - 'categ_id': cls.env.ref('product.product_category_all').id, - }) - bom_form = Form(cls.env['mrp.bom']) - bom_form.type = 'subcontract' - bom_form.product_tmpl_id = cls.finished.product_tmpl_id - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = cls.comp1 - bom_line.product_qty = 1 - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = cls.comp2 - bom_line.product_qty = 1 - cls.bom = bom_form.save() - - # Create a BoM for cls.comp2 - cls.comp2comp = cls.env['product.product'].create({ - 'name': 'component for Component2', - 'type': 'product', - 'categ_id': cls.env.ref('product.product_category_all').id, - }) - bom_form = Form(cls.env['mrp.bom']) - bom_form.product_tmpl_id = cls.comp2.product_tmpl_id - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = cls.comp2comp - bom_line.product_qty = 1 - cls.comp2_bom = bom_form.save() - - def test_flow_1(self): - """ Don't tick any route on the components and trigger the creation of the subcontracting - manufacturing order through a receipt picking. Create a reordering rule in the - subcontracting locations for a component and run the scheduler to resupply. Checks if the - resupplying actually works - """ - # Create a receipt picking from the subcontractor - picking_form = Form(self.env['stock.picking']) - picking_form.picking_type_id = self.env.ref('stock.picking_type_in') - picking_form.partner_id = self.subcontractor_partner1 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.finished - move.product_uom_qty = 1 - picking_receipt = picking_form.save() - picking_receipt.action_confirm() - - # Nothing should be tracked - self.assertFalse(picking_receipt.display_action_record_components) - self.assertFalse(picking_receipt.display_view_subcontracted_move_lines) - - # Check the created manufacturing order - mo = self.env['mrp.production'].search([('bom_id', '=', self.bom.id)]) - self.assertEqual(len(mo), 1) - self.assertEqual(len(mo.picking_ids), 0) - wh = picking_receipt.picking_type_id.warehouse_id - self.assertEquals(mo.picking_type_id, wh.subcontracting_type_id) - self.assertFalse(mo.picking_type_id.active) - - # Create a RR - pg1 = self.env['procurement.group'].create({}) - self.env['stock.warehouse.orderpoint'].create({ - 'name': 'xxx', - 'product_id': self.comp1.id, - 'product_min_qty': 0, - 'product_max_qty': 0, - 'location_id': self.env.user.company_id.subcontracting_location_id.id, - 'group_id': pg1.id, - }) - - # Run the scheduler and check the created picking - self.env['procurement.group'].run_scheduler() - picking = self.env['stock.picking'].search([('group_id', '=', pg1.id)]) - self.assertEqual(len(picking), 1) - self.assertEquals(picking.picking_type_id, wh.out_type_id) - picking_receipt.move_lines.quantity_done = 1 - picking_receipt.button_validate() - self.assertEquals(mo.state, 'done') - - # Now that the picking is done, the details stat button should be visible - self.assertTrue(picking_receipt.display_view_subcontracted_move_lines) - - # Available quantities should be negative at the subcontracting location for each components - avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_comp2 = self.env['stock.quant']._get_available_quantity(self.comp2, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_finished = self.env['stock.quant']._get_available_quantity(self.finished, wh.lot_stock_id) - self.assertEquals(avail_qty_comp1, -1) - self.assertEquals(avail_qty_comp2, -1) - self.assertEquals(avail_qty_finished, 1) - - def test_flow_2(self): - """ Tick "Resupply Subcontractor on Order" on the components and trigger the creation of - the subcontracting manufacturing order through a receipt picking. Checks if the resupplying - actually works. - """ - # Tick "resupply subconractor on order" - resupply_sub_on_order_route = self.env['stock.location.route'].search([('name', '=', 'Resupply Subcontractor on Order')]) - (self.comp1 + self.comp2).write({'route_ids': [(4, resupply_sub_on_order_route.id, None)]}) - - # Create a receipt picking from the subcontractor - picking_form = Form(self.env['stock.picking']) - picking_form.picking_type_id = self.env.ref('stock.picking_type_in') - picking_form.partner_id = self.subcontractor_partner1 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.finished - move.product_uom_qty = 1 - picking_receipt = picking_form.save() - picking_receipt.action_confirm() - - # Nothing should be tracked - self.assertFalse(picking_receipt.display_action_record_components) - self.assertFalse(picking_receipt.display_view_subcontracted_move_lines) - - # Pickings should directly be created - mo = self.env['mrp.production'].search([('bom_id', '=', self.bom.id)]) - self.assertEqual(len(mo.picking_ids), 1) - self.assertEquals(mo.state, 'to_close') - self.assertEqual(len(mo.picking_ids.move_lines), 2) - - picking = mo.picking_ids - wh = picking.picking_type_id.warehouse_id - - # The picking should be a delivery order - self.assertEquals(picking.picking_type_id, wh.out_type_id) - - self.assertEquals(mo.picking_type_id, wh.subcontracting_type_id) - self.assertFalse(mo.picking_type_id.active) - - # No manufacturing order for `self.comp2` - comp2mo = self.env['mrp.production'].search([('bom_id', '=', self.comp2_bom.id)]) - self.assertEqual(len(comp2mo), 0) - - picking_receipt.move_lines.quantity_done = 1 - picking_receipt.button_validate() - self.assertEquals(mo.state, 'done') - - # Now that the picking is done, the details stat button should be visible - self.assertTrue(picking_receipt.display_view_subcontracted_move_lines) - - # Available quantities should be negative at the subcontracting location for each components - avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_comp2 = self.env['stock.quant']._get_available_quantity(self.comp2, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_finished = self.env['stock.quant']._get_available_quantity(self.finished, wh.lot_stock_id) - self.assertEquals(avail_qty_comp1, -1) - self.assertEquals(avail_qty_comp2, -1) - self.assertEquals(avail_qty_finished, 1) - - def test_flow_3(self): - """ Tick "Resupply Subcontractor on Order" and "MTO" on the components and trigger the - creation of the subcontracting manufacturing order through a receipt picking. Checks if the - resupplying actually works. One of the component has also "manufacture" set and a BOM - linked. Checks that an MO is created for this one. - """ - # Tick "resupply subconractor on order" - resupply_sub_on_order_route = self.env['stock.location.route'].search([('name', '=', 'Resupply Subcontractor on Order')]) - (self.comp1 + self.comp2).write({'route_ids': [(4, resupply_sub_on_order_route.id, None)]}) - - # Tick "manufacture" and MTO on self.comp2 - mto_route = self.env['stock.location.route'].search([('name', '=', 'Replenish on Order (MTO)')]) - manufacture_route = self.env['stock.location.route'].search([('name', '=', 'Manufacture')]) - self.comp2.write({'route_ids': [(4, manufacture_route.id, None)]}) - self.comp2.write({'route_ids': [(4, mto_route.id, None)]}) - - # Create a receipt picking from the subcontractor - picking_form = Form(self.env['stock.picking']) - picking_form.picking_type_id = self.env.ref('stock.picking_type_in') - picking_form.partner_id = self.subcontractor_partner1 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.finished - move.product_uom_qty = 1 - picking_receipt = picking_form.save() - picking_receipt.action_confirm() - - # Nothing should be tracked - self.assertFalse(picking_receipt.display_action_record_components) - self.assertFalse(picking_receipt.display_view_subcontracted_move_lines) - - # Pickings should directly be created - mo = self.env['mrp.production'].search([('bom_id', '=', self.bom.id)]) - self.assertEqual(len(mo.picking_ids), 1) - self.assertEquals(mo.state, 'to_close') - self.assertEqual(len(mo.picking_ids.move_lines), 2) - - # The picking should be a delivery order - wh = picking_receipt.picking_type_id.warehouse_id - self.assertEquals(mo.picking_ids.picking_type_id, wh.out_type_id) - - self.assertEquals(mo.picking_type_id, wh.subcontracting_type_id) - self.assertFalse(mo.picking_type_id.active) - - # As well as a manufacturing order for `self.comp2` - comp2mo = self.env['mrp.production'].search([('bom_id', '=', self.comp2_bom.id)]) - self.assertEqual(len(comp2mo), 1) - picking_receipt.move_lines.quantity_done = 1 - picking_receipt.button_validate() - self.assertEquals(mo.state, 'done') - - # Now that the picking is done, the details stat button should be visible - self.assertTrue(picking_receipt.display_view_subcontracted_move_lines) - - # Available quantities should be negative at the subcontracting location for each components - avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_comp2 = self.env['stock.quant']._get_available_quantity(self.comp2, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_finished = self.env['stock.quant']._get_available_quantity(self.finished, wh.lot_stock_id) - self.assertEquals(avail_qty_comp1, -1) - self.assertEquals(avail_qty_comp2, -1) - self.assertEquals(avail_qty_finished, 1) - - def test_flow_4(self): - """ Check that the correct BoM is chosen accordingly to the partner - """ - # We create a second partner of type subcontractor - main_partner_2 = self.env['res.partner'].create({'name': 'main_partner'}) - subcontractor_partner2 = self.env['res.partner'].create({ - 'name': 'subcontractor_partner', - 'type': 'subcontractor', - 'parent_id': main_partner_2.id, - }) - - # We create a different BoM for the same product - comp3 = self.env['product.product'].create({ - 'name': 'Component1', - 'type': 'product', - 'categ_id': self.env.ref('product.product_category_all').id, - }) - - bom_form = Form(self.env['mrp.bom']) - bom_form.type = 'subcontract' - bom_form.product_tmpl_id = self.finished.product_tmpl_id - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = self.comp1 - bom_line.product_qty = 1 - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = comp3 - bom_line.product_qty = 1 - bom2 = bom_form.save() - - # We assign the second BoM to the new partner - self.bom.write({'subcontractor_ids': [(4, self.subcontractor_partner1.id, None)]}) - bom2.write({'subcontractor_ids': [(4, subcontractor_partner2.id, None)]}) - - # Create a receipt picking from the subcontractor1 - picking_form = Form(self.env['stock.picking']) - picking_form.picking_type_id = self.env.ref('stock.picking_type_in') - picking_form.partner_id = self.subcontractor_partner1 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.finished - move.product_uom_qty = 1 - picking_receipt1 = picking_form.save() - picking_receipt1.action_confirm() - - # Create a receipt picking from the subcontractor2 - picking_form = Form(self.env['stock.picking']) - picking_form.picking_type_id = self.env.ref('stock.picking_type_in') - picking_form.partner_id = subcontractor_partner2 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.finished - move.product_uom_qty = 1 - picking_receipt2 = picking_form.save() - picking_receipt2.action_confirm() - - mo_pick1 = picking_receipt1.move_lines.mapped('move_orig_ids.production_id') - mo_pick2 = picking_receipt2.move_lines.mapped('move_orig_ids.production_id') - self.assertEquals(len(mo_pick1), 1) - self.assertEquals(len(mo_pick2), 1) - self.assertEquals(mo_pick1.bom_id, self.bom) - self.assertEquals(mo_pick2.bom_id, bom2) - - -class TestSubcontractingTracking(TransactionCase): - def setUp(self): - super(TestSubcontractingTracking, self).setUp() - # 1: Create a subcontracting partner - main_company_1 = self.env['res.partner'].create({'name': 'main_partner'}) - self.subcontractor_partner1 = self.env['res.partner'].create({ - 'name': 'Subcontractor 1', - 'type': 'subcontractor', - 'parent_id': main_company_1.id, - }) - - # 2. Create a BOM of subcontracting type - # 2.1. Comp1 has tracking by lot - self.comp1_sn = self.env['product.product'].create({ - 'name': 'Component1', - 'type': 'product', - 'categ_id': self.env.ref('product.product_category_all').id, - 'tracking': 'serial' - }) - self.comp2 = self.env['product.product'].create({ - 'name': 'Component2', - 'type': 'product', - 'categ_id': self.env.ref('product.product_category_all').id, - }) - - # 2.2. Finished prodcut has tracking by serial number - self.finished_lot = self.env['product.product'].create({ - 'name': 'finished', - 'type': 'product', - 'categ_id': self.env.ref('product.product_category_all').id, - 'tracking': 'lot' - }) - bom_form = Form(self.env['mrp.bom']) - bom_form.type = 'subcontract' - bom_form.product_tmpl_id = self.finished_lot.product_tmpl_id - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = self.comp1_sn - bom_line.product_qty = 1 - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = self.comp2 - bom_line.product_qty = 1 - self.bom_tracked = bom_form.save() - - def test_flow_tracked_1(self): - """ This test mimics test_flow_1 but with a BoM that has tracking included in it. - """ - # Create a receipt picking from the subcontractor - picking_form = Form(self.env['stock.picking']) - picking_form.picking_type_id = self.env.ref('stock.picking_type_in') - picking_form.partner_id = self.subcontractor_partner1 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.finished_lot - move.product_uom_qty = 1 - picking_receipt = picking_form.save() - picking_receipt.action_confirm() - - # We should be able to call the 'record_components' button - self.assertTrue(picking_receipt.display_action_record_components) - self.assertFalse(picking_receipt.display_view_subcontracted_move_lines) - - # Check the created manufacturing order - mo = self.env['mrp.production'].search([('bom_id', '=', self.bom_tracked.id)]) - self.assertEqual(len(mo), 1) - self.assertEquals(mo.state, 'confirmed') - self.assertEqual(len(mo.picking_ids), 0) - wh = picking_receipt.picking_type_id.warehouse_id - self.assertEquals(mo.picking_type_id, wh.subcontracting_type_id) - self.assertFalse(mo.picking_type_id.active) - - # Create a RR - pg1 = self.env['procurement.group'].create({}) - self.env['stock.warehouse.orderpoint'].create({ - 'name': 'xxx', - 'product_id': self.comp1_sn.id, - 'product_min_qty': 0, - 'product_max_qty': 0, - 'location_id': self.env.user.company_id.subcontracting_location_id.id, - 'group_id': pg1.id, - }) - - # Run the scheduler and check the created picking - self.env['procurement.group'].run_scheduler() - picking = self.env['stock.picking'].search([('group_id', '=', pg1.id)]) - self.assertEqual(len(picking), 1) - self.assertEquals(picking.picking_type_id, wh.out_type_id) - - lot_id = self.env['stock.production.lot'].create({ - 'name': 'lot1', - 'product_id': self.finished_lot.id - }) - serial_id = self.env['stock.production.lot'].create({ - 'name': 'lot1', - 'product_id': self.comp1_sn.id - }) - produce_form = Form(self.env['mrp.product.produce'].with_context({ - 'active_id': mo.id, - 'active_ids': [mo.id], - })) - produce_form.finished_lot_id = lot_id - produce_form.raw_workorder_line_ids._records[0]['lot_id'] = serial_id.id - wiz_produce = produce_form.save() - wiz_produce.do_produce() - - # We should not be able to call the 'record_components' button - self.assertFalse(picking_receipt.display_action_record_components) - # We should see the move lines stat button - self.assertTrue(picking_receipt.display_view_subcontracted_move_lines) - - picking_receipt.move_lines.quantity_done = 1 - picking_receipt.move_lines.move_line_ids.lot_name = 'lot00001test' - picking_receipt.button_validate() - self.assertEquals(mo.state, 'done') - - # Available quantities should be negative at the subcontracting location for each components - avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1_sn, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_comp2 = self.env['stock.quant']._get_available_quantity(self.comp2, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_finished = self.env['stock.quant']._get_available_quantity(self.finished_lot, wh.lot_stock_id) - self.assertEquals(avail_qty_comp1, -1) - self.assertEquals(avail_qty_comp2, -1) - self.assertEquals(avail_qty_finished, 1) diff --git a/addons/mrp_subcontracting/views/mrp_bom_views.xml b/addons/mrp_subcontracting/views/mrp_bom_views.xml deleted file mode 100644 index d384f1ab4ea..00000000000 --- a/addons/mrp_subcontracting/views/mrp_bom_views.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - mrp.bom.form.view - mrp.bom - - - - - - - - - diff --git a/addons/mrp_subcontracting/views/stock_picking_views.xml b/addons/mrp_subcontracting/views/stock_picking_views.xml deleted file mode 100644 index 23145eeabbf..00000000000 --- a/addons/mrp_subcontracting/views/stock_picking_views.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - stock.picking.form.view - stock.picking - - - - -