Pnl sale side open
This commit is contained in:
@@ -122,6 +122,12 @@ Owner technique: `a completer`
|
|||||||
- une sale matchee doit donc voir:
|
- une sale matchee doit donc voir:
|
||||||
- ses lignes `sale *`
|
- ses lignes `sale *`
|
||||||
- les lignes purchase portees par le lot physique partage
|
- les lignes purchase portees par le lot physique partage
|
||||||
|
- avant creation du lot physique, si le matching existe seulement via
|
||||||
|
`lot.qt` (`lot_p` purchase ouvert -> `lot_s` sale ouvert), les lignes PnL
|
||||||
|
purchase-side doivent aussi renseigner `sale` et `sale_line` afin
|
||||||
|
d'apparaitre dans l'onglet PnL de la sale matchee
|
||||||
|
- si plusieurs sales differentes sont matchees au meme lot ouvert, ne pas
|
||||||
|
attacher arbitrairement une sale unique aux lignes purchase-side
|
||||||
- Priorite:
|
- Priorite:
|
||||||
- `structurante`
|
- `structurante`
|
||||||
|
|
||||||
|
|||||||
@@ -358,11 +358,122 @@ class PurchaseTradeTestCase(ModuleTestCase):
|
|||||||
|
|
||||||
self.assertEqual(len(values), 2)
|
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.assertEqual(values[0]['sale_line'], finished_sale_line.id)
|
||||||
self.assertEqual(values[1]['type'], 'sale priced')
|
self.assertEqual(values[1]['type'], 'sale priced')
|
||||||
self.assertEqual(values[1]['sale_line'], finished_sale_line.id)
|
self.assertEqual(values[1]['sale_line'], finished_sale_line.id)
|
||||||
self.assertEqual(values[1]['reference'], 'Sale/Physic')
|
self.assertEqual(values[1]['reference'], 'Sale/Physic')
|
||||||
|
|
||||||
|
def test_purchase_open_pnl_price_links_sale_from_lot_qt(self):
|
||||||
|
'purchase-side open price pnl is linked to the matched sale via lot.qt'
|
||||||
|
Valuation = Pool().get('valuation.valuation')
|
||||||
|
currency = Mock(id=1)
|
||||||
|
company = Mock(currency=currency.id)
|
||||||
|
unit = Mock(id=1)
|
||||||
|
product = Mock(id=1)
|
||||||
|
purchase_party = Mock(id=1)
|
||||||
|
sale_party = Mock(id=2)
|
||||||
|
sale = Mock(id=3, currency=currency, company=company, party=sale_party)
|
||||||
|
sale_line = Mock(
|
||||||
|
id=4,
|
||||||
|
finished=False,
|
||||||
|
price_type='priced',
|
||||||
|
mtm=[],
|
||||||
|
sale=sale,
|
||||||
|
unit=unit,
|
||||||
|
product=product,
|
||||||
|
)
|
||||||
|
sale_lot = Mock(
|
||||||
|
id=5,
|
||||||
|
sale_line=sale_line,
|
||||||
|
lot_price_sale=Decimal('20'),
|
||||||
|
lot_type='virtual',
|
||||||
|
)
|
||||||
|
sale_lot.get_current_quantity_converted.return_value = Decimal('2')
|
||||||
|
purchase_lot = Mock(
|
||||||
|
id=6,
|
||||||
|
sale_line=None,
|
||||||
|
lot_price=Decimal('10'),
|
||||||
|
lot_type='virtual',
|
||||||
|
)
|
||||||
|
purchase_lot.get_current_quantity_converted.return_value = Decimal('2')
|
||||||
|
line = Mock(
|
||||||
|
finished=False,
|
||||||
|
lots=[purchase_lot],
|
||||||
|
price_type='priced',
|
||||||
|
mtm=[],
|
||||||
|
purchase=Mock(
|
||||||
|
id=7, currency=currency, company=company, party=purchase_party),
|
||||||
|
unit=unit,
|
||||||
|
product=product,
|
||||||
|
)
|
||||||
|
|
||||||
|
lot_qt_model = Mock()
|
||||||
|
lot_qt_model.search.return_value = [Mock(lot_s=sale_lot)]
|
||||||
|
|
||||||
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
||||||
|
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)
|
||||||
|
|
||||||
|
purchase_values = [v for v in values if v['type'] == 'pur. priced']
|
||||||
|
self.assertEqual(purchase_values[0]['sale'], sale.id)
|
||||||
|
self.assertEqual(purchase_values[0]['sale_line'], sale_line.id)
|
||||||
|
|
||||||
|
def test_purchase_open_pnl_fee_links_sale_from_lot_qt(self):
|
||||||
|
'purchase-side open fee pnl is linked to the matched sale via lot.qt'
|
||||||
|
Valuation = Pool().get('valuation.valuation')
|
||||||
|
currency = Mock(id=1)
|
||||||
|
unit = Mock(id=2)
|
||||||
|
product = Mock(id=3, name='Financing interests')
|
||||||
|
supplier = Mock(id=4)
|
||||||
|
sale = Mock(id=5)
|
||||||
|
sale_line = Mock(id=6, sale=sale, finished=False)
|
||||||
|
sale_lot = Mock(id=7, sale_line=sale_line, lot_type='virtual')
|
||||||
|
purchase_lot = Mock(id=8, sale_line=None, lot_type='virtual')
|
||||||
|
purchase_lot.get_current_quantity_converted.return_value = Decimal('2')
|
||||||
|
fee = Mock(
|
||||||
|
product=product,
|
||||||
|
supplier=supplier,
|
||||||
|
type='budgeted',
|
||||||
|
p_r='pay',
|
||||||
|
mode='lumpsum',
|
||||||
|
price=Decimal('10'),
|
||||||
|
currency=currency,
|
||||||
|
shipment_in=None,
|
||||||
|
sale_line=None,
|
||||||
|
unit=unit,
|
||||||
|
)
|
||||||
|
line = Mock(
|
||||||
|
id=9,
|
||||||
|
finished=False,
|
||||||
|
lots=[purchase_lot],
|
||||||
|
get_matched_lines=Mock(return_value=[]),
|
||||||
|
purchase=Mock(id=10, currency=currency),
|
||||||
|
unit=unit,
|
||||||
|
)
|
||||||
|
lot_qt_model = Mock()
|
||||||
|
lot_qt_model.search.return_value = [Mock(lot_s=sale_lot)]
|
||||||
|
fee_lots = Mock()
|
||||||
|
fee_lots.search.return_value = [Mock(fee=fee)]
|
||||||
|
|
||||||
|
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
|
||||||
|
PoolMock.return_value.get.side_effect = lambda name: {
|
||||||
|
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
|
||||||
|
'currency.currency': Mock(),
|
||||||
|
'fee.lots': fee_lots,
|
||||||
|
'lot.qt': lot_qt_model,
|
||||||
|
}[name]
|
||||||
|
|
||||||
|
values = Valuation.create_pnl_fee_from_line(line)
|
||||||
|
|
||||||
|
self.assertEqual(values[0]['type'], 'pur. fee')
|
||||||
|
self.assertEqual(values[0]['sale'], sale.id)
|
||||||
|
self.assertEqual(values[0]['sale_line'], sale_line.id)
|
||||||
|
|
||||||
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'
|
||||||
Sale = Pool().get('sale.sale')
|
Sale = Pool().get('sale.sale')
|
||||||
|
|||||||
@@ -91,6 +91,43 @@ class ValuationBase(ModelSQL):
|
|||||||
def _ignore_finished_open_lot(cls, line, lot):
|
def _ignore_finished_open_lot(cls, line, lot):
|
||||||
return cls._is_finished(line) and lot.lot_type != 'physic'
|
return cls._is_finished(line) and lot.lot_type != 'physic'
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_matched_sale_line_from_purchase_lot(cls, lot, LotQt=None):
|
||||||
|
sale_line = getattr(lot, 'sale_line', None)
|
||||||
|
if sale_line:
|
||||||
|
return sale_line
|
||||||
|
|
||||||
|
lot_id = getattr(lot, 'id', None)
|
||||||
|
if not lot_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if LotQt is None:
|
||||||
|
LotQt = Pool().get('lot.qt')
|
||||||
|
|
||||||
|
sale_lines = []
|
||||||
|
seen = set()
|
||||||
|
for lqt in LotQt.search([
|
||||||
|
('lot_p', '=', lot_id),
|
||||||
|
('lot_s', '>', 0),
|
||||||
|
('lot_quantity', '>', 0),
|
||||||
|
]):
|
||||||
|
lot_s = getattr(lqt, 'lot_s', None)
|
||||||
|
sale_line = getattr(lot_s, 'sale_line', None)
|
||||||
|
sale_line_id = getattr(sale_line, 'id', None)
|
||||||
|
if sale_line and sale_line_id not in seen:
|
||||||
|
sale_lines.append(sale_line)
|
||||||
|
seen.add(sale_line_id)
|
||||||
|
|
||||||
|
if len(sale_lines) == 1:
|
||||||
|
return sale_lines[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _set_matched_sale_values(cls, values, sale_line):
|
||||||
|
if values and sale_line:
|
||||||
|
values['sale'] = sale_line.sale.id
|
||||||
|
values['sale_line'] = sale_line.id
|
||||||
|
|
||||||
@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:
|
||||||
@@ -335,6 +372,8 @@ class ValuationBase(ModelSQL):
|
|||||||
LotQt = Pool().get('lot.qt')
|
LotQt = Pool().get('lot.qt')
|
||||||
|
|
||||||
for lot in cls._valuation_lots(line):
|
for lot in cls._valuation_lots(line):
|
||||||
|
matched_sale_line = cls._get_matched_sale_line_from_purchase_lot(
|
||||||
|
lot, LotQt=LotQt)
|
||||||
|
|
||||||
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)
|
||||||
@@ -349,9 +388,7 @@ class ValuationBase(ModelSQL):
|
|||||||
sign=-1,
|
sign=-1,
|
||||||
pnl_type='pur. priced'
|
pnl_type='pur. priced'
|
||||||
)
|
)
|
||||||
if values and lot.sale_line:
|
cls._set_matched_sale_values(values, matched_sale_line)
|
||||||
values['sale'] = lot.sale_line.sale.id
|
|
||||||
values['sale_line'] = lot.sale_line.id
|
|
||||||
if line.mtm and cls._supports_strategy_mtm(values):
|
if line.mtm and cls._supports_strategy_mtm(values):
|
||||||
for strat in line.mtm:
|
for strat in line.mtm:
|
||||||
values['mtm_price'] = cls._get_strategy_mtm_price(strat, line)
|
values['mtm_price'] = cls._get_strategy_mtm_price(strat, line)
|
||||||
@@ -369,9 +406,7 @@ class ValuationBase(ModelSQL):
|
|||||||
values = cls._build_basis_pnl(
|
values = cls._build_basis_pnl(
|
||||||
line=line, lot=lot, sale_line=None, pc=pc, sign=-1,
|
line=line, lot=lot, sale_line=None, pc=pc, sign=-1,
|
||||||
extra_price=premium_delta)
|
extra_price=premium_delta)
|
||||||
if values and lot.sale_line:
|
cls._set_matched_sale_values(values, matched_sale_line)
|
||||||
values['sale'] = lot.sale_line.sale.id
|
|
||||||
values['sale_line'] = lot.sale_line.id
|
|
||||||
if line.mtm and cls._supports_strategy_mtm(values):
|
if line.mtm and cls._supports_strategy_mtm(values):
|
||||||
for strat in line.mtm:
|
for strat in line.mtm:
|
||||||
values['mtm_price'] = cls._get_strategy_mtm_price(strat, line)
|
values['mtm_price'] = cls._get_strategy_mtm_price(strat, line)
|
||||||
@@ -394,9 +429,7 @@ class ValuationBase(ModelSQL):
|
|||||||
sign=-1,
|
sign=-1,
|
||||||
pnl_type=f'pur. {line.price_type}'
|
pnl_type=f'pur. {line.price_type}'
|
||||||
)
|
)
|
||||||
if values and lot.sale_line:
|
cls._set_matched_sale_values(values, matched_sale_line)
|
||||||
values['sale'] = lot.sale_line.sale.id
|
|
||||||
values['sale_line'] = lot.sale_line.id
|
|
||||||
if line.mtm and cls._supports_strategy_mtm(values):
|
if line.mtm and cls._supports_strategy_mtm(values):
|
||||||
for strat in line.mtm:
|
for strat in line.mtm:
|
||||||
values['mtm_price'] = cls._get_strategy_mtm_price(strat, line)
|
values['mtm_price'] = cls._get_strategy_mtm_price(strat, line)
|
||||||
@@ -675,6 +708,8 @@ class ValuationBase(ModelSQL):
|
|||||||
for lot in all_lots:
|
for lot in all_lots:
|
||||||
if lot.sale_line and cls._ignore_finished_open_lot(lot.sale_line, lot):
|
if lot.sale_line and cls._ignore_finished_open_lot(lot.sale_line, lot):
|
||||||
continue
|
continue
|
||||||
|
matched_sale_line = cls._get_matched_sale_line_from_purchase_lot(
|
||||||
|
lot, LotQt=LotQt)
|
||||||
fl = FeeLots.search([('lot', '=', lot.id)])
|
fl = FeeLots.search([('lot', '=', lot.id)])
|
||||||
if not fl:
|
if not fl:
|
||||||
continue
|
continue
|
||||||
@@ -698,7 +733,8 @@ class ValuationBase(ModelSQL):
|
|||||||
price = Currency.compute(sf.currency, price, line.purchase.currency)
|
price = Currency.compute(sf.currency, price, line.purchase.currency)
|
||||||
fee_lines.append({
|
fee_lines.append({
|
||||||
'lot': lot.id,
|
'lot': lot.id,
|
||||||
'sale': lot.sale_line.sale.id if lot.sale_line else None,
|
'sale': matched_sale_line.sale.id if matched_sale_line else None,
|
||||||
|
'sale_line': matched_sale_line.id if matched_sale_line else None,
|
||||||
'purchase': line.purchase.id,
|
'purchase': line.purchase.id,
|
||||||
'line': line.id,
|
'line': line.id,
|
||||||
'type': (
|
'type': (
|
||||||
|
|||||||
Reference in New Issue
Block a user