Pricing manual
This commit is contained in:
@@ -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.
|
- `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.
|
- `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.
|
- `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.
|
||||||
|
|||||||
@@ -333,10 +333,10 @@ class Pricing(ModelSQL,ModelView):
|
|||||||
price_component = fields.Many2One('pricing.component', "Component")#, domain=[('id', 'in', Eval('line.price_components'))], ondelete='CASCADE')
|
price_component = fields.Many2One('pricing.component', "Component")#, domain=[('id', 'in', Eval('line.price_components'))], ondelete='CASCADE')
|
||||||
quantity = fields.Numeric("Qt",digits='unit')
|
quantity = fields.Numeric("Qt",digits='unit')
|
||||||
settl_price = fields.Numeric("Settl. price",digits='unit')
|
settl_price = fields.Numeric("Settl. price",digits='unit')
|
||||||
fixed_qt = fields.Numeric("Fixed qt",digits='unit')
|
fixed_qt = fields.Numeric("Fixed qt",digits='unit', readonly=True)
|
||||||
fixed_qt_price = fields.Numeric("Fixed qt price",digits='unit')
|
fixed_qt_price = fields.Numeric("Fixed qt price",digits='unit', readonly=True)
|
||||||
unfixed_qt = fields.Numeric("Unfixed qt",digits='unit')
|
unfixed_qt = fields.Numeric("Unfixed qt",digits='unit', readonly=True)
|
||||||
unfixed_qt_price = fields.Numeric("Unfixed qt price",digits='unit')
|
unfixed_qt_price = fields.Numeric("Unfixed qt price",digits='unit', readonly=True)
|
||||||
eod_price = fields.Numeric("EOD price",digits='unit',readonly=True)
|
eod_price = fields.Numeric("EOD price",digits='unit',readonly=True)
|
||||||
last = fields.Boolean("Last")
|
last = fields.Boolean("Last")
|
||||||
|
|
||||||
@@ -413,6 +413,7 @@ class Pricing(ModelSQL,ModelView):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, vlist):
|
def create(cls, vlist):
|
||||||
records = super(Pricing, cls).create(vlist)
|
records = super(Pricing, cls).create(vlist)
|
||||||
|
cls._sync_manual_values(records)
|
||||||
cls._sync_manual_last(records)
|
cls._sync_manual_last(records)
|
||||||
cls._sync_eod_price(records)
|
cls._sync_eod_price(records)
|
||||||
return records
|
return records
|
||||||
@@ -428,6 +429,7 @@ class Pricing(ModelSQL,ModelView):
|
|||||||
for record_set, values in zip(actions, actions):
|
for record_set, values in zip(actions, actions):
|
||||||
if values:
|
if values:
|
||||||
records.extend(record_set)
|
records.extend(record_set)
|
||||||
|
cls._sync_manual_values(records)
|
||||||
cls._sync_manual_last(records)
|
cls._sync_manual_last(records)
|
||||||
cls._sync_eod_price(records)
|
cls._sync_eod_price(records)
|
||||||
|
|
||||||
@@ -445,30 +447,109 @@ class Pricing(ModelSQL,ModelView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
@classmethod
|
@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)
|
component = getattr(record, 'price_component', None)
|
||||||
if getattr(record, 'sale_line', None):
|
if getattr(record, 'sale_line', None):
|
||||||
domain = [
|
return [
|
||||||
('sale_line', '=', record.sale_line.id),
|
('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):
|
if getattr(record, 'line', None):
|
||||||
domain = [
|
return [
|
||||||
('line', '=', record.line.id),
|
('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
|
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
|
@classmethod
|
||||||
def _sync_manual_last(cls, records):
|
def _sync_manual_last(cls, records):
|
||||||
if not records:
|
if not records:
|
||||||
@@ -505,17 +586,22 @@ class Pricing(ModelSQL,ModelView):
|
|||||||
def get_fixed_price(self):
|
def get_fixed_price(self):
|
||||||
price = Decimal(0)
|
price = Decimal(0)
|
||||||
Pricing = Pool().get('pricing.pricing')
|
Pricing = Pool().get('pricing.pricing')
|
||||||
pricings = Pricing.search(['price_component','=',self.price_component.id],order=[('pricing_date', 'ASC')])
|
domain = self._get_pricing_group_domain(self)
|
||||||
if pricings:
|
if not domain:
|
||||||
cumul_qt = Decimal(0)
|
return price
|
||||||
cumul_qt_price = Decimal(0)
|
pricings = Pricing.search(domain, order=[('pricing_date', 'ASC'), ('id', 'ASC')])
|
||||||
for pr in pricings:
|
if pricings:
|
||||||
cumul_qt += pr.quantity
|
cumul_qt = Decimal(0)
|
||||||
cumul_qt_price += pr.quantity * pr.settl_price
|
cumul_qt_price = Decimal(0)
|
||||||
if pr.id == self.id:
|
for pr in pricings:
|
||||||
break
|
quantity = Decimal(str(pr.quantity or 0))
|
||||||
if cumul_qt > 0:
|
settl_price = Decimal(str(pr.settl_price or 0))
|
||||||
price = cumul_qt_price / cumul_qt
|
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)
|
return round(price,4)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1650,20 +1650,9 @@ class Line(metaclass=PoolMeta):
|
|||||||
Pricing = Pool().get('pricing.pricing')
|
Pricing = Pool().get('pricing.pricing')
|
||||||
pricings = Pricing.search(['price_component','=',pc.id],order=[('pricing_date', 'ASC')])
|
pricings = Pricing.search(['price_component','=',pc.id],order=[('pricing_date', 'ASC')])
|
||||||
if pricings:
|
if pricings:
|
||||||
cumul_qt = Decimal(0)
|
Pricing._sync_manual_values(pricings)
|
||||||
base_quantity = self._get_pricing_base_quantity()
|
Pricing._sync_manual_last(pricings)
|
||||||
index = 0
|
Pricing._sync_eod_price(pricings)
|
||||||
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])
|
|
||||||
|
|
||||||
if pc.triggers and pc.auto:
|
if pc.triggers and pc.auto:
|
||||||
prDate = []
|
prDate = []
|
||||||
|
|||||||
@@ -1344,20 +1344,9 @@ class SaleLine(metaclass=PoolMeta):
|
|||||||
Pricing = Pool().get('pricing.pricing')
|
Pricing = Pool().get('pricing.pricing')
|
||||||
pricings = Pricing.search(['price_component','=',pc.id],order=[('pricing_date', 'ASC')])
|
pricings = Pricing.search(['price_component','=',pc.id],order=[('pricing_date', 'ASC')])
|
||||||
if pricings:
|
if pricings:
|
||||||
cumul_qt = Decimal(0)
|
Pricing._sync_manual_values(pricings)
|
||||||
base_quantity = self._get_pricing_base_quantity()
|
Pricing._sync_manual_last(pricings)
|
||||||
index = 0
|
Pricing._sync_eod_price(pricings)
|
||||||
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])
|
|
||||||
|
|
||||||
if pc.triggers and pc.auto:
|
if pc.triggers and pc.auto:
|
||||||
prDate = []
|
prDate = []
|
||||||
|
|||||||
@@ -247,13 +247,15 @@ class PurchaseTradeTestCase(ModuleTestCase):
|
|||||||
self.assertEqual(pricing_model.save.call_args_list[1].args[0][0].unfixed_qt, Decimal('12'))
|
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):
|
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')
|
Pricing = Pool().get('pricing.pricing')
|
||||||
|
|
||||||
self.assertFalse(Pricing.fixed_qt.readonly)
|
self.assertFalse(Pricing.quantity.readonly)
|
||||||
self.assertFalse(Pricing.fixed_qt_price.readonly)
|
self.assertFalse(Pricing.settl_price.readonly)
|
||||||
self.assertFalse(Pricing.unfixed_qt.readonly)
|
self.assertTrue(Pricing.fixed_qt.readonly)
|
||||||
self.assertFalse(Pricing.unfixed_qt_price.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)
|
self.assertTrue(Pricing.eod_price.readonly)
|
||||||
|
|
||||||
def test_pricing_eod_uses_weighted_average_for_manual_rows(self):
|
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[0], [first])
|
||||||
self.assertEqual(super_mock.return_value.write.call_args_list[1].args[1], {'last': True})
|
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):
|
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 and purchase trader/operator fields are filtered by TRADER/OPERATOR categories'
|
||||||
Sale = Pool().get('sale.sale')
|
Sale = Pool().get('sale.sale')
|
||||||
|
|||||||
@@ -87,3 +87,4 @@ Scope: templates Relatorio + ponts `report_*` Python.
|
|||||||
- `trader` filtre sur `TRADER`.
|
- `trader` filtre sur `TRADER`.
|
||||||
- `operator` filtre sur `OPERATOR`.
|
- `operator` filtre sur `OPERATOR`.
|
||||||
- les quotas/pricings doivent fallback sur `quantity` si `quantity_theorical` est vide.
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user