Mark as finished

This commit is contained in:
2026-07-21 17:47:10 +02:00
parent a70c2c4853
commit 2fe13c1e76
8 changed files with 339 additions and 36 deletions

View File

@@ -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,

View File

@@ -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'):

View File

@@ -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

View File

@@ -245,7 +245,12 @@ this repository contains the full copyright notices and license terms. -->
<field name="type">tree</field>
<field name="name">lot_weighing_lot_tree</field>
</record>
<record model="ir.action.wizard" id="act_weighing">
<record model="ir.ui.view" id="lot_mark_finished_start_view_form">
<field name="model">lot.mark_finished.start</field>
<field name="type">form</field>
<field name="name">lot_mark_finished_start_form</field>
</record>
<record model="ir.action.wizard" id="act_weighing">
<field name="name">📦 Do weighing</field>
<field name="wiz_name">lot.weighing</field>
<field name="model">lot.report</field>
@@ -389,7 +394,18 @@ this repository contains the full copyright notices and license terms. -->
<field name="action" ref="act_unmatch"/>
</record>
<record model="ir.action.wizard" id="act_unship">
<record model="ir.action.wizard" id="act_mark_finished">
<field name="name">Mark as finished</field>
<field name="wiz_name">lot.mark_finished</field>
<field name="model">lot.report</field>
</record>
<record model="ir.action.keyword" id="act_mark_finished_keyword">
<field name="keyword">form_action</field>
<field name="model">lot.report,-1</field>
<field name="action" ref="act_mark_finished"/>
</record>
<record model="ir.action.wizard" id="act_unship">
<field name="name">🚢 Unlink transport</field>
<field name="wiz_name">lot.unship</field>
<field name="model">lot.report</field>

View File

@@ -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()

View File

@@ -2,15 +2,19 @@
col_widths="1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr,1fr">
<field name="transport_missing" invisible="1"/>
<group id="physical_lot_summary" colspan="12" col="6"
<group id="physical_lot_summary" colspan="12" col="10"
panel="summary" xalign="0" yalign="0"
col_widths="min-content,160px,min-content,140px,min-content,60px">
col_widths="min-content,160px,min-content,140px,min-content,60px,min-content,80px,min-content,80px">
<label name="quantity"/>
<field name="quantity"/>
<label name="unit"/>
<field name="unit"/>
<label name="unlink_remainder"/>
<field name="unlink_remainder"/>
<label name="mark_purchase_line_finished"/>
<field name="mark_purchase_line_finished"/>
<label name="mark_sale_line_finished"/>
<field name="mark_sale_line_finished"/>
</group>
<group id="physical_lot_transport" string="Transport"

View File

@@ -0,0 +1,7 @@
<?xml version="1.0"?>
<form col="2">
<label name="mark_purchase_line_finished"/>
<field name="mark_purchase_line_finished"/>
<label name="mark_sale_line_finished"/>
<field name="mark_sale_line_finished"/>
</form>

View File

@@ -8,6 +8,8 @@
<field name="create_new_shipment"/>
<label name="shipment_supplier"/>
<field name="shipment_supplier"/>
<label name="shipment_vessel"/>
<field name="shipment_vessel"/>
<label name="shipment_out"/>
<field name="shipment_out"/>
<label name="shipment_internal"/>