diff --git a/modules/purchase_trade/ctrm_reporting.py b/modules/purchase_trade/ctrm_reporting.py index 12f2f1c..3e2f060 100644 --- a/modules/purchase_trade/ctrm_reporting.py +++ b/modules/purchase_trade/ctrm_reporting.py @@ -21,7 +21,10 @@ PHYSICAL_VALUATION_TYPES = [ 'market', ] DERIVATIVE_VALUATION_TYPES = ['derivative'] -ALL_VALUATION_TYPES = PHYSICAL_VALUATION_TYPES + DERIVATIVE_VALUATION_TYPES +MTM_VALUATION_TYPES = ['mtm'] +ALL_VALUATION_TYPES = ( + PHYSICAL_VALUATION_TYPES + DERIVATIVE_VALUATION_TYPES + + MTM_VALUATION_TYPES) class CTRMValuationContextMixin: @@ -249,7 +252,8 @@ class CTRMNetPosition(ModelSQL, ModelView): context = Transaction().context where = val.type.in_( - PHYSICAL_VALUATION_TYPES + DERIVATIVE_VALUATION_TYPES) + PHYSICAL_VALUATION_TYPES + DERIVATIVE_VALUATION_TYPES + + MTM_VALUATION_TYPES) if context.get('date'): where &= val.date == context['date'] if context.get('product'): @@ -268,14 +272,17 @@ class CTRMNetPosition(ModelSQL, ModelView): where &= val.state == context['state'] is_derivative = val.type.in_(DERIVATIVE_VALUATION_TYPES) + is_mtm = val.type.in_(MTM_VALUATION_TYPES) physical_quantity = Case( - (is_derivative, 0), else_=Coalesce(val.quantity, 0)) + (is_derivative | is_mtm, 0), else_=Coalesce(val.quantity, 0)) derivative_quantity = Case( (is_derivative, Coalesce(val.quantity, 0)), else_=0) physical_amount = Case( - (is_derivative, 0), else_=Coalesce(val.amount, 0)) + (is_derivative | is_mtm, 0), else_=Coalesce(val.amount, 0)) derivative_amount = Case( (is_derivative, Coalesce(val.amount, 0)), else_=0) + net_quantity = Case( + (is_mtm, 0), else_=Coalesce(val.quantity, 0)) group_by = [ val.date, @@ -302,7 +309,7 @@ class CTRMNetPosition(ModelSQL, ModelView): val.strategy.as_('strategy'), Sum(physical_quantity).as_('physical_quantity'), Sum(derivative_quantity).as_('derivative_quantity'), - Sum(Coalesce(val.quantity, 0)).as_('net_quantity'), + Sum(net_quantity).as_('net_quantity'), Sum(physical_amount).as_('physical_amount'), Sum(derivative_amount).as_('derivative_amount'), Sum(Coalesce(val.amount, 0)).as_('net_amount'), @@ -396,7 +403,8 @@ class CTRMRealizedPnl(ModelSQL, ModelView): context = Transaction().context realization = context.get( 'realization', 'purchase_and_sale_invoiced') - where = val.type.in_(ALL_VALUATION_TYPES) + where = val.type.in_( + PHYSICAL_VALUATION_TYPES + DERIVATIVE_VALUATION_TYPES) if context.get('date'): where &= val.date == context['date'] if context.get('product'): @@ -487,6 +495,7 @@ class CTRMMtmPnl(ModelSQL, ModelView): ('sale fee', 'Sale fee'), ('shipment fee', 'Shipment fee'), ('market', 'Market'), + ('mtm', 'Mtm'), ('derivative', 'Derivative'), ], "Type") reference = fields.Char("Reference") @@ -610,8 +619,10 @@ class CTRMPnlExplain(ModelSQL, ModelView): 'shipment fee', ]) is_derivative = val.type.in_(DERIVATIVE_VALUATION_TYPES) + is_mtm = val.type.in_(MTM_VALUATION_TYPES) physical_pnl = Case( - (is_fee | is_derivative, 0), else_=Coalesce(val.amount, 0)) + (is_fee | is_derivative | is_mtm, 0), + else_=Coalesce(val.amount, 0)) fee_pnl = Case((is_fee, Coalesce(val.amount, 0)), else_=0) derivative_pnl = Case( (is_derivative, Coalesce(val.amount, 0)), else_=0) @@ -737,7 +748,9 @@ class CTRMPnlDimension(ModelSQL, ModelView): val.unit.as_('unit'), val.state.as_('state'), val.strategy.as_('strategy'), - Sum(Coalesce(val.quantity, 0)).as_('quantity'), + Sum(Case( + (val.type.in_(MTM_VALUATION_TYPES), 0), + else_=Coalesce(val.quantity, 0))).as_('quantity'), Sum(Coalesce(val.amount, 0)).as_('amount'), Sum(Coalesce(val.mtm, 0)).as_('mtm'), Sum(Coalesce(val.mtm, 0) - Coalesce(val.amount, 0)).as_( @@ -1613,7 +1626,8 @@ class CTRMAccruals(ModelSQL, ModelView): else_='physical') context = Transaction().context where = ( - val.type.in_(ALL_VALUATION_TYPES) + val.type.in_( + PHYSICAL_VALUATION_TYPES + DERIVATIVE_VALUATION_TYPES) & ((val.lot == Null) | ((lot.invoice_line == Null) & (lot.sale_invoice_line == Null)))) diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index 550df35..05af680 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -1022,11 +1022,11 @@ class Lot(metaclass=PoolMeta): return True return False - def createMove(self,r,qt,sh): - Move = Pool().get('stock.move') - nm = Move() - nm.from_location = r.lot_p.line.purchase.from_location - nm.to_location = r.lot_p.line.purchase.to_location + def createMove(self,r,qt,sh): + Move = Pool().get('stock.move') + nm = Move() + nm.from_location = r.lot_p.line.purchase.from_location + nm.to_location = r.lot_p.line.purchase.to_location nm.product = r.lot_p.lot_product nm.unit = r.lot_unit nm.quantity = qt @@ -1034,11 +1034,32 @@ class Lot(metaclass=PoolMeta): nm.shipment = sh nm.currency = r.lot_p.line.currency nm.unit_price = r.lot_p.line.unit_price - Move.save([nm]) - return nm.id - - @classmethod - def create(cls, vlist): + Move.save([nm]) + return nm.id + + def create_shipment_move(self, shipment_origin): + Move = Pool().get('stock.move') + line = self.line + if not line or not getattr(line, 'purchase', None): + return + unit = self.lot_unit or self.lot_unit_line or line.unit + quantity = self.get_current_quantity_converted(0, unit) + move = Move() + move.from_location = line.purchase.from_location + move.to_location = line.purchase.to_location + move.product = self.lot_product + move.unit = unit + move.quantity = quantity + move.origin = line + move.shipment = shipment_origin + move.currency = line.currency + move.unit_price = line.unit_price + move.lot = self + Move.save([move]) + return move + + @classmethod + def create(cls, vlist): vlist = [x.copy() for x in vlist] L = Pool().get('lot.lot') Uom = Pool().get('product.uom') @@ -3095,10 +3116,16 @@ class LotShipping(Wizard): elif self.ship.shipment == 'int': l.lot_shipment_internal = self.ship.shipment_internal logger.info("IN_SHIPPING2:%s",l.move) + move = None if not l.move: - continue + move = l.create_shipment_move(shipment_origin) + if not move: + raise UserError( + "Cannot create shipment move for lot %s." + % l) logger.info("IN_SHIPPING3:%s",r) - move = Move(l.move) + if not move: + move = Move(l.move) move.shipment = shipment_origin Move.save([move]) linked_transit_move = move.get_linked_transit_move() diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 7c2c951..c20d53c 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -440,6 +440,60 @@ class PurchaseTradeTestCase(ModuleTestCase): with self.assertRaises(UserError): wizard.transition_shipping() + def test_lot_shipping_physical_without_move_creates_shipment_move(self): + 'linking a physical lot to shipment creates the missing stock move' + wizard = lot_module.LotShipping() + shipment = Mock(id=40) + wizard.ship = Mock( + shipment='in', + shipment_in=shipment, + shipment_out=None, + shipment_internal=None, + create_new_shipment=False, + quantity=None, + ) + record = Mock( + id=2014, + r_lot_type='physic', + ) + wizard.records = [record] + move = Mock() + move.get_linked_transit_move.return_value = None + lot = Mock( + id=2014, + move=None, + line=Mock(), + sale_line=None, + ) + lot.get_current_quantity_converted.return_value = Decimal('22') + lot.getVlot_p.return_value = Mock() + lot.getVlot_s.return_value = None + lot.create_shipment_move.return_value = move + Lot = Mock() + Lot.return_value = lot + Lot.skip_quantity_consistency.return_value.__enter__ = Mock() + Lot.skip_quantity_consistency.return_value.__exit__ = Mock() + LotQt = Mock() + Move = Mock() + + pool = Mock() + pool.get.side_effect = [Lot, LotQt, Move] + with patch('trytond.modules.purchase_trade.lot.Pool', + return_value=pool): + state = wizard.transition_shipping() + + self.assertEqual(state, 'end') + self.assertEqual(lot.lot_shipment_in, shipment) + lot.create_shipment_move.assert_called_once_with( + 'stock.shipment.in,40') + self.assertEqual(move.shipment, 'stock.shipment.in,40') + Move.save.assert_called_once_with([move]) + lot.updateVirtualPart.assert_called_once_with( + Decimal('-22'), 'stock.shipment.in,40', None) + Lot.save.assert_called() + Lot.assert_lines_quantity_consistency.assert_called_once_with([ + lot.line]) + def test_lot_shipping_scheduled_lotqt_keeps_planned_locations(self): 'scheduled lot.qt keeps the planned from and to locations' wizard = lot_module.LotShipping() @@ -556,8 +610,8 @@ class PurchaseTradeTestCase(ModuleTestCase): Decimal('-349167.53')) @with_transaction() - def test_strategy_mtm_lines_split_by_curve_with_previous_price(self): - 'strategy MTM creates one valuation line per curve with previous price' + def test_strategy_mtm_lines_are_separate_from_realized_price(self): + 'strategy MTM creates separate curve lines and preserves realized line' Valuation = Pool().get('valuation.valuation') valuation_date = datetime.date(2026, 6, 5) previous_date = datetime.date(2026, 6, 4) @@ -583,6 +637,7 @@ class PurchaseTradeTestCase(ModuleTestCase): price_index=curve_b, ratio=Decimal('40')), ]) + line.mtm = [strategy] values = { 'type': 'pur. priced', 'price': Decimal('10'), @@ -596,22 +651,28 @@ class PurchaseTradeTestCase(ModuleTestCase): with patch('trytond.modules.purchase_trade.valuation.Pool') as PoolMock: PoolMock.return_value.get.return_value = price_value - Valuation._append_strategy_mtm_lines( - target, values, strategy, line) + Valuation._append_pnl_values(target, values, line) - self.assertEqual(len(target), 2) - self.assertEqual(target[0]['mtm_curve'], curve_a.id) - self.assertEqual(target[0]['amount'], Decimal('-60.00')) - self.assertEqual(target[0]['mtm_price'], Decimal('60.0000')) - self.assertEqual(target[0]['mtm_price_prev'], Decimal('54.0000')) - self.assertEqual(target[0]['amount_prev'], Decimal('-540.00')) - self.assertEqual(target[0]['mtm'], Decimal('-600.00')) - self.assertEqual(target[1]['mtm_curve'], curve_b.id) - self.assertEqual(target[1]['amount'], Decimal('-40.00')) - self.assertEqual(target[1]['mtm_price'], Decimal('20.0000')) - self.assertEqual(target[1]['mtm_price_prev'], Decimal('18.0000')) - self.assertEqual(target[1]['amount_prev'], Decimal('-180.00')) - self.assertEqual(target[1]['mtm'], Decimal('-200.00')) + self.assertEqual(len(target), 3) + self.assertEqual(target[0], values) + self.assertEqual(target[1]['type'], 'mtm') + self.assertEqual(target[1]['mtm_curve'], curve_a.id) + self.assertEqual(target[1]['price'], None) + self.assertEqual(target[1]['amount'], Decimal('0')) + self.assertEqual(target[1]['quantity'], Decimal('10')) + self.assertEqual(target[1]['mtm_price'], Decimal('100.0000')) + self.assertEqual(target[1]['mtm_price_prev'], Decimal('90.0000')) + self.assertEqual(target[1]['amount_prev'], Decimal('-540.00')) + self.assertEqual(target[1]['mtm'], Decimal('-600.00')) + self.assertEqual(target[2]['type'], 'mtm') + self.assertEqual(target[2]['mtm_curve'], curve_b.id) + self.assertEqual(target[2]['price'], None) + self.assertEqual(target[2]['amount'], Decimal('0')) + self.assertEqual(target[2]['quantity'], Decimal('10')) + self.assertEqual(target[2]['mtm_price'], Decimal('50.0000')) + self.assertEqual(target[2]['mtm_price_prev'], Decimal('45.0000')) + self.assertEqual(target[2]['amount_prev'], Decimal('-180.00')) + self.assertEqual(target[2]['mtm'], Decimal('-200.00')) def test_purchase_pnl_uses_partial_lotqt_match_quantity(self): 'open matched purchase and sale pnl use the matched lot.qt quantity' @@ -1095,6 +1156,7 @@ class PurchaseTradeTestCase(ModuleTestCase): Valuation._get_generate_types('derivatives'), {'derivative'}) self.assertIn('pur. priced', Valuation._get_generate_types('goods')) + self.assertIn('mtm', Valuation._get_generate_types('goods')) def test_filter_values_by_types_keeps_matching_entries_only(self): 'type filtering keeps only the requested valuation entries' diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index cd28954..d41df89 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -32,6 +32,7 @@ VALTYPE = [ ('sale fee', 'Sale fee'), ('shipment fee', 'Shipment fee'), ('market', 'Market'), + ('mtm', 'Mtm'), ('derivative', 'Derivative'), ] @@ -75,7 +76,7 @@ class ValuationBase(ModelSQL): 'fees': {'line fee', 'pur. fee', 'sale fee', 'shipment fee'}, 'goods': { 'priced', 'pur. priced', 'pur. efp', - 'sale priced', 'sale efp', 'market', + 'sale priced', 'sale efp', 'market', 'mtm', }, 'derivatives': {'derivative'}, } @@ -573,11 +574,6 @@ class ValuationBase(ModelSQL): scenario.valuation_date, line.unit, strategy.currency) - if component.ratio: - ratio = Decimal(component.ratio) / Decimal(100) - value *= ratio - if previous is not None: - previous *= ratio return round(value, 4), ( round(previous, 4) if previous is not None else None) @@ -587,6 +583,34 @@ class ValuationBase(ModelSQL): return value return round(Decimal(value) * share, digits) + @classmethod + def _mtm_sign(cls, values): + amount = values.get('amount') + if amount and amount < 0: + return Decimal(-1) + if not amount and values.get('type') in {'pur. priced', 'pur. efp'}: + return Decimal(-1) + return Decimal(1) + + @classmethod + def _mtm_line_values(cls, values, strategy, mtm_price, mtm_amount, + mtm_curve=None, mtm_price_prev=None, mtm_amount_prev=None): + line_values = dict(values) + line_values.update({ + 'type': 'mtm', + 'reference': 'MTM/%s' % (values.get('reference') or ''), + 'price': None, + 'amount': Decimal(0), + 'base_amount': Decimal(0), + 'mtm_price': mtm_price, + 'mtm_price_prev': mtm_price_prev, + 'mtm_curve': mtm_curve, + 'amount_prev': mtm_amount_prev, + 'mtm': mtm_amount, + 'strategy': strategy, + }) + return line_values + @classmethod def _append_strategy_mtm_lines(cls, target, values, strategy, line): components = list(strategy.components or []) @@ -595,76 +619,53 @@ class ValuationBase(ModelSQL): if component.price_source_type == 'curve' and component.price_index] if not curve_components: - line_values = dict(values) - line_values['mtm_price'] = cls._get_strategy_mtm_price( - strategy, line) - line_values['amount_prev'] = None - line_values['mtm'] = cls._signed_strategy_mtm( - values, strategy, line) - line_values['strategy'] = strategy - target.append(line_values) + target.append(cls._mtm_line_values( + values, + strategy, + cls._get_strategy_mtm_price(strategy, line), + cls._signed_strategy_mtm(values, strategy, line))) return curve_component_ids = {id(component) for component in curve_components} - total_weight = sum( - cls._component_weight(component) - for component in components) or Decimal(100) total_mtm_price = cls._get_strategy_mtm_price(strategy, line) curve_mtm_price = Decimal(0) - signed_factor = Decimal(1) - amount = values.get('amount') - if amount and amount < 0: - signed_factor = Decimal(-1) - elif not amount and values.get('type') in {'pur. priced', 'pur. efp'}: - signed_factor = Decimal(-1) + signed_factor = cls._mtm_sign(values) for component in curve_components: - share = cls._component_weight(component) / total_weight + ratio = cls._component_weight(component) / Decimal(100) mtm_price, mtm_price_prev = cls._curve_component_price( component, line, strategy) - curve_mtm_price += mtm_price - line_values = dict(values) - line_values['price'] = cls._split_value( - values.get('price'), share, digits=4) - line_values['amount'] = cls._split_value( - values.get('amount'), share) - line_values['base_amount'] = cls._split_value( - values.get('base_amount'), share) - line_values['mtm_price'] = mtm_price - line_values['mtm_price_prev'] = mtm_price_prev - line_values['mtm_curve'] = component.price_index.id - line_values['amount_prev'] = ( + weighted_mtm_price = mtm_price * ratio + curve_mtm_price += weighted_mtm_price + mtm_amount_prev = ( round( - mtm_price_prev * Decimal(values['quantity']) + mtm_price_prev * ratio * Decimal(values['quantity']) * signed_factor, 2) if mtm_price_prev is not None else None) - line_values['mtm'] = round( - mtm_price * Decimal(values['quantity']) * signed_factor, 2) - line_values['strategy'] = strategy - target.append(line_values) + target.append(cls._mtm_line_values( + values, + strategy, + mtm_price, + round( + mtm_price * ratio * Decimal(values['quantity']) + * signed_factor, 2), + mtm_curve=component.price_index.id, + mtm_price_prev=mtm_price_prev, + mtm_amount_prev=mtm_amount_prev)) residual_weight = sum( cls._component_weight(component) for component in components if id(component) not in curve_component_ids) if residual_weight: - share = residual_weight / total_weight mtm_price = round(total_mtm_price - curve_mtm_price, 4) - line_values = dict(values) - line_values['price'] = cls._split_value( - values.get('price'), share, digits=4) - line_values['amount'] = cls._split_value( - values.get('amount'), share) - line_values['base_amount'] = cls._split_value( - values.get('base_amount'), share) - line_values['mtm_price'] = mtm_price - line_values['mtm_price_prev'] = None - line_values['mtm_curve'] = None - line_values['amount_prev'] = None - line_values['mtm'] = round( - mtm_price * Decimal(values['quantity']) * signed_factor, 2) - line_values['strategy'] = strategy - target.append(line_values) + target.append(cls._mtm_line_values( + values, + strategy, + mtm_price, + round( + mtm_price * Decimal(values['quantity']) * signed_factor, + 2))) @staticmethod def _get_basis_component_total(record): @@ -813,6 +814,7 @@ class ValuationBase(ModelSQL): def _append_pnl_values(cls, price_lines, values, mtm_source): if (values and getattr(mtm_source, 'mtm', None) and cls._supports_strategy_mtm(values)): + price_lines.append(values) for strat in mtm_source.mtm: cls._append_strategy_mtm_lines( price_lines, values, strat, mtm_source) @@ -1014,26 +1016,14 @@ class ValuationBase(ModelSQL): state='unfixed', pnl_type='sale priced' ) - if sale_line.mtm and cls._supports_strategy_mtm(values): - for strat in sale_line.mtm: - cls._append_strategy_mtm_lines( - price_lines, values, strat, sale_line) - else: - if values: - price_lines.append(values) + cls._append_pnl_values(price_lines, values, sale_line) continue for pc in summaries: values = cls._build_basis_pnl_from_sale_line( sale_line=sale_line, lot=lot, pc=pc, extra_price=premium_delta) - if sale_line.mtm and cls._supports_strategy_mtm(values): - for strat in sale_line.mtm: - cls._append_strategy_mtm_lines( - price_lines, values, strat, sale_line) - else: - if values: - price_lines.append(values) + cls._append_pnl_values(price_lines, values, sale_line) elif sale_line.price_type in ('priced', 'efp'): price = cls._get_sale_lot_price(sale_line, lot) @@ -1046,13 +1036,7 @@ class ValuationBase(ModelSQL): state='fixed' if sale_line.price_type == 'priced' else 'not fixed', pnl_type=f'sale {sale_line.price_type}' ) - if sale_line.mtm and cls._supports_strategy_mtm(values): - for strat in sale_line.mtm: - cls._append_strategy_mtm_lines( - price_lines, values, strat, sale_line) - else: - if values: - price_lines.append(values) + cls._append_pnl_values(price_lines, values, sale_line) return price_lines diff --git a/modules/purchase_trade/view/valuation_list.xml b/modules/purchase_trade/view/valuation_list.xml index d3baf8f..a1adffa 100644 --- a/modules/purchase_trade/view/valuation_list.xml +++ b/modules/purchase_trade/view/valuation_list.xml @@ -6,7 +6,7 @@ + badge_colors="priced:#2563eb,Price:#2563eb,pur. priced:#1d4ed8,Pur. price:#1d4ed8,pur. efp:#38bdf8,Pur. efp:#38bdf8,sale priced:#16a34a,Sale price:#16a34a,sale efp:#86efac,Sale efp:#86efac,market:#64748b,Market:#64748b,line fee:#d97706,Line fee:#d97706,pur. fee:#f59e0b,Pur. fee:#f59e0b,sale fee:#fb923c,Sale fee:#fb923c,shipment fee:#0f766e,Shipment fee:#0f766e,mtm:#9f1239,Mtm:#9f1239,derivative:#8b5cf6,Derivative:#8b5cf6,*:#94a3b8"/> diff --git a/modules/purchase_trade/view/valuation_tree_sequence3.xml b/modules/purchase_trade/view/valuation_tree_sequence3.xml index 3d6c496..c1ddced 100755 --- a/modules/purchase_trade/view/valuation_tree_sequence3.xml +++ b/modules/purchase_trade/view/valuation_tree_sequence3.xml @@ -7,7 +7,7 @@ this repository contains the full copyright notices and license terms. --> + badge_colors="priced:#2563eb,Price:#2563eb,pur. priced:#1d4ed8,Pur. price:#1d4ed8,pur. efp:#38bdf8,Pur. efp:#38bdf8,sale priced:#16a34a,Sale price:#16a34a,sale efp:#86efac,Sale efp:#86efac,market:#64748b,Market:#64748b,line fee:#d97706,Line fee:#d97706,pur. fee:#f59e0b,Pur. fee:#f59e0b,sale fee:#fb923c,Sale fee:#fb923c,shipment fee:#0f766e,Shipment fee:#0f766e,mtm:#9f1239,Mtm:#9f1239,derivative:#8b5cf6,Derivative:#8b5cf6,*:#94a3b8"/>