From ec359f6b8aed68fee43bbef3144361a955b6417d Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Mon, 6 Apr 2026 17:30:50 +0200 Subject: [PATCH] Add insurance template --- modules/account_invoice/invoice_melya.fodt | 10 +- modules/purchase_trade/__init__.py | 2 + modules/purchase_trade/configuration.py | 2 + .../docs/template-properties.md | 26 +- modules/purchase_trade/invoice.py | 16 + modules/purchase_trade/sale.py | 14 + modules/purchase_trade/stock.py | 208 +++- modules/purchase_trade/stock.xml | 22 +- modules/purchase_trade/tests/test_module.py | 111 ++ .../view/template_configuration_form.xml | 6 + modules/sale/sale_melya.fodt | 6 +- modules/stock/insurance.fodt | 1075 +++++++++++++++++ 12 files changed, 1472 insertions(+), 26 deletions(-) create mode 100644 modules/stock/insurance.fodt diff --git a/modules/account_invoice/invoice_melya.fodt b/modules/account_invoice/invoice_melya.fodt index 8c7d0a2..5be081f 100644 --- a/modules/account_invoice/invoice_melya.fodt +++ b/modules/account_invoice/invoice_melya.fodt @@ -1528,12 +1528,9 @@ COMMERCIAL INVOICE - Invoice Nr: Date: - - <invoice.number or ''><format_date(invoice.invoice_date, invoice.party.lang) if invoice.invoice_date else ''> + Invoice: <invoice.number or ''>Date: <format_date(invoice.invoice_date, invoice.party.lang) if invoice.invoice_date else ''> - Order reference: - <invoice.report_contract_number or ''> + Reference: <invoice.report_contract_number or ''> @@ -1626,6 +1623,7 @@ <format_number_symbol(invoice.report_net, invoice.party.lang, invoice.lines[0].unit, digits=2) if invoice.lines else ''> + <invoice.report_product_name or ''> <invoice.report_product_description or ''> @@ -1707,4 +1705,4 @@ </for> - \ No newline at end of file + diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py index 2076a30..cf32aab 100755 --- a/modules/purchase_trade/__init__.py +++ b/modules/purchase_trade/__init__.py @@ -278,5 +278,7 @@ def register(): invoice.InvoiceReport, invoice.SaleReport, invoice.PurchaseReport, + stock.ShipmentShippingReport, + stock.ShipmentInsuranceReport, module='purchase_trade', type_='report') diff --git a/modules/purchase_trade/configuration.py b/modules/purchase_trade/configuration.py index 538e225..3851127 100644 --- a/modules/purchase_trade/configuration.py +++ b/modules/purchase_trade/configuration.py @@ -13,3 +13,5 @@ class Configuration(ModelSingleton, ModelSQL, ModelView): invoice_cndn_report_template = fields.Char("CN/DN Template") invoice_prepayment_report_template = fields.Char("Prepayment Template") purchase_report_template = fields.Char("Purchase Template") + shipment_shipping_report_template = fields.Char("Shipping Template") + shipment_insurance_report_template = fields.Char("Insurance Template") diff --git a/modules/purchase_trade/docs/template-properties.md b/modules/purchase_trade/docs/template-properties.md index eebe7ba..2f51a3c 100644 --- a/modules/purchase_trade/docs/template-properties.md +++ b/modules/purchase_trade/docs/template-properties.md @@ -29,6 +29,8 @@ Derniere mise a jour: `2026-03-27` - reutiliser si possible les proprietes `report_*` deja presentes sur `sale.sale` - Pour un achat: - reutiliser si possible les proprietes `report_*` deja presentes sur `purchase.purchase` +- Pour un shipment entrant: + - reutiliser si possible les proprietes `report_*` exposees sur `stock.shipment.in` ## 4) Propriete disponibles sur `account.invoice` @@ -288,8 +290,30 @@ Usage typique: - `modules/account_invoice/invoice_ict.fodt` - `modules/account_invoice/invoice_ict_final.fodt` - `modules/sale/sale_ict.fodt` +- `modules/stock/insurance.fodt` -## 9) Recommandations +## 9) Proprietes utiles deja presentes sur `stock.shipment.in` + +Source code: `modules/purchase_trade/stock.py` + +- `report_product_name` +- `report_product_description` +- `report_insurance_footer_ref` +- `report_insurance_certificate_number` +- `report_insurance_account_of` +- `report_insurance_goods_description` +- `report_insurance_loading_port` +- `report_insurance_discharge_port` +- `report_insurance_transport` +- `report_insurance_amount` +- `report_insurance_surveyor` +- `report_insurance_issue_place_and_date` + +Usage typique: +- templates shipment relies a l'assurance +- templates qui lisent le fee `Insurance` d'un `stock.shipment.in` + +## 10) Recommandations - Avant d'ajouter une nouvelle expression dans un `.fodt`, verifier si une propriete `report_*` existe deja ici. diff --git a/modules/purchase_trade/invoice.py b/modules/purchase_trade/invoice.py index 72f6cd0..9760453 100644 --- a/modules/purchase_trade/invoice.py +++ b/modules/purchase_trade/invoice.py @@ -198,6 +198,13 @@ class Invoice(metaclass=PoolMeta): return line.product.description or '' return '' + @property + def report_product_name(self): + line = self._get_report_trade_line() + if line and line.product: + return line.product.name or '' + return '' + @property def report_description_upper(self): if self.lines: @@ -598,6 +605,15 @@ class InvoiceLine(metaclass=PoolMeta): return origin.product.description or '' return '' + @property + def report_product_name(self): + if self.product: + return self.product.name or '' + origin = getattr(self, 'origin', None) + if origin and getattr(origin, 'product', None): + return origin.product.name or '' + return '' + @property def report_description_upper(self): return Invoice._clean_report_description(self.description) diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index fb61cd7..8ffd94c 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -492,6 +492,20 @@ class Sale(metaclass=PoolMeta): return 'NB BALES: ' + str(int(nb_bale)) return '' + @property + def report_product_name(self): + line = self._get_report_first_line() + if line and line.product: + return line.product.name or '' + return '' + + @property + def report_product_description(self): + line = self._get_report_first_line() + if line and line.product: + return line.product.description or '' + return '' + @property def report_crop_name(self): if self.crop: diff --git a/modules/purchase_trade/stock.py b/modules/purchase_trade/stock.py index 432f2de..38f78e9 100755 --- a/modules/purchase_trade/stock.py +++ b/modules/purchase_trade/stock.py @@ -6,7 +6,7 @@ from trytond.pyson import Bool, Eval, Id from trytond.model import (ModelSQL, ModelView) from trytond.tools import is_full_text, lstrip_wildcard from trytond.transaction import Transaction, inactive_records -from decimal import getcontext, Decimal, ROUND_HALF_UP +from decimal import getcontext, Decimal, ROUND_HALF_UP from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr from sql.conditionals import Case from sql import Column, Literal @@ -23,8 +23,10 @@ import io import base64 import logging import json -import re -import html +import re +import html +from trytond.exceptions import UserError +from trytond.modules.stock.shipment import SupplierShipping as BaseSupplierShipping logger = logging.getLogger(__name__) @@ -387,8 +389,8 @@ class ShipmentWR(ModelSQL,ModelView): shipment_in = fields.Many2One('stock.shipment.in',"Shipment In") wr = fields.Many2One('weight.report',"WR") -class ShipmentIn(metaclass=PoolMeta): - __name__ = 'stock.shipment.in' +class ShipmentIn(metaclass=PoolMeta): + __name__ = 'stock.shipment.in' from_location = fields.Many2One('stock.location', 'From location') to_location = fields.Many2One('stock.location', 'To location') @@ -459,9 +461,134 @@ class ShipmentIn(metaclass=PoolMeta): 'send': {}, }) - def get_vessel_type(self,name=None): - if self.vessel: - return self.vessel.vessel_type + def get_vessel_type(self,name=None): + if self.vessel: + return self.vessel.vessel_type + + def _get_report_primary_move(self): + moves = list(self.incoming_moves or self.moves or []) + return moves[0] if moves else None + + def _get_report_primary_lot(self): + move = self._get_report_primary_move() + return getattr(move, 'lot', None) if move else None + + def _get_report_trade_line(self): + lot = self._get_report_primary_lot() + if not lot: + return None + return getattr(lot, 'sale_line', None) or getattr(lot, 'line', None) + + def _get_report_insurance_fee(self): + for fee in self.fees or []: + product = getattr(fee, 'product', None) + name = ((getattr(product, 'name', '') or '')).strip().lower() + if 'insurance' in name: + return fee + return None + + @staticmethod + def _format_report_amount(value): + if value in (None, ''): + return '' + value = Decimal(str(value or 0)).quantize(Decimal('0.01')) + return format(value, 'f') + + @property + def report_product_name(self): + line = self._get_report_trade_line() + product = getattr(line, 'product', None) if line else None + if product: + return product.name or '' + move = self._get_report_primary_move() + product = getattr(move, 'product', None) if move else None + return getattr(product, 'name', '') or '' + + @property + def report_product_description(self): + line = self._get_report_trade_line() + product = getattr(line, 'product', None) if line else None + if product: + return product.description or '' + move = self._get_report_primary_move() + product = getattr(move, 'product', None) if move else None + return getattr(product, 'description', '') or '' + + @property + def report_insurance_footer_ref(self): + return self.bl_number or self.number or '' + + @property + def report_insurance_certificate_number(self): + return self.bl_number or self.number or '' + + @property + def report_insurance_account_of(self): + line = self._get_report_trade_line() + trade = getattr(line, 'sale', None) or getattr(line, 'purchase', None) + party = getattr(trade, 'party', None) if trade else None + if party: + return party.rec_name or '' + return getattr(self.supplier, 'rec_name', '') or '' + + @property + def report_insurance_goods_description(self): + name = self.report_product_name + description = self.report_product_description + if description and description != name: + return ' - '.join(part for part in [name, description] if part) + return name or description + + @property + def report_insurance_loading_port(self): + return getattr(self.from_location, 'name', '') or '' + + @property + def report_insurance_discharge_port(self): + return getattr(self.to_location, 'name', '') or '' + + @property + def report_insurance_transport(self): + if self.vessel and self.vessel.vessel_name: + return self.vessel.vessel_name + return self.transport_type or '' + + @property + def report_insurance_amount(self): + fee = self._get_report_insurance_fee() + if not fee: + return '' + currency = getattr(fee, 'currency', None) + currency_text = ( + getattr(currency, 'rec_name', None) + or getattr(currency, 'code', None) + or getattr(currency, 'symbol', None) + or '') + amount = self._format_report_amount(fee.get_amount()) + return ' '.join(part for part in [currency_text, amount] if part) + + @property + def report_insurance_surveyor(self): + if self.controller: + return self.controller.rec_name or '' + fee = self._get_report_insurance_fee() + supplier = getattr(fee, 'supplier', None) if fee else None + return getattr(supplier, 'rec_name', '') or '' + + @property + def report_insurance_issue_place_and_date(self): + Date = Pool().get('ir.date') + address = None + if self.company and getattr(self.company, 'party', None): + address = self.company.party.address_get() + place = ( + getattr(address, 'city', None) + or getattr(self.company.party, 'rec_name', None) + if self.company and getattr(self.company, 'party', None) else '' + ) or '' + today = Date.today() + date_text = today.strftime('%d-%m-%Y') if today else '' + return ', '.join(part for part in [place, date_text] if part) def get_rec_name(self, name=None): if self.number: @@ -1888,7 +2015,7 @@ class Revaluate(Wizard): return 'end' -class RevaluateStart(ModelView): +class RevaluateStart(ModelView): "Revaluate" __name__ = 'account.revaluate.start' revaluation_date = fields.Date( @@ -1902,5 +2029,64 @@ class RevaluateStart(ModelView): return Date.today() @classmethod - def default_delete_after(cls): - return False + def default_delete_after(cls): + return False + + +class ShipmentTemplateReportMixin: + + @classmethod + def _get_purchase_trade_configuration(cls): + Configuration = Pool().get('purchase_trade.configuration') + configurations = Configuration.search([], limit=1) + return configurations[0] if configurations else None + + @classmethod + def _get_action_report_path(cls, action): + if isinstance(action, dict): + return action.get('report') or '' + return getattr(action, 'report', '') or '' + + @classmethod + def _resolve_template_path(cls, field_name, default_prefix): + config = cls._get_purchase_trade_configuration() + template = getattr(config, field_name, '') if config else '' + template = (template or '').strip() + if not template: + raise UserError('No template found') + if '/' not in template: + return f'{default_prefix}/{template}' + return template + + @classmethod + def _get_resolved_action(cls, action): + report_path = cls._resolve_configured_report_path(action) + if isinstance(action, dict): + resolved = dict(action) + resolved['report'] = report_path + return resolved + setattr(action, 'report', report_path) + return action + + @classmethod + def _execute(cls, records, header, data, action): + resolved_action = cls._get_resolved_action(action) + return super()._execute(records, header, data, resolved_action) + + +class ShipmentShippingReport(ShipmentTemplateReportMixin, BaseSupplierShipping): + __name__ = 'stock.shipment.in.shipping' + + @classmethod + def _resolve_configured_report_path(cls, action): + return cls._resolve_template_path( + 'shipment_shipping_report_template', 'stock') + + +class ShipmentInsuranceReport(ShipmentTemplateReportMixin, BaseSupplierShipping): + __name__ = 'stock.shipment.in.insurance' + + @classmethod + def _resolve_configured_report_path(cls, action): + return cls._resolve_template_path( + 'shipment_insurance_report_template', 'stock') diff --git a/modules/purchase_trade/stock.xml b/modules/purchase_trade/stock.xml index 9c58f3c..9790592 100755 --- a/modules/purchase_trade/stock.xml +++ b/modules/purchase_trade/stock.xml @@ -56,10 +56,22 @@ this repository contains the full copyright notices and license terms. --> stock.shipment.in,-1 - - Find Vessel - https://www.vesselfinder.com - + + Find Vessel + https://www.vesselfinder.com + + + + Insurance + stock.shipment.in + stock.shipment.in.insurance + stock/insurance.fodt + + + form_print + stock.shipment.in,-1 + + Update with SoF PDF @@ -126,4 +138,4 @@ this repository contains the full copyright notices and license terms. --> id="menu_revaluate"/> - \ No newline at end of file + diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index a58f4d4..96a5954 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -419,6 +419,96 @@ class PurchaseTradeTestCase(ModuleTestCase): }), 'purchase/purchase_melya.fodt') + def test_shipment_reports_use_templates_from_configuration(self): + 'shipment report paths are resolved from purchase_trade configuration' + shipping_report = Pool().get('stock.shipment.in.shipping', type='report') + insurance_report = Pool().get('stock.shipment.in.insurance', type='report') + config_model = Mock() + config_model.search.return_value = [ + Mock( + shipment_shipping_report_template='si_custom.fodt', + shipment_insurance_report_template='insurance_custom.fodt', + ) + ] + + with patch( + 'trytond.modules.purchase_trade.stock.Pool' + ) as PoolMock: + PoolMock.return_value.get.return_value = config_model + + self.assertEqual( + shipping_report._resolve_configured_report_path({ + 'name': 'Shipping instructions', + 'report': 'stock/si.fodt', + }), + 'stock/si_custom.fodt') + self.assertEqual( + insurance_report._resolve_configured_report_path({ + 'name': 'Insurance', + 'report': 'stock/insurance.fodt', + }), + 'stock/insurance_custom.fodt') + + def test_shipment_insurance_helpers_use_fee_and_controller(self): + 'shipment insurance helpers read insurance fee and shipment context' + ShipmentIn = Pool().get('stock.shipment.in') + shipment = ShipmentIn() + shipment.number = 'IN/0001' + shipment.bl_number = 'BL-001' + shipment.from_location = Mock(name='LIVERPOOL') + shipment.to_location = Mock(name='LE HAVRE') + shipment.vessel = Mock(vessel_name='MV ATLANTIC') + shipment.controller = Mock(rec_name='CONTROL UNION') + shipment.supplier = Mock(rec_name='MELYA SA') + + sale_party = Mock(rec_name='SGT FR') + sale = Mock(party=sale_party) + product = Mock(name='COTTON UPLAND', description='RAW WHITE COTTON') + line = Mock(product=product, sale=sale) + lot = Mock(sale_line=line, line=None) + move = Mock(lot=lot, product=product) + shipment.incoming_moves = [move] + shipment.moves = [move] + + insurance_fee = Mock() + insurance_fee.product = Mock(name='Insurance') + insurance_fee.currency = Mock(rec_name='USD') + insurance_fee.get_amount.return_value = Decimal('1234.56') + insurance_fee.supplier = Mock(rec_name='HELVETIA') + shipment.fees = [insurance_fee] + + with patch( + 'trytond.modules.purchase_trade.stock.Pool' + ) as PoolMock: + date_model = Mock() + date_model.today.return_value = datetime.date(2026, 4, 6) + config_model = Mock() + PoolMock.return_value.get.side_effect = lambda name: { + 'ir.date': date_model, + 'purchase_trade.configuration': config_model, + }[name] + shipment.company = Mock( + party=Mock( + rec_name='MELYA SA', + address_get=Mock(return_value=Mock(city='GENEVA')), + ) + ) + + self.assertEqual( + shipment.report_insurance_certificate_number, 'BL-001') + self.assertEqual( + shipment.report_insurance_account_of, 'SGT FR') + self.assertEqual( + shipment.report_insurance_goods_description, + 'COTTON UPLAND - RAW WHITE COTTON') + self.assertEqual( + shipment.report_insurance_amount, 'USD 1234.56') + self.assertEqual( + shipment.report_insurance_surveyor, 'CONTROL UNION') + self.assertEqual( + shipment.report_insurance_issue_place_and_date, + 'GENEVA, 06-04-2026') + def test_sale_report_multi_line_helpers_aggregate_all_lines(self): 'sale report helpers aggregate quantity, price lines and shipment periods' Sale = Pool().get('sale.sale') @@ -466,6 +556,27 @@ class PurchaseTradeTestCase(ModuleTestCase): 'USC 70.2500 PER POUND (SEVENTY USC AND TWENTY FIVE CENTS) ON ICE Cotton #2 MAY 2026', ]) + def test_report_product_fields_expose_name_and_description(self): + 'sale and invoice templates use stable product name/description helpers' + Sale = Pool().get('sale.sale') + Invoice = Pool().get('account.invoice') + + product = Mock(name='COTTON UPLAND', description='RAW WHITE COTTON') + sale_line = Mock(product=product) + invoice_line = Mock(product=product) + + sale = Sale() + sale.lines = [sale_line] + + invoice = Invoice() + invoice.lines = [invoice_line] + invoice._get_report_trade_line = Mock(return_value=sale_line) + + self.assertEqual(sale.report_product_name, 'COTTON UPLAND') + self.assertEqual(sale.report_product_description, 'RAW WHITE COTTON') + self.assertEqual(invoice.report_product_name, 'COTTON UPLAND') + self.assertEqual(invoice.report_product_description, 'RAW WHITE COTTON') + def test_contract_factory_uses_one_line_per_selected_source(self): 'matched multi-lot contract creation keeps one generated line per source lot' contract_detail = Mock(quantity=Decimal('2000')) diff --git a/modules/purchase_trade/view/template_configuration_form.xml b/modules/purchase_trade/view/template_configuration_form.xml index 559a7fc..ffcd6b1 100644 --- a/modules/purchase_trade/view/template_configuration_form.xml +++ b/modules/purchase_trade/view/template_configuration_form.xml @@ -19,4 +19,10 @@