Padding logic in fee

This commit is contained in:
2026-06-23 09:21:10 +02:00
parent d08510f084
commit 423d9476fc
8 changed files with 3517 additions and 16588 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -73,3 +73,23 @@ Seuls les fees coches `To invoice` sont traites. En provisoire, un fee sans
facture est facture normalement et un fee deja facture est ignore. En finale,
un fee sans facture suit la facturation standard; un fee deja facture suit le
flux DN/CN existant de `fee.fee.invoice`.
## Padding de facture vente dans les commissions broker
Le champ `lot.sale_invoice_padding` augmente la quantite facturee au client sans
modifier le poids physique du lot. Pour les fees, `fee.quantity` reste donc la
quantite reelle des lots effectifs.
Si un fee est coche `Add padding`, le calcul de facturation utilise une quantite
dediee:
`quantite des lots effectifs du fee + padding vente des memes lots`
Cette quantite supplementaire s'applique uniquement aux modes:
- `Per qt`: montant = quantite avec padding * prix du fee;
- `% price`: montant = quantite avec padding * prix unitaire de la ligne * %.
La purchase technique du fee et la ligne de facture service utilisent la meme
base. Ainsi, une modification du padding apres une premiere facture est detectee
par le flux DN/CN existant comme un changement de quantite ou de montant.

View File

@@ -80,11 +80,12 @@ class Fee(ModelSQL,ModelView):
('ppack', 'Per packing'),
], 'Mode', required=True)
auto_calculation = fields.Boolean("Auto",states={'readonly': (Eval('mode') != 'ppack')})
inherit_qt = fields.Boolean("Inh Qt",states={'readonly': Eval('mode') != 'ppack'})
quantity = fields.Numeric("Qt",digits='unit',states={'readonly': (Eval('mode') != 'ppack') | Bool(Eval('auto_calculation'))})
unit = fields.Many2One('product.uom',"Unit",domain=[
If(Eval('mode') == 'ppack',
('category', '=', Eval('packing_category')),
inherit_qt = fields.Boolean("Inh Qt",states={'readonly': Eval('mode') != 'ppack'})
quantity = fields.Numeric("Qt",digits='unit',states={'readonly': (Eval('mode') != 'ppack') | Bool(Eval('auto_calculation'))})
add_padding = fields.Boolean("Add padding")
unit = fields.Many2One('product.uom',"Unit",domain=[
If(Eval('mode') == 'ppack',
('category', '=', Eval('packing_category')),
()),
],
states={
@@ -140,6 +141,10 @@ class Fee(ModelSQL,ModelView):
@classmethod
def default_generated_by_rule(cls):
return False
@classmethod
def default_add_padding(cls):
return False
@classmethod
def default_qt_state(cls):
@@ -526,6 +531,8 @@ class Fee(ModelSQL,ModelView):
def percent_price_lots_cover_line(self):
if self.mode != 'pprice':
return True
if getattr(self, 'add_padding', False):
return True
line_lots = self._get_effective_line_lots()
if not line_lots:
return True
@@ -644,6 +651,66 @@ class Fee(ModelSQL,ModelView):
return None
return sum(quantities)
def _convert_padding_quantity(self, quantity, from_unit):
quantity = Decimal(str(quantity or 0))
if not quantity:
return Decimal(0)
to_unit = getattr(self, 'unit', None)
if not from_unit or not to_unit or from_unit == to_unit:
return quantity
Uom = Pool().get('product.uom')
return Decimal(str(
Uom.compute_qty(from_unit, float(quantity), to_unit) or 0))
def _get_lot_padding_quantity(self, lot):
padding = Decimal(str(
getattr(lot, 'sale_invoice_padding', 0) or 0))
if not padding:
return Decimal(0)
source_line = (
getattr(self, 'sale_line', None)
or getattr(lot, 'sale_line', None)
or getattr(self, '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)
return self._convert_padding_quantity(padding, source_unit)
def get_padding_quantity(self):
if not getattr(self, 'add_padding', False):
return Decimal(0)
return sum(
self._get_lot_padding_quantity(lot)
for lot in self._get_effective_fee_lots())
def get_billing_quantity(self):
quantity = self._get_effective_fee_lots_quantity()
if quantity is None:
quantity = self.quantity
if quantity is None:
quantity = self.get_quantity()
return Decimal(quantity or 0) + self.get_padding_quantity()
def _get_percent_price_unit_price(self):
line = self.line or self.sale_line
if line:
return Decimal(str(getattr(line, 'unit_price', 0) or 0))
lots = self._get_effective_fee_lots()
lot = lots[0] if lots else None
if lot:
if getattr(lot, 'line', None):
return Decimal(str(getattr(lot.line, 'unit_price', 0) or 0))
if getattr(lot, 'sale_line', None):
return Decimal(str(
getattr(lot.sale_line, 'unit_price', 0) or 0))
return Decimal(0)
def _get_padding_percent_price_amount(self):
return (
self.get_billing_quantity()
* self._get_percent_price_unit_price())
def sync_quantity_from_lots(self):
if self.mode == 'ppack' and self.auto_calculation:
self.assert_quantity_consistency()
@@ -740,6 +807,9 @@ class Fee(ModelSQL,ModelView):
* self._get_amount_quantity() * sign, 2))
elif self.mode == 'perqt':
if self.add_padding:
return self._unsigned_amount(round(
self.get_billing_quantity() * self.price * sign, 2))
if self.shipment_in:
StockMove = Pool().get('stock.move')
sm = StockMove.search(['shipment','=','stock.shipment.in,'+str(self.shipment_in.id)])
@@ -753,6 +823,11 @@ class Fee(ModelSQL,ModelView):
return self._unsigned_amount(round((self.quantity if self.quantity else 0) * self.price * sign,2))
elif self.mode == 'pprice':
if self.add_padding:
return self._unsigned_amount(round(
self.price / 100
* self._get_padding_percent_price_amount()
* sign, 2))
if self.line:
if self.percent_price_lots_cover_line():
return self._unsigned_amount(round(
@@ -819,6 +894,12 @@ class Fee(ModelSQL,ModelView):
self.purchase.lines[0].unit_price = self.amount
if self.purchase.lines[0].quantity != 1:
self.purchase.lines[0].quantity = 1
elif self.mode == 'perqt' and self.add_padding:
quantity = self.get_billing_quantity()
if self.price != self.purchase.lines[0].unit_price:
self.purchase.lines[0].unit_price = self.price
if quantity != self.purchase.lines[0].quantity:
self.purchase.lines[0].quantity = quantity
else:
if self.get_price_per_qt() != self.purchase.lines[0].unit_price:
self.purchase.lines[0].unit_price = self.get_price_per_qt()
@@ -945,6 +1026,8 @@ class Fee(ModelSQL,ModelView):
elif getattr(self, 'quantity', None) is not None:
quantity = Decimal(self.quantity or 0)
unit = getattr(self, 'unit', None)
if self.mode == 'perqt' and getattr(self, 'add_padding', False):
quantity = self.get_billing_quantity()
return quantity, unit
def _get_ordered_purchase_locations(self):

View File

@@ -1945,6 +1945,38 @@ class Line(metaclass=PoolMeta):
super().__setup__()
cls.quantity.readonly = True
def _get_service_fee_invoice_lines(self, invoice_line, lot):
Fee = Pool().get('fee.fee')
fee = Fee.search(['purchase', '=', self.purchase.id])
if not fee:
return [invoice_line]
fee = fee[0]
invoice_line.fee = fee
fee.warn_percent_price_partial_lots()
if fee.mode == 'lumpsum':
invoice_line.quantity = 1
elif fee.mode == 'pprice':
invoice_line.quantity = 1
elif fee.mode == 'ppack':
invoice_line.quantity = fee.quantity
elif fee.mode == 'perqt' and getattr(fee, 'add_padding', False):
invoice_line.quantity = fee.get_billing_quantity()
else:
invoice_line.quantity = fee.get_fee_lots_qt()
if getattr(fee, 'state', None) != 'invoiced':
return [invoice_line]
previous_line = self._get_last_fee_invoice_line(fee, lot)
if not self._fee_invoice_line_changed(previous_line, invoice_line):
return []
reversal_line = self._get_fee_reversal_invoice_line(
previous_line, self)
if reversal_line:
return [reversal_line, invoice_line]
return [invoice_line]
@staticmethod
def _is_empty_quantity(quantity):
if quantity in (None, ''):

View File

@@ -3047,6 +3047,64 @@ class PurchaseTradeTestCase(ModuleTestCase):
fee, '_get_effective_fee_lots', return_value=[lot]):
self.assertEqual(fee.get_amount(), Decimal('20.00'))
def test_per_quantity_fee_with_padding_uses_lots_plus_padding(self):
'per quantity fee can bill linked lots plus sale invoice padding'
Fee = Pool().get('fee.fee')
fee = Fee()
unit = SimpleNamespace(id=1)
sale_line = SimpleNamespace(unit=unit)
lot = SimpleNamespace(
id=1,
lot_type='physic',
sale_line=sale_line,
sale_invoice_padding=Decimal('2'),
lot_hist=[],
get_current_quantity_converted=(
lambda state=0, unit=None: Decimal('5')))
fee.mode = 'perqt'
fee.price = Decimal('10')
fee.add_padding = True
fee.unit = unit
fee.quantity = Decimal('5')
fee.line = None
fee.sale_line = sale_line
fee.shipment_in = None
with patch.object(
fee, '_get_effective_fee_lots', return_value=[lot]):
self.assertEqual(fee.get_billing_quantity(), Decimal('7'))
self.assertEqual(fee.get_amount(), Decimal('70.00'))
def test_percent_price_fee_with_padding_uses_lots_padding_unit_price(self):
'% price fee with padding uses fee lots plus padding as amount base'
Fee = Pool().get('fee.fee')
fee = Fee()
unit = SimpleNamespace(id=1)
sale_line = SimpleNamespace(
unit=unit,
unit_price=Decimal('100'),
amount=Decimal('9999'))
lot = SimpleNamespace(
id=1,
lot_type='physic',
sale_line=sale_line,
sale_invoice_padding=Decimal('2'),
lot_hist=[],
get_current_quantity_converted=(
lambda state=0, unit=None: Decimal('5')))
fee.mode = 'pprice'
fee.price = Decimal('2')
fee.add_padding = True
fee.unit = unit
fee.quantity = Decimal('5')
fee.line = None
fee.sale_line = sale_line
fee.shipment_in = None
with patch.object(
fee, '_get_effective_fee_lots', return_value=[lot]):
self.assertEqual(fee.get_amount(), Decimal('14.00'))
def test_percent_price_fee_warns_when_line_lots_are_partial(self):
'% price fee warns when it does not cover all line lots'
Fee = Pool().get('fee.fee')
@@ -3111,6 +3169,75 @@ class PurchaseTradeTestCase(ModuleTestCase):
purchase_line_model.save.assert_called_once_with([purchase_line])
purchase_model.save.assert_called_once_with([fee.purchase])
def test_per_quantity_fee_padding_adjusts_generated_purchase_quantity(self):
'per quantity fee with padding updates the generated purchase quantity'
Fee = Pool().get('fee.fee')
fee = Fee()
fee.type = 'ordered'
fee.mode = 'perqt'
fee.add_padding = True
fee.price = Decimal('10')
fee.product = Mock()
fee.supplier = Mock()
fee.currency = Mock()
purchase_line = Mock(
unit_price=Decimal('10'),
quantity=Decimal('5'),
product=fee.product)
fee.purchase = Mock(
lines=[purchase_line],
party=fee.supplier,
currency=fee.currency)
purchase_model = Mock()
purchase_line_model = Mock()
with patch(
'trytond.modules.purchase_trade.fee.Pool'
) as PoolMock, patch.object(
fee, 'get_billing_quantity',
return_value=Decimal('7')):
PoolMock.return_value.get.side_effect = lambda name: {
'purchase.purchase': purchase_model,
'purchase.line': purchase_line_model,
}[name]
fee.adjust_purchase_values()
self.assertEqual(purchase_line.unit_price, Decimal('10'))
self.assertEqual(purchase_line.quantity, Decimal('7'))
purchase_line_model.save.assert_called_once_with([purchase_line])
purchase_model.save.assert_called_once_with([fee.purchase])
def test_purchase_trade_service_fee_invoice_uses_padding_quantity(self):
'service fee invoice line uses billing quantity when padding is enabled'
Line = purchase_module.Line
line = Line()
line.purchase = Mock(id=20)
lot = Mock(id=30)
invoice_line = Mock(quantity=Decimal('5'), unit_price=Decimal('10'))
fee = Mock(
state='not invoiced',
mode='perqt',
add_padding=True,
warn_percent_price_partial_lots=Mock(),
get_billing_quantity=Mock(return_value=Decimal('7')))
fee_model = Mock()
fee_model.search.return_value = [fee]
with patch(
'trytond.modules.purchase_trade.purchase.Pool'
) as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
'fee.fee': fee_model,
}[name]
result = line._get_service_fee_invoice_lines(invoice_line, lot)
self.assertEqual(result, [invoice_line])
self.assertEqual(invoice_line.fee, fee)
self.assertEqual(invoice_line.quantity, Decimal('7'))
fee.get_billing_quantity.assert_called_once_with()
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()

View File

@@ -7,6 +7,8 @@
<field name="currency"/>
<label name="mode"/>
<field name="mode"/>
<label name="add_padding"/>
<field name="add_padding"/>
<label name="price"/>
<field name="price"/>
<label name="enable_linked_currency"/>

View File

@@ -8,6 +8,7 @@ this repository contains the full copyright notices and license terms. -->
<field name="mode"/>
<field name="unit"/>
<field name="auto_calculation" width="60"/>
<field name="add_padding" width="80"/>
<field name="enable_linked_currency" width="90"/>
<field name="price"/>
<field name="currency"/>

View File

@@ -8,6 +8,7 @@ this repository contains the full copyright notices and license terms. -->
<field name="mode"/>
<field name="unit"/>
<field name="auto_calculation" width="60"/>
<field name="add_padding" width="80"/>
<field name="enable_linked_currency" width="90"/>
<field name="price"/>
<field name="currency"/>