Valuation/ark as finished
This commit is contained in:
@@ -191,6 +191,26 @@ Owner technique: `a completer`
|
||||
- Priorite:
|
||||
- `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`
|
||||
|
||||
- Intent: garantir que le montant total valorise et facture reflete toujours le
|
||||
|
||||
@@ -147,41 +147,57 @@ class PurchaseTradeTestCase(ModuleTestCase):
|
||||
{'type': 'derivative', 'amount': Decimal('30')},
|
||||
])
|
||||
|
||||
def test_generate_skips_finished_purchase_line(self):
|
||||
'valuation generation keeps deleting old rows but skips finished purchase lines'
|
||||
def test_generate_keeps_finished_purchase_line_physical_pnl(self):
|
||||
'finished purchase lines still generate physical lot valuation'
|
||||
Valuation = Pool().get('valuation.valuation')
|
||||
line = Mock(finished=True)
|
||||
|
||||
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_price_from_line') as create_prices, patch.object(
|
||||
Valuation, 'create_pnl_der_from_line') as create_derivatives, patch(
|
||||
Valuation, 'create_pnl_fee_from_line',
|
||||
return_value=[{'type': 'pur. fee'}]) as create_fees, patch.object(
|
||||
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:
|
||||
Valuation.generate(line)
|
||||
|
||||
delete_existing.assert_called_once_with(line, selected_types=None)
|
||||
create_fees.assert_not_called()
|
||||
create_prices.assert_not_called()
|
||||
create_derivatives.assert_not_called()
|
||||
PoolMock.return_value.get.assert_not_called()
|
||||
create_fees.assert_called_once_with(line)
|
||||
create_prices.assert_called_once_with(line)
|
||||
create_derivatives.assert_called_once_with(line)
|
||||
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):
|
||||
'sale valuation generation keeps deleting old rows but skips finished sale lines'
|
||||
def test_generate_keeps_finished_sale_line_physical_pnl(self):
|
||||
'finished sale lines still generate physical lot valuation'
|
||||
Valuation = Pool().get('valuation.valuation')
|
||||
sale_line = Mock(finished=True)
|
||||
|
||||
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_price_from_sale_line') as create_prices, patch.object(
|
||||
Valuation, 'create_pnl_der_from_sale_line') as create_derivatives, patch(
|
||||
Valuation, 'create_pnl_fee_from_sale_line',
|
||||
return_value=[{'type': 'sale fee'}]) as create_fees, patch.object(
|
||||
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:
|
||||
Valuation.generate_from_sale_line(sale_line)
|
||||
|
||||
delete_existing.assert_called_once_with(sale_line, selected_types=None)
|
||||
create_fees.assert_not_called()
|
||||
create_prices.assert_not_called()
|
||||
create_derivatives.assert_not_called()
|
||||
PoolMock.return_value.get.assert_not_called()
|
||||
create_fees.assert_called_once_with(sale_line)
|
||||
create_prices.assert_called_once_with(sale_line)
|
||||
create_derivatives.assert_called_once_with(sale_line)
|
||||
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):
|
||||
'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'))
|
||||
|
||||
def test_create_pnl_price_from_line_ignores_finished_matched_sale_line(self):
|
||||
'purchase valuation does not add sale-side pnl when the matched sale line is finished'
|
||||
def test_create_pnl_price_from_line_keeps_finished_physical_sale_line(self):
|
||||
'purchase valuation keeps finished sale-side pnl on physical lots'
|
||||
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=True,
|
||||
price_type='priced',
|
||||
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(
|
||||
id=4,
|
||||
sale_line=None,
|
||||
lot_price=Decimal('10'),
|
||||
lot_type='physic',
|
||||
@@ -277,9 +309,9 @@ class PurchaseTradeTestCase(ModuleTestCase):
|
||||
lots=[purchase_lot],
|
||||
price_type='priced',
|
||||
mtm=[],
|
||||
purchase=Mock(id=1, currency=Mock(id=1), company=Mock(currency=Mock(id=1)), party=Mock(id=1)),
|
||||
unit=Mock(id=1),
|
||||
product=Mock(id=1),
|
||||
purchase=Mock(id=1, currency=currency, company=company, party=party),
|
||||
unit=unit,
|
||||
product=product,
|
||||
)
|
||||
|
||||
lot_qt_model = Mock()
|
||||
@@ -287,13 +319,20 @@ class PurchaseTradeTestCase(ModuleTestCase):
|
||||
|
||||
with patch(
|
||||
'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)
|
||||
|
||||
self.assertEqual(len(values), 1)
|
||||
self.assertEqual(len(values), 2)
|
||||
self.assertEqual(values[0]['type'], 'pur. priced')
|
||||
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):
|
||||
'sale report crop name returns an empty string when crop is missing'
|
||||
|
||||
@@ -80,6 +80,17 @@ class ValuationBase(ModelSQL):
|
||||
def _is_finished(cls, record):
|
||||
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
|
||||
def _filter_values_by_types(cls, values, selected_types):
|
||||
if selected_types is None:
|
||||
@@ -323,7 +334,7 @@ class ValuationBase(ModelSQL):
|
||||
price_lines = []
|
||||
LotQt = Pool().get('lot.qt')
|
||||
|
||||
for lot in line.lots:
|
||||
for lot in cls._valuation_lots(line):
|
||||
|
||||
if line.price_type == 'basis':
|
||||
premium_delta = cls._get_basis_premium_delta(line)
|
||||
@@ -410,7 +421,9 @@ class ValuationBase(ModelSQL):
|
||||
sl_line = sl.sale_line
|
||||
if not sl_line:
|
||||
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
|
||||
|
||||
if sl_line.price_type == 'basis':
|
||||
@@ -555,7 +568,7 @@ class ValuationBase(ModelSQL):
|
||||
def create_pnl_price_from_sale_line(cls, sale_line):
|
||||
price_lines = []
|
||||
|
||||
for lot in sale_line.lots or []:
|
||||
for lot in cls._valuation_lots(sale_line):
|
||||
if sale_line.price_type == 'basis':
|
||||
summaries = sale_line.price_summary or []
|
||||
premium_delta = cls._get_basis_premium_delta(sale_line)
|
||||
@@ -655,10 +668,13 @@ class ValuationBase(ModelSQL):
|
||||
sale_open_lots = tuple(
|
||||
s.lot_s for s in sale_lines
|
||||
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:
|
||||
if lot.sale_line and cls._ignore_finished_open_lot(lot.sale_line, lot):
|
||||
continue
|
||||
fl = FeeLots.search([('lot', '=', lot.id)])
|
||||
if not fl:
|
||||
continue
|
||||
@@ -714,7 +730,7 @@ class ValuationBase(ModelSQL):
|
||||
Currency = Pool().get('currency.currency')
|
||||
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)])
|
||||
if not fl:
|
||||
continue
|
||||
@@ -836,8 +852,6 @@ class ValuationBase(ModelSQL):
|
||||
def generate(cls, line, valuation_type='all'):
|
||||
selected_types = cls._get_generate_types(valuation_type)
|
||||
cls._delete_existing(line, selected_types=selected_types)
|
||||
if cls._is_finished(line):
|
||||
return
|
||||
values = []
|
||||
values.extend(cls.create_pnl_fee_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'):
|
||||
selected_types = cls._get_generate_types(valuation_type)
|
||||
cls._delete_existing_sale_line(sale_line, selected_types=selected_types)
|
||||
if cls._is_finished(sale_line):
|
||||
return
|
||||
values = []
|
||||
values.extend(cls.create_pnl_fee_from_sale_line(sale_line))
|
||||
values.extend(cls.create_pnl_price_from_sale_line(sale_line))
|
||||
|
||||
Reference in New Issue
Block a user