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 @@
+
diff --git a/modules/purchase_trade/view/lot_report_list.xml b/modules/purchase_trade/view/lot_report_list.xml
index 65efa3e..1df3276 100755
--- a/modules/purchase_trade/view/lot_report_list.xml
+++ b/modules/purchase_trade/view/lot_report_list.xml
@@ -13,6 +13,12 @@
+
+
+
+
+
+