From 2fe13c1e7668d30907ac6b4bd0f3713408239267 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Tue, 21 Jul 2026 17:47:10 +0200 Subject: [PATCH] Mark as finished --- modules/purchase_trade/__init__.py | 6 +- modules/purchase_trade/company_defaults.py | 10 + modules/purchase_trade/lot.py | 173 +++++++++++++++--- modules/purchase_trade/lot.xml | 20 +- modules/purchase_trade/tests/test_module.py | 149 ++++++++++++++- .../view/lot_add_start_form.xml | 8 +- .../view/lot_mark_finished_start_form.xml | 7 + .../view/lot_shipping_start_form.xml | 2 + 8 files changed, 339 insertions(+), 36 deletions(-) create mode 100644 modules/purchase_trade/view/lot_mark_finished_start_form.xml 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"> +
+