Shipment Pnl

This commit is contained in:
2026-06-10 11:37:33 +02:00
parent 96ad6dd1c6
commit 925fdfd32b
3 changed files with 261 additions and 112 deletions

View File

@@ -1814,27 +1814,11 @@ class ShipmentIn(metaclass=PoolMeta):
return lot_ids
def get_pnl_lines(self, name=None):
lot_ids = self._pnl_lot_ids()
if not lot_ids and not self.id:
if not self.id:
return []
ValuationLine = Pool().get('valuation.valuation.line')
if self.id and lot_ids:
domain = ['OR',
[('shipment_in', '=', self.id)],
[
('shipment_in', '=', None),
('lot', 'in', lot_ids),
],
]
elif self.id:
domain = [('shipment_in', '=', self.id)]
else:
domain = [
('shipment_in', '=', None),
('lot', 'in', lot_ids),
]
lines = ValuationLine.search(
domain,
[('shipment_in', '=', self.id)],
order=[('date', 'DESC'), ('id', 'DESC')])
return [line.id for line in lines]

View File

@@ -1235,6 +1235,7 @@ class PurchaseTradeTestCase(ModuleTestCase):
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 23))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_line(line)
@@ -1625,7 +1626,12 @@ class PurchaseTradeTestCase(ModuleTestCase):
)
lot_qt_model = Mock()
lot_qt_model.search.return_value = [Mock(lot_s=sale_lot)]
lot_qt_model.search.return_value = [
Mock(
lot_s=sale_lot,
lot_quantity=Decimal('2'),
lot_unit=unit,
lot_shipment_in=None)]
with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock:
PoolMock.return_value.get.side_effect = lambda name: {
@@ -1673,7 +1679,12 @@ class PurchaseTradeTestCase(ModuleTestCase):
unit=unit,
)
lot_qt_model = Mock()
lot_qt_model.search.return_value = [Mock(lot_s=sale_lot)]
lot_qt_model.search.return_value = [
Mock(
lot_s=sale_lot,
lot_quantity=Decimal('2'),
lot_unit=unit,
lot_shipment_in=None)]
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
@@ -1739,6 +1750,65 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(values[0]['type'], 'sale fee')
self.assertEqual(values[0]['lot'], sale_lot.id)
def test_sale_open_fee_pnl_splits_by_shipment_lotqt(self):
'sale open fee pnl is split between shipped and unshipped lot.qt'
Valuation = Pool().get('valuation.valuation')
currency = Mock(id=1)
unit = Mock(id=2)
shipment = Mock(id=70)
sale = Mock(id=3, currency=currency)
sale_line = Mock(id=4, sale=sale, unit=unit, finished=False)
sale_lot = Mock(id=5, sale_line=sale_line, lot_type='virtual')
sale_lot.get_current_quantity_converted.return_value = Decimal('220')
sale_line.lots = [sale_lot]
fee = Mock(
product=Mock(id=6, name='Broker commission'),
supplier=Mock(id=7),
type='budgeted',
p_r='pay',
mode='perqt',
price=Decimal('10'),
currency=currency,
shipment_in=None,
sale_line=sale_line,
unit=unit,
)
fee.is_effective_fee_lot.return_value = True
fee.get_price_per_qt.return_value = Decimal('10')
fee_lots = Mock()
fee_lots.search.return_value = [Mock(fee=fee)]
lot_qt_model = Mock()
lot_qt_model.search.return_value = [
Mock(
lot_s=sale_lot,
lot_quantity=Decimal('88'),
lot_unit=unit,
lot_shipment_in=shipment),
Mock(
lot_s=sale_lot,
lot_quantity=Decimal('132'),
lot_unit=unit,
lot_shipment_in=None),
]
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, 5, 14))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': lot_qt_model,
}[name]
values = Valuation.create_pnl_fee_from_sale_line(sale_line)
self.assertEqual(len(values), 2)
self.assertEqual(values[0]['shipment_in'], shipment.id)
self.assertEqual(values[0]['quantity'], Decimal('88.00000'))
self.assertEqual(values[0]['amount'], Decimal('-880.00'))
self.assertIsNone(values[1]['shipment_in'])
self.assertEqual(values[1]['quantity'], Decimal('132.00000'))
self.assertEqual(values[1]['amount'], Decimal('-1320.00'))
def test_snapshot_identity_prefers_sale_line_over_purchase_line(self):
'valuation snapshot identity ignores purchase line when sale line exists'
Valuation = Pool().get('valuation.valuation')
@@ -1839,6 +1909,7 @@ class PurchaseTradeTestCase(ModuleTestCase):
'ir.date': Mock(today=Mock(return_value=datetime.date(2026, 4, 29))),
'currency.currency': Mock(),
'fee.lots': fee_lots,
'lot.qt': Mock(),
}[name]
values = Valuation.create_pnl_fee_from_sale_line(sale_line)
@@ -3005,13 +3076,7 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(shipment.get_pnl_lines(None), [10, 11])
valuation_model.search.assert_called_once_with(
['OR',
[('shipment_in', '=', 99)],
[
('shipment_in', '=', None),
('lot', 'in', [1, 2, 3]),
],
],
[('shipment_in', '=', 99)],
order=[('date', 'DESC'), ('id', 'DESC')])
def test_sale_and_purchase_tolerance_option_defaults_to_empty(self):

View File

@@ -227,6 +227,57 @@ class ValuationBase(ModelSQL):
values['sale'] = sale_line.sale.id
values['sale_line'] = sale_line.id
@classmethod
def _fee_lot_segments(cls, lot, line, LotQt):
if getattr(lot, 'lot_type', None) == 'physic':
return [{
'quantity': None,
'share': None,
'shipment_in': cls._lot_shipment_in_id(lot),
}]
lot_id = getattr(lot, 'id', None)
if not lot_id:
return [{
'quantity': None,
'share': None,
'shipment_in': cls._lot_shipment_in_id(lot),
}]
domains = [[('lot_p', '=', lot_id), ('lot_quantity', '>', 0)]]
if getattr(lot, 'sale_line', None):
domains = [[('lot_s', '=', lot_id), ('lot_quantity', '>', 0)]]
lqts = []
seen = set()
for domain in domains:
for lqt in LotQt.search(domain):
lqt_id = cls._record_id(lqt)
if lqt_id in seen:
continue
seen.add(lqt_id)
lqts.append(lqt)
if not lqts:
return [{
'quantity': None,
'share': None,
'shipment_in': cls._lot_shipment_in_id(lot),
}]
segment_line = getattr(lot, 'sale_line', None) or line
quantities = [cls._lotqt_quantity(lqt, segment_line) for lqt in lqts]
total = sum(quantities, Decimal(0))
segments = []
for lqt, quantity in zip(lqts, quantities):
segments.append({
'quantity': quantity,
'share': (quantity / total) if total else None,
'shipment_in': cls._lotqt_shipment_in_id(lqt),
})
return segments
@staticmethod
def _record_id(record):
return getattr(record, 'id', record)
@@ -1049,6 +1100,74 @@ class ValuationBase(ModelSQL):
return Decimal(0)
return cls._fee_amount_or_zero(fee)
@classmethod
def _fee_segment_quantity(cls, fee, lot, segment):
quantity = segment.get('quantity')
if quantity is None:
qty = round(lot.get_current_quantity_converted(), 5)
if fee.mode == 'ppack' and getattr(lot, 'lot_qt', None):
qty = Decimal(str(lot.lot_qt or 0))
return qty
if fee.mode == 'ppack' and getattr(lot, 'lot_qt', None):
share = segment.get('share')
if share is not None:
return round(Decimal(str(lot.lot_qt or 0)) * share, 5)
return Decimal(str(lot.lot_qt or 0))
return round(Decimal(str(quantity)), 5)
@classmethod
def _fee_segment_amount(cls, fee, lot, qty, sign, segment):
share = segment.get('share')
if fee.mode == 'ppack':
price = fee.price
amount = cls._fee_amount_for_lot_or_zero(fee, lot)
if share is not None:
amount = round(amount * share, 2)
return price, amount * sign
if fee.mode == 'rate':
price = fee.price
line = fee.line or getattr(fee, 'sale_line', None)
if line and line.estimated_date:
est_lines = [
dd for dd in line.estimated_date
if dd.trigger == 'bldate']
est_line = est_lines[0] if est_lines else None
if est_line and est_line.fin_int_delta:
factor = (
Decimal(fee.price or 0) / Decimal(100)
* Decimal(est_line.fin_int_delta) / Decimal(360))
amount = abs(round(
factor * Decimal(line.unit_price or 0)
* Decimal(qty or 0), 2))
return price, amount * sign
return price, Decimal(0)
if fee.mode == 'lumpsum':
price = fee.price
amount = Decimal(price or 0)
if share is not None:
amount = round(amount * share, 2)
return price, amount * sign
price = Decimal(fee.get_price_per_qt())
return price, round(price * Decimal(qty or 0) * sign, 2)
@classmethod
def _fee_segments_for_fee(cls, fee, lot, line, LotQt):
segments = cls._fee_lot_segments(lot, line, LotQt)
shipment = getattr(fee, 'shipment_in', None)
shipment_id = cls._record_id(shipment) if shipment else None
if not shipment_id:
return segments
selected = [
segment for segment in segments
if segment.get('shipment_in') == shipment_id]
if selected:
return selected
return [{
'quantity': None,
'share': None,
'shipment_in': shipment_id,
}]
@staticmethod
def _check_fee_required_fields(fee):
if not getattr(fee, 'currency', None):
@@ -1094,50 +1213,40 @@ class ValuationBase(ModelSQL):
for sf in cls.group_fees_by_type_supplier(line, fees):
cls._check_fee_required_fields(sf)
sign = -1 if sf.p_r == 'pay' else 1
qty = round(lot.get_current_quantity_converted(), 5)
if sf.mode == 'ppack' and getattr(lot, 'lot_qt', None):
qty = Decimal(str(lot.lot_qt or 0))
if sf.mode == 'ppack' or sf.mode == 'rate':
price = sf.price
amount = cls._fee_amount_for_lot_or_zero(sf, lot) * sign
elif sf.mode == 'lumpsum':
price = sf.price
amount = sf.price * sign
qty = 1
else:
price = Decimal(sf.get_price_per_qt())
amount = round(price * lot.get_current_quantity_converted() * sign, 2)
if sf.currency != line.purchase.currency:
with Transaction().set_context(date=Date.today()):
price = Currency.compute(sf.currency, price, line.purchase.currency)
fee_lines.append({
'lot': lot.id,
'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,
'line': line.id,
'shipment_in': (
sf.shipment_in.id if sf.shipment_in
else cls._lot_shipment_in_id(lot)),
'type': (
'shipment fee' if sf.shipment_in
else 'sale fee' if sf.sale_line
else 'pur. fee'
),
'date': Date.today(),
'price': price,
'counterparty': sf.supplier.id,
'reference': f"{sf.product.name}/{'Physic' if lot.lot_type == 'physic' else 'Open'}",
'product': sf.product.id,
'state': sf.type,
'quantity': qty,
'amount': amount,
'mtm_price': None,
'mtm': None,
'strategy': None,
'unit': sf.unit.id if sf.unit else line.unit.id,
'currency': sf.currency.id,
})
for segment in cls._fee_segments_for_fee(sf, lot, line, LotQt):
qty = cls._fee_segment_quantity(sf, lot, segment)
price, amount = cls._fee_segment_amount(
sf, lot, qty, sign, segment)
if sf.currency != line.purchase.currency:
with Transaction().set_context(date=Date.today()):
price = Currency.compute(
sf.currency, price, line.purchase.currency)
fee_lines.append({
'lot': lot.id,
'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,
'line': line.id,
'shipment_in': segment.get('shipment_in'),
'type': (
'shipment fee' if sf.shipment_in
else 'sale fee' if sf.sale_line
else 'pur. fee'
),
'date': Date.today(),
'price': price,
'counterparty': sf.supplier.id,
'reference': f"{sf.product.name}/{'Physic' if lot.lot_type == 'physic' else 'Open'}",
'product': sf.product.id,
'state': sf.type,
'quantity': qty,
'amount': amount,
'mtm_price': None,
'mtm': None,
'strategy': None,
'unit': sf.unit.id if sf.unit else line.unit.id,
'currency': sf.currency.id,
})
return fee_lines
@@ -1165,47 +1274,38 @@ class ValuationBase(ModelSQL):
for sf in cls.group_fees_by_type_supplier(sale_line, fees):
cls._check_fee_required_fields(sf)
sign = -1 if sf.p_r == 'pay' else 1
qty = round(lot.get_current_quantity_converted(), 5)
if sf.mode == 'ppack' and getattr(lot, 'lot_qt', None):
qty = Decimal(str(lot.lot_qt or 0))
if sf.mode == 'ppack' or sf.mode == 'rate':
price = sf.price
amount = cls._fee_amount_for_lot_or_zero(sf, lot) * sign
elif sf.mode == 'lumpsum':
price = sf.price
amount = sf.price * sign
qty = 1
else:
price = Decimal(sf.get_price_per_qt())
amount = round(price * lot.get_current_quantity_converted() * sign, 2)
if sf.currency != sale_line.sale.currency:
with Transaction().set_context(date=Date.today()):
price = Currency.compute(sf.currency, price, sale_line.sale.currency)
fee_lines.append({
'lot': lot.id,
'sale': sale_line.sale.id,
'sale_line': sale_line.id,
'shipment_in': (
sf.shipment_in.id if sf.shipment_in
else cls._lot_shipment_in_id(lot)),
'type': (
'shipment fee' if sf.shipment_in
else 'sale fee'
),
'date': Date.today(),
'price': price,
'counterparty': sf.supplier.id,
'reference': f"{sf.product.name}/{'Physic' if lot.lot_type == 'physic' else 'Open'}",
'product': sf.product.id,
'state': sf.type,
'quantity': qty,
'amount': amount,
'mtm_price': None,
'mtm': None,
'strategy': None,
'unit': sf.unit.id if sf.unit else sale_line.unit.id,
'currency': sf.currency.id,
})
for segment in cls._fee_segments_for_fee(
sf, lot, sale_line, LotQt):
qty = cls._fee_segment_quantity(sf, lot, segment)
price, amount = cls._fee_segment_amount(
sf, lot, qty, sign, segment)
if sf.currency != sale_line.sale.currency:
with Transaction().set_context(date=Date.today()):
price = Currency.compute(
sf.currency, price, sale_line.sale.currency)
fee_lines.append({
'lot': lot.id,
'sale': sale_line.sale.id,
'sale_line': sale_line.id,
'shipment_in': segment.get('shipment_in'),
'type': (
'shipment fee' if sf.shipment_in
else 'sale fee'
),
'date': Date.today(),
'price': price,
'counterparty': sf.supplier.id,
'reference': f"{sf.product.name}/{'Physic' if lot.lot_type == 'physic' else 'Open'}",
'product': sf.product.id,
'state': sf.type,
'quantity': qty,
'amount': amount,
'mtm_price': None,
'mtm': None,
'strategy': None,
'unit': sf.unit.id if sf.unit else sale_line.unit.id,
'currency': sf.currency.id,
})
return fee_lines