diff --git a/modules/purchase_trade/stock.py b/modules/purchase_trade/stock.py index 44c3d39..41c65fd 100755 --- a/modules/purchase_trade/stock.py +++ b/modules/purchase_trade/stock.py @@ -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] diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 7214b56..5d17459 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -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): diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index d08d7e3..bea23cf 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -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