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
+
+
+- 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/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. -->
+