From d659f14e123d87fe6bfa510589a8d4a2cd87f172 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Sat, 6 Jun 2026 19:46:51 +0200 Subject: [PATCH] Plan to transport --- modules/purchase_trade/__init__.py | 14 +- modules/purchase_trade/lot.py | 367 ++++++++++++++++-- modules/purchase_trade/lot.xml | 45 ++- modules/purchase_trade/tests/test_module.py | 102 +++++ .../view/lot_plan_transport_start_form.xml | 20 + .../purchase_trade/view/lot_report_list.xml | 6 + 6 files changed, 497 insertions(+), 57 deletions(-) create mode 100644 modules/purchase_trade/view/lot_plan_transport_start_form.xml diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py index ef89fbe..fb362a4 100755 --- a/modules/purchase_trade/__init__.py +++ b/modules/purchase_trade/__init__.py @@ -188,10 +188,11 @@ def register(): stock.ShipmentContainer, lot.Lot, lot.LotQt, - lot.LotReport, - lot.LotContext, - lot.LotShippingStart, - lot.LotMatchingStart, + lot.LotReport, + lot.LotContext, + lot.LotShippingStart, + lot.LotPlanTransportStart, + lot.LotMatchingStart, lot.LotWeighingStart, lot.LotAddLot, lot.LotInvoicingLot, @@ -299,8 +300,9 @@ def register(): sale.PremiumComposition, module='sale', type_='model') Pool.register( - lot.LotShipping, - lot.LotMatching, + lot.LotShipping, + lot.LotPlanTransport, + lot.LotMatching, lot.LotGoMatching, #lot.LotMatchingUnit, lot.LotWeighing, diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index 5e4e848..255df31 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -1160,10 +1160,22 @@ class LotQt( lot_s = fields.Many2One('lot.lot',"Sale lot") lot_quantity = fields.Numeric("Qt",digits=(1,5)) lot_unit = fields.Many2One('product.uom', "Unit") - lot_shipment_in = fields.Many2One('stock.shipment.in',"Shipment In") - lot_shipment_out = fields.Many2One('stock.shipment.out',"Shipment Out") - lot_shipment_internal = fields.Many2One('stock.shipment.internal',"Shipment Internal") - lot_move = fields.Many2One('stock.move',"Move") + lot_shipment_in = fields.Many2One('stock.shipment.in',"Shipment In") + lot_shipment_out = fields.Many2One('stock.shipment.out',"Shipment Out") + lot_shipment_internal = fields.Many2One('stock.shipment.internal',"Shipment Internal") + planned_from_location = fields.Many2One( + 'stock.location', "Planned From") + planned_to_location = fields.Many2One( + 'stock.location', "Planned To") + planned_transport_type = fields.Selection([ + (None, ''), + ('vessel', 'Vessel'), + ('truck', 'Truck'), + ], "Planned Transport") + planned_from_date = fields.Date("Planned From Date") + planned_to_date = fields.Date("Planned To Date") + planned_note = fields.Char("Planning Note") + lot_move = fields.Many2One('stock.move',"Move") lot_av = fields.Selection([ ('available', 'Available'), ('reserved', 'Reserved'), @@ -1213,13 +1225,116 @@ class LotQt( def default_lot_status(cls): return 'forecast' - def get_rec_name(self, name): - return self.id - - def isVirtualP(self): - if self.lot_p: - Lot = Pool().get('lot.lot') - l = Lot(self.lot_p) + def get_rec_name(self, name): + return self.id + + def is_planned_transport(self): + return bool( + self.planned_from_location + or self.planned_to_location + or self.planned_transport_type + or self.planned_from_date + or self.planned_to_date + or self.planned_note) + + def has_shipment(self): + return bool( + self.lot_shipment_in + or self.lot_shipment_out + or self.lot_shipment_internal) + + @classmethod + def _split_plan_values(cls, source, quantity, values): + return { + 'lot_p': source.lot_p.id if source.lot_p else None, + 'lot_s': source.lot_s.id if source.lot_s else None, + 'lot_quantity': quantity, + 'lot_unit': source.lot_unit.id if source.lot_unit else None, + 'lot_av': source.lot_av, + 'lot_status': source.lot_status, + 'lot_visible': source.lot_visible, + 'planned_from_location': values.get('planned_from_location'), + 'planned_to_location': values.get('planned_to_location'), + 'planned_transport_type': values.get('planned_transport_type'), + 'planned_from_date': values.get('planned_from_date'), + 'planned_to_date': values.get('planned_to_date'), + 'planned_note': values.get('planned_note'), + } + + @classmethod + def plan_to_transport(cls, source, quantity, values): + quantity = Decimal(str(quantity or 0)).quantize(Decimal("0.00001")) + if quantity <= 0: + raise UserError("Quantity to plan must be positive.") + if not any(values.values()): + raise UserError("Please enter at least one planning value.") + if source.has_shipment(): + raise UserError("Already linked quantities cannot be planned.") + if source.is_planned_transport(): + raise UserError("This quantity is already planned.") + + available = Decimal(str(source.lot_quantity or 0)).quantize( + Decimal("0.00001")) + if quantity > abs(available): + raise UserError("Quantity to plan exceeds available quantity.") + + signed_quantity = -quantity if available < 0 else quantity + source.lot_quantity = available - signed_quantity + planned_values = cls._split_plan_values( + source, signed_quantity, values) + cls.save([source]) + cls.create([planned_values]) + + @classmethod + def _cancel_plan_domain(cls, planned): + return [ + ('lot_p', '=', planned.lot_p.id if planned.lot_p else None), + ('lot_s', '=', planned.lot_s.id if planned.lot_s else None), + ('lot_shipment_in', '=', None), + ('lot_shipment_out', '=', None), + ('lot_shipment_internal', '=', None), + ('planned_from_location', '=', None), + ('planned_to_location', '=', None), + ('planned_transport_type', '=', None), + ('planned_from_date', '=', None), + ('planned_to_date', '=', None), + ('planned_note', '=', None), + ] + + @classmethod + def _cancel_plan_values(cls, planned): + return { + 'lot_p': planned.lot_p.id if planned.lot_p else None, + 'lot_s': planned.lot_s.id if planned.lot_s else None, + 'lot_quantity': planned.lot_quantity, + 'lot_unit': planned.lot_unit.id if planned.lot_unit else None, + 'lot_av': planned.lot_av, + 'lot_status': planned.lot_status, + 'lot_visible': planned.lot_visible, + } + + @classmethod + def cancel_transport_plan(cls, planned): + if planned.has_shipment(): + raise UserError("Linked quantities cannot have their plan removed.") + if not planned.is_planned_transport(): + raise UserError("This quantity is not planned.") + + targets = cls.search(cls._cancel_plan_domain(planned), limit=1) + if targets: + target = targets[0] + target.lot_quantity = ( + Decimal(str(target.lot_quantity or 0)) + + Decimal(str(planned.lot_quantity or 0))) + cls.save([target]) + else: + cls.create([cls._cancel_plan_values(planned)]) + cls.delete([planned]) + + def isVirtualP(self): + if self.lot_p: + Lot = Pool().get('lot.lot') + l = Lot(self.lot_p) return (l.lot_type == 'virtual') return False @@ -1992,10 +2107,16 @@ class LotQt( (MaQt + AvQt).as_('r_tot'), pu.party.as_('r_supplier'), sa.party.as_('r_client'), - Case((lqt.lot_shipment_in > 0, si.from_location),else_=Case((lqt.lot_shipment_internal > 0, sin.from_location),else_=pu.from_location)).as_('r_from_l'), - Case((lqt.lot_shipment_in > 0, si.to_location),else_=Case((lqt.lot_shipment_internal > 0, sin.to_location),else_=pu.to_location)).as_('r_from_t'), - Literal(None).as_('r_lot_pur_inv_state'), - Literal(None).as_('r_lot_sale_inv_state'), + Case((lqt.lot_shipment_in > 0, si.from_location),else_=Case((lqt.lot_shipment_internal > 0, sin.from_location),else_=pu.from_location)).as_('r_from_l'), + Case((lqt.lot_shipment_in > 0, si.to_location),else_=Case((lqt.lot_shipment_internal > 0, sin.to_location),else_=pu.to_location)).as_('r_from_t'), + lqt.planned_from_location.as_('r_planned_from_location'), + lqt.planned_to_location.as_('r_planned_to_location'), + lqt.planned_transport_type.as_('r_planned_transport_type'), + lqt.planned_from_date.as_('r_planned_from_date'), + lqt.planned_to_date.as_('r_planned_to_date'), + lqt.planned_note.as_('r_planned_note'), + Literal(None).as_('r_lot_pur_inv_state'), + Literal(None).as_('r_lot_sale_inv_state'), Literal(None).as_('r_lot_pur_inv'), Literal(None).as_('r_lot_sal_inv'), Literal(None).as_('r_lot_state'), @@ -2140,10 +2261,16 @@ class LotQt( (MaQt2 + Abs(AvQt2)).as_("r_tot"), pu.party.as_("r_supplier"), sa.party.as_("r_client"), - Case((lp.lot_shipment_in > 0, si.from_location), else_=Case((lp.lot_shipment_internal > 0, sin.from_location), else_=pu.from_location)).as_("r_from_l"), - Case((lp.lot_shipment_in > 0, si.to_location), else_=Case((lp.lot_shipment_internal > 0, sin.to_location), else_=pu.to_location)).as_("r_from_t"), - lp.lot_pur_inv_state.as_('r_lot_pur_inv_state'), - lp.lot_sale_inv_state.as_('r_lot_sale_inv_state'), + Case((lp.lot_shipment_in > 0, si.from_location), else_=Case((lp.lot_shipment_internal > 0, sin.from_location), else_=pu.from_location)).as_("r_from_l"), + Case((lp.lot_shipment_in > 0, si.to_location), else_=Case((lp.lot_shipment_internal > 0, sin.to_location), else_=pu.to_location)).as_("r_from_t"), + Literal(None).as_('r_planned_from_location'), + Literal(None).as_('r_planned_to_location'), + Literal(None).as_('r_planned_transport_type'), + Literal(None).as_('r_planned_from_date'), + Literal(None).as_('r_planned_to_date'), + Literal(None).as_('r_planned_note'), + lp.lot_pur_inv_state.as_('r_lot_pur_inv_state'), + lp.lot_sale_inv_state.as_('r_lot_sale_inv_state'), Case((final.id > 0, final.id), else_=prov.id).as_('r_lot_pur_inv'), Case((final_s.id > 0, final_s.id), else_=prov_s.id).as_('r_lot_sal_inv'), lp.lot_state.as_('r_lot_state'), @@ -2210,10 +2337,16 @@ class LotQt( Sum(MaQt2 + Abs(AvQt2)).as_("r_tot"), Max(pu.party).as_("r_supplier"), Max(sa.party).as_("r_client"), - Max(Case((lp.lot_shipment_in > 0, si.from_location), else_=Case((lp.lot_shipment_internal > 0, si.from_location), else_=pu.from_location))).as_("r_from_l"), - Max(Case((lp.lot_shipment_in > 0, si.to_location), else_=Case((lp.lot_shipment_internal > 0, si.to_location), else_=pu.to_location))).as_("r_from_t"), - Max(lp.lot_pur_inv_state).as_('r_lot_pur_inv_state'), - Max(lp.lot_sale_inv_state).as_('r_lot_sale_inv_state'), + Max(Case((lp.lot_shipment_in > 0, si.from_location), else_=Case((lp.lot_shipment_internal > 0, si.from_location), else_=pu.from_location))).as_("r_from_l"), + Max(Case((lp.lot_shipment_in > 0, si.to_location), else_=Case((lp.lot_shipment_internal > 0, si.to_location), else_=pu.to_location))).as_("r_from_t"), + Literal(None).as_('r_planned_from_location'), + Literal(None).as_('r_planned_to_location'), + Literal(None).as_('r_planned_transport_type'), + Literal(None).as_('r_planned_from_date'), + Literal(None).as_('r_planned_to_date'), + Literal(None).as_('r_planned_note'), + Max(lp.lot_pur_inv_state).as_('r_lot_pur_inv_state'), + Max(lp.lot_sale_inv_state).as_('r_lot_sale_inv_state'), Max(Case((final.id > 0, final.id), else_=prov.id)).as_('r_lot_pur_inv'), Max(Case((final_s.id > 0, final_s.id), else_=prov_s.id)).as_('r_lot_sal_inv'), Max(lp.lot_state).as_('r_lot_state'), @@ -2267,10 +2400,16 @@ class LotQt( union.r_sale_line.as_("r_sale_line"), union.r_tot.as_("r_tot"), union.r_supplier.as_("r_supplier"), - union.r_client.as_("r_client"), - union.r_from_l.as_("r_from_l"), - union.r_from_t.as_("r_from_t"), - union.r_lot_pur_inv_state.as_('r_lot_pur_inv_state'), + union.r_client.as_("r_client"), + union.r_from_l.as_("r_from_l"), + union.r_from_t.as_("r_from_t"), + union.r_planned_from_location.as_('r_planned_from_location'), + union.r_planned_to_location.as_('r_planned_to_location'), + union.r_planned_transport_type.as_('r_planned_transport_type'), + union.r_planned_from_date.as_('r_planned_from_date'), + union.r_planned_to_date.as_('r_planned_to_date'), + union.r_planned_note.as_('r_planned_note'), + union.r_lot_pur_inv_state.as_('r_lot_pur_inv_state'), union.r_lot_sale_inv_state.as_('r_lot_sale_inv_state'), union.r_lot_pur_inv.as_('r_lot_pur_inv'), union.r_lot_sal_inv.as_('r_lot_sal_inv'), @@ -2351,6 +2490,18 @@ class LotReport( r_client = fields.Many2One('party.party',"Client") r_from_l = fields.Many2One('stock.location', 'From') r_from_t = fields.Many2One('stock.location', 'To') + r_planned_from_location = fields.Many2One( + 'stock.location', "Planned From") + r_planned_to_location = fields.Many2One( + 'stock.location', "Planned To") + r_planned_transport_type = fields.Selection([ + (None, ''), + ('vessel', 'Vessel'), + ('truck', 'Truck'), + ], "Planned Transport") + r_planned_from_date = fields.Date("Planned From Date") + r_planned_to_date = fields.Date("Planned To Date") + r_planned_note = fields.Char("Planning Note") r_shipping_status = fields.Selection([ (None, ''), ('unshipped', 'Unshipped'), @@ -2855,12 +3006,37 @@ class LotShipping(Wizard): affected_lines.append(l.sale_line) else: lqt = LotQt(r.id - 10000000) + available_to_ship = abs(Decimal(str( + lqt.lot_quantity or 0))) + if shipped_quantity > available_to_ship: + raise UserError( + "Quantity to link exceeds available quantity.") + plan_lot = lqt.lot_p + plan_mode = 'both' + if not plan_lot: + plan_lot = lqt.lot_s + plan_mode = 'only sale' + signed_shipped_quantity = shipped_quantity + if Decimal(str(lqt.lot_quantity or 0)) < 0: + signed_shipped_quantity = -shipped_quantity #Increase forecasted virtual part shipped - if not lqt.lot_p.updateVirtualPart(shipped_quantity,shipment_origin,lqt.lot_s): + if not plan_lot.updateVirtualPart( + signed_shipped_quantity, shipment_origin, + lqt.lot_s, plan_mode): logger.info("LotShipping2:%s",shipped_quantity) - lqt.lot_p.createVirtualPart(shipped_quantity,shipment_origin,lqt.lot_s) + plan_lot.createVirtualPart( + signed_shipped_quantity, shipment_origin, + lqt.lot_s, plan_mode) #Decrease forecasted virtual part non shipped - lqt.lot_p.updateVirtualPart(-shipped_quantity,None,lqt.lot_s) + if lqt.is_planned_transport(): + lqt.lot_quantity -= signed_shipped_quantity + if abs(lqt.lot_quantity) < Decimal("0.00001"): + lqt.lot_quantity = 0 + LotQt.save([lqt]) + else: + plan_lot.updateVirtualPart( + -signed_shipped_quantity, None, + lqt.lot_s, plan_mode) if lqt.lot_p and lqt.lot_p.line: affected_lines.append(lqt.lot_p.line) if lqt.lot_s and lqt.lot_s.sale_line: @@ -2872,9 +3048,9 @@ class LotShipping(Wizard): def end(self): return 'reload' -class LotShippingStart(ModelView): - "Link lot to transport" - __name__ = "lot.shipping.start" +class LotShippingStart(ModelView): + "Link lot to transport" + __name__ = "lot.shipping.start" shipment = fields.Selection([ ('in', 'Shipment In'), ('out', 'Shipment Out'), @@ -2891,9 +3067,128 @@ class LotShippingStart(ModelView): return True @classmethod - def default_shipment(cls): - return 'in' - + def default_shipment(cls): + return 'in' + + +class LotPlanTransport(Wizard): + "Plan lot to transport" + __name__ = "lot.plan_transport" + + start = StateTransition() + + plan = StateView( + 'lot.plan_transport.start', + 'purchase_trade.plan_transport_view_form', [ + Button("Cancel", 'end', 'tryton-cancel'), + Button("Apply", 'planning', 'tryton-ok', default=True), + ]) + + planning = StateTransition() + + def transition_start(self): + if self.model.__name__ == 'lot.report': + return 'plan' + return 'end' + + def default_plan(self, fields): + if not self.records: + return {} + record = self.records[0] + quantity = record.r_lot_quantity or record.r_lot_matched or 0 + values = { + 'quantity': quantity, + 'unit': record.r_lot_unit_line.id if record.r_lot_unit_line else None, + 'cancel_plan': bool( + record.r_planned_from_location + or record.r_planned_to_location + or record.r_planned_transport_type + or record.r_planned_from_date + or record.r_planned_to_date + or record.r_planned_note), + 'planned_from_location': ( + record.r_planned_from_location.id + if record.r_planned_from_location else None), + 'planned_to_location': ( + record.r_planned_to_location.id + if record.r_planned_to_location else None), + 'planned_transport_type': record.r_planned_transport_type, + 'planned_from_date': record.r_planned_from_date, + 'planned_to_date': record.r_planned_to_date, + 'planned_note': record.r_planned_note, + } + return values + + def transition_planning(self): + Lot = Pool().get('lot.lot') + LotQt = Pool().get('lot.qt') + affected_lines = [] + if len(self.records) != 1: + raise UserError("Please select exactly one quantity line to plan.") + plan_values = { + 'planned_from_location': ( + self.plan.planned_from_location.id + if self.plan.planned_from_location else None), + 'planned_to_location': ( + self.plan.planned_to_location.id + if self.plan.planned_to_location else None), + 'planned_transport_type': self.plan.planned_transport_type, + 'planned_from_date': self.plan.planned_from_date, + 'planned_to_date': self.plan.planned_to_date, + 'planned_note': self.plan.planned_note, + } + with Lot.skip_quantity_consistency(): + for record in self.records: + if record.id < 10000000: + raise UserError( + "Only open quantity lines can be planned.") + lotqt = LotQt(record.id - 10000000) + if self.plan.cancel_plan: + LotQt.cancel_transport_plan(lotqt) + else: + LotQt.plan_to_transport( + lotqt, self.plan.quantity, plan_values) + if lotqt.lot_p and lotqt.lot_p.line: + affected_lines.append(lotqt.lot_p.line) + if lotqt.lot_s and lotqt.lot_s.sale_line: + affected_lines.append(lotqt.lot_s.sale_line) + Lot.assert_lines_quantity_consistency(affected_lines) + return 'end' + + def end(self): + return 'reload' + + +class LotPlanTransportStart(ModelView): + "Plan lot to transport" + __name__ = "lot.plan_transport.start" + + cancel_plan = fields.Boolean("Cancel existing plan") + planned_from_location = fields.Many2One( + 'stock.location', "Planned From", + states={'readonly': Eval('cancel_plan')}) + planned_to_location = fields.Many2One( + 'stock.location', "Planned To", + states={'readonly': Eval('cancel_plan')}) + planned_transport_type = fields.Selection([ + (None, ''), + ('vessel', 'Vessel'), + ('truck', 'Truck'), + ], "Planned Transport", states={'readonly': Eval('cancel_plan')}) + planned_from_date = fields.Date( + "Planned From Date", states={'readonly': Eval('cancel_plan')}) + planned_to_date = fields.Date( + "Planned To Date", states={'readonly': Eval('cancel_plan')}) + planned_note = fields.Char( + "Planning Note", states={'readonly': Eval('cancel_plan')}) + quantity = fields.Numeric( + "Quantity", digits='unit', states={'readonly': Eval('cancel_plan')}) + unit = fields.Many2One('product.uom', "Unit", readonly=True) + + @classmethod + def default_cancel_plan(cls): + return False + class QtWarning(UserWarning): pass diff --git a/modules/purchase_trade/lot.xml b/modules/purchase_trade/lot.xml index dcdaaaf..ac6ed4e 100755 --- a/modules/purchase_trade/lot.xml +++ b/modules/purchase_trade/lot.xml @@ -113,23 +113,38 @@ this repository contains the full copyright notices and license terms. --> lot_report_context_form - - lot.shipping.start - form - lot_shipping_start_form - - - 🚢 Link lots to transport - lot.shipping - lot.report - + + lot.shipping.start + form + lot_shipping_start_form + + + lot.plan_transport.start + form + lot_plan_transport_start_form + + + 🚢 Link lots to transport + lot.shipping + lot.report + form_action - lot.report,-1 - - - - + lot.report,-1 + + + + Plan to transport + lot.plan_transport + lot.report + + + form_action + lot.report,-1 + + + + lot.add.lot form lot_add_start_form diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 78225f7..c8d34a3 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -89,6 +89,108 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual( Valuation.get_totals(), (Decimal('42.50'), Decimal(0))) + def test_lotqt_plan_to_transport_splits_source_quantity(self): + 'planning transport creates an isolated lot.qt split' + LotQt = lot_module.LotQt + source = Mock( + lot_p=Mock(id=1), + lot_s=Mock(id=2), + lot_quantity=Decimal('500'), + lot_unit=Mock(id=3), + lot_av='available', + lot_status='forecast', + lot_visible=True, + ) + source.has_shipment.return_value = False + source.is_planned_transport.return_value = False + values = { + 'planned_from_location': 10, + 'planned_to_location': 20, + 'planned_transport_type': 'truck', + 'planned_from_date': datetime.date(2026, 7, 1), + 'planned_to_date': datetime.date(2026, 7, 5), + 'planned_note': 'Early planning', + } + + with patch.object(LotQt, 'save') as save, patch.object( + LotQt, 'create') as create: + LotQt.plan_to_transport(source, Decimal('125'), values) + + self.assertEqual(source.lot_quantity, Decimal('375.00000')) + save.assert_called_once_with([source]) + create.assert_called_once_with([{ + 'lot_p': 1, + 'lot_s': 2, + 'lot_quantity': Decimal('125.00000'), + 'lot_unit': 3, + 'lot_av': 'available', + 'lot_status': 'forecast', + 'lot_visible': True, + 'planned_from_location': 10, + 'planned_to_location': 20, + 'planned_transport_type': 'truck', + 'planned_from_date': datetime.date(2026, 7, 1), + 'planned_to_date': datetime.date(2026, 7, 5), + 'planned_note': 'Early planning', + }]) + + def test_lotqt_plan_to_transport_preserves_sale_only_sign(self): + 'planning a sale-only lot.qt keeps the negative quantity direction' + LotQt = lot_module.LotQt + source = Mock( + lot_p=None, + lot_s=Mock(id=2), + lot_quantity=Decimal('-500'), + lot_unit=Mock(id=3), + lot_av='available', + lot_status='forecast', + lot_visible=True, + ) + source.has_shipment.return_value = False + source.is_planned_transport.return_value = False + + with patch.object(LotQt, 'save'), patch.object( + LotQt, 'create') as create: + LotQt.plan_to_transport( + source, Decimal('125'), + {'planned_transport_type': 'vessel'}) + + self.assertEqual(source.lot_quantity, Decimal('-375.00000')) + self.assertEqual( + create.call_args[0][0][0]['lot_quantity'], + Decimal('-125.00000')) + self.assertEqual(create.call_args[0][0][0]['lot_p'], None) + self.assertEqual(create.call_args[0][0][0]['lot_s'], 2) + + def test_lotqt_cancel_transport_plan_merges_matching_open_line(self): + 'cancelling a transport plan returns quantity to the open lot.qt' + LotQt = lot_module.LotQt + planned = Mock( + lot_p=Mock(id=1), + lot_s=None, + lot_quantity=Decimal('125'), + lot_unit=Mock(id=3), + lot_av='available', + lot_status='forecast', + lot_visible=True, + ) + planned.has_shipment.return_value = False + planned.is_planned_transport.return_value = True + target = Mock(lot_quantity=Decimal('375')) + + with patch.object( + LotQt, 'search', return_value=[target]) as search, patch.object( + LotQt, 'save') as save, patch.object( + LotQt, 'create') as create, patch.object( + LotQt, 'delete') as delete: + LotQt.cancel_transport_plan(planned) + + search.assert_called_once() + self.assertEqual(target.lot_quantity, Decimal('500')) + save.assert_called_once_with([target]) + create.assert_not_called() + delete.assert_called_once_with([planned]) + @with_transaction() def test_get_mtm_applies_component_ratio_as_percentage(self): 'get_mtm treats component ratio as a percentage' diff --git a/modules/purchase_trade/view/lot_plan_transport_start_form.xml b/modules/purchase_trade/view/lot_plan_transport_start_form.xml new file mode 100644 index 0000000..c95d176 --- /dev/null +++ b/modules/purchase_trade/view/lot_plan_transport_start_form.xml @@ -0,0 +1,20 @@ +
+