This commit is contained in:
2026-04-09 19:46:08 +02:00
parent 5ae8af84fb
commit a1ab7dec82
11 changed files with 669 additions and 118 deletions

View File

@@ -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,7 +1896,6 @@ 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

View File

@@ -4059,7 +4059,7 @@
<text:p text:style-name="P13">Controller Name</text:p>
</table:table-cell>
<table:table-cell table:style-name="Tableau10.A1" office:value-type="string">
<text:p text:style-name="P25"><text:placeholder text:placeholder-type="text">&lt;invoice.report_si_number&gt;</text:placeholder></text:p>
<text:p text:style-name="P25"><text:placeholder text:placeholder-type="text">&lt;invoice.report_si_reference&gt;</text:placeholder></text:p>
<text:p text:style-name="P25"/>
<text:p text:style-name="P25"><text:placeholder text:placeholder-type="text">&lt;invoice.report_controller_name&gt;</text:placeholder></text:p>
</table:table-cell>

View File

@@ -3956,7 +3956,7 @@
</table:table-row>
<table:table-row table:style-name="Tableau6.1">
<table:table-cell table:style-name="Tableau6.A2" office:value-type="string">
<text:p text:style-name="P15"><text:placeholder text:placeholder-type="text">&lt;invoice.report_nb_bale&gt;</text:placeholder><text:s/></text:p>
<text:p text:style-name="P15"><text:placeholder text:placeholder-type="text">&lt;invoice.report_cndn_nb_bale&gt;</text:placeholder><text:s/></text:p>
</table:table-cell>
<table:table-cell table:style-name="Tableau6.A2" office:value-type="string">
<text:p text:style-name="P15"><text:placeholder text:placeholder-type="text">&lt;format_number(invoice.report_gross, invoice.party.lang) if invoice.report_gross != &apos;&apos; else &apos;&apos;&gt;</text:placeholder><text:s/></text:p>
@@ -4044,7 +4044,7 @@
<text:p text:style-name="P13">Controller Name</text:p>
</table:table-cell>
<table:table-cell table:style-name="Tableau10.A1" office:value-type="string">
<text:p text:style-name="P26"><text:placeholder text:placeholder-type="text">&lt;invoice.report_si_number&gt;</text:placeholder></text:p>
<text:p text:style-name="P26"><text:placeholder text:placeholder-type="text">&lt;invoice.report_si_reference&gt;</text:placeholder></text:p>
<text:p text:style-name="P26"/>
<text:p text:style-name="P26"><text:placeholder text:placeholder-type="text">&lt;invoice.report_controller_name&gt;</text:placeholder></text:p>
</table:table-cell>

View File

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

View File

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

View File

@@ -1336,6 +1336,7 @@ class LotQt(
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((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'),
@@ -1443,6 +1444,7 @@ class LotQt(
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"),
@@ -1508,6 +1510,7 @@ class LotQt(
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"),
@@ -1561,6 +1564,7 @@ class LotQt(
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"),
@@ -1636,6 +1640,7 @@ class LotReport(
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')
@@ -3055,6 +3060,7 @@ class LotWeighing(Wizard):
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
@@ -3081,6 +3087,7 @@ class LotWeighing(Wizard):
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
@@ -3121,6 +3128,7 @@ class LotWeighingLot(ModelView):
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_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)

View File

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

View File

@@ -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')
@@ -396,18 +400,83 @@ class Sale(metaclass=PoolMeta):
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):
lines = self._get_report_lines()
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(

View File

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

View File

@@ -3,6 +3,7 @@
<prefix name="qt_icon"/>
</field>
<field name="r_lot_p" width="60"/>
<field name="r_del_period" width="110"/>
<field name="r_supplier" width="90"/>
<field name="r_purchase" width="120"/>
<field name="r_lot_pur_inv" width="120"/>

View File

@@ -4,6 +4,7 @@
<field name="lot_shipment_origin"/>
<field name="lot_product"/>
<field name="lot_unit_line"/>
<field name="lot_qt"/>
<field name="lot_quantity"/>
<field name="lot_gross_quantity"/>
<field name="lot_quantity_new"/>