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