Lot invoicing + fee padding

This commit is contained in:
2026-06-23 13:12:37 +02:00
parent ef8d914820
commit fbf3842e6c
3 changed files with 200 additions and 10 deletions

View File

@@ -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()

View File

@@ -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):

View File

@@ -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'