From 82143ba87db7125c7ed3a7b103a5d28ab0023a50 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Tue, 7 Jul 2026 18:42:34 +0200 Subject: [PATCH] Linkage report --- modules/purchase_trade/__init__.py | 1 + modules/purchase_trade/stock.py | 220 ++++++++++++++++++++ modules/purchase_trade/stock.xml | 6 +- modules/purchase_trade/tests/test_module.py | 105 ++++++++++ 4 files changed, 329 insertions(+), 3 deletions(-) diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py index c003d0e..d9dd13b 100755 --- a/modules/purchase_trade/__init__.py +++ b/modules/purchase_trade/__init__.py @@ -365,5 +365,6 @@ def register(): stock.ShipmentPackingListReport, stock.ShipmentLinkageReport, stock.LotQtLinkageReport, + stock.LotReportLinkageReport, module='purchase_trade', type_='report') diff --git a/modules/purchase_trade/stock.py b/modules/purchase_trade/stock.py index a00ed62..8336d95 100755 --- a/modules/purchase_trade/stock.py +++ b/modules/purchase_trade/stock.py @@ -4470,6 +4470,226 @@ class LotQtLinkageReport(ShipmentTemplateReportMixin, BaseSupplierShipping): 'shipment_linkage_report_template', 'stock') +class LotReportLinkageRecord: + + def __init__(self, lot, lot_report=None): + self.lot = lot + self.lot_report = lot_report + self.id = getattr(lot_report, 'id', None) or getattr(lot, 'id', None) + + @property + def rec_name(self): + return ( + getattr(self.lot_report, 'rec_name', None) + or getattr(self.lot, 'rec_name', None) + or getattr(self.lot, 'lot_name', None) + or str(self.id)) + + def set_lang(self, language=None): + set_lang = getattr(self.lot, 'set_lang', None) + if callable(set_lang): + set_lang(language) + + @staticmethod + def _report_record_key(record): + return ShipmentIn._report_record_key(record) + + @classmethod + def _report_unique_records(cls, records): + return ShipmentIn._report_unique_records(records) + + @staticmethod + def _report_linkage_lot_purchase_line(lot): + return ShipmentIn._report_linkage_lot_purchase_line(lot) + + @staticmethod + def _report_linkage_lot_sale_line(lot): + return ShipmentIn._report_linkage_lot_sale_line(lot) + + @staticmethod + def _report_rec_name(record): + return ShipmentIn._report_rec_name(record) + + @classmethod + def _report_number(cls, record): + return ShipmentIn._report_number(record) + + @staticmethod + def _report_date(value): + return ShipmentIn._report_date(value) + + @staticmethod + def _report_amount(value): + return ShipmentIn._report_amount(value) + + @staticmethod + def _report_price(value): + return ShipmentIn._report_price(value) + + @classmethod + def _report_currency_code(cls, record): + return ShipmentIn._report_currency_code(record) + + @classmethod + def _report_unit_symbol(cls, line): + return ShipmentIn._report_unit_symbol(line) + + @classmethod + def _report_line_quantity(cls, line): + return ShipmentIn._report_line_quantity(line) + + @classmethod + def _report_line_amount(cls, line): + return ShipmentIn._report_line_amount(line) + + @classmethod + def _report_fee_amount(cls, fee): + return ShipmentIn._report_fee_amount(fee) + + @classmethod + def _report_column(cls, rows, index, amount=False): + return ShipmentIn._report_column(rows, index, amount=amount) + + @classmethod + def _report_table_rows(cls, rows, names, amount_names=()): + return ShipmentIn._report_table_rows(rows, names, amount_names) + + @staticmethod + def _format_report_quantity(value, digits='0.001'): + return ShipmentIn._format_report_quantity(value, digits=digits) + + @staticmethod + def _get_report_unit_text(unit): + return ShipmentIn._get_report_unit_text(unit) + + def _get_report_primary_move(self): + return getattr(self.lot, 'move', None) + + def _get_report_linkage_lots(self): + return [self.lot] + + _get_report_linkage_purchase_lines = ( + ShipmentIn._get_report_linkage_purchase_lines) + _get_report_linkage_sale_lines = ShipmentIn._get_report_linkage_sale_lines + _get_report_linkage_purchase = ShipmentIn._get_report_linkage_purchase + _get_report_linkage_sale = ShipmentIn._get_report_linkage_sale + _get_report_linkage_product = ShipmentIn._get_report_linkage_product + + def _get_report_linkage_fees(self): + shipment = getattr(self.lot, 'lot_shipment_in', None) + return list(getattr(shipment, 'fees', []) or []) + + _get_report_linkage_summary_rows = ( + ShipmentIn._get_report_linkage_summary_rows) + _get_report_linkage_movement_rows = ( + ShipmentIn._get_report_linkage_movement_rows) + _get_report_linkage_movement_row = ( + ShipmentIn._get_report_linkage_movement_row) + _get_report_linkage_pricing_rows = ( + ShipmentIn._get_report_linkage_pricing_rows) + _get_report_linkage_detail_rows = ( + ShipmentIn._get_report_linkage_detail_rows) + _get_report_linkage_detail_row = ( + ShipmentIn._get_report_linkage_detail_row) + _get_report_linkage_fee_detail_row = ( + ShipmentIn._get_report_linkage_fee_detail_row) + + report_linkage_title = ShipmentIn.report_linkage_title + report_linkage_desk = ShipmentIn.report_linkage_desk + report_linkage_book = ShipmentIn.report_linkage_book + report_linkage_strategy = ShipmentIn.report_linkage_strategy + + @property + def report_linkage_bl_date(self): + shipment = getattr(self.lot, 'lot_shipment_in', None) + bl_date = getattr(shipment, 'bl_date', None) + if not bl_date: + move = self._get_report_primary_move() + bl_date = getattr(move, 'bldate', None) + return bl_date and bl_date.strftime('%A, %B %d, %Y') or '' + + report_linkage_trading_unit = ShipmentIn.report_linkage_trading_unit + report_linkage_finance_user = ShipmentIn.report_linkage_finance_user + report_linkage_summary_groups = ShipmentIn.report_linkage_summary_groups + report_linkage_summary_cost_types = ( + ShipmentIn.report_linkage_summary_cost_types) + report_linkage_summary_estimated = ( + ShipmentIn.report_linkage_summary_estimated) + report_linkage_summary_validated = ( + ShipmentIn.report_linkage_summary_validated) + report_linkage_summary_rows = ShipmentIn.report_linkage_summary_rows + report_linkage_movement_types = ShipmentIn.report_linkage_movement_types + report_linkage_movement_references = ( + ShipmentIn.report_linkage_movement_references) + report_linkage_movement_counterparts = ( + ShipmentIn.report_linkage_movement_counterparts) + report_linkage_movement_commodities = ( + ShipmentIn.report_linkage_movement_commodities) + report_linkage_movement_quantities = ( + ShipmentIn.report_linkage_movement_quantities) + report_linkage_movement_deliveries = ( + ShipmentIn.report_linkage_movement_deliveries) + report_linkage_movement_basis = ShipmentIn.report_linkage_movement_basis + report_linkage_movement_periods = ( + ShipmentIn.report_linkage_movement_periods) + report_linkage_movement_from_dates = ( + ShipmentIn.report_linkage_movement_from_dates) + report_linkage_movement_to_dates = ( + ShipmentIn.report_linkage_movement_to_dates) + report_linkage_movement_rows = ShipmentIn.report_linkage_movement_rows + report_linkage_bank_deal = ShipmentIn.report_linkage_bank_deal + report_linkage_bank_type = ShipmentIn.report_linkage_bank_type + report_linkage_bank_name = ShipmentIn.report_linkage_bank_name + report_linkage_lc_type = ShipmentIn.report_linkage_lc_type + report_linkage_lc_number = ShipmentIn.report_linkage_lc_number + report_linkage_lc_amount = ShipmentIn.report_linkage_lc_amount + report_linkage_lc_expiry_date = ShipmentIn.report_linkage_lc_expiry_date + report_linkage_pricing_deals = ShipmentIn.report_linkage_pricing_deals + report_linkage_pricing_sides = ShipmentIn.report_linkage_pricing_sides + report_linkage_pricing_types = ShipmentIn.report_linkage_pricing_types + report_linkage_pricing_symbols = ShipmentIn.report_linkage_pricing_symbols + report_linkage_pricing_periods = ShipmentIn.report_linkage_pricing_periods + report_linkage_pricing_input_prices = ( + ShipmentIn.report_linkage_pricing_input_prices) + report_linkage_pricing_rows = ShipmentIn.report_linkage_pricing_rows + report_linkage_detail_groups = ShipmentIn.report_linkage_detail_groups + report_linkage_detail_cost_types = ( + ShipmentIn.report_linkage_detail_cost_types) + report_linkage_detail_counterparts = ( + ShipmentIn.report_linkage_detail_counterparts) + report_linkage_detail_sources = ShipmentIn.report_linkage_detail_sources + report_linkage_detail_unit_prices = ( + ShipmentIn.report_linkage_detail_unit_prices) + report_linkage_detail_quantities = ( + ShipmentIn.report_linkage_detail_quantities) + report_linkage_detail_estimated = ( + ShipmentIn.report_linkage_detail_estimated) + report_linkage_detail_validated = ( + ShipmentIn.report_linkage_detail_validated) + report_linkage_detail_rows = ShipmentIn.report_linkage_detail_rows + + +class LotReportLinkageReport(LotQtLinkageReport): + __name__ = 'lot.report.linkage' + + @classmethod + def _get_records(cls, ids, model, data): + LotReport = Pool().get('lot.report') + records = LotReport.browse(ids) + linkage_records = [] + for record in records: + if getattr(record, 'r_lot_type', None) != 'physic': + raise UserError( + 'Linkage report is only available on physical lot lines.') + lot = getattr(record, 'r_lot_p', None) or getattr( + record, 'r_lot_s', None) + if not lot: + raise UserError( + 'Linkage report needs a physical lot on the selected line.') + linkage_records.append(LotReportLinkageRecord(lot, record)) + return linkage_records + + class ShipmentCOOReport(ShipmentTemplateReportMixin, BaseSupplierShipping): __name__ = 'stock.shipment.in.coo' diff --git a/modules/purchase_trade/stock.xml b/modules/purchase_trade/stock.xml index ab0953f..50720cc 100755 --- a/modules/purchase_trade/stock.xml +++ b/modules/purchase_trade/stock.xml @@ -250,13 +250,13 @@ this repository contains the full copyright notices and license terms. --> Linkage - lot.qt - lot.qt.linkage + lot.report + lot.report.linkage stock/linkage.fodt form_print - lot.qt,-1 + lot.report,-1 diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index cfbcc81..31b795c 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -6584,6 +6584,111 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertIn('80.000 USD/MT', lotqt.report_linkage_pricing_input_prices) self.assertIn('100.000 USD/MT', lotqt.report_linkage_pricing_input_prices) + def test_lot_report_linkage_rejects_non_physical_lines(self): + 'lot.report linkage report is only available for physical lines' + linkage_report = Pool().get('lot.report.linkage', type='report') + lot_report_model = Mock() + lot_report_model.browse.return_value = [ + SimpleNamespace( + id=10000001, + rec_name='Open quantity', + r_lot_type='virtual', + r_lot_p=None, + r_lot_s=None, + ) + ] + + with patch('trytond.modules.purchase_trade.stock.Pool') as PoolMock: + PoolMock.return_value.get.return_value = lot_report_model + + with self.assertRaises(UserError): + linkage_report._get_records([10000001], 'lot.report', {}) + + def test_lot_report_linkage_helpers_use_physical_lot(self): + 'lot.report linkage report exposes data from the selected physical lot' + currency = SimpleNamespace(code='USD') + unit = SimpleNamespace(symbol='MT') + product = SimpleNamespace( + rec_name='Sulphuric Acid', + name='Sulphuric Acid', + code='H2SO4') + purchase = SimpleNamespace( + id=1, + number='P-10', + reference='26.0001', + currency=currency, + party=SimpleNamespace(rec_name='SUPPLIER SA'), + company=SimpleNamespace(party=SimpleNamespace(rec_name='TRADING SA')), + to_location=SimpleNamespace(rec_name='Odda'), + incoterm=SimpleNamespace(code='FOB'), + operator=SimpleNamespace(rec_name='Operator A')) + sale = SimpleNamespace( + id=2, + number='S-20', + reference='26.0001', + currency=currency, + party=SimpleNamespace(rec_name='CLIENT SA'), + to_location=SimpleNamespace(rec_name='Mejillones'), + incoterm=SimpleNamespace(code='CFR')) + period = SimpleNamespace(rec_name='Chile FY26') + purchase_line = SimpleNamespace( + id=11, + product=product, + purchase=purchase, + quantity_theorical=Decimal('100'), + quantity=Decimal('100'), + unit_price=Decimal('80'), + unit=unit, + price_type='fixed', + del_period=period, + period_at='laycan', + from_del=datetime.date(2026, 4, 30), + to_del=datetime.date(2026, 5, 8), + fees=[], + mtm=[]) + sale_line = SimpleNamespace( + id=12, + product=product, + sale=sale, + quantity_theorical=Decimal('100'), + quantity=Decimal('100'), + unit_price=Decimal('100'), + unit=unit, + price_type='fixed', + del_period=period, + period_at='laycan', + from_del=datetime.date(2026, 4, 30), + to_del=datetime.date(2026, 4, 30), + fees=[], + mtm=[]) + physical_lot = SimpleNamespace( + id=101, + rec_name='LOT-101', + line=purchase_line, + sale_line=sale_line, + lot_shipment_in=SimpleNamespace( + bl_date=datetime.date(2026, 4, 30), + fees=[]), + move=None) + lot_report = SimpleNamespace( + id=101, + rec_name='Physical lot', + r_lot_type='physic', + r_lot_p=physical_lot, + r_lot_s=None) + record = stock_module.LotReportLinkageRecord( + physical_lot, lot_report) + + self.assertEqual( + record.report_linkage_title, 'Linkage P-10/S-20 26.0001') + self.assertEqual(record.report_linkage_bl_date, 'Thursday, April 30, 2026') + self.assertIn('Purchases', record.report_linkage_summary_groups) + self.assertIn('Sales', record.report_linkage_summary_groups) + self.assertIn('P-10', record.report_linkage_movement_references) + self.assertIn('S-20', record.report_linkage_movement_references) + self.assertIn('80.000 USD/MT', record.report_linkage_pricing_input_prices) + self.assertIn('100.000 USD/MT', record.report_linkage_pricing_input_prices) + def test_shipment_insurance_contact_surveyor_prefers_shipment_surveyor(self): 'insurance contact surveyor property uses shipment surveyor first' ShipmentIn = Pool().get('stock.shipment.in')