From a1ab7dec8229c0697ad7f127dd4379449f1afc2f Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Thu, 9 Apr 2026 19:46:08 +0200 Subject: [PATCH] ICT bulk --- modules/account_invoice/invoice.py | 11 +- modules/account_invoice/invoice_ict.fodt | 2 +- .../account_invoice/invoice_ict_final.fodt | 4 +- modules/account_invoice/tests/test_module.py | 39 +++ modules/purchase_trade/invoice.py | 188 +++++++++++- modules/purchase_trade/lot.py | 140 +++++---- modules/purchase_trade/purchase.py | 8 +- modules/purchase_trade/sale.py | 111 +++++-- modules/purchase_trade/tests/test_module.py | 282 +++++++++++++++++- .../purchase_trade/view/lot_report_list.xml | 1 + .../view/lot_weighing_lot_tree.xml | 1 + 11 files changed, 669 insertions(+), 118 deletions(-) diff --git a/modules/account_invoice/invoice.py b/modules/account_invoice/invoice.py index 7a00e0b..f9e023e 100755 --- a/modules/account_invoice/invoice.py +++ b/modules/account_invoice/invoice.py @@ -485,7 +485,7 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin): }) cls.__rpc__.update({ 'post': RPC( - readonly=False, instantiate=0, fresh_session=True), + readonly=False, instantiate=0, fresh_session=False), }) @classmethod @@ -1896,11 +1896,10 @@ class Invoice(Workflow, ModelSQL, ModelView, TaxableMixin, InvoiceReportMixin): moves = [] for invoice in invoices: - if invoice.type == 'in': - move = invoice.get_move() - if move != invoice.move: - invoice.move = move - moves.append(move) + move = invoice.get_move() + if move != invoice.move: + invoice.move = move + moves.append(move) invoice.do_lot_invoicing() if moves: Move.save(moves) diff --git a/modules/account_invoice/invoice_ict.fodt b/modules/account_invoice/invoice_ict.fodt index c65e8fa..96c8262 100644 --- a/modules/account_invoice/invoice_ict.fodt +++ b/modules/account_invoice/invoice_ict.fodt @@ -4059,7 +4059,7 @@ Controller Name - <invoice.report_si_number> + <invoice.report_si_reference> <invoice.report_controller_name> diff --git a/modules/account_invoice/invoice_ict_final.fodt b/modules/account_invoice/invoice_ict_final.fodt index 029d29c..4bcf3b3 100644 --- a/modules/account_invoice/invoice_ict_final.fodt +++ b/modules/account_invoice/invoice_ict_final.fodt @@ -3956,7 +3956,7 @@ - <invoice.report_nb_bale> + <invoice.report_cndn_nb_bale> <format_number(invoice.report_gross, invoice.party.lang) if invoice.report_gross != '' else ''> @@ -4044,7 +4044,7 @@ Controller Name - <invoice.report_si_number> + <invoice.report_si_reference> <invoice.report_controller_name> diff --git a/modules/account_invoice/tests/test_module.py b/modules/account_invoice/tests/test_module.py index c40c806..ef331c6 100755 --- a/modules/account_invoice/tests/test_module.py +++ b/modules/account_invoice/tests/test_module.py @@ -3,6 +3,7 @@ import datetime from decimal import Decimal +from unittest.mock import Mock, patch from trytond.modules.account_invoice.exceptions import ( PaymentTermValidationError) @@ -251,5 +252,43 @@ class AccountInvoiceTestCase( (datetime.date(2012, 1, 14), Decimal('-1.0')), ]) + def test_post_rpc_does_not_require_fresh_session(self): + 'posting invoices does not force a fresh session' + Invoice = Pool().get('account.invoice') + + self.assertFalse(Invoice.__rpc__['post'].fresh_session) + + @with_transaction() + def test_validate_invoice_creates_move_for_customer_invoice(self): + 'validating customer invoices now creates the account move' + Invoice = Pool().get('account.invoice') + + move = Mock() + invoice = Invoice() + invoice.type = 'out' + invoice.move = None + invoice.get_move = Mock(return_value=move) + invoice.do_lot_invoicing = Mock() + + move_model = Mock() + + with patch.object(Invoice, '_check_taxes'), patch.object( + Invoice, '_store_cache'), patch.object( + Invoice, 'browse', return_value=[]), patch.object( + Invoice, 'cleanMoves') as clean_moves, patch.object( + Invoice, 'save') as save_invoices, patch( + 'trytond.modules.account_invoice.invoice.Pool' + ) as PoolMock: + PoolMock.return_value.get.return_value = move_model + + Invoice.validate_invoice([invoice]) + + self.assertIs(invoice.move, move) + invoice.get_move.assert_called_once_with() + invoice.do_lot_invoicing.assert_called_once_with() + move_model.save.assert_called_once_with([move]) + clean_moves.assert_called_once_with([move]) + save_invoices.assert_called() + del ModuleTestCase diff --git a/modules/purchase_trade/invoice.py b/modules/purchase_trade/invoice.py index 17af42c..ad7907b 100644 --- a/modules/purchase_trade/invoice.py +++ b/modules/purchase_trade/invoice.py @@ -39,6 +39,98 @@ class Invoice(metaclass=PoolMeta): ] return lines or list(self.lines or []) + @staticmethod + def _get_report_related_lots(line): + lots = [] + seen = set() + + def add_lot(lot): + if not lot: + return + lot_id = getattr(lot, 'id', None) + key = ('id', lot_id) if lot_id is not None else ('obj', id(lot)) + if key in seen: + return + seen.add(key) + lots.append(lot) + + add_lot(getattr(line, 'lot', None)) + origin = getattr(line, 'origin', None) + for lot in getattr(origin, 'lots', []) or []: + add_lot(lot) + return lots + + @classmethod + def _get_report_preferred_lots(cls, line): + lots = cls._get_report_related_lots(line) + physicals = [ + lot for lot in lots + if getattr(lot, 'lot_type', None) == 'physic' + ] + if physicals: + return physicals + virtuals = [ + lot for lot in lots + if getattr(lot, 'lot_type', None) == 'virtual' + ] + if len(virtuals) == 1: + return virtuals + return [] + + @staticmethod + def _get_report_line_sign(line): + quantity = Decimal(str(getattr(line, 'quantity', 0) or 0)) + return Decimal(-1) if quantity < 0 else Decimal(1) + + @staticmethod + def _get_report_lot_hist_weights(lot): + if not lot: + return None, None + if hasattr(lot, 'get_hist_quantity'): + net, gross = lot.get_hist_quantity() + return ( + Decimal(str(net or 0)), + Decimal(str(gross if gross not in (None, '') else net or 0)), + ) + hist = list(getattr(lot, 'lot_hist', []) or []) + state = getattr(lot, 'lot_state', None) + state_id = getattr(state, 'id', None) + if state_id is not None: + for entry in hist: + quantity_type = getattr(entry, 'quantity_type', None) + if getattr(quantity_type, 'id', None) == state_id: + net = Decimal(str(getattr(entry, 'quantity', 0) or 0)) + gross = Decimal(str( + getattr(entry, 'gross_quantity', None) + if getattr(entry, 'gross_quantity', None) not in (None, '') + else net)) + return net, gross + return None, None + + def _get_report_invoice_line_weights(self, line): + lots = self._get_report_preferred_lots(line) + if lots: + sign = self._get_report_line_sign(line) + net_total = Decimal(0) + gross_total = Decimal(0) + for lot in lots: + net, gross = self._get_report_lot_hist_weights(lot) + if net is None: + continue + net_total += net + gross_total += gross + if net_total or gross_total: + return net_total * sign, gross_total * sign + quantity = Decimal(str(getattr(line, 'quantity', 0) or 0)) + return quantity, quantity + + @staticmethod + def _get_report_invoice_line_unit(line): + lots = Invoice._get_report_preferred_lots(line) + if lots and getattr(lots[0], 'lot_unit_line', None): + return lots[0].lot_unit_line + return getattr(line, 'unit', None) + @staticmethod def _clean_report_description(value): text = (value or '').strip() @@ -81,6 +173,35 @@ class Invoice(metaclass=PoolMeta): return lot return line.lots[0] + @staticmethod + def _get_report_lot_shipment(lot): + if not lot: + return None + return ( + getattr(lot, 'lot_shipment_in', None) + or getattr(lot, 'lot_shipment_out', None) + or getattr(lot, 'lot_shipment_internal', None) + ) + + def _get_report_invoice_shipments(self): + shipments = [] + seen = set() + for line in self._get_report_invoice_lines(): + for lot in self._get_report_preferred_lots(line): + shipment = self._get_report_lot_shipment(lot) + if not shipment: + continue + shipment_id = getattr(shipment, 'id', None) + key = ( + getattr(shipment, '__name__', None), + shipment_id if shipment_id is not None else id(shipment), + ) + if key in seen: + continue + seen.add(key) + shipments.append(shipment) + return shipments + def _get_report_invoice_lots(self): invoice_lines = self._get_report_invoice_lines() if not invoice_lines: @@ -140,14 +261,13 @@ class Invoice(metaclass=PoolMeta): return fees[0] if fees else None def _get_report_shipment(self): - lot = self._get_report_lot() - if not lot: + shipments = self._get_report_invoice_shipments() + if len(shipments) == 1: + return shipments[0] + if len(shipments) > 1: return None - return ( - getattr(lot, 'lot_shipment_in', None) - or getattr(lot, 'lot_shipment_out', None) - or getattr(lot, 'lot_shipment_internal', None) - ) + lot = self._get_report_lot() + return self._get_report_lot_shipment(lot) @staticmethod def _get_report_bank_account(party): @@ -390,16 +510,14 @@ class Invoice(metaclass=PoolMeta): def report_quantity_lines(self): details = [] for line in self._get_report_invoice_lines(): - quantity = getattr(line, 'report_net', '') - if quantity == '': - quantity = getattr(line, 'quantity', '') + quantity, _ = self._get_report_invoice_line_weights(line) if quantity == '': continue quantity_text = self._format_report_number( quantity, keep_trailing_decimal=True) - unit = getattr(line, 'unit', None) + unit = self._get_report_invoice_line_unit(line) unit_name = unit.rec_name.upper() if unit and unit.rec_name else '' - lbs = getattr(line, 'report_lbs', '') + lbs = round(Decimal(quantity) * Decimal('2204.62'), 2) parts = [quantity_text, unit_name] if lbs != '': parts.append( @@ -586,11 +704,18 @@ class Invoice(metaclass=PoolMeta): return 'NB BALES: ' + str(int(nb_bale)) return '' + @property + def report_cndn_nb_bale(self): + nb_bale = self.report_nb_bale + if nb_bale == 'NB BALES: 0': + return 'Unchanged' + return nb_bale + @property def report_gross(self): if self.lines: return sum( - Decimal(str(getattr(line, 'quantity', 0) or 0)) + self._get_report_invoice_line_weights(line)[1] for line in self._get_report_invoice_lines()) line = self._get_report_trade_line() if line and line.lots: @@ -604,7 +729,7 @@ class Invoice(metaclass=PoolMeta): def report_net(self): if self.lines: return sum( - Decimal(str(getattr(line, 'quantity', 0) or 0)) + self._get_report_invoice_line_weights(line)[0] for line in self._get_report_invoice_lines()) line = self._get_report_trade_line() if line and line.lots: @@ -625,8 +750,15 @@ class Invoice(metaclass=PoolMeta): @property def report_weight_unit_upper(self): - line = self._get_report_trade_line() or self._get_report_invoice_line() - unit = getattr(line, 'unit', None) if line else None + invoice_line = self._get_report_invoice_line() + unit = self._get_report_invoice_line_unit(invoice_line) if invoice_line else None + if not unit: + line = self._get_report_trade_line() + lot = self._get_report_lot() + unit = ( + getattr(lot, 'lot_unit_line', None) + or getattr(line, 'unit', None) if line else None + ) if unit and unit.rec_name: return unit.rec_name.upper() return 'KGS' @@ -634,6 +766,16 @@ class Invoice(metaclass=PoolMeta): @property def report_note_title(self): total = Decimal(str(self.total_amount or 0)) + invoice_type = getattr(self, 'type', None) + if not invoice_type: + if self.sales: + invoice_type = 'out' + elif self.purchases: + invoice_type = 'in' + if invoice_type == 'out': + if total < 0: + return 'Credit Note' + return 'Debit Note' if total < 0: return 'Debit Note' return 'Credit Note' @@ -721,6 +863,13 @@ class Invoice(metaclass=PoolMeta): return shipment.number or '' return '' + @property + def report_si_reference(self): + shipment = self._get_report_shipment() + if shipment: + return getattr(shipment, 'reference', None) or '' + return '' + @property def report_freight_amount(self): fee = self._get_report_freight_fee() @@ -832,6 +981,13 @@ class InvoiceLine(metaclass=PoolMeta): @property def report_net(self): if self.type == 'line': + lot = getattr(self, 'lot', None) + if lot: + net, _ = Invoice._get_report_lot_hist_weights(lot) + if net is None: + net = 0 + sign = Invoice._get_report_line_sign(self) + return Decimal(str(net or 0)) * sign return self.quantity return '' diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index dcc061f..f9252d7 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -1334,11 +1334,12 @@ class LotQt( Case((lp.id>0, lp.lot_premium_sale),else_=ls.lot_premium_sale).as_('r_lot_premium_sale'), Case((lp.id>0, lp.lot_parent),else_=ls.lot_parent).as_('r_lot_parent'), Case((lp.id>0, lp.lot_himself),else_=ls.lot_himself).as_('r_lot_himself'), - Case((lp.id>0, lp.lot_container),else_=ls.lot_container).as_('r_lot_container'), - Case((lp.id>0, lp.line),else_=None).as_('r_line'), - Case((pu.id>0, pu.id),else_=None).as_('r_purchase'), - Case((sa.id>0, sa.id),else_=None).as_('r_sale'), - Case((ls.id>0, ls.sale_line),else_=None).as_('r_sale_line'), + Case((lp.id>0, lp.lot_container),else_=ls.lot_container).as_('r_lot_container'), + Case((lp.id>0, lp.line),else_=None).as_('r_line'), + Case((pl.id>0, pl.del_period),else_=None).as_('r_del_period'), + Case((pu.id>0, pu.id),else_=None).as_('r_purchase'), + Case((sa.id>0, sa.id),else_=None).as_('r_sale'), + Case((ls.id>0, ls.sale_line),else_=None).as_('r_sale_line'), (MaQt + AvQt).as_('r_tot'), pu.party.as_('r_supplier'), sa.party.as_('r_client'), @@ -1439,13 +1440,14 @@ class LotQt( lp.lot_av.as_("r_lot_av"), lp.lot_premium.as_("r_lot_premium"), lp.lot_premium_sale.as_("r_lot_premium_sale"), - lp.lot_parent.as_("r_lot_parent"), - lp.lot_himself.as_("r_lot_himself"), - lp.lot_container.as_("r_lot_container"), - lp.line.as_("r_line"), - Case((pu.id > 0, pu.id), else_=None).as_("r_purchase"), - Case((sa.id > 0, sa.id), else_=None).as_("r_sale"), - lp.sale_line.as_("r_sale_line"), + lp.lot_parent.as_("r_lot_parent"), + lp.lot_himself.as_("r_lot_himself"), + lp.lot_container.as_("r_lot_container"), + lp.line.as_("r_line"), + pl.del_period.as_("r_del_period"), + Case((pu.id > 0, pu.id), else_=None).as_("r_purchase"), + Case((sa.id > 0, sa.id), else_=None).as_("r_sale"), + lp.sale_line.as_("r_sale_line"), (MaQt2 + Abs(AvQt2)).as_("r_tot"), pu.party.as_("r_supplier"), sa.party.as_("r_client"), @@ -1504,13 +1506,14 @@ class LotQt( Max(lp.lot_av).as_("r_lot_av"), Avg(lp.lot_premium).as_("r_lot_premium"), Literal(None).as_("r_lot_premium_sale"), - Literal(None).as_("r_lot_parent"), - Literal(None).as_("r_lot_himself"), - Max(lp.lot_container).as_("r_lot_container"), - lp.line.as_("r_line"), - Max(Case((pu.id > 0, pu.id), else_=None)).as_("r_purchase"), - Max(Case((sa.id > 0, sa.id), else_=None)).as_("r_sale"), - lp.sale_line.as_("r_sale_line"), + Literal(None).as_("r_lot_parent"), + Literal(None).as_("r_lot_himself"), + Max(lp.lot_container).as_("r_lot_container"), + lp.line.as_("r_line"), + Max(pl.del_period).as_("r_del_period"), + Max(Case((pu.id > 0, pu.id), else_=None)).as_("r_purchase"), + Max(Case((sa.id > 0, sa.id), else_=None)).as_("r_sale"), + lp.sale_line.as_("r_sale_line"), Sum(MaQt2 + Abs(AvQt2)).as_("r_tot"), Max(pu.party).as_("r_supplier"), Max(sa.party).as_("r_client"), @@ -1557,13 +1560,14 @@ class LotQt( union.r_lot_av.as_("r_lot_av"), union.r_lot_premium.as_("r_lot_premium"), union.r_lot_premium_sale.as_("r_lot_premium_sale"), - union.r_lot_parent.as_("r_lot_parent"), - union.r_lot_himself.as_("r_lot_himself"), - union.r_lot_container.as_("r_lot_container"), - union.r_line.as_("r_line"), - union.r_purchase.as_("r_purchase"), - union.r_sale.as_("r_sale"), - union.r_sale_line.as_("r_sale_line"), + union.r_lot_parent.as_("r_lot_parent"), + union.r_lot_himself.as_("r_lot_himself"), + union.r_lot_container.as_("r_lot_container"), + union.r_line.as_("r_line"), + union.r_del_period.as_("r_del_period"), + union.r_purchase.as_("r_purchase"), + union.r_sale.as_("r_sale"), + 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"), @@ -1630,14 +1634,15 @@ class LotReport( r_lot_shipment_out = fields.Many2One('stock.shipment.out', "Shipment Out") r_lot_shipment_internal = fields.Many2One('stock.shipment.internal', "Shipment Internal") r_lot_move = fields.Many2One('stock.move', "Move") - r_lot_parent = fields.Many2One('lot.lot',"Parent") - r_lot_himself = fields.Many2One('lot.lot',"Lot") - r_lot_container = fields.Char("Container") - r_lot_unit_line = fields.Function(fields.Many2One('product.uom', "Unit"),'get_unit') - r_lot_price = fields.Function(fields.Numeric("Price", digits='r_lot_unit_line'),'get_lot_price') - r_lot_price_sale = fields.Function(fields.Numeric("Price", digits='r_lot_unit_line'),'get_lot_sale_price') - r_sale_line = fields.Many2One('sale.line',"S. line") - r_sale = fields.Many2One('sale.sale',"Sale") + r_lot_parent = fields.Many2One('lot.lot',"Parent") + r_lot_himself = fields.Many2One('lot.lot',"Lot") + r_lot_container = fields.Char("Container") + r_lot_unit_line = fields.Function(fields.Many2One('product.uom', "Unit"),'get_unit') + r_lot_price = fields.Function(fields.Numeric("Price", digits='r_lot_unit_line'),'get_lot_price') + r_lot_price_sale = fields.Function(fields.Numeric("Price", digits='r_lot_unit_line'),'get_lot_sale_price') + r_del_period = fields.Many2One('product.month', "Delivery Period") + r_sale_line = fields.Many2One('sale.line',"S. line") + r_sale = fields.Many2One('sale.sale',"Sale") r_tot = fields.Numeric("Qt tot", digits='r_lot_unit_line') r_supplier = fields.Many2One('party.party',"Supplier") r_client = fields.Many2One('party.party',"Client") @@ -3041,25 +3046,26 @@ class LotWeighing(Wizard): Lot = Pool().get('lot.lot') context = Transaction().context ids = context.get('active_ids') - for i in ids: - if i > 10000000: - raise UserError("Trying to do weighing on open quantity!") - val = {} - lot = Lot(i) - val['lot'] = lot.id - val['lot_name'] = lot.lot_name + for i in ids: + if i > 10000000: + raise UserError("Trying to do weighing on open quantity!") + val = {} + lot = Lot(i) + val['lot'] = lot.id + val['lot_name'] = lot.lot_name if lot.lot_shipment_in: val['lot_shipment_in'] = lot.lot_shipment_in.id if lot.lot_shipment_internal: val['lot_shipment_internal'] = lot.lot_shipment_internal.id - if lot.lot_shipment_out: - val['lot_shipment_out'] = lot.lot_shipment_out.id - val['lot_product'] = lot.lot_product.id - val['lot_quantity'] = lot.lot_quantity - val['lot_gross_quantity'] = lot.lot_gross_quantity - val['lot_unit'] = lot.lot_unit.id - val['lot_unit_line'] = lot.lot_unit_line.id - lot_p.append(val) + if lot.lot_shipment_out: + val['lot_shipment_out'] = lot.lot_shipment_out.id + val['lot_product'] = lot.lot_product.id + val['lot_qt'] = lot.lot_qt + val['lot_quantity'] = lot.lot_quantity + val['lot_gross_quantity'] = lot.lot_gross_quantity + val['lot_unit'] = lot.lot_unit.id + val['lot_unit_line'] = lot.lot_unit_line.id + lot_p.append(val) return { 'lot_p': lot_p, } @@ -3074,17 +3080,18 @@ class LotWeighing(Wizard): lhs = LotHist.search([('lot',"=",l.lot.id),('quantity_type','=',self.w.lot_state.id)]) if lhs: lh = lhs[0] - else: - lh = LotHist() - lh.lot = l.lot - lh.quantity_type = self.w.lot_state - lh.quantity = round(l.lot_quantity_new,5) - lh.gross_quantity = round(l.lot_gross_quantity_new,5) - LotHist.save([lh]) - - if self.w.lot_update_state : - l.lot.lot_state = self.w.lot_state - Lot.save([l.lot]) + else: + lh = LotHist() + lh.lot = l.lot + lh.quantity_type = self.w.lot_state + lh.quantity = round(l.lot_quantity_new,5) + lh.gross_quantity = round(l.lot_gross_quantity_new,5) + LotHist.save([lh]) + l.lot.lot_qt = l.lot_qt + + if self.w.lot_update_state : + l.lot.lot_state = self.w.lot_state + Lot.save([l.lot]) diff = round(Decimal(l.lot.get_current_quantity_converted() - quantity),5) if diff != 0 : #need to update virtual part @@ -3119,12 +3126,13 @@ class LotWeighingLot(ModelView): lot_name = fields.Char("Name",readonly=True) lot_shipment_in = fields.Many2One('stock.shipment.in',"Shipment In") lot_shipment_internal = fields.Many2One('stock.shipment.internal',"Shipment Internal") - lot_shipment_out = fields.Many2One('stock.shipment.out',"Shipment Out") - lot_product = fields.Many2One('product.product',"Product",readonly=True) - lot_quantity = fields.Numeric("Net weight",digits=(1,5),readonly=True) - lot_gross_quantity = fields.Numeric("Gross weight",digits=(1,5),readonly=True) - lot_unit = fields.Many2One('product.uom',"Unit",readonly=True) - lot_unit_line = fields.Many2One('product.uom',"Unit",readonly=True) + lot_shipment_out = fields.Many2One('stock.shipment.out',"Shipment Out") + lot_product = fields.Many2One('product.product',"Product",readonly=True) + lot_qt = fields.Integer("Qt") + lot_quantity = fields.Numeric("Net weight",digits=(1,5),readonly=True) + lot_gross_quantity = fields.Numeric("Gross weight",digits=(1,5),readonly=True) + lot_unit = fields.Many2One('product.uom',"Unit",readonly=True) + lot_unit_line = fields.Many2One('product.uom',"Unit",readonly=True) lot_quantity_new = fields.Numeric("New net weight",digits=(1,5)) lot_gross_quantity_new = fields.Numeric("New gross weight",digits=(1,5)) lot_shipment_origin = fields.Function( diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index 1501a40..216b643 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -289,8 +289,12 @@ class Purchase(metaclass=PoolMeta): 'purchase', 'Analytic Dimensions' ) - trader = fields.Many2One('party.party',"Trader") - operator = fields.Many2One('party.party',"Operator") + trader = fields.Many2One( + 'party.party', "Trader", + domain=[('categories.name', '=', 'TRADER')]) + operator = fields.Many2One( + 'party.party', "Operator", + domain=[('categories.name', '=', 'OPERATOR')]) our_reference = fields.Char("Our Reference") company_visible = fields.Function( fields.Boolean("Visible"), 'on_change_with_company_visible') diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index 0b0893e..04c0ed1 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -253,8 +253,12 @@ class Sale(metaclass=PoolMeta): 'sale', 'Analytic Dimensions' ) - trader = fields.Many2One('party.party',"Trader") - operator = fields.Many2One('party.party',"Operator") + trader = fields.Many2One( + 'party.party', "Trader", + domain=[('categories.name', '=', 'TRADER')]) + operator = fields.Many2One( + 'party.party', "Operator", + domain=[('categories.name', '=', 'OPERATOR')]) our_reference = fields.Char("Our Reference") company_visible = fields.Function( fields.Boolean("Visible"), 'on_change_with_company_visible') @@ -395,6 +399,76 @@ class Sale(metaclass=PoolMeta): if line: return line.note return '' + + @staticmethod + def _get_report_line_lots(line): + return list(getattr(line, 'lots', []) or []) + + @classmethod + def _get_report_preferred_lots(cls, line): + lots = cls._get_report_line_lots(line) + physicals = [ + lot for lot in lots + if getattr(lot, 'lot_type', None) == 'physic' + ] + if physicals: + return physicals + virtuals = [ + lot for lot in lots + if getattr(lot, 'lot_type', None) == 'virtual' + ] + if len(virtuals) == 1: + return virtuals + return [] + + @staticmethod + def _get_report_lot_hist_weights(lot): + if not lot: + return None, None + if hasattr(lot, 'get_hist_quantity'): + net, gross = lot.get_hist_quantity() + return ( + Decimal(str(net or 0)), + Decimal(str(gross if gross not in (None, '') else net or 0)), + ) + hist = list(getattr(lot, 'lot_hist', []) or []) + state = getattr(lot, 'lot_state', None) + state_id = getattr(state, 'id', None) + if state_id is not None: + for entry in hist: + quantity_type = getattr(entry, 'quantity_type', None) + if getattr(quantity_type, 'id', None) == state_id: + net = Decimal(str(getattr(entry, 'quantity', 0) or 0)) + gross = Decimal(str( + getattr(entry, 'gross_quantity', None) + if getattr(entry, 'gross_quantity', None) not in (None, '') + else net)) + return net, gross + return None, None + + @classmethod + def _get_report_line_weights(cls, line): + lots = cls._get_report_preferred_lots(line) + if lots: + net_total = Decimal(0) + gross_total = Decimal(0) + for lot in lots: + net, gross = cls._get_report_lot_hist_weights(lot) + if net is None: + continue + net_total += net + gross_total += gross + if net_total or gross_total: + return net_total, gross_total + quantity = Decimal(str(getattr(line, 'quantity', 0) or 0)) + return quantity, quantity + + @classmethod + def _get_report_line_unit(cls, line): + lots = cls._get_report_preferred_lots(line) + if lots and getattr(lots[0], 'lot_unit_line', None): + return lots[0].lot_unit_line + return getattr(line, 'unit', None) @property def report_gross(self): @@ -402,12 +476,7 @@ class Sale(metaclass=PoolMeta): if lines: total = Decimal(0) for line in lines: - phys_lots = [l for l in line.lots if l.lot_type == 'physic'] - if phys_lots: - total += sum(Decimal(str(l.get_current_gross_quantity() or 0)) - for l in phys_lots) - else: - total += Decimal(str(line.quantity or 0)) + total += self._get_report_line_weights(line)[1] return total return '' @@ -417,12 +486,7 @@ class Sale(metaclass=PoolMeta): if lines: total = Decimal(0) for line in lines: - phys_lots = [l for l in line.lots if l.lot_type == 'physic'] - if phys_lots: - total += sum(Decimal(str(l.get_current_quantity() or 0)) - for l in phys_lots) - else: - total += Decimal(str(line.quantity or 0)) + total += self._get_report_line_weights(line)[0] return total return '' @@ -430,23 +494,20 @@ class Sale(metaclass=PoolMeta): def report_total_quantity(self): lines = self._get_report_lines() if lines: - total = sum(Decimal(str(line.quantity or 0)) for line in lines) + total = sum(self._get_report_line_weights(line)[0] for line in lines) return self._format_report_number(total, keep_trailing_decimal=True) return '0.0' @property def report_quantity_unit_upper(self): line = self._get_report_first_line() - if line and line.unit: - return line.unit.rec_name.upper() + unit = self._get_report_line_unit(line) if line else None + if unit and unit.rec_name: + return unit.rec_name.upper() return '' def _get_report_line_quantity(self, line): - phys_lots = [l for l in line.lots if l.lot_type == 'physic'] - if phys_lots: - return sum(Decimal(str(l.get_current_quantity() or 0)) - for l in phys_lots) - return Decimal(str(line.quantity or 0)) + return self._get_report_line_weights(line)[0] @property def report_qt(self): @@ -466,7 +527,11 @@ class Sale(metaclass=PoolMeta): current_quantity = self._get_report_line_quantity(line) quantity = self._format_report_number( current_quantity, keep_trailing_decimal=True) - unit = line.unit.rec_name.upper() if line.unit and line.unit.rec_name else '' + line_unit = self._get_report_line_unit(line) + unit = ( + line_unit.rec_name.upper() + if line_unit and line_unit.rec_name else '' + ) words = quantity_to_words(current_quantity) period = line.del_period.description if getattr(line, 'del_period', None) else '' detail = ' '.join( diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index e92d22c..0c891e3 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -184,6 +184,20 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual( PurchaseLine.default_pricing_rule(), 'Default pricing rule') + def test_sale_and_purchase_trader_operator_domains_use_explicit_categories(self): + 'sale and purchase trader/operator fields are filtered by TRADER/OPERATOR categories' + Sale = Pool().get('sale.sale') + Purchase = Pool().get('purchase.purchase') + + self.assertEqual( + Sale.trader.domain, [('categories.name', '=', 'TRADER')]) + self.assertEqual( + Sale.operator.domain, [('categories.name', '=', 'OPERATOR')]) + self.assertEqual( + Purchase.trader.domain, [('categories.name', '=', 'TRADER')]) + self.assertEqual( + Purchase.operator.domain, [('categories.name', '=', 'OPERATOR')]) + def test_sale_line_write_updates_virtual_lot_when_theorical_qty_increases(self): 'sale line write increases virtual lot and open lot.qt when contractual qty grows' SaleLine = Pool().get('sale.line') @@ -841,15 +855,86 @@ class PurchaseTradeTestCase(ModuleTestCase): 'USC 8.3000 PER POUND (EIGHT USC AND THIRTY CENTS) ON ICE Cotton #2 MARCH 2026', )]) - def test_invoice_report_note_title_uses_total_amount_sign(self): - 'final invoice title switches between credit and debit note' + def test_sale_report_uses_single_virtual_lot_hist_when_no_physical(self): + 'sale report uses the unique virtual lot hist when no physical lot exists' + Sale = Pool().get('sale.sale') + + virtual = Mock(lot_type='virtual', lot_unit_line=Mock(rec_name='LBS')) + virtual.get_hist_quantity.return_value = ( + Decimal('930'), + Decimal('0'), + ) + line = Mock(type='line', quantity=Decimal('1000')) + line.lots = [virtual] + line.unit = Mock(rec_name='MT') + line.del_period = Mock(description='MARCH 2026') + + sale = Sale() + sale.lines = [line] + + self.assertEqual(sale.report_net, Decimal('930')) + self.assertEqual(sale.report_gross, Decimal('930')) + self.assertEqual(sale.report_total_quantity, '930.0') + self.assertEqual(sale.report_quantity_unit_upper, 'LBS') + self.assertEqual( + sale.report_quantity_lines, + '930.0 LBS (NINE HUNDRED AND THIRTY POUNDS) - MARCH 2026') + + def test_sale_report_prefers_physical_lot_hist_over_virtual(self): + 'sale report prioritizes physical lot hist values over virtual ones' + Sale = Pool().get('sale.sale') + + virtual = Mock(lot_type='virtual', lot_unit_line=Mock(rec_name='LBS')) + virtual.get_hist_quantity.return_value = ( + Decimal('930'), + Decimal('940'), + ) + physical = Mock(lot_type='physic', lot_unit_line=Mock(rec_name='LBS')) + physical.get_hist_quantity.return_value = ( + Decimal('950'), + Decimal('980'), + ) + line = Mock(type='line', quantity=Decimal('1000')) + line.lots = [virtual, physical] + line.unit = Mock(rec_name='MT') + line.del_period = Mock(description='MARCH 2026') + + sale = Sale() + sale.lines = [line] + + self.assertEqual(sale.report_net, Decimal('950')) + self.assertEqual(sale.report_gross, Decimal('980')) + self.assertEqual(sale.report_total_quantity, '950.0') + self.assertEqual(sale.report_quantity_unit_upper, 'LBS') + self.assertEqual( + sale.report_quantity_lines, + '950.0 LBS (NINE HUNDRED AND FIFTY POUNDS) - MARCH 2026') + + def test_invoice_report_note_title_uses_sale_direction(self): + 'sale final note title is inverted from the raw amount sign' + Invoice = Pool().get('account.invoice') + + debit = Invoice() + debit.type = 'out' + debit.total_amount = Decimal('10') + self.assertEqual(debit.report_note_title, 'Debit Note') + + credit = Invoice() + credit.type = 'out' + credit.total_amount = Decimal('-10') + self.assertEqual(credit.report_note_title, 'Credit Note') + + def test_invoice_report_note_title_keeps_inverse_for_purchase(self): + 'purchase final note title keeps the opposite mapping' Invoice = Pool().get('account.invoice') credit = Invoice() + credit.type = 'in' credit.total_amount = Decimal('10') self.assertEqual(credit.report_note_title, 'Credit Note') debit = Invoice() + debit.type = 'in' debit.total_amount = Decimal('-10') self.assertEqual(debit.report_note_title, 'Debit Note') @@ -864,6 +949,187 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(invoice.report_net, Decimal('800')) + def test_invoice_report_weights_use_current_lot_hist_values(self): + 'invoice net and gross weights come from the current lot hist entry' + Invoice = Pool().get('account.invoice') + + unit = Mock(rec_name='LBS') + lot = Mock(lot_unit_line=unit) + lot.get_hist_quantity.return_value = ( + Decimal('950'), + Decimal('980'), + ) + line = Mock(type='line', quantity=Decimal('1000'), lot=lot, unit=Mock(rec_name='MT')) + invoice = Invoice() + invoice.lines = [line] + + self.assertEqual(invoice.report_net, Decimal('950')) + self.assertEqual(invoice.report_gross, Decimal('980')) + self.assertEqual(invoice.report_weight_unit_upper, 'LBS') + self.assertEqual( + invoice.report_quantity_lines, + '950.0 LBS (2094389.00 LBS)') + + def test_invoice_report_weights_keep_line_sign_with_lot_hist_values(self): + 'invoice lot hist values keep the invoice line sign for final notes' + Invoice = Pool().get('account.invoice') + + positive_lot = Mock(lot_unit_line=Mock(rec_name='LBS')) + positive_lot.get_hist_quantity.return_value = ( + Decimal('950'), + Decimal('980'), + ) + negative_lot = Mock(lot_unit_line=Mock(rec_name='LBS')) + negative_lot.get_hist_quantity.return_value = ( + Decimal('150'), + Decimal('160'), + ) + positive = Mock(type='line', quantity=Decimal('1000'), lot=positive_lot) + negative = Mock(type='line', quantity=Decimal('-200'), lot=negative_lot) + invoice = Invoice() + invoice.lines = [positive, negative] + + self.assertEqual(invoice.report_net, Decimal('800')) + self.assertEqual(invoice.report_gross, Decimal('820')) + + def test_invoice_report_weights_use_single_virtual_lot_when_no_physical(self): + 'invoice uses the unique virtual lot hist when no physical lot exists' + Invoice = Pool().get('account.invoice') + + virtual = Mock(id=1, lot_type='virtual', lot_unit_line=Mock(rec_name='LBS')) + virtual.get_hist_quantity.return_value = ( + Decimal('930'), + Decimal('0'), + ) + origin = Mock(lots=[virtual]) + line = Mock( + type='line', + quantity=Decimal('1000'), + lot=None, + origin=origin, + unit=Mock(rec_name='MT'), + ) + invoice = Invoice() + invoice.lines = [line] + + self.assertEqual(invoice.report_net, Decimal('930')) + self.assertEqual(invoice.report_gross, Decimal('930')) + self.assertEqual(invoice.report_weight_unit_upper, 'LBS') + + def test_invoice_report_weights_prefer_physical_lots_over_virtual(self): + 'invoice uses physical lot hist values whenever physical lots exist' + Invoice = Pool().get('account.invoice') + + virtual = Mock(id=1, lot_type='virtual', lot_unit_line=Mock(rec_name='LBS')) + virtual.get_hist_quantity.return_value = ( + Decimal('930'), + Decimal('940'), + ) + physical = Mock(id=2, lot_type='physic', lot_unit_line=Mock(rec_name='LBS')) + physical.get_hist_quantity.return_value = ( + Decimal('950'), + Decimal('980'), + ) + origin = Mock(lots=[virtual, physical]) + line = Mock( + type='line', + quantity=Decimal('1000'), + lot=virtual, + origin=origin, + unit=Mock(rec_name='MT'), + ) + invoice = Invoice() + invoice.lines = [line] + + self.assertEqual(invoice.report_net, Decimal('950')) + self.assertEqual(invoice.report_gross, Decimal('980')) + self.assertEqual(invoice.report_weight_unit_upper, 'LBS') + + def test_invoice_report_shipment_uses_invoice_line_lot_not_first_trade_line(self): + 'invoice shipment info comes from the lots linked to the invoiced line' + Invoice = Pool().get('account.invoice') + + shipment_a = Mock( + id=1, + bl_date='2026-04-01', + bl_number='BL-A', + vessel=Mock(vessel_name='VESSEL A'), + from_location=Mock(rec_name='LOADING A'), + to_location=Mock(rec_name='DISCHARGE A'), + controller=Mock(rec_name='CTRL A'), + number='SI-A', + ) + shipment_b = Mock( + id=2, + bl_date='2026-04-05', + bl_number='BL-B', + vessel=Mock(vessel_name='VESSEL B'), + from_location=Mock(rec_name='LOADING B'), + to_location=Mock(rec_name='DISCHARGE B'), + controller=Mock(rec_name='CTRL B'), + number='SI-B', + ) + lot_a = Mock(id=10, lot_type='physic', lot_shipment_in=shipment_a) + lot_b = Mock(id=20, lot_type='physic', lot_shipment_in=shipment_b) + line_a = Mock(lots=[lot_a]) + line_b = Mock(lots=[lot_b]) + purchase = Mock(lines=[line_a, line_b]) + + invoice_line = Mock(type='line', lot=lot_b, origin=line_b) + invoice = Invoice() + invoice.purchases = [purchase] + invoice.lines = [invoice_line] + + self.assertEqual(invoice.report_bl_nb, 'BL-B') + self.assertEqual(invoice.report_bl_date, '2026-04-05') + self.assertEqual(invoice.report_vessel, 'VESSEL B') + self.assertEqual(invoice.report_loading_port, 'LOADING B') + self.assertEqual(invoice.report_discharge_port, 'DISCHARGE B') + self.assertEqual(invoice.report_controller_name, 'CTRL B') + self.assertEqual(invoice.report_si_number, 'SI-B') + self.assertEqual(invoice.report_si_reference, 'REF-B') + + def test_invoice_report_shipment_is_blank_if_invoice_mixes_shipments(self): + 'invoice shipment fields stay empty when multiple shipments are invoiced together' + Invoice = Pool().get('account.invoice') + + shipment_a = Mock( + id=1, + bl_date='2026-04-01', + bl_number='BL-A', + vessel=Mock(vessel_name='VESSEL A'), + from_location=Mock(rec_name='LOADING A'), + to_location=Mock(rec_name='DISCHARGE A'), + controller=Mock(rec_name='CTRL A'), + number='SI-A', + ) + shipment_b = Mock( + id=2, + bl_date='2026-04-05', + bl_number='BL-B', + reference='REF-B', + vessel=Mock(vessel_name='VESSEL B'), + from_location=Mock(rec_name='LOADING B'), + to_location=Mock(rec_name='DISCHARGE B'), + controller=Mock(rec_name='CTRL B'), + number='SI-B', + ) + lot_a = Mock(id=10, lot_type='physic', lot_shipment_in=shipment_a) + lot_b = Mock(id=20, lot_type='physic', lot_shipment_in=shipment_b) + line_a = Mock(type='line', lot=lot_a, origin=Mock(lots=[lot_a])) + line_b = Mock(type='line', lot=lot_b, origin=Mock(lots=[lot_b])) + invoice = Invoice() + invoice.lines = [line_a, line_b] + + self.assertIsNone(invoice.report_bl_nb) + self.assertIsNone(invoice.report_bl_date) + self.assertEqual(invoice.report_vessel, None) + self.assertEqual(invoice.report_loading_port, '') + self.assertEqual(invoice.report_discharge_port, '') + self.assertEqual(invoice.report_controller_name, '') + self.assertEqual(invoice.report_si_number, '') + self.assertEqual(invoice.report_si_reference, '') + def test_invoice_report_nb_bale_sums_signed_line_lot_quantities(self): 'invoice reports packaging from the signed sum of line lot_qt values' Invoice = Pool().get('account.invoice') @@ -876,6 +1142,18 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(invoice.report_nb_bale, 'NB BALES: 0') + def test_invoice_report_cndn_nb_bale_displays_unchanged_for_zero(self): + 'CN/DN bale label displays Unchanged when the signed balance is zero' + Invoice = Pool().get('account.invoice') + + lot = Mock(lot_qt=Decimal('350'), lot_unit=Mock(symbol='bale')) + negative = Mock(type='line', quantity=Decimal('-1000'), lot=lot) + positive = Mock(type='line', quantity=Decimal('1000'), lot=lot) + invoice = Invoice() + invoice.lines = [negative, positive] + + self.assertEqual(invoice.report_cndn_nb_bale, 'Unchanged') + def test_invoice_report_positive_rate_lines_keep_positive_components(self): 'invoice final note pricing section keeps only positive component lines' Invoice = Pool().get('account.invoice') diff --git a/modules/purchase_trade/view/lot_report_list.xml b/modules/purchase_trade/view/lot_report_list.xml index 1fea232..c1268af 100755 --- a/modules/purchase_trade/view/lot_report_list.xml +++ b/modules/purchase_trade/view/lot_report_list.xml @@ -3,6 +3,7 @@ + diff --git a/modules/purchase_trade/view/lot_weighing_lot_tree.xml b/modules/purchase_trade/view/lot_weighing_lot_tree.xml index 45a4b5e..20797b9 100755 --- a/modules/purchase_trade/view/lot_weighing_lot_tree.xml +++ b/modules/purchase_trade/view/lot_weighing_lot_tree.xml @@ -4,6 +4,7 @@ +