diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index c3a594d102..5e5513a3e0 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -250,6 +250,9 @@ class AccountMove(models.Model): date = date or fields.Date.today() reversed_moves = self.env['account.move'] for ac_move in self: + #unreconcile all lines reversed + aml = ac_move.line_ids.filtered(lambda x: x.account_id.reconcile or x.account_id.internal_type == 'liquidity') + aml.remove_move_reconcile() reversed_move = ac_move._reverse_move(date=date, journal_id=journal_id) reversed_moves |= reversed_move @@ -298,7 +301,7 @@ class AccountMoveLine(models.Model): for unreconciled lines, and something in-between for partially reconciled lines. """ for line in self: - if not line.account_id.reconcile: + if not line.account_id.reconcile and line.account_id.internal_type != 'liquidity': line.reconciled = False line.amount_residual = 0 line.amount_residual_currency = 0 @@ -441,7 +444,7 @@ class AccountMoveLine(models.Model): help="This field is used for payable and receivable journal entries. You can put the limit date for the payment of this line.") date = fields.Date(related='move_id.date', string='Date', index=True, store=True, copy=False) # related is required analytic_line_ids = fields.One2many('account.analytic.line', 'move_id', string='Analytic lines', oldname="analytic_lines") - tax_ids = fields.Many2many('account.tax', string='Taxes') + tax_ids = fields.Many2many('account.tax', string='Taxes', domain=['|', ('active', '=', False), ('active', '=', True)]) tax_line_id = fields.Many2one('account.tax', string='Originator tax', ondelete='restrict') analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account') analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic tags') diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js index be6f467c68..6f111daac6 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_model.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js @@ -527,14 +527,17 @@ var StatementModel = BasicModel.extend({ // Retrieve the toggle proposition var selected; - _.each(line.reconciliation_proposition, function (prop) { + var targetLineAmount = line.st_line.amount; + line.reconciliation_proposition.every(function (prop) { if (!prop.invalid) { - if (((line.balance.amount < 0 || !line.partial_reconcile) && prop.amount > 0 && line.st_line.amount > 0 && line.st_line.amount < prop.amount) || - ((line.balance.amount > 0 || !line.partial_reconcile) && prop.amount < 0 && line.st_line.amount < 0 && line.st_line.amount > prop.amount)) { + if (((line.balance.amount < 0 || !line.partial_reconcile) && prop.amount > 0 && targetLineAmount > 0 && targetLineAmount < prop.amount) || + ((line.balance.amount > 0 || !line.partial_reconcile) && prop.amount < 0 && targetLineAmount < 0 && targetLineAmount > prop.amount)) { selected = prop; return false; } + targetLineAmount -= prop.amount; } + return true; }); // If no toggled proposition found, reject it diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js index 1f70529ac5..54072e3d3b 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -359,18 +359,21 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { // loop state propositions var props = []; - var nb_debit_props = 0; - var nb_credit_props = 0; + var partialDebitProps = 0; + var partialCreditProps = 0; _.each(state.reconciliation_proposition, function (prop) { if (prop.display) { props.push(prop); - if (prop.amount < 0) - nb_debit_props += 1; - else if (prop.amount > 0) - nb_credit_props += 1; + if (prop.amount > 0 && prop.amount > state.st_line.amount) { + partialDebitProps++; + } else if (prop.amount < 0 && prop.amount < state.st_line.amount) { + partialCreditProps++; + } + } }); + var targetLineAmount = state.st_line.amount; _.each(props, function (line) { var $line = $(qweb.render("reconciliation.line.mv_line", {'line': line, 'state': state})); if (!isNaN(line.id)) { @@ -379,10 +382,10 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { .attr("data-content", qweb.render('reconciliation.line.mv_line.details', {'line': line})); } if (line.already_paid === false && - ((state.balance.amount_currency < 0 || line.partial_reconcile) && nb_credit_props == 1 - && line.amount > 0 && state.st_line.amount > 0 && state.st_line.amount < line.amount) || - ((state.balance.amount_currency > 0 || line.partial_reconcile) && nb_debit_props == 1 - && line.amount < 0 && state.st_line.amount < 0 && state.st_line.amount > line.amount)) { + ((state.balance.amount_currency < 0 || line.partial_reconcile) + && line.amount > 0 && state.st_line.amount > 0 && targetLineAmount < line.amount && partialDebitProps <= 1) || + ((state.balance.amount_currency > 0 || line.partial_reconcile) + && line.amount < 0 && state.st_line.amount < 0 && targetLineAmount > line.amount && partialCreditProps <= 1)) { var $cell = $line.find(line.amount > 0 ? '.cell_right' : '.cell_left'); var text; if (line.partial_reconcile) { @@ -396,6 +399,8 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { .prependTo($cell) .attr("data-content", text); } + targetLineAmount -= line.amount; + $props.append($line); }); diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index d23fc8e19e..8508260148 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -1,6 +1,14 @@ flectra.define('account.reconciliation_tests.data', function () { "use strict"; +/* + * Debug tip: + * To be able to "see" the test in the browser: + * var $body = $('body'); + * $body.addClass('debug'); + * clientAction.appendTo($body); + */ + var Datas = {}; var db = { @@ -1490,5 +1498,194 @@ QUnit.module('account', { clientAction.destroy(); }); + + QUnit.test('Reconciliation: Payment < inv1 + inv2(partial)', function (assert) { + assert.expect(4); + + /* + * One payment: $1175 + * Two Invoices + * The first invoice will be fully reconciled $650 + * The second invoice will be partially paid with the rest of the payment $999 + */ + + // modify the second line that is already in db to put it at $999 + var indexModif = _.findIndex(this.params.mv_lines['[5,"",0,6]'], function (line) {return line.id === 112}); + this.params.mv_lines['[5,"",0,6]'][indexModif] = + {account_type: "receivable", amount_currency_str: "", currency_id: false, date_maturity: "2017-02-07", date: "2017-01-08", + total_amount_str: "$ 999.00", partner_id: 8, account_name: "101200 Account Receivable", name: "INV/2017/0003", + partner_name: "Agrolait", total_amount_currency_str: "", id: 112, credit: 0.0, journal_id: [1, "Customer Invoices"], + amount_str: "$ 999.00", debit: 999.0, account_code: "101200", ref: "", already_paid: false}; + + var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options); + testUtils.addMockEnvironment(clientAction, { + data: this.params.data, + mockRPC: function (route, args) { + if (args.method === 'process_reconciliations') { + assert.deepEqual(args.args, + [ + [5], // Id of the bank statement line + + [{counterpart_aml_dicts: + [{name:"INV/2017/0002", + debit: 0, + credit: 650, + counterpart_aml_id: 109}, + + {name: "INV/2017/0003", + debit: 0, + credit: 525, + counterpart_aml_id: 112}], + + payment_aml_ids: [], + partner_id: 8, + new_aml_dicts: []}] + ], "should call process_reconciliations with partial reconcile values"); + } + return this._super(route, args); + }, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$" + } + } + }, + }); + clientAction.appendTo($('#qunit-fixture')); + + // The first reconciliation "line" is where it happens + var widget = clientAction.widgets[0]; + + // Add first invoice to reconcile fully + widget.$('.match .cell_account_code:first').trigger('click'); + assert.notOk( widget.$('.cell_right .line_info_button').length, + "should not display the partial reconciliation alert"); + + // Add second invoice to reconcile partially + widget.$('.match .cell_account_code:first').trigger('click'); + var $reconciliationAlert = widget.$('.cell_right .line_info_button'); + + assert.ok($reconciliationAlert.length, + "should display the partial reconciliation alert"); + + $reconciliationAlert.click(); + + var $buttonReconcile = widget.$('button.o_reconcile:not(hidden)'); + + assert.equal($buttonReconcile.length, 1, + 'The reconcile button must be visible'); + + $buttonReconcile.click(); + + clientAction.destroy(); + }); + + QUnit.test('Reconciliation: payment and 2 partials', function (assert) { + assert.expect(6); + + /* + * One payment: $1175 + * Two Invoices as Inv1 = 1200; Inv2 = 1200: + * Payment < Inv1 AND Payment < Inv2 + * No partial reconcile is possible, as a write-off of 1225 is necessary + */ + + // modify the invoice line to have their amount > payment + var indexInv1 = _.findIndex(this.params.mv_lines['[5,"",0,6]'], function (line) {return line.id === 109}); + this.params.mv_lines['[5,"",0,6]'][indexInv1] = + {account_type: "receivable", amount_currency_str: "", currency_id: false, date_maturity: "2017-02-07", date: "2017-01-08", + total_amount_str: "$ 1200.00", partner_id: 8, account_name: "101200 Account Receivable", name: "INV/2017/0002", partner_name: "Agrolait", + total_amount_currency_str: "", id: 109, credit: 0.0, journal_id: [1, "Customer Invoices"], amount_str: "$ 1200.00", debit: 1200.0, + account_code: "101200", ref: "", already_paid: false}; + + var indexInv2 = _.findIndex(this.params.mv_lines['[5,"",0,6]'], function (line) {return line.id === 112}); + this.params.mv_lines['[5,"",0,6]'][indexInv2] = + {account_type: "receivable", amount_currency_str: "", currency_id: false, date_maturity: "2017-02-07", date: "2017-01-08", + total_amount_str: "$ 1200.00", partner_id: 8, account_name: "101200 Account Receivable", name: "INV/2017/0003", + partner_name: "Agrolait", total_amount_currency_str: "", id: 112, credit: 0.0, journal_id: [1, "Customer Invoices"], + amount_str: "$ 1200.00", debit: 1200.0, account_code: "101200", ref: "", already_paid: false}; + + var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options); + testUtils.addMockEnvironment(clientAction, { + data: this.params.data, + mockRPC: function (route, args) { + if (args.method === 'process_reconciliations') { + assert.deepEqual(args.args, + [ + [5], // Id of the bank statement line + + [{counterpart_aml_dicts: + [{name:"INV/2017/0002", + debit: 0, + credit: 1200, + counterpart_aml_id: 109}, + + {name: "INV/2017/0003", + debit: 0, + credit: 1200, + counterpart_aml_id: 112}], + + payment_aml_ids: [], + partner_id: 8, + new_aml_dicts: [ + {account_id: 282, + credit: 0, + debit: 1225, + name: 'SAJ/2014/002 and SAJ/2014/003', + } + ]}] + ], "should call process_reconciliations with new aml dict reconcile values"); + } + return this._super(route, args); + }, + session: { + currencies: { + 3: { + digits: [69, 2], + position: "before", + symbol: "$" + } + } + }, + }); + clientAction.appendTo($('#qunit-fixture')); + + // The first reconciliation "line" is where it happens + var widget = clientAction.widgets[0]; + + // Add first invoice + // There should be the opportunity to reconcile partially + widget.$('.match .cell_account_code:first').trigger('click'); + assert.ok(widget.$('.cell_right .line_info_button').length, + "should display the partial reconciliation alert"); + + // Add second invoice + widget.$('.match .cell_account_code:first').trigger('click'); + assert.notOk(widget.$('.cell_right .line_info_button').length, + "should not display the partial reconciliation alert"); + + var writeOffCreate = widget.$('div.create'); + + assert.equal(writeOffCreate.length, 1, + 'A write-off creation should be present'); + + assert.equal(writeOffCreate.find('input[name=amount]').val(), -1225, + 'The right amount should be proposed for the write-off'); + + writeOffCreate.find('.create_account_id input.ui-autocomplete-input').click(); + $('ul.ui-autocomplete li a:first').click(); // select first account to do the write off in + + var $buttonReconcile = widget.$('button.o_reconcile:not(hidden)'); + + assert.equal($buttonReconcile.length, 1, + 'The reconcile button must be visible'); + + $buttonReconcile.click(); + + clientAction.destroy(); + }); }); }); diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index 788267d29c..a9d82701d7 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -789,11 +789,19 @@ class TestReconciliation(AccountingTestCase): statement = self.make_payment(invoice, journal, 50) + # The report searches on the create_date to dispatch reconciled lines to report periods + # Also, in this case, there can be only 1 partial_reconcile + statement_partial_id = statement.move_line_ids.mapped(lambda l: l.matched_credit_ids + l.matched_debit_ids) + self.env.cr.execute('UPDATE account_partial_reconcile SET create_date = %(date)s WHERE id = %(partial_id)s', + {'date': report_date_to + ' 00:00:00', + 'partial_id': statement_partial_id.id}) + # Case 1: The invoice and payment are reconciled: Nothing should appear report_lines, total, amls = AgedReport._get_partner_move_lines(account_type, report_date_to, 'posted', 30, self.env.user.company_id.branch_id) + partner_lines = [line for line in report_lines if line['partner_id'] == partner.id] self.assertEqual(partner_lines, [], 'The aged receivable shouldn\'t have lines at this point') - self.assertFalse(partner.id in amls, 'The aged receivable should not have amls either') + self.assertFalse(amls.get(partner.id, False), 'The aged receivable should not have amls either') # Case 2: The invoice and payment are not reconciled: we should have one line on the report # and 2 amls @@ -810,3 +818,71 @@ class TestReconciliation(AccountingTestCase): self.assertEqual(positive_line[0]['amount'], 50.0, 'The amount of the amls should be 50') self.assertEqual(negative_line[0]['amount'], -50.0, 'The amount of the amls should be -50') + + def test_revert_payment_and_reconcile_exchange(self): + + # A reversal of a reconciled payment which created a currency exchange entry, should create reversal moves + # which move lines should be reconciled two by two with the original move's lines + + def _determine_debit_credit_line(move): + line_ids_reconciliable = move.line_ids.filtered(lambda l: l.account_id.reconcile or l.account_id.internal_type == 'liquidity') + return line_ids_reconciliable.filtered(lambda l: l.debit), line_ids_reconciliable.filtered(lambda l: l.credit) + + def _move_revert_test_pair(move, revert): + self.assertTrue(move.line_ids) + self.assertTrue(revert.line_ids) + + move_lines = _determine_debit_credit_line(move) + revert_lines = _determine_debit_credit_line(revert) + + # in the case of the exchange entry, only one pair of lines will be found + if move_lines[0] and revert_lines[1]: + self.assertTrue(move_lines[0].full_reconcile_id.exists()) + self.assertEqual(move_lines[0].full_reconcile_id.id, revert_lines[1].full_reconcile_id.id) + + if move_lines[1] and revert_lines[0]: + self.assertTrue(move_lines[1].full_reconcile_id.exists()) + self.assertEqual(move_lines[1].full_reconcile_id.id, revert_lines[0].full_reconcile_id.id) + + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_usd_id, + 'company_id': self.env.ref('base.main_company').id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-08-01', + 'rate': 0.5, + 'currency_id': self.currency_usd_id, + 'company_id': self.env.ref('base.main_company').id + }) + inv = self.create_invoice(invoice_amount=111, currency_id=self.currency_usd_id) + payment = self.env['account.payment'].create({ + 'payment_type': 'inbound', + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + 'partner_type': 'customer', + 'partner_id': self.partner_agrolait_id, + 'amount': 111, + 'currency_id': self.currency_usd_id, + 'journal_id': self.bank_journal_usd.id, + 'payment_date': time.strftime('%Y') + '-08-01', + }) + payment.post() + + credit_aml = payment.move_line_ids.filtered('credit') + inv.assign_outstanding_credit(credit_aml.id) + self.assertTrue(inv.state == 'paid', 'The invoice should be paid') + + exchange_reconcile = payment.move_line_ids.mapped('full_reconcile_id') + exchange_move = exchange_reconcile.exchange_move_id + payment_move = payment.move_line_ids[0].move_id + + reverted_payment_move = self.env['account.move'].browse(payment_move.reverse_moves(time.strftime('%Y') + '-08-01')) + + # After reversal of payment, the invoice should be open + self.assertTrue(inv.state == 'open', 'The invoice should be open again') + self.assertFalse(exchange_reconcile.exists()) + + reverted_exchange_move = self.env['account.move'].search([('journal_id', '=', exchange_move.journal_id.id), ('ref', 'ilike', exchange_move.name)], limit=1) + _move_revert_test_pair(payment_move, reverted_payment_move) + _move_revert_test_pair(exchange_move, reverted_exchange_move) diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index 5e4f607602..56504c5f5b 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -944,6 +944,14 @@ class Meeting(models.Model): self.start = self.start_datetime self.stop = fields.Datetime.to_string(start + timedelta(hours=self.duration)) + @api.onchange('start_date') + def _onchange_start_date(self): + self.start = self.start_date + + @api.onchange('stop_date') + def _onchange_stop_date(self): + self.stop = self.stop_date + #################################################### # Calendar Business, Reccurency, ... #################################################### diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index a28907e80f..c3093de9e3 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -250,7 +250,8 @@ class ProductTemplate(models.Model): @api.one @api.depends('product_variant_ids.product_tmpl_id') def _compute_product_variant_count(self): - self.product_variant_count = len(self.product_variant_ids) + # do not pollute variants to be prefetched when counting variants + self.product_variant_count = len(self.with_prefetch().product_variant_ids) @api.depends('product_variant_ids', 'product_variant_ids.default_code') def _compute_default_code(self): diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 31b4ab5a25..59825b4597 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -593,7 +593,9 @@ class ProductUoM(models.Model): if 'factor' in values or 'factor_inv' in values or 'category_id' in values: changed = self.filtered( lambda u: any(u[f] != values[f] if f in values else False - for f in {'factor', 'factor_inv', 'category_id'})) + for f in {'factor', 'factor_inv'})) + self.filtered( + lambda u: any(u[f].id != int(values[f]) if f in values else False + for f in {'category_id'})) if changed: stock_move_lines = self.env['stock.move.line'].search_count([ ('product_uom_id.category_id', 'in', changed.mapped('category_id.id')), diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py index 26a4fa70ff..80ab67565e 100644 --- a/addons/stock_account/models/product.py +++ b/addons/stock_account/models/product.py @@ -345,7 +345,8 @@ class ProductProduct(models.Model): qty_delivered += qty_to_consider # `move.price_unit` is negative if the move is out and positive if the move is # dropshipped. Use its absolute value to compute the average price unit. - average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + abs(move.price_unit) * qty_to_consider) / qty_delivered + if qty_delivered: + average_price_unit = (average_price_unit * (qty_delivered - qty_to_consider) + abs(move.price_unit) * qty_to_consider) / qty_delivered if qty_delivered == quantity: break return average_price_unit diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index 05a3825307..2c3f4725a6 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -151,7 +151,7 @@ class StockMove(models.Model): def _get_price_unit(self): """ Returns the unit price to store on the quant """ - return self.price_unit or self.product_id.standard_price + return not self.company_id.currency_id.is_zero(self.price_unit) and self.price_unit or self.product_id.standard_price @api.model def _get_in_base_domain(self, company_id=False):