diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py
index 15f049a..6377c3a 100755
--- a/modules/purchase_trade/__init__.py
+++ b/modules/purchase_trade/__init__.py
@@ -215,6 +215,7 @@ def register():
lot.LotShippingStart,
lot.LotPlanTransportStart,
lot.LotPlanTransportLine,
+ lot.LotMarkFinishedStart,
lot.LotMatchingStart,
lot.LotWeighingStart,
lot.LotAddLot,
@@ -330,8 +331,9 @@ def register():
#lot.LotMatchingUnit,
lot.LotWeighing,
lot.CreateContracts,
- lot.LotUnmatch,
- lot.LotUnship,
+ lot.LotUnmatch,
+ lot.LotMarkFinished,
+ lot.LotUnship,
lot.LotRemove,
lot.LotInvoice,
lot.LotAdding,
diff --git a/modules/purchase_trade/company_defaults.py b/modules/purchase_trade/company_defaults.py
index 5a3283d..b2b9d36 100644
--- a/modules/purchase_trade/company_defaults.py
+++ b/modules/purchase_trade/company_defaults.py
@@ -68,6 +68,16 @@ def default_itsa_unit():
return record_id(unit) if unit else None
+def default_itsa_bulk_unit():
+ unit = _search_first('product.uom', [
+ [('symbol', '=', 'Bulk')],
+ [('name', '=', 'Bulk')],
+ [('symbol', '=', 'bulk')],
+ [('name', '=', 'bulk')],
+ ])
+ return record_id(unit) if unit else None
+
+
def _company_from_values(values):
company = values.get('company')
if company and hasattr(company, 'party'):
diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py
index 4b2d474..ee03d5a 100755
--- a/modules/purchase_trade/lot.py
+++ b/modules/purchase_trade/lot.py
@@ -23,7 +23,7 @@ import logging
from trytond.exceptions import UserWarning, UserError
from trytond.modules.purchase_trade.service import ContractFactory
from trytond.modules.purchase_trade.company_defaults import (
- default_itsa_unit, is_itsa_company)
+ default_itsa_bulk_unit, default_itsa_unit, is_itsa_company)
logger = logging.getLogger(__name__)
@@ -1689,11 +1689,51 @@ class LotQt(
affected_lines.append(lqt.lot_s.sale_line)
return affected_lines
+ @classmethod
+ def unmatch_lotqt(cls, lqt):
+ if not lqt or not lqt.lot_p or not lqt.lot_s:
+ return []
+ lot = lqt.lot_p
+ qt = lqt.lot_quantity
+ if not lot.updateVirtualPart(qt, lqt.lot_shipment_origin, None):
+ lot.createVirtualPart(qt, lqt.lot_shipment_origin, None)
+ if not lot.updateVirtualPart(
+ qt, lqt.lot_shipment_origin, lqt.lot_s, 'only sale'):
+ lot.createVirtualPart(
+ qt, lqt.lot_shipment_origin, lqt.lot_s, 'only sale')
+ lot.updateVirtualPart(-qt, lqt.lot_shipment_origin, lqt.lot_s)
+ return cls._affected_lines_for_lqt(lqt)
+
+ @classmethod
+ def mark_lines_finished_from_lotqt(cls, lqt, mark_purchase=False,
+ mark_sale=False, unmatch=False):
+ if not mark_purchase and not mark_sale:
+ return []
+
+ affected_lines = []
+ if mark_purchase and lqt.lot_p and lqt.lot_p.line:
+ purchase_line = lqt.lot_p.line
+ purchase_line.finished = True
+ Pool().get('purchase.line').save([purchase_line])
+ affected_lines.append(purchase_line)
+ if mark_sale and lqt.lot_s and lqt.lot_s.sale_line:
+ sale_line = lqt.lot_s.sale_line
+ sale_line.finished = True
+ Pool().get('sale.line').save([sale_line])
+ affected_lines.append(sale_line)
+ if unmatch:
+ if not lqt.lot_p or not lqt.lot_s:
+ raise UserError(
+ "Mark as finished is only available on matched virtual "
+ "lines.")
+ affected_lines.extend(cls.unmatch_lotqt(lqt))
+ return affected_lines
+
def isVirtualP(self):
if self.lot_p:
Lot = Pool().get('lot.lot')
l = Lot(self.lot_p)
- return (l.lot_type == 'virtual')
+ return (l.lot_type == 'virtual')
return False
def isVirtualS(self):
@@ -3439,7 +3479,7 @@ class LotShipping(Wizard):
if self.ship.shipment != 'in':
raise UserError("Shipment creation is only available for Shipment In.")
if not self.ship.shipment_supplier:
- raise UserError("Shipment supplier is required.")
+ raise UserError("Broker is required.")
values = {
'supplier': self._record_id(self.ship.shipment_supplier),
}
@@ -3447,6 +3487,9 @@ class LotShipping(Wizard):
values['from_location'] = self.ship.planned_from_location.id
if self.ship.planned_to_location:
values['to_location'] = self.ship.planned_to_location.id
+ vessel = getattr(self.ship, 'shipment_vessel', None)
+ if vessel:
+ values['vessel'] = vessel.id
bl_number = getattr(self.ship, 'shipment_bl_number', None)
bl_date = getattr(self.ship, 'shipment_bl_date', None)
if bl_number:
@@ -3563,12 +3606,16 @@ class LotShippingStart(ModelView):
"Create a new shipment",
states={'invisible': Eval('shipment') != 'in'})
shipment_supplier = fields.Many2One(
- 'party.party', "Shipment supplier",
+ 'party.party', "Broker",
states={
'invisible': ~Bool(Eval('create_new_shipment')),
'required': Bool(Eval('create_new_shipment')),
},
depends=['create_new_shipment'])
+ shipment_vessel = fields.Many2One(
+ 'trade.vessel', "Vessel",
+ states={'invisible': ~Bool(Eval('create_new_shipment'))},
+ depends=['create_new_shipment'])
planned_from_location = fields.Many2One(
'stock.location', "Planned From", readonly=True)
planned_to_location = fields.Many2One(
@@ -4161,9 +4208,9 @@ class LotMatchingLot(ModelView):
return None
-class LotUnmatch(Wizard):
- "Unmatch"
- __name__ = "lot.unmatch"
+class LotUnmatch(Wizard):
+ "Unmatch"
+ __name__ = "lot.unmatch"
start = StateTransition()
@@ -4176,20 +4223,7 @@ class LotUnmatch(Wizard):
if r.id > 10000000 :
lqt = LotQt(r.id - 10000000)
if lqt.lot_p and lqt.lot_s:
- lot = lqt.lot_p
- qt = lqt.lot_quantity
- #Increase forecasted virtual parts non matched
- if not lot.updateVirtualPart(qt,lqt.lot_shipment_origin,None):
- lot.createVirtualPart(qt,lqt.lot_shipment_origin,None)
- if not lot.updateVirtualPart(qt,lqt.lot_shipment_origin,lqt.lot_s,'only sale'):
- lot.createVirtualPart(qt,lqt.lot_shipment_origin,lqt.lot_s,'only sale')
- #Decrease forecasted virtual part matched
- logger.info("UNMATCH_QT:%s",qt)
- lot.updateVirtualPart(-qt,lqt.lot_shipment_origin,lqt.lot_s)
- 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:
- affected_lines.append(lqt.lot_s.sale_line)
+ affected_lines.extend(LotQt.unmatch_lotqt(lqt))
else:
lot = Lot(r.id)
qt = lot.get_current_quantity_converted()
@@ -4211,12 +4245,65 @@ class LotUnmatch(Wizard):
Lot.assert_lines_quantity_consistency(affected_lines)
return 'end'
- def end(self):
- return 'reload'
-
+ def end(self):
+ return 'reload'
+
+class LotMarkFinished(Wizard):
+ "Mark as finished"
+ __name__ = "lot.mark_finished"
+
+ start = StateView(
+ 'lot.mark_finished.start',
+ 'purchase_trade.lot_mark_finished_start_view_form', [
+ Button("Cancel", 'end', 'tryton-cancel'),
+ Button("Apply", 'marking', 'tryton-ok', default=True),
+ ])
+
+ marking = StateTransition()
+
+ def transition_marking(self):
+ if not (self.start.mark_purchase_line_finished
+ or self.start.mark_sale_line_finished):
+ raise UserError("Select at least one line side to mark.")
+ Lot = Pool().get('lot.lot')
+ LotQt = Pool().get('lot.qt')
+ affected_lines = []
+ with Lot.skip_quantity_consistency():
+ for record in self.records:
+ if record.id <= 10000000:
+ raise UserError(
+ "Mark as finished is only available on matched "
+ "virtual lines.")
+ lqt = LotQt(record.id - 10000000)
+ if not lqt.lot_p or not lqt.lot_s:
+ raise UserError(
+ "Mark as finished is only available on matched "
+ "virtual lines.")
+ affected_lines.extend(LotQt.mark_lines_finished_from_lotqt(
+ lqt,
+ self.start.mark_purchase_line_finished,
+ self.start.mark_sale_line_finished,
+ unmatch=True))
+ Lot.assert_lines_quantity_consistency(affected_lines)
+ return 'end'
+
+ def end(self):
+ return 'reload'
+
+
+class LotMarkFinishedStart(ModelView):
+ "Mark as finished"
+ __name__ = "lot.mark_finished.start"
+
+ mark_purchase_line_finished = fields.Boolean(
+ "Mark purchase line as finished")
+ mark_sale_line_finished = fields.Boolean(
+ "Mark sale line as finished")
+
+
class LotUnship(Wizard):
"Unlink transport"
- __name__ = "lot.unship"
+ __name__ = "lot.unship"
start = StateTransition()
@@ -4390,6 +4477,10 @@ class LotAdding(Wizard):
'stock.shipment.in,%s' % shipment.id)
LotQt.add_physical_lots(
lqt, self.add.lots, self.add.unlink_remainder)
+ LotQt.mark_lines_finished_from_lotqt(
+ lqt,
+ getattr(self.add, 'mark_purchase_line_finished', False),
+ getattr(self.add, 'mark_sale_line_finished', False))
return 'end'
def end(self):
@@ -4401,6 +4492,10 @@ class LotAddLot(ModelView):
unit = fields.Many2One('product.uom',"Unit",readonly=True)
quantity = fields.Numeric("Quantity available",digits='unit',readonly=True)
unlink_remainder = fields.Boolean("Unlink remaining quantity")
+ mark_purchase_line_finished = fields.Boolean(
+ "Mark purchase line as finished")
+ mark_sale_line_finished = fields.Boolean(
+ "Mark sale line as finished")
transport_missing = fields.Boolean(
"Transport missing", readonly=True,
states={'invisible': ~Eval('transport_missing')})
@@ -4443,6 +4538,14 @@ class LotAddLot(ModelView):
def default_unlink_remainder(cls):
return True
+ @classmethod
+ def default_mark_purchase_line_finished(cls):
+ return False
+
+ @classmethod
+ def default_mark_sale_line_finished(cls):
+ return False
+
@classmethod
def default_create_new_shipment(cls):
return False
@@ -4460,18 +4563,30 @@ class LotAddLine(ModelView):
lot_premium = fields.Numeric("Premium")
lot_chunk_key = fields.Integer("Chunk key")
+ @classmethod
+ def default_lot_qt(cls):
+ if is_itsa_company():
+ return Decimal('1')
+
@classmethod
def default_lot_unit(cls):
if is_itsa_company():
- return default_itsa_unit()
+ return default_itsa_bulk_unit()
@classmethod
def default_lot_unit_line(cls):
if is_itsa_company():
return default_itsa_unit()
-
- # @fields.depends('lot_qt')
- # def on_change_with_lot_quantity(self):
+
+ @fields.depends('lot_quantity', 'lot_gross_quantity')
+ def on_change_lot_quantity(self):
+ if (is_itsa_company()
+ and self.lot_quantity
+ and not self.lot_gross_quantity):
+ self.lot_gross_quantity = self.lot_quantity
+
+ # @fields.depends('lot_qt')
+ # def on_change_with_lot_quantity(self):
# if not self.lot_quantity:
# return self.lot_qt
diff --git a/modules/purchase_trade/lot.xml b/modules/purchase_trade/lot.xml
index 0ad25e8..2b92f3b 100755
--- a/modules/purchase_trade/lot.xml
+++ b/modules/purchase_trade/lot.xml
@@ -245,7 +245,12 @@ this repository contains the full copyright notices and license terms. -->
tree
lot_weighing_lot_tree
-
+
+ lot.mark_finished.start
+ form
+ lot_mark_finished_start_form
+
+
📦 Do weighing
lot.weighing
lot.report
@@ -389,7 +394,18 @@ this repository contains the full copyright notices and license terms. -->
-
+
+ Mark as finished
+ lot.mark_finished
+ lot.report
+
+
+ form_action
+ lot.report,-1
+
+
+
+
🚢 Unlink transport
lot.unship
lot.report
diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py
index c9b6ac0..86d4d66 100644
--- a/modules/purchase_trade/tests/test_module.py
+++ b/modules/purchase_trade/tests/test_module.py
@@ -381,6 +381,68 @@ class PurchaseTradeTestCase(ModuleTestCase):
company_defaults._book_year_suffix(datetime.date(2026, 4, 1)),
'26')
+ def test_itsa_bulk_unit_default_prefers_bulk_uom(self):
+ 'ITSA bulk unit default resolves the Bulk UoM'
+ bulk = Mock(id=42)
+ Uom = Mock()
+ Uom.search.side_effect = [[bulk]]
+ pool = Mock()
+ pool.get.return_value = Uom
+
+ with patch.object(company_defaults, 'Pool', return_value=pool):
+ self.assertEqual(company_defaults.default_itsa_bulk_unit(), 42)
+ Uom.search.assert_called_once_with(
+ [('symbol', '=', 'Bulk')], limit=1)
+
+ def test_itsa_add_physical_lot_defaults(self):
+ 'ITSA Add Physical lot defaults packing to 1 Bulk and weight unit to Mt'
+ with patch('trytond.modules.purchase_trade.lot.is_itsa_company',
+ return_value=True), patch(
+ 'trytond.modules.purchase_trade.lot.default_itsa_bulk_unit',
+ return_value=42), patch(
+ 'trytond.modules.purchase_trade.lot.default_itsa_unit',
+ return_value=7):
+ self.assertEqual(
+ lot_module.LotAddLine.default_lot_qt(), Decimal('1'))
+ self.assertEqual(lot_module.LotAddLine.default_lot_unit(), 42)
+ self.assertEqual(lot_module.LotAddLine.default_lot_unit_line(), 7)
+
+ def test_itsa_add_physical_lot_defaults_gross_from_net(self):
+ 'ITSA Add Physical lot copies net weight to empty gross weight'
+ line = lot_module.LotAddLine()
+ line.lot_quantity = Decimal('12.500')
+ line.lot_gross_quantity = None
+
+ with patch('trytond.modules.purchase_trade.lot.is_itsa_company',
+ return_value=True):
+ line.on_change_lot_quantity()
+
+ self.assertEqual(line.lot_gross_quantity, Decimal('12.500'))
+
+ def test_itsa_add_physical_lot_keeps_existing_gross_weight(self):
+ 'ITSA Add Physical lot does not overwrite an existing gross weight'
+ line = lot_module.LotAddLine()
+ line.lot_quantity = Decimal('12.500')
+ line.lot_gross_quantity = Decimal('13.000')
+
+ with patch('trytond.modules.purchase_trade.lot.is_itsa_company',
+ return_value=True):
+ line.on_change_lot_quantity()
+
+ self.assertEqual(line.lot_gross_quantity, Decimal('13.000'))
+
+ def test_non_itsa_add_physical_lot_does_not_default_gross_weight(self):
+ 'non-ITSA Add Physical lot does not copy net weight to gross weight'
+ line = lot_module.LotAddLine()
+ line.lot_quantity = Decimal('12.500')
+ line.lot_gross_quantity = None
+
+ with patch('trytond.modules.purchase_trade.lot.is_itsa_company',
+ return_value=False):
+ line.on_change_lot_quantity()
+
+ self.assertIsNone(line.lot_gross_quantity)
+
@with_transaction()
def test_purchase_list_fields_use_first_trade_line(self):
'purchase list helper fields use the first real purchase line'
@@ -762,9 +824,10 @@ class PurchaseTradeTestCase(ModuleTestCase):
self.assertEqual(wizard.default_ship(None), {})
def test_lot_shipping_link_creates_shipment_from_dialog_supplier(self):
- 'link to transport creates shipment in from dialog supplier'
+ 'link to transport creates shipment in from dialog broker'
wizard = lot_module.LotShipping()
supplier = Mock(id=30)
+ vessel = Mock(id=50)
created_shipment = Mock(id=40)
ShipmentIn = Mock()
ShipmentIn.create.return_value = [created_shipment]
@@ -776,6 +839,7 @@ class PurchaseTradeTestCase(ModuleTestCase):
shipment_internal=None,
create_new_shipment=True,
shipment_supplier=supplier,
+ shipment_vessel=vessel,
planned_from_location=Mock(id=10),
planned_to_location=Mock(id=20),
)
@@ -794,10 +858,93 @@ class PurchaseTradeTestCase(ModuleTestCase):
'supplier': 30,
'from_location': 10,
'to_location': 20,
+ 'vessel': 50,
}])
self.assertEqual(wizard.ship.shipment_in, created_shipment)
Lot.assert_lines_quantity_consistency.assert_called_once_with([])
+ def test_lot_shipping_start_uses_broker_label(self):
+ 'link to transport uses the shipment in Broker label'
+ self.assertEqual(
+ lot_module.LotShippingStart.shipment_supplier.string, 'Broker')
+
+ def test_lotqt_mark_lines_finished_saves_selected_lines(self):
+ 'mark finished helper can finish purchase and sale lines'
+ purchase_line = Mock()
+ sale_line = Mock()
+ lqt = Mock(
+ lot_p=Mock(line=purchase_line),
+ lot_s=Mock(sale_line=sale_line))
+ PurchaseLine = Mock()
+ SaleLine = Mock()
+ pool = Mock()
+ pool.get.side_effect = lambda name: {
+ 'purchase.line': PurchaseLine,
+ 'sale.line': SaleLine,
+ }[name]
+
+ with patch('trytond.modules.purchase_trade.lot.Pool',
+ return_value=pool):
+ affected = lot_module.LotQt.mark_lines_finished_from_lotqt(
+ lqt, mark_purchase=True, mark_sale=True)
+
+ self.assertTrue(purchase_line.finished)
+ self.assertTrue(sale_line.finished)
+ PurchaseLine.save.assert_called_once_with([purchase_line])
+ SaleLine.save.assert_called_once_with([sale_line])
+ self.assertEqual(affected, [purchase_line, sale_line])
+
+ def test_lotqt_mark_lines_finished_can_unmatch(self):
+ 'mark finished helper can release a matched virtual quantity'
+ purchase_line = Mock()
+ sale_line = Mock()
+ lot_s = Mock(sale_line=sale_line)
+ lot_p = Mock(line=purchase_line)
+ lot_p.updateVirtualPart.side_effect = [False, False, True]
+ lqt = Mock(
+ lot_p=lot_p,
+ lot_s=lot_s,
+ lot_quantity=Decimal('12.5'),
+ lot_shipment_origin='stock.shipment.in,10')
+ PurchaseLine = Mock()
+ pool = Mock()
+ pool.get.return_value = PurchaseLine
+
+ with patch('trytond.modules.purchase_trade.lot.Pool',
+ return_value=pool):
+ affected = lot_module.LotQt.mark_lines_finished_from_lotqt(
+ lqt, mark_purchase=True, unmatch=True)
+
+ self.assertTrue(purchase_line.finished)
+ PurchaseLine.save.assert_called_once_with([purchase_line])
+ lot_p.createVirtualPart.assert_any_call(
+ Decimal('12.5'), 'stock.shipment.in,10', None)
+ lot_p.createVirtualPart.assert_any_call(
+ Decimal('12.5'), 'stock.shipment.in,10', lot_s, 'only sale')
+ lot_p.updateVirtualPart.assert_any_call(
+ -Decimal('12.5'), 'stock.shipment.in,10', lot_s)
+ self.assertEqual(affected, [
+ purchase_line, purchase_line, sale_line])
+
+ def test_lot_mark_finished_requires_matched_virtual_lotqt(self):
+ 'mark finished action rejects non matched rows'
+ wizard = lot_module.LotMarkFinished()
+ wizard.start = Mock(
+ mark_purchase_line_finished=True,
+ mark_sale_line_finished=False)
+ wizard.records = [Mock(id=10000007)]
+ Lot = Mock()
+ Lot.skip_quantity_consistency.return_value.__enter__ = Mock()
+ Lot.skip_quantity_consistency.return_value.__exit__ = Mock()
+ LotQt = Mock(return_value=Mock(lot_p=Mock(), lot_s=None))
+ pool = Mock()
+ pool.get.side_effect = [Lot, LotQt]
+
+ with patch('trytond.modules.purchase_trade.lot.Pool',
+ return_value=pool):
+ with self.assertRaises(UserError):
+ wizard.transition_marking()
+
def test_lot_shipping_rejects_existing_and_new_shipment(self):
'link to transport rejects choosing existing shipment and create new'
wizard = lot_module.LotShipping()
diff --git a/modules/purchase_trade/view/lot_add_start_form.xml b/modules/purchase_trade/view/lot_add_start_form.xml
index 3bce898..08337ea 100755
--- a/modules/purchase_trade/view/lot_add_start_form.xml
+++ b/modules/purchase_trade/view/lot_add_start_form.xml
@@ -2,15 +2,19 @@
col_widths="1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr">
-
+ col_widths="min-content,160px,min-content,140px,min-content,60px,min-content,80px,min-content,80px">
+
+
+
+
+
diff --git a/modules/purchase_trade/view/lot_shipping_start_form.xml b/modules/purchase_trade/view/lot_shipping_start_form.xml
index 7361edb..a0f25fd 100755
--- a/modules/purchase_trade/view/lot_shipping_start_form.xml
+++ b/modules/purchase_trade/view/lot_shipping_start_form.xml
@@ -8,6 +8,8 @@
+
+