From b68f475e22c6ccc1fa8c0a0081d81fa62302ccbc Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Fri, 10 Apr 2026 07:52:59 +0200 Subject: [PATCH] Pricing manual --- AGENTS.md | 1 + modules/purchase_trade/pricing.py | 146 ++++++++++++++++---- modules/purchase_trade/purchase.py | 17 +-- modules/purchase_trade/sale.py | 17 +-- modules/purchase_trade/tests/test_module.py | 58 +++++++- notes/template_business_rules.md | 1 + 6 files changed, 177 insertions(+), 63 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 65698ed..11d9fbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,3 +136,4 @@ Toujours fournir: - `account.invoice`: `Validate` cree aussi le `account.move` pour les factures client; `Post` ne doit plus forcer une fresh session sur ce flux. - `pricing.pricing`: saisie manuelle autorisee meme sans composant; `eod_price` non editable et calcule en prix moyen pondere; `last=True` gere par groupe `line + component`, choisi sur la `pricing_date` la plus grande. - `purchase_trade`: `trader` filtre sur `TRADER`, `operator` sur `OPERATOR`; fallback sur `quantity` si `quantity_theorical` est vide dans les quotas/pricings. + - `sale.line` / `purchase.line`: en mode `basis`, sans `price_component`, le `Price` et le `Fix. progress` de la ligne doivent remonter depuis la ligne `Summary` sans component. diff --git a/modules/purchase_trade/pricing.py b/modules/purchase_trade/pricing.py index 9fbff42..9141c23 100755 --- a/modules/purchase_trade/pricing.py +++ b/modules/purchase_trade/pricing.py @@ -333,10 +333,10 @@ class Pricing(ModelSQL,ModelView): price_component = fields.Many2One('pricing.component', "Component")#, domain=[('id', 'in', Eval('line.price_components'))], ondelete='CASCADE') quantity = fields.Numeric("Qt",digits='unit') settl_price = fields.Numeric("Settl. price",digits='unit') - fixed_qt = fields.Numeric("Fixed qt",digits='unit') - fixed_qt_price = fields.Numeric("Fixed qt price",digits='unit') - unfixed_qt = fields.Numeric("Unfixed qt",digits='unit') - unfixed_qt_price = fields.Numeric("Unfixed qt price",digits='unit') + fixed_qt = fields.Numeric("Fixed qt",digits='unit', readonly=True) + fixed_qt_price = fields.Numeric("Fixed qt price",digits='unit', readonly=True) + unfixed_qt = fields.Numeric("Unfixed qt",digits='unit', readonly=True) + unfixed_qt_price = fields.Numeric("Unfixed qt price",digits='unit', readonly=True) eod_price = fields.Numeric("EOD price",digits='unit',readonly=True) last = fields.Boolean("Last") @@ -413,6 +413,7 @@ class Pricing(ModelSQL,ModelView): @classmethod def create(cls, vlist): records = super(Pricing, cls).create(vlist) + cls._sync_manual_values(records) cls._sync_manual_last(records) cls._sync_eod_price(records) return records @@ -428,6 +429,7 @@ class Pricing(ModelSQL,ModelView): for record_set, values in zip(actions, actions): if values: records.extend(record_set) + cls._sync_manual_values(records) cls._sync_manual_last(records) cls._sync_eod_price(records) @@ -445,30 +447,109 @@ class Pricing(ModelSQL,ModelView): }) @classmethod - def _get_manual_last_group_domain(cls, record): + def _is_manual_pricing_record(cls, record): + component = getattr(record, 'price_component', None) + if component is None: + return True + return not bool(getattr(component, 'auto', False)) + + @classmethod + def _get_pricing_group_domain(cls, record): component = getattr(record, 'price_component', None) if getattr(record, 'sale_line', None): - domain = [ + return [ ('sale_line', '=', record.sale_line.id), + ('price_component', '=', + component.id if getattr(component, 'id', None) else None), ] - domain.append(( - 'price_component', - '=', - component.id if getattr(component, 'id', None) else None, - )) - return domain if getattr(record, 'line', None): - domain = [ + return [ ('line', '=', record.line.id), + ('price_component', '=', + component.id if getattr(component, 'id', None) else None), ] - domain.append(( - 'price_component', - '=', - component.id if getattr(component, 'id', None) else None, - )) - return domain return None + @classmethod + def _get_base_quantity(cls, record): + owner = getattr(record, 'sale_line', None) or getattr(record, 'line', None) + if not owner: + return Decimal(0) + if hasattr(owner, '_get_pricing_base_quantity'): + return Decimal(str(owner._get_pricing_base_quantity() or 0)) + quantity = getattr(owner, 'quantity_theorical', None) + if quantity is None: + quantity = getattr(owner, 'quantity', None) + return Decimal(str(quantity or 0)) + + @classmethod + def _sync_manual_values(cls, records): + if (not records + or Transaction().context.get('skip_pricing_manual_sync')): + return + domains = [] + seen = set() + for record in records: + if not cls._is_manual_pricing_record(record): + continue + domain = cls._get_pricing_group_domain(record) + if not domain: + continue + key = tuple(domain) + if key in seen: + continue + seen.add(key) + domains.append(domain) + if not domains: + return + with Transaction().set_context( + skip_pricing_manual_sync=True, + skip_pricing_last_sync=True, + skip_pricing_eod_sync=True): + for domain in domains: + pricings = cls.search( + domain, + order=[('pricing_date', 'ASC'), ('id', 'ASC')]) + if not pricings: + continue + base_quantity = cls._get_base_quantity(pricings[0]) + cumul_qt = Decimal(0) + cumul_qt_price = Decimal(0) + total = len(pricings) + for index, pricing in enumerate(pricings): + quantity = Decimal(str(pricing.quantity or 0)) + settl_price = Decimal(str(pricing.settl_price or 0)) + cumul_qt += quantity + cumul_qt_price += quantity * settl_price + fixed_qt = cumul_qt + if fixed_qt > 0: + fixed_qt_price = round(cumul_qt_price / fixed_qt, 4) + else: + fixed_qt_price = Decimal(0) + unfixed_qt = base_quantity - fixed_qt + if unfixed_qt < Decimal('0.001'): + unfixed_qt = Decimal(0) + fixed_qt = base_quantity + values = { + 'fixed_qt': fixed_qt, + 'fixed_qt_price': fixed_qt_price, + 'unfixed_qt': unfixed_qt, + 'unfixed_qt_price': settl_price, + 'last': index == (total - 1), + } + eod_price = cls._weighted_average_price( + values['fixed_qt'], + values['fixed_qt_price'], + values['unfixed_qt'], + values['unfixed_qt_price'], + ) + values['eod_price'] = eod_price + super(Pricing, cls).write([pricing], values) + + @classmethod + def _get_manual_last_group_domain(cls, record): + return cls._get_pricing_group_domain(record) + @classmethod def _sync_manual_last(cls, records): if not records: @@ -505,17 +586,22 @@ class Pricing(ModelSQL,ModelView): def get_fixed_price(self): price = Decimal(0) Pricing = Pool().get('pricing.pricing') - pricings = Pricing.search(['price_component','=',self.price_component.id],order=[('pricing_date', 'ASC')]) - if pricings: - cumul_qt = Decimal(0) - cumul_qt_price = Decimal(0) - for pr in pricings: - cumul_qt += pr.quantity - cumul_qt_price += pr.quantity * pr.settl_price - if pr.id == self.id: - break - if cumul_qt > 0: - price = cumul_qt_price / cumul_qt + domain = self._get_pricing_group_domain(self) + if not domain: + return price + pricings = Pricing.search(domain, order=[('pricing_date', 'ASC'), ('id', 'ASC')]) + if pricings: + cumul_qt = Decimal(0) + cumul_qt_price = Decimal(0) + for pr in pricings: + quantity = Decimal(str(pr.quantity or 0)) + settl_price = Decimal(str(pr.settl_price or 0)) + cumul_qt += quantity + cumul_qt_price += quantity * settl_price + if pr.id == self.id: + break + if cumul_qt > 0: + price = cumul_qt_price / cumul_qt return round(price,4) diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index 3cd2b5e..99d8ca8 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -1650,20 +1650,9 @@ class Line(metaclass=PoolMeta): Pricing = Pool().get('pricing.pricing') pricings = Pricing.search(['price_component','=',pc.id],order=[('pricing_date', 'ASC')]) if pricings: - cumul_qt = Decimal(0) - base_quantity = self._get_pricing_base_quantity() - index = 0 - for pr in pricings: - cumul_qt += pr.quantity - pr.fixed_qt = cumul_qt - pr.fixed_qt_price = pr.get_fixed_price() - pr.unfixed_qt = base_quantity - pr.fixed_qt - pr.unfixed_qt_price = pr.fixed_qt_price - pr.eod_price = pr.get_eod_price_purchase() - if index == len(pricings) - 1: - pr.last = True - index += 1 - Pricing.save([pr]) + Pricing._sync_manual_values(pricings) + Pricing._sync_manual_last(pricings) + Pricing._sync_eod_price(pricings) if pc.triggers and pc.auto: prDate = [] diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index 4dc2535..5941e91 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -1344,20 +1344,9 @@ class SaleLine(metaclass=PoolMeta): Pricing = Pool().get('pricing.pricing') pricings = Pricing.search(['price_component','=',pc.id],order=[('pricing_date', 'ASC')]) if pricings: - cumul_qt = Decimal(0) - base_quantity = self._get_pricing_base_quantity() - index = 0 - for pr in pricings: - cumul_qt += pr.quantity - pr.fixed_qt = cumul_qt - pr.fixed_qt_price = pr.get_fixed_price() - pr.unfixed_qt = base_quantity - pr.fixed_qt - pr.unfixed_qt_price = pr.fixed_qt_price - pr.eod_price = pr.get_eod_price_sale() - if index == len(pricings) - 1: - pr.last = True - index += 1 - Pricing.save([pr]) + Pricing._sync_manual_values(pricings) + Pricing._sync_manual_last(pricings) + Pricing._sync_eod_price(pricings) if pc.triggers and pc.auto: prDate = [] diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 1f535f0..0d8d5b1 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -247,13 +247,15 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(pricing_model.save.call_args_list[1].args[0][0].unfixed_qt, Decimal('12')) def test_pricing_manual_fields_are_editable_except_eod(self): - 'manual pricing rows can edit fixed and unfixed values while eod stays computed' + 'manual pricing rows only edit quantity and settlement while derived values stay computed' Pricing = Pool().get('pricing.pricing') - self.assertFalse(Pricing.fixed_qt.readonly) - self.assertFalse(Pricing.fixed_qt_price.readonly) - self.assertFalse(Pricing.unfixed_qt.readonly) - self.assertFalse(Pricing.unfixed_qt_price.readonly) + self.assertFalse(Pricing.quantity.readonly) + self.assertFalse(Pricing.settl_price.readonly) + self.assertTrue(Pricing.fixed_qt.readonly) + self.assertTrue(Pricing.fixed_qt_price.readonly) + self.assertTrue(Pricing.unfixed_qt.readonly) + self.assertTrue(Pricing.unfixed_qt_price.readonly) self.assertTrue(Pricing.eod_price.readonly) def test_pricing_eod_uses_weighted_average_for_manual_rows(self): @@ -322,6 +324,52 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(super_mock.return_value.write.call_args_list[1].args[0], [first]) self.assertEqual(super_mock.return_value.write.call_args_list[1].args[1], {'last': True}) + def test_pricing_sync_manual_values_recomputes_manual_group_from_quantity_and_settl(self): + 'manual pricing rows derive fixed/unfixed values from quantity and settlement price' + Pricing = Pool().get('pricing.pricing') + + sale_line = Mock(id=10) + sale_line._get_pricing_base_quantity = Mock(return_value=Decimal('10')) + component = Mock(id=33, auto=False) + first = Mock( + id=1, + price_component=component, + sale_line=sale_line, + line=None, + pricing_date=datetime.date(2026, 4, 10), + quantity=Decimal('4'), + settl_price=Decimal('100'), + ) + second = Mock( + id=2, + price_component=component, + sale_line=sale_line, + line=None, + pricing_date=datetime.date(2026, 4, 11), + quantity=Decimal('3'), + settl_price=Decimal('110'), + ) + + with patch.object(Pricing, 'search', return_value=[first, second]), patch( + 'trytond.modules.purchase_trade.pricing.super') as super_mock: + Pricing._sync_manual_values([first, second]) + + first_values = super_mock.return_value.write.call_args_list[0].args[1] + self.assertEqual(first_values['fixed_qt'], Decimal('4')) + self.assertEqual(first_values['fixed_qt_price'], Decimal('100.0000')) + self.assertEqual(first_values['unfixed_qt'], Decimal('6')) + self.assertEqual(first_values['unfixed_qt_price'], Decimal('100')) + self.assertEqual(first_values['eod_price'], Decimal('100.0000')) + self.assertFalse(first_values['last']) + + second_values = super_mock.return_value.write.call_args_list[1].args[1] + self.assertEqual(second_values['fixed_qt'], Decimal('7')) + self.assertEqual(second_values['fixed_qt_price'], Decimal('104.2857')) + self.assertEqual(second_values['unfixed_qt'], Decimal('3')) + self.assertEqual(second_values['unfixed_qt_price'], Decimal('110')) + self.assertEqual(second_values['eod_price'], Decimal('106.0000')) + self.assertTrue(second_values['last']) + def test_sale_and_purchase_trader_operator_domains_use_explicit_categories(self): 'sale and purchase trader/operator fields are filtered by TRADER/OPERATOR categories' Sale = Pool().get('sale.sale') diff --git a/notes/template_business_rules.md b/notes/template_business_rules.md index 7afd4fe..e37018a 100644 --- a/notes/template_business_rules.md +++ b/notes/template_business_rules.md @@ -87,3 +87,4 @@ Scope: templates Relatorio + ponts `report_*` Python. - `trader` filtre sur `TRADER`. - `operator` filtre sur `OPERATOR`. - les quotas/pricings doivent fallback sur `quantity` si `quantity_theorical` est vide. +- `sale.line` / `purchase.line`: en mode `basis`, si aucun `price_component` n'est defini, le prix et la progression doivent remonter depuis la ligne `Summary` / `pricing.summary` sans component.