From 0ffd43149bbc1bc1f97f51dd4cb3af5b229ae0e3 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Mon, 27 Jul 2026 20:24:44 +0200 Subject: [PATCH] Create mirror action --- modules/purchase_trade/__init__.py | 15 +- .../docs/business/lots-management.md | 24 +++ .../docs_source/business/lots-management.md | 21 ++ modules/purchase_trade/invoice.py | 26 ++- modules/purchase_trade/lot.py | 197 +++++++++++++++++- modules/purchase_trade/lot.xml | 46 ++-- modules/purchase_trade/sale.py | 26 ++- modules/purchase_trade/service.py | 164 +++++++++++++++ modules/purchase_trade/tests/test_module.py | 179 ++++++++++++++++ .../purchase_trade/view/mirror_line_tree.xml | 14 ++ .../purchase_trade/view/mirror_start_form.xml | 34 +++ modules/purchase_trade/view/purchase_form.xml | 1 + modules/purchase_trade/view/sale_form.xml | 1 + 13 files changed, 710 insertions(+), 38 deletions(-) create mode 100644 modules/purchase_trade/view/mirror_line_tree.xml create mode 100644 modules/purchase_trade/view/mirror_start_form.xml diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py index af478fa..82e1c04 100755 --- a/modules/purchase_trade/__init__.py +++ b/modules/purchase_trade/__init__.py @@ -227,10 +227,12 @@ def register(): lot.LotImportLot, lot.LotInvoiceStart, lot.LotMatchingLot, - lot.LotWeighingLot, - lot.ContractsStart, - lot.ContractDetail, - lot.LotFCR, + lot.LotWeighingLot, + lot.ContractsStart, + lot.ContractDetail, + lot.MirrorStart, + lot.MirrorLine, + lot.LotFCR, lot.LotMove, lot.LotAccountingGraph, workflow.ExecutionPlan, @@ -330,8 +332,9 @@ def register(): lot.LotMatching, lot.LotGoMatching, #lot.LotMatchingUnit, - lot.LotWeighing, - lot.CreateContracts, + lot.LotWeighing, + lot.CreateContracts, + lot.CreateMirror, lot.LotUnmatch, lot.LotMarkFinished, lot.LotUnship, diff --git a/modules/purchase_trade/docs/business/lots-management.md b/modules/purchase_trade/docs/business/lots-management.md index 96279d5..49c2d09 100644 --- a/modules/purchase_trade/docs/business/lots-management.md +++ b/modules/purchase_trade/docs/business/lots-management.md @@ -33,3 +33,27 @@ commercial, le sens achat/vente et l'avancement logistique.
  • Mark as finished masque seulement les reliquats ouverts / virtuels, pas les lots physiques.
  • + +## BR-PT-LOTMGT-002 - Create mirror depuis quantites ouvertes + +Source: demande metier `2026-07-27` + +### Regle consultant + +Depuis Lots Management, l'action `Create mirror` cree un seul contrat oppose +avec exactement une ligne miroir par quantite ouverte selectionnee. + +### Notes developpeur + + diff --git a/modules/purchase_trade/docs_source/business/lots-management.md b/modules/purchase_trade/docs_source/business/lots-management.md index fd89c29..6729866 100644 --- a/modules/purchase_trade/docs_source/business/lots-management.md +++ b/modules/purchase_trade/docs_source/business/lots-management.md @@ -25,3 +25,24 @@ commercial, le sens achat/vente et l'avancement logistique. - `Mark as finished` masque seulement les reliquats ouverts / virtuels, pas les lots physiques. +## BR-PT-LOTMGT-002 - Create mirror depuis quantites ouvertes + +Source: demande metier `2026-07-27` + +### Regle consultant + +Depuis Lots Management, l'action `Create mirror` cree un seul contrat oppose +avec exactement une ligne miroir par quantite ouverte selectionnee. + +### Notes developpeur + +- La selection doit contenir uniquement des `lot.qt` ouverts, virtuels et non + matches. +- Les lignes achat et vente ne peuvent pas etre melangees dans la meme + selection. +- Les lots physiques sont exclus. +- Les produits et unites peuvent differer entre lignes, car ils sont portes + par chaque ligne miroir et non par le header. +- Le wizard ne doit pas permettre fonctionnellement d'ajouter, supprimer ou + remplacer les lignes sources selectionnees; le serveur revalide ces + invariants a la creation. diff --git a/modules/purchase_trade/invoice.py b/modules/purchase_trade/invoice.py index 5623516..c7a896c 100644 --- a/modules/purchase_trade/invoice.py +++ b/modules/purchase_trade/invoice.py @@ -2326,13 +2326,25 @@ class Invoice(metaclass=PoolMeta): if not trade: return '' incoterm = trade.incoterm.code if getattr(trade, 'incoterm', None) else '' - location = ( - trade.incoterm_location.party_name - if getattr(trade, 'incoterm_location', None) else '' - ) - if incoterm and location: - return f"{incoterm} {location}" - return incoterm or location + if not incoterm: + return '' + if incoterm.upper() not in {'CIF', 'CFR', 'CIP'}: + return incoterm + parts = [incoterm] + to_location = getattr(trade, 'to_location', None) + if to_location: + location = ( + getattr(to_location, 'party_name', None) + or getattr(to_location, 'rec_name', None) + or getattr(to_location, 'name', None) + ) + if location: + parts.append(location) + country = getattr(to_location, 'country', None) + country_name = getattr(country, 'name', None) + if country_name: + parts.append(country_name) + return ' '.join(parts) @property def report_proforma_invoice_number(self): diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index fc68879..e96d193 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -5535,7 +5535,7 @@ class LotWeighingLot(ModelView): return 'stock.shipment.internal,' + str(self.lot_shipment_internal.id) return None -class CreateContracts(Wizard): +class CreateContracts(Wizard): "Create Contracts" __name__ = "create.contracts" @@ -5623,7 +5623,7 @@ class CreateContracts(Wizard): def end(self): return 'reload' -class ContractsStart(ModelView): +class ContractsStart(ModelView): "Create Contracts" __name__ = "contracts.start" type = fields.Char("Type of contract to create",readonly=True) @@ -5646,8 +5646,197 @@ class ContractsStart(ModelView): contracts = fields.One2Many('contract.detail','cd',"Contracts") @classmethod - def default_matched(cls): - return True + def default_matched(cls): + return True + +class CreateMirror(Wizard): + "Create Mirror Contract" + __name__ = "create.mirror" + + start = StateTransition() + + mirror = StateView( + 'mirror.start', + 'purchase_trade.mirror_start_view_form', [ + Button("Cancel", 'end', 'tryton-cancel'), + Button("Create", 'creating', 'tryton-ok', default=True), + ]) + + creating = StateTransition() + + def transition_start(self): + return 'mirror' + + def default_mirror(self, fields): + return MirrorStart.default_from_context() + + def transition_creating(self): + ContractFactory.create_mirror_contract( + self.mirror, + self.mirror.lines, + type_=self.mirror.type, + ) + return 'end' + + def end(self): + return 'reload' + +class MirrorStart(ModelView): + "Create Mirror Contract" + __name__ = "mirror.start" + + type = fields.Char("Type of contract to create", readonly=True) + category = fields.Integer("Category") + party = fields.Many2One( + 'party.party', "Party", required=True, + domain=[('categories.parent', 'child_of', Eval('category'))], + depends=['category']) + currency = fields.Many2One('currency.currency', "Currency", required=True) + incoterm = fields.Many2One('incoterm.incoterm', "Incoterm", required=True) + payment_term = fields.Many2One( + 'account.invoice.payment_term', "Payment Term", required=True) + reference = fields.Char("Reference") + tol_min = fields.Numeric("Tol - in %") + tol_max = fields.Numeric("Tol + in %") + crop = fields.Many2One( + 'purchase.crop', "Crop", + states={'invisible': Eval('company_visible')}) + from_location = fields.Many2One( + 'stock.location', "From location", + domain=[('type', 'in', ['storage', 'supplier'])]) + to_location = fields.Many2One( + 'stock.location', "To location", + domain=[('type', 'in', ['storage', 'customer'])]) + company_visible = fields.Boolean("Visible") + selected_source_count = fields.Integer("Selected sources", readonly=True) + selected_source_ids = fields.Char("Selected source ids", readonly=True) + lines = fields.One2Many('mirror.line', 'mirror', "Lines") + + @classmethod + def default_company_visible(cls): + return ContractDetail.default_company_visible() + + @classmethod + def default_from_context(cls): + sources, type_ = cls._selected_sources() + base_contract = cls._base_contract(sources[0], type_) + from_location, to_location = ContractFactory._get_mirror_locations( + base_contract, type_) + return { + 'type': type_, + 'category': 1 if type_ == 'Sale' else 2, + 'currency': getattr(getattr(base_contract, 'currency', None), 'id', None), + 'incoterm': getattr(getattr(base_contract, 'incoterm', None), 'id', None), + 'payment_term': getattr( + getattr(base_contract, 'payment_term', None), 'id', None), + 'reference': getattr(base_contract, 'reference', None), + 'tol_min': getattr(base_contract, 'tol_min', None), + 'tol_max': getattr(base_contract, 'tol_max', None), + 'crop': getattr(getattr(base_contract, 'crop', None), 'id', None), + 'from_location': getattr(from_location, 'id', None), + 'to_location': getattr(to_location, 'id', None), + 'selected_source_count': len(sources), + 'selected_source_ids': ','.join( + str(source['lqt'].id) for source in sources), + 'lines': [cls._line_defaults(source, type_) for source in sources], + } + + @staticmethod + def _base_contract(source, type_): + lot = source['lot'] + if type_ == 'Purchase': + return lot.sale_line.sale + return lot.line.purchase + + @classmethod + def _selected_sources(cls): + LotQt = Pool().get('lot.qt') + context = Transaction().context + ids = context.get('active_ids') or [] + if not ids: + raise UserError("You must select open quantities to mirror.") + sources = [] + type_ = None + for record_id in ids: + if record_id < 10000000: + raise UserError("You must create mirror from open quantities.") + lqt = LotQt(record_id - 10000000) + if bool(lqt.lot_p) == bool(lqt.lot_s): + raise UserError( + "You must select unmatched open quantities from one side.") + lot = lqt.lot_p or lqt.lot_s + if getattr(lot, 'lot_type', None) != 'virtual': + raise UserError("You must create mirror from open quantities.") + current_type = "Sale" if lqt.lot_p else "Purchase" + if type_ and current_type != type_: + raise UserError( + "You must select open quantities from the same side.") + type_ = current_type + sources.append({ + 'lqt': lqt, + 'lot': lot, + 'trade_line': lot.line if lqt.lot_p else lot.sale_line, + }) + return sources, type_ + + @staticmethod + def _line_defaults(source, type_): + lqt = source['lqt'] + line = source['trade_line'] + lot = source['lot'] + currency_unit = None + if getattr(line, 'enable_linked_currency', False): + currency_unit = "0_%s" % line.linked_unit.id + else: + contract = line.purchase if type_ == 'Sale' else line.sale + currency = getattr(contract, 'currency', None) + unit = getattr(line, 'unit', None) + if currency and unit: + currency_unit = "%s_%s" % (currency.id, unit.id) + return { + 'source_lqt': lqt.id, + 'source_lot': lot.id, + 'product': line.product.id, + 'unit': line.unit.id, + 'quantity': abs(Decimal(str(lqt.lot_quantity or 0))), + 'price': getattr(line, 'linked_price', None) + if getattr(line, 'enable_linked_currency', False) + else getattr(line, 'unit_price', None), + 'price_type': getattr(line, 'price_type', None) or 'priced', + 'premium': getattr(line, 'premium', None) or Decimal(0), + 'currency_unit': currency_unit, + 'del_period': getattr(getattr(line, 'del_period', None), 'id', None), + 'from_del': getattr(line, 'from_del', None), + 'to_del': getattr(line, 'to_del', None), + } + +class MirrorLine(ModelView): + "Mirror Contract Line" + __name__ = "mirror.line" + + mirror = fields.Many2One('mirror.start', "Mirror") + source_lqt = fields.Many2One('lot.qt', "Open quantity", readonly=True) + source_lot = fields.Many2One('lot.lot', "Source lot", readonly=True) + product = fields.Many2One('product.product', "Product", required=True) + quantity = fields.Numeric("Quantity", digits=(1, 5), required=True) + unit = fields.Many2One('product.uom', "Unit", required=True) + price = fields.Numeric("Price", required=True, digits=(1, 4), + states={'invisible': Eval('price_type') != 'priced'}) + price_type = fields.Selection([ + ('cash', 'Cash Price'), + ('priced', 'Priced'), + ('basis', 'Basis'), + ], 'Price type', required=True) + premium = fields.Numeric("Prem/Disc", digits=(1, 4)) + currency_unit = fields.Selection( + 'get_currency_unit', string="Curr/Unit") + del_period = fields.Many2One('product.month', "Shipment Period") + from_del = fields.Date("From") + to_del = fields.Date("To") + + @staticmethod + def get_currency_unit(): + return ContractDetail.get_currency_unit() class ContractDetail(ModelView): diff --git a/modules/purchase_trade/lot.xml b/modules/purchase_trade/lot.xml index 2b92f3b..7a210e5 100755 --- a/modules/purchase_trade/lot.xml +++ b/modules/purchase_trade/lot.xml @@ -266,23 +266,43 @@ this repository contains the full copyright notices and license terms. --> form contracts_start_form - - contract.detail - tree - contract_detail_tree - - + + contract.detail + tree + contract_detail_tree + + + mirror.start + form + mirror_start_form + + + mirror.line + tree + mirror_line_tree + + 📝 Create contracts create.contracts lot.report - - form_action - lot.report,-1 - - - - + diff --git a/modules/purchase_trade/view/sale_form.xml b/modules/purchase_trade/view/sale_form.xml index b014fb0..ab60744 100755 --- a/modules/purchase_trade/view/sale_form.xml +++ b/modules/purchase_trade/view/sale_form.xml @@ -4,6 +4,7 @@ this repository contains the full copyright notices and license terms. --> +