Valuation/ark as finished

This commit is contained in:
2026-04-29 10:13:53 +02:00
parent 27450d51f6
commit 56b173d225
3 changed files with 107 additions and 36 deletions

View File

@@ -191,6 +191,26 @@ Owner technique: `a completer`
- Priorite: - Priorite:
- `structurante` - `structurante`
### BR-PT-007-bis - Mark as finished ignore seulement le reliquat ouvert
- Intent: conserver le PnL reel des lots executes tout en masquant le reliquat
ouvert d'une ligne terminee.
- Description:
- Sur une `purchase.line` ou une `sale.line`, le champ `finished`
(`Mark as finished`) ne signifie pas que la ligne ne doit plus etre
valorisee.
- Il signifie seulement que les lots ouverts / virtuels restants ne doivent
plus alimenter la valuation.
- Resultat attendu:
- les lots physiques continuent de produire:
- PnL prix
- PnL fees
- les derivatives continuent de produire du PnL meme si la ligne est
marquee finie
- les lots virtuels / ouverts d'une ligne finie sont ignores
- Priorite:
- `structurante`
### BR-PT-008 - Le premium fait partie du prix contractuel en `priced` et en `basis` ### BR-PT-008 - Le premium fait partie du prix contractuel en `priced` et en `basis`
- Intent: garantir que le montant total valorise et facture reflete toujours le - Intent: garantir que le montant total valorise et facture reflete toujours le

View File

@@ -147,41 +147,57 @@ class PurchaseTradeTestCase(ModuleTestCase):
{'type': 'derivative', 'amount': Decimal('30')}, {'type': 'derivative', 'amount': Decimal('30')},
]) ])
def test_generate_skips_finished_purchase_line(self): def test_generate_keeps_finished_purchase_line_physical_pnl(self):
'valuation generation keeps deleting old rows but skips finished purchase lines' 'finished purchase lines still generate physical lot valuation'
Valuation = Pool().get('valuation.valuation') Valuation = Pool().get('valuation.valuation')
line = Mock(finished=True) line = Mock(finished=True)
with patch.object(Valuation, '_delete_existing') as delete_existing, patch.object( with patch.object(Valuation, '_delete_existing') as delete_existing, patch.object(
Valuation, 'create_pnl_fee_from_line') as create_fees, patch.object( Valuation, 'create_pnl_fee_from_line',
Valuation, 'create_pnl_price_from_line') as create_prices, patch.object( return_value=[{'type': 'pur. fee'}]) as create_fees, patch.object(
Valuation, 'create_pnl_der_from_line') as create_derivatives, patch( Valuation, 'create_pnl_price_from_line',
return_value=[{'type': 'pur. priced'}]) as create_prices, patch.object(
Valuation, 'create_pnl_der_from_line',
return_value=[{'type': 'derivative'}]) as create_derivatives, patch(
'trytond.modules.purchase_trade.valuation.Pool') as PoolMock: 'trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
Valuation.generate(line) Valuation.generate(line)
delete_existing.assert_called_once_with(line, selected_types=None) delete_existing.assert_called_once_with(line, selected_types=None)
create_fees.assert_not_called() create_fees.assert_called_once_with(line)
create_prices.assert_not_called() create_prices.assert_called_once_with(line)
create_derivatives.assert_not_called() create_derivatives.assert_called_once_with(line)
PoolMock.return_value.get.assert_not_called() valuation_model = PoolMock.return_value.get.return_value
valuation_model.create.assert_any_call([
{'type': 'pur. fee'},
{'type': 'pur. priced'},
{'type': 'derivative'},
])
def test_generate_skips_finished_sale_line(self): def test_generate_keeps_finished_sale_line_physical_pnl(self):
'sale valuation generation keeps deleting old rows but skips finished sale lines' 'finished sale lines still generate physical lot valuation'
Valuation = Pool().get('valuation.valuation') Valuation = Pool().get('valuation.valuation')
sale_line = Mock(finished=True) sale_line = Mock(finished=True)
with patch.object(Valuation, '_delete_existing_sale_line') as delete_existing, patch.object( with patch.object(Valuation, '_delete_existing_sale_line') as delete_existing, patch.object(
Valuation, 'create_pnl_fee_from_sale_line') as create_fees, patch.object( Valuation, 'create_pnl_fee_from_sale_line',
Valuation, 'create_pnl_price_from_sale_line') as create_prices, patch.object( return_value=[{'type': 'sale fee'}]) as create_fees, patch.object(
Valuation, 'create_pnl_der_from_sale_line') as create_derivatives, patch( Valuation, 'create_pnl_price_from_sale_line',
return_value=[{'type': 'sale priced'}]) as create_prices, patch.object(
Valuation, 'create_pnl_der_from_sale_line',
return_value=[{'type': 'derivative'}]) as create_derivatives, patch(
'trytond.modules.purchase_trade.valuation.Pool') as PoolMock: 'trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
Valuation.generate_from_sale_line(sale_line) Valuation.generate_from_sale_line(sale_line)
delete_existing.assert_called_once_with(sale_line, selected_types=None) delete_existing.assert_called_once_with(sale_line, selected_types=None)
create_fees.assert_not_called() create_fees.assert_called_once_with(sale_line)
create_prices.assert_not_called() create_prices.assert_called_once_with(sale_line)
create_derivatives.assert_not_called() create_derivatives.assert_called_once_with(sale_line)
PoolMock.return_value.get.assert_not_called() valuation_model = PoolMock.return_value.get.return_value
valuation_model.create.assert_any_call([
{'type': 'sale fee'},
{'type': 'sale priced'},
{'type': 'derivative'},
])
def test_create_pnl_fee_from_line_accepts_missing_rate_amount(self): def test_create_pnl_fee_from_line_accepts_missing_rate_amount(self):
'purchase fee valuation treats an uncomputed rate amount as zero' 'purchase fee valuation treats an uncomputed rate amount as zero'
@@ -257,16 +273,32 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(fee.get_amount(), Decimal('13.33')) self.assertEqual(fee.get_amount(), Decimal('13.33'))
def test_create_pnl_price_from_line_ignores_finished_matched_sale_line(self): def test_create_pnl_price_from_line_keeps_finished_physical_sale_line(self):
'purchase valuation does not add sale-side pnl when the matched sale line is finished' 'purchase valuation keeps finished sale-side pnl on physical lots'
Valuation = Pool().get('valuation.valuation') Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
company = Mock(currency=currency.id)
unit = Mock(id=1)
product = Mock(id=1)
party = Mock(id=1)
sale = Mock(id=2, currency=currency, company=company, party=party)
finished_sale_line = Mock( finished_sale_line = Mock(
finished=True, finished=True,
price_type='priced', price_type='priced',
mtm=[], mtm=[],
sale=sale,
unit=unit,
product=product,
) )
sale_lot = Mock(sale_line=finished_sale_line) sale_lot = Mock(
id=3,
sale_line=finished_sale_line,
lot_price_sale=Decimal('20'),
lot_type='physic',
)
sale_lot.get_current_quantity_converted.return_value = Decimal('2')
purchase_lot = Mock( purchase_lot = Mock(
id=4,
sale_line=None, sale_line=None,
lot_price=Decimal('10'), lot_price=Decimal('10'),
lot_type='physic', lot_type='physic',
@@ -277,9 +309,9 @@ class PurchaseTradeTestCase(ModuleTestCase):
lots=[purchase_lot], lots=[purchase_lot],
price_type='priced', price_type='priced',
mtm=[], mtm=[],
purchase=Mock(id=1, currency=Mock(id=1), company=Mock(currency=Mock(id=1)), party=Mock(id=1)), purchase=Mock(id=1, currency=currency, company=company, party=party),
unit=Mock(id=1), unit=unit,
product=Mock(id=1), product=product,
) )
lot_qt_model = Mock() lot_qt_model = Mock()
@@ -287,13 +319,20 @@ class PurchaseTradeTestCase(ModuleTestCase):
with patch( with patch(
'trytond.modules.purchase_trade.valuation.Pool') as PoolMock: 'trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.return_value = lot_qt_model PoolMock.return_value.get.side_effect = lambda name: {
'lot.qt': lot_qt_model,
'currency.currency': Mock(),
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
}[name]
values = Valuation.create_pnl_price_from_line(line) values = Valuation.create_pnl_price_from_line(line)
self.assertEqual(len(values), 1) self.assertEqual(len(values), 2)
self.assertEqual(values[0]['type'], 'pur. priced') self.assertEqual(values[0]['type'], 'pur. priced')
self.assertNotIn('sale_line', values[0]) self.assertNotIn('sale_line', values[0])
self.assertEqual(values[1]['type'], 'sale priced')
self.assertEqual(values[1]['sale_line'], finished_sale_line.id)
self.assertEqual(values[1]['reference'], 'Sale/Physic')
def test_sale_report_crop_name_handles_missing_crop(self): def test_sale_report_crop_name_handles_missing_crop(self):
'sale report crop name returns an empty string when crop is missing' 'sale report crop name returns an empty string when crop is missing'

View File

@@ -80,6 +80,17 @@ class ValuationBase(ModelSQL):
def _is_finished(cls, record): def _is_finished(cls, record):
return bool(getattr(record, 'finished', False)) return bool(getattr(record, 'finished', False))
@classmethod
def _valuation_lots(cls, record):
lots = tuple(record.lots or ())
if cls._is_finished(record):
return tuple(lot for lot in lots if lot.lot_type == 'physic')
return lots
@classmethod
def _ignore_finished_open_lot(cls, line, lot):
return cls._is_finished(line) and lot.lot_type != 'physic'
@classmethod @classmethod
def _filter_values_by_types(cls, values, selected_types): def _filter_values_by_types(cls, values, selected_types):
if selected_types is None: if selected_types is None:
@@ -323,7 +334,7 @@ class ValuationBase(ModelSQL):
price_lines = [] price_lines = []
LotQt = Pool().get('lot.qt') LotQt = Pool().get('lot.qt')
for lot in line.lots: for lot in cls._valuation_lots(line):
if line.price_type == 'basis': if line.price_type == 'basis':
premium_delta = cls._get_basis_premium_delta(line) premium_delta = cls._get_basis_premium_delta(line)
@@ -410,7 +421,9 @@ class ValuationBase(ModelSQL):
sl_line = sl.sale_line sl_line = sl.sale_line
if not sl_line: if not sl_line:
continue continue
if cls._is_finished(sl_line): if cls._ignore_finished_open_lot(line, sl):
continue
if cls._ignore_finished_open_lot(sl_line, sl):
continue continue
if sl_line.price_type == 'basis': if sl_line.price_type == 'basis':
@@ -555,7 +568,7 @@ class ValuationBase(ModelSQL):
def create_pnl_price_from_sale_line(cls, sale_line): def create_pnl_price_from_sale_line(cls, sale_line):
price_lines = [] price_lines = []
for lot in sale_line.lots or []: for lot in cls._valuation_lots(sale_line):
if sale_line.price_type == 'basis': if sale_line.price_type == 'basis':
summaries = sale_line.price_summary or [] summaries = sale_line.price_summary or []
premium_delta = cls._get_basis_premium_delta(sale_line) premium_delta = cls._get_basis_premium_delta(sale_line)
@@ -655,10 +668,13 @@ class ValuationBase(ModelSQL):
sale_open_lots = tuple( sale_open_lots = tuple(
s.lot_s for s in sale_lines s.lot_s for s in sale_lines
if s.lot_s and s.lot_s.sale_line if s.lot_s and s.lot_s.sale_line
and not cls._is_finished(s.lot_s.sale_line) and not cls._ignore_finished_open_lot(line, s.lot_s)
and not cls._ignore_finished_open_lot(s.lot_s.sale_line, s.lot_s)
) )
all_lots = (line.lots or ()) + sale_open_lots all_lots = cls._valuation_lots(line) + sale_open_lots
for lot in all_lots: for lot in all_lots:
if lot.sale_line and cls._ignore_finished_open_lot(lot.sale_line, lot):
continue
fl = FeeLots.search([('lot', '=', lot.id)]) fl = FeeLots.search([('lot', '=', lot.id)])
if not fl: if not fl:
continue continue
@@ -714,7 +730,7 @@ class ValuationBase(ModelSQL):
Currency = Pool().get('currency.currency') Currency = Pool().get('currency.currency')
FeeLots = Pool().get('fee.lots') FeeLots = Pool().get('fee.lots')
for lot in sale_line.lots or (): for lot in cls._valuation_lots(sale_line):
fl = FeeLots.search([('lot', '=', lot.id)]) fl = FeeLots.search([('lot', '=', lot.id)])
if not fl: if not fl:
continue continue
@@ -836,8 +852,6 @@ class ValuationBase(ModelSQL):
def generate(cls, line, valuation_type='all'): def generate(cls, line, valuation_type='all'):
selected_types = cls._get_generate_types(valuation_type) selected_types = cls._get_generate_types(valuation_type)
cls._delete_existing(line, selected_types=selected_types) cls._delete_existing(line, selected_types=selected_types)
if cls._is_finished(line):
return
values = [] values = []
values.extend(cls.create_pnl_fee_from_line(line)) values.extend(cls.create_pnl_fee_from_line(line))
values.extend(cls.create_pnl_price_from_line(line)) values.extend(cls.create_pnl_price_from_line(line))
@@ -854,8 +868,6 @@ class ValuationBase(ModelSQL):
def generate_from_sale_line(cls, sale_line, valuation_type='all'): def generate_from_sale_line(cls, sale_line, valuation_type='all'):
selected_types = cls._get_generate_types(valuation_type) selected_types = cls._get_generate_types(valuation_type)
cls._delete_existing_sale_line(sale_line, selected_types=selected_types) cls._delete_existing_sale_line(sale_line, selected_types=selected_types)
if cls._is_finished(sale_line):
return
values = [] values = []
values.extend(cls.create_pnl_fee_from_sale_line(sale_line)) values.extend(cls.create_pnl_fee_from_sale_line(sale_line))
values.extend(cls.create_pnl_price_from_sale_line(sale_line)) values.extend(cls.create_pnl_price_from_sale_line(sale_line))