Shipment Pnl
This commit is contained in:
@@ -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]
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user