diff --git a/modules/purchase_trade/invoice.py b/modules/purchase_trade/invoice.py index ab445ea..a7ea4c9 100644 --- a/modules/purchase_trade/invoice.py +++ b/modules/purchase_trade/invoice.py @@ -130,13 +130,43 @@ class Invoice(metaclass=PoolMeta): lots[getattr(lot, 'id', id(lot))] = lot return list(lots.values()) + @classmethod + def _fee_updates_on_delete(cls, invoices): + reset_fees = {} + clear_dn_cn_fees = {} + for invoice in invoices: + if getattr(invoice, 'number', None): + continue + invoice_id = getattr(invoice, 'id', None) + for line in getattr(invoice, 'lines', None) or []: + fee = getattr(line, 'fee', None) + if not fee: + continue + fee_key = getattr(fee, 'id', None) or id(fee) + dn_cn = getattr(fee, 'dn_cn', None) + dn_cn_id = getattr(dn_cn, 'id', dn_cn) + if invoice_id and dn_cn_id == invoice_id: + clear_dn_cn_fees[fee_key] = fee + else: + reset_fees[fee_key] = fee + return list(reset_fees.values()), list(clear_dn_cn_fees.values()) + @classmethod def delete(cls, invoices): Lot = Pool().get('lot.lot') + Fee = Pool().get('fee.fee') padding_lots = cls._sale_padding_lots_to_clear_on_delete(invoices) + reset_fees, clear_dn_cn_fees = cls._fee_updates_on_delete(invoices) super().delete(invoices) if padding_lots: Lot.write(padding_lots, {'sale_invoice_padding': Decimal(0)}) + if reset_fees: + Fee.write(reset_fees, { + 'state': 'not invoiced', + 'dn_cn': None, + }) + if clear_dn_cn_fees: + Fee.write(clear_dn_cn_fees, {'dn_cn': None}) def do_lot_invoicing(self): super().do_lot_invoicing() diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index 9b0f79e..a96c452 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -4916,11 +4916,9 @@ class LotInvoice(Wizard): def default_message(self, fields): fee_count = getattr(self, '_invoiced_fee_count', 0) - message = 'The invoice has been successfully created.' + message = 'Invoice created.' if fee_count: - message += ( - ' %s selected fee invoice(s) have also been processed.' - % fee_count) + message += ' Fee invoices: %s.' % fee_count return { 'message': message, } @@ -5031,10 +5029,83 @@ class LotInvoiceStart(ModelView): amount += l.lot_amount amount += padding_share * Decimal(str(l.lot_price or 0)) return amount - - @classmethod - def default_action(cls): - return 'prov' + + def _get_sale_padding_by_lot(self): + lots = [ + line.lot for line in (self.lot_s or []) + if getattr(line, 'lot', None)] + return self._split_sale_padding(self.sale_padding, lots) + + @staticmethod + def _record_key(record): + return getattr(record, 'id', None) or id(record) + + def _fee_wizard_padding_quantity(self, fee, padding_by_lot): + if not getattr(fee, 'add_padding', False): + return Decimal(0) + quantity = Decimal(0) + for lot in fee._get_effective_fee_lots(): + padding = padding_by_lot.get(self._record_key(lot), Decimal(0)) + if not padding: + continue + source_line = ( + getattr(fee, 'sale_line', None) + or getattr(lot, 'sale_line', None) + or getattr(fee, 'line', None) + or getattr(lot, 'line', None)) + source_unit = getattr(source_line, 'unit', None) + if not source_unit: + source_unit = getattr(lot, 'lot_unit_line', None) + quantity += fee._convert_padding_quantity(padding, source_unit) + return quantity + + def _fee_wizard_billing_quantity(self, fee, padding_by_lot): + quantity = fee._get_effective_fee_lots_quantity() + if quantity is None: + quantity = getattr(fee, 'quantity', None) + if quantity is None: + quantity = fee.get_quantity() + return ( + Decimal(quantity or 0) + + self._fee_wizard_padding_quantity(fee, padding_by_lot)) + + @staticmethod + def _fee_wizard_unit_price(fee): + line = getattr(fee, 'sale_line', None) or getattr(fee, 'line', None) + if line: + return Decimal(str(getattr(line, 'unit_price', 0) or 0)) + for lot in fee._get_effective_fee_lots(): + if getattr(lot, 'sale_line', None): + return Decimal(str( + getattr(lot.sale_line, 'unit_price', 0) or 0)) + if getattr(lot, 'line', None): + return Decimal(str(getattr(lot.line, 'unit_price', 0) or 0)) + return Decimal(0) + + @fields.depends('type', 'sale_padding', 'lot_s', 'fee_sale') + def on_change_sale_padding(self): + if self.type != 'sale' or not self.fee_sale: + return + padding_by_lot = self._get_sale_padding_by_lot() + for fee_line in self.fee_sale: + fee = getattr(fee_line, 'fee', None) + if not fee or not getattr(fee, 'add_padding', False): + continue + if fee.mode not in {'perqt', 'pprice'}: + continue + quantity = self._fee_wizard_billing_quantity(fee, padding_by_lot) + fee_line.fee_quantity = quantity + if fee.mode == 'perqt': + fee_line.fee_amount = round( + quantity * Decimal(str(fee.price or 0)), 2) + elif fee.mode == 'pprice': + fee_line.fee_amount = round( + Decimal(str(fee.price or 0)) / Decimal(100) + * quantity * self._fee_wizard_unit_price(fee), 2) + + @classmethod + def default_action(cls): + return 'prov' @classmethod def default_type(cls): diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 8ace310..7a6a391 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -3238,6 +3238,66 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(invoice_line.quantity, Decimal('7')) fee.get_billing_quantity.assert_called_once_with() + def test_lot_invoice_sale_padding_refreshes_per_quantity_fee_amount(self): + 'sale padding refreshes displayed per quantity fee amount' + start = lot_module.LotInvoiceStart() + unit = SimpleNamespace(id=1) + lot = SimpleNamespace(id=10, sale_line=SimpleNamespace(unit=unit)) + fee = SimpleNamespace( + add_padding=True, + mode='perqt', + price=Decimal('10'), + quantity=Decimal('5'), + _get_effective_fee_lots=Mock(return_value=[lot]), + _get_effective_fee_lots_quantity=Mock(return_value=Decimal('5')), + _convert_padding_quantity=Mock( + side_effect=lambda quantity, unit: Decimal(str(quantity))), + get_quantity=Mock(return_value=Decimal('5'))) + fee_line = SimpleNamespace( + fee=fee, + fee_quantity=Decimal('5'), + fee_amount=Decimal('50')) + start.type = 'sale' + start.sale_padding = Decimal('2') + start.lot_s = [SimpleNamespace(lot=lot)] + start.fee_sale = [fee_line] + + start.on_change_sale_padding() + + self.assertEqual(fee_line.fee_quantity, Decimal('7')) + self.assertEqual(fee_line.fee_amount, Decimal('70.00')) + + def test_lot_invoice_sale_padding_refreshes_percent_price_fee_amount(self): + 'sale padding refreshes displayed percent price fee amount' + start = lot_module.LotInvoiceStart() + unit = SimpleNamespace(id=1) + sale_line = SimpleNamespace(unit=unit, unit_price=Decimal('100')) + lot = SimpleNamespace(id=10, sale_line=sale_line) + fee = SimpleNamespace( + add_padding=True, + mode='pprice', + price=Decimal('2'), + quantity=Decimal('5'), + sale_line=sale_line, + _get_effective_fee_lots=Mock(return_value=[lot]), + _get_effective_fee_lots_quantity=Mock(return_value=Decimal('5')), + _convert_padding_quantity=Mock( + side_effect=lambda quantity, unit: Decimal(str(quantity))), + get_quantity=Mock(return_value=Decimal('5'))) + fee_line = SimpleNamespace( + fee=fee, + fee_quantity=Decimal('5'), + fee_amount=Decimal('10')) + start.type = 'sale' + start.sale_padding = Decimal('2') + start.lot_s = [SimpleNamespace(lot=lot)] + start.fee_sale = [fee_line] + + start.on_change_sale_padding() + + self.assertEqual(fee_line.fee_quantity, Decimal('7')) + self.assertEqual(fee_line.fee_amount, Decimal('14.00')) + def test_fee_get_non_cog_returns_zero_without_move_lines(self): 'fee non-cog amount is zero before any accounting move line exists' fee = fee_module.Fee() @@ -7939,6 +7999,36 @@ class PurchaseTradeTestCase(ModuleTestCase): Invoice._sale_padding_lots_to_clear_on_delete([invoice]), [lot]) + def test_fee_invoice_delete_collects_fee_to_reset(self): + 'deleting a draft fee invoice resets the linked fee to not invoiced' + Invoice = Pool().get('account.invoice') + + fee = SimpleNamespace(id=20, dn_cn=None) + invoice = SimpleNamespace( + id=10, + number=None, + lines=[SimpleNamespace(fee=fee)]) + + reset_fees, clear_dn_cn_fees = Invoice._fee_updates_on_delete( + [invoice]) + + self.assertEqual(reset_fees, [fee]) + self.assertEqual(clear_dn_cn_fees, []) + + def test_fee_dn_cn_delete_collects_fee_to_clear_dn_cn_only(self): + 'deleting a draft fee DN/CN clears only the fee DN/CN link' + Invoice = Pool().get('account.invoice') + + invoice = SimpleNamespace(id=10, number=None, lines=[]) + fee = SimpleNamespace(id=20, dn_cn=invoice) + invoice.lines = [SimpleNamespace(fee=fee)] + + reset_fees, clear_dn_cn_fees = Invoice._fee_updates_on_delete( + [invoice]) + + self.assertEqual(reset_fees, []) + self.assertEqual(clear_dn_cn_fees, [fee]) + def test_lot_invoice_message_mentions_processed_fees(self): 'lot invoice success message mentions selected fee invoicing' wizard = lot_module.LotInvoice() @@ -7946,8 +8036,7 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual( wizard.default_message(None)['message'], - 'The invoice has been successfully created. ' - '2 selected fee invoice(s) have also been processed.') + 'Invoice created. Fee invoices: 2.') def test_invoice_report_net_sums_signed_invoice_lines(self): 'invoice report net uses the signed differential from invoice lines'