Create mirror action

This commit is contained in:
2026-07-27 20:24:44 +02:00
parent 9429f7d2e3
commit 0ffd43149b
13 changed files with 710 additions and 38 deletions

View File

@@ -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,

View File

@@ -33,3 +33,27 @@ commercial, le sens achat/vente et l'avancement logistique.
<li style="margin:0.38rem 0;"><code>Mark as finished</code> masque seulement les reliquats ouverts / virtuels, pas les lots physiques.
</li>
</ul>
## 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
<ul style="margin:0.65rem 0 1rem 1.1rem; padding-left:1rem; list-style-type:disc;">
<li style="margin:0.38rem 0;">La selection doit contenir uniquement des <code>lot.qt</code> ouverts, virtuels et non matches.
</li>
<li style="margin:0.38rem 0;">Les lignes achat et vente ne peuvent pas etre melangees dans la meme selection.
</li>
<li style="margin:0.38rem 0;">Les lots physiques sont exclus.
</li>
<li style="margin:0.38rem 0;">Les produits et unites peuvent differer entre lignes, car ils sont portes par chaque ligne miroir et non par le header.
</li>
<li style="margin:0.38rem 0;">Le wizard ne doit pas permettre fonctionnellement d&#x27;ajouter, supprimer ou remplacer les lignes sources selectionnees; le serveur revalide ces invariants a la creation.
</li>
</ul>

View File

@@ -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.

View File

@@ -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):

View File

@@ -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):

View File

@@ -266,23 +266,43 @@ this repository contains the full copyright notices and license terms. -->
<field name="type">form</field>
<field name="name">contracts_start_form</field>
</record>
<record model="ir.ui.view" id="contract_detail_view_tree">
<field name="model">contract.detail</field>
<field name="type">tree</field>
<field name="name">contract_detail_tree</field>
</record>
<record model="ir.action.wizard" id="act_create_contracts">
<record model="ir.ui.view" id="contract_detail_view_tree">
<field name="model">contract.detail</field>
<field name="type">tree</field>
<field name="name">contract_detail_tree</field>
</record>
<record model="ir.ui.view" id="mirror_start_view_form">
<field name="model">mirror.start</field>
<field name="type">form</field>
<field name="name">mirror_start_form</field>
</record>
<record model="ir.ui.view" id="mirror_line_view_tree">
<field name="model">mirror.line</field>
<field name="type">tree</field>
<field name="name">mirror_line_tree</field>
</record>
<record model="ir.action.wizard" id="act_create_contracts">
<field name="name">📝 Create contracts</field>
<field name="wiz_name">create.contracts</field>
<field name="model">lot.report</field>
</record>
<record model="ir.action.keyword" id="act_create_contract_keyword">
<field name="keyword">form_action</field>
<field name="model">lot.report,-1</field>
<field name="action" ref="act_create_contracts"/>
</record>
<!-- <record model="ir.action.wizard" id="matching_unit">
<record model="ir.action.keyword" id="act_create_contract_keyword">
<field name="keyword">form_action</field>
<field name="model">lot.report,-1</field>
<field name="action" ref="act_create_contracts"/>
</record>
<record model="ir.action.wizard" id="act_create_mirror">
<field name="name">Create mirror</field>
<field name="wiz_name">create.mirror</field>
<field name="model">lot.report</field>
</record>
<record model="ir.action.keyword" id="act_create_mirror_keyword">
<field name="keyword">form_action</field>
<field name="model">lot.report,-1</field>
<field name="action" ref="act_create_mirror"/>
</record>
<!-- <record model="ir.action.wizard" id="matching_unit">
<field name="name">Matching unit</field>
<field name="wiz_name">lot.matching_unit</field>
<field name="model">sale.line</field>

View File

@@ -959,14 +959,24 @@ class Sale(metaclass=PoolMeta):
@property
def report_melya_incoterm(self):
parts = []
if self.incoterm and self.incoterm.code:
parts.append(self.incoterm.code)
if self.incoterm_location:
if self.incoterm_location.party_name:
parts.append(self.incoterm_location.party_name)
if self.incoterm_location.country:
parts.append(self.incoterm_location.country.name)
if not self.incoterm or not self.incoterm.code:
return ''
code = self.incoterm.code
parts = [code]
if code.upper() not in {'CIF', 'CFR', 'CIP'}:
return code
if self.to_location:
location = (
getattr(self.to_location, 'party_name', None)
or getattr(self.to_location, 'rec_name', None)
or getattr(self.to_location, 'name', None)
)
if location:
parts.append(location)
country = getattr(self.to_location, 'country', None)
country_name = getattr(country, 'name', None)
if country_name:
parts.append(country_name)
return ' '.join(parts)
@staticmethod

View File

@@ -118,6 +118,111 @@ class ContractFactory:
Lot.assert_lines_quantity_consistency(lines_to_check)
return created
@classmethod
def create_mirror_contract(cls, header, lines, *, type_):
"""
Create one opposite contract with one line per selected open quantity.
:param header: object carrying contract header values
:param lines: iterable of mirror line wizard rows
:param type_: 'Purchase' or 'Sale'
:return: created Purchase or Sale
"""
pool = Pool()
Sale = pool.get('sale.sale')
Purchase = pool.get('purchase.purchase')
SaleLine = pool.get('sale.line')
PurchaseLine = pool.get('purchase.line')
Lot = pool.get('lot.lot')
Date = pool.get('ir.date')
lines = list(lines or [])
if (getattr(header, 'selected_source_count', None)
and len(lines) != header.selected_source_count):
raise UserError(
'Mirror creation must keep one line per selected open quantity.')
sources = cls._get_mirror_sources(lines, type_)
expected_source_ids = cls._mirror_expected_source_ids(header)
if expected_source_ids is not None:
actual_source_ids = [
getattr(source['lqt'], 'id', None) for source in sources]
if actual_source_ids != expected_source_ids:
raise UserError(
'Mirror creation must keep the selected open quantities.')
cls._validate_mirror_lines(lines, sources)
base_contract = cls._get_base_contract(sources, header, type_)
contract = Purchase() if type_ == 'Purchase' else Sale()
contract.currency = getattr(
getattr(header, 'currency', None), 'id',
getattr(header, 'currency', None)) or getattr(
getattr(base_contract, 'currency', None), 'id',
getattr(base_contract, 'currency', None)) or 1
contract.party = header.party
contract.crop = cls._get_crop(header, base_contract)
contract.tol_min = cls._get_tolerance(header, 'tol_min')
contract.tol_max = cls._get_tolerance(header, 'tol_max')
contract.payment_term = cls._get_payment_term(
header, base_contract, type_)
contract.reference = header.reference
from_location, to_location = cls._get_locations(
header, base_contract, type_)
contract.from_location = from_location
contract.to_location = to_location
context = Transaction().context
contract.company = context.get('company') if context else None
if type_ == 'Purchase':
contract.purchase_date = Date.today()
else:
contract.sale_date = Date.today()
cls._apply_party_data(contract, header.party, base_contract, type_)
if type_ == 'Sale':
contract.product_origin = getattr(base_contract, 'product_origin', None)
contract.incoterm = cls._get_contract_value(
header, base_contract, 'incoterm')
addresses = getattr(header.party, 'addresses', None)
if addresses:
contract.invoice_address = addresses[0]
if type_ == 'Sale':
contract.shipment_address = addresses[0]
contract.save()
lines_to_check = []
for mirror_line, source in zip(lines, sources):
line = PurchaseLine() if type_ == 'Purchase' else SaleLine()
quantity = cls._normalize_quantity(mirror_line.quantity)
line.quantity = quantity
line.quantity_theorical = quantity
line.product = mirror_line.product
line.unit = mirror_line.unit
line.price_type = mirror_line.price_type
line.created_by_code = True
line.premium = getattr(mirror_line, 'premium', None) or Decimal(0)
if type_ == 'Purchase':
line.purchase = contract.id
else:
line.sale = contract.id
line_parts = cls._get_currency_unit_parts(mirror_line, header)
cls._apply_price(line, mirror_line, line_parts)
cls._apply_delivery(line, mirror_line, source)
line.save()
cls._create_lot(line, mirror_line, source, type_)
lines_to_check.append(line)
if source.get('trade_line'):
lines_to_check.append(source['trade_line'])
Lot.assert_lines_quantity_consistency(lines_to_check)
return contract
# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------
@@ -322,6 +427,65 @@ class ContractFactory:
cls._validate_sources(sources, type_)
return sources
@classmethod
def _get_mirror_sources(cls, lines, type_):
sources = []
for line in lines:
lqt = getattr(line, 'source_lqt', None)
if not lqt:
raise UserError('Mirror lines must keep their source open quantity.')
lot = lqt.lot_s if type_ == 'Purchase' else lqt.lot_p
if not lot:
raise UserError('Mirror lines must keep their source open quantity.')
trade_line = (
lot.sale_line if type_ == 'Purchase' else lot.line
)
sources.append({
'lqt': lqt,
'lot': lot,
'trade_line': trade_line,
'quantity': cls._normalize_quantity(line.quantity),
'shipment_origin': lqt.lot_shipment_origin,
'use_source_delivery': False,
})
return sources
@staticmethod
def _mirror_expected_source_ids(header):
value = getattr(header, 'selected_source_ids', None)
if not isinstance(value, str) or not value:
return None
return [
int(source_id)
for source_id in value.split(',')
if source_id]
@classmethod
def _validate_mirror_lines(cls, lines, sources):
if len(lines) != len(sources):
raise UserError(
'Mirror creation must keep one line per selected open quantity.')
seen = set()
for line, source in zip(lines, sources):
lqt = source['lqt']
lqt_id = getattr(lqt, 'id', None)
if not lqt_id or lqt_id in seen:
raise UserError(
'Mirror creation must keep each selected open quantity once.')
seen.add(lqt_id)
lot = source['lot']
if getattr(lot, 'lot_type', None) != 'virtual':
raise UserError('Mirror creation only accepts open quantities.')
if bool(getattr(lqt, 'lot_p', None)) == bool(getattr(lqt, 'lot_s', None)):
raise UserError(
'Mirror creation only accepts unmatched open quantities.')
if cls._normalize_quantity(line.quantity) <= 0:
raise UserError('Mirror line quantity must be positive.')
available = cls._normalize_quantity(getattr(lqt, 'lot_quantity', 0))
if cls._normalize_quantity(line.quantity) > available:
raise UserError(
'The requested quantity exceeds the selected open quantity.')
@classmethod
def _validate_sources(cls, sources, type_):
if not sources:

View File

@@ -8344,6 +8344,45 @@ description</t></is></c>
self.assertEqual(invoice.report_contract_number, 'P-12/S-34')
def test_sale_melya_incoterm_uses_destination_for_cost_incoterms(self):
'sale_melya incoterm uses sale destination only for CIF/CFR/CIP'
Sale = Pool().get('sale.sale')
sale = Sale()
sale.incoterm = SimpleNamespace(code='CFR')
sale.incoterm_location = SimpleNamespace(
party_name='Ignored Port',
country=SimpleNamespace(name='Ignored Country'))
sale.to_location = SimpleNamespace(
rec_name='Mejillones',
country=SimpleNamespace(name='Chile'))
self.assertEqual(sale.report_melya_incoterm, 'CFR Mejillones Chile')
sale.incoterm = SimpleNamespace(code='FOB')
self.assertEqual(sale.report_melya_incoterm, 'FOB')
def test_invoice_melya_incoterm_uses_destination_for_cost_incoterms(self):
'invoice_melya incoterm uses sale destination only for CIF/CFR/CIP'
Invoice = Pool().get('account.invoice')
invoice = Invoice()
trade = SimpleNamespace(
incoterm=SimpleNamespace(code='CIP'),
incoterm_location=SimpleNamespace(party_name='Ignored Port'),
to_location=SimpleNamespace(
rec_name='Rotterdam',
country=SimpleNamespace(name='Netherlands')))
invoice._get_report_trade = Mock(return_value=trade)
self.assertEqual(
invoice.report_incoterm, 'CIP Rotterdam Netherlands')
trade.incoterm = SimpleNamespace(code='FCA')
self.assertEqual(invoice.report_incoterm, 'FCA')
def test_invoice_melya_bank_uses_sale_our_bank_account(self):
'invoice_melya bank block uses the sale company bank account'
Invoice = Pool().get('account.invoice')
@@ -8628,6 +8667,146 @@ description</t></is></c>
self.assertIsNone(
ContractFactory._get_shipment_origin(ContractsStartMock()))
def test_contract_factory_create_mirror_keeps_product_per_line(self):
'create mirror builds one opposite contract with one line per source'
product_a = Mock(id=10)
product_b = Mock(id=20)
unit_a = Mock(id=30)
unit_b = Mock(id=40)
purchase = Mock(
currency=Mock(id=1),
crop=None,
tol_min=Decimal('0'),
tol_max=Decimal('0'),
payment_term=Mock(),
reference='PUR-1',
from_location=Mock(type='supplier'),
to_location=Mock(type='storage'),
wb=None,
association=None,
certif=None,
incoterm=Mock(),
product_origin=None)
purchase_line_a = Mock(
product=product_a, unit=unit_a, purchase=purchase)
purchase_line_b = Mock(
product=product_b, unit=unit_b, purchase=purchase)
lot_a = Mock(
id=101, lot_type='virtual', line=purchase_line_a)
lot_b = Mock(
id=102, lot_type='virtual', line=purchase_line_b)
lqt_a = Mock(
id=201, lot_p=lot_a, lot_s=None,
lot_quantity=Decimal('100'),
lot_shipment_origin=None)
lqt_b = Mock(
id=202, lot_p=lot_b, lot_s=None,
lot_quantity=Decimal('200'),
lot_shipment_origin=None)
header = Mock(
selected_source_count=2,
selected_source_ids='201,202',
party=Mock(addresses=[]),
currency=Mock(id=1),
crop=None,
tol_min=Decimal('0'),
tol_max=Decimal('0'),
payment_term=Mock(),
reference='MIRROR',
from_location=None,
to_location=None,
incoterm=Mock())
mirror_lines = [
Mock(
source_lqt=lqt_a,
product=product_a,
unit=unit_a,
quantity=Decimal('100'),
price_type='priced',
price=Decimal('10'),
premium=Decimal('1'),
currency_unit='1_30',
del_period=None,
from_del=None,
to_del=None),
Mock(
source_lqt=lqt_b,
product=product_b,
unit=unit_b,
quantity=Decimal('200'),
price_type='priced',
price=Decimal('20'),
premium=Decimal('2'),
currency_unit='1_40',
del_period=None,
from_del=None,
to_del=None),
]
created_contracts = []
created_lines = []
def new_sale():
sale = Mock(id=301)
sale.save = Mock()
created_contracts.append(sale)
return sale
def new_sale_line():
line = Mock()
line.save = Mock()
created_lines.append(line)
return line
sale_model = Mock(side_effect=new_sale)
sale_line_model = Mock(side_effect=new_sale_line)
purchase_model = Mock()
purchase_line_model = Mock()
lot_model = Mock()
date_model = Mock()
date_model.today.return_value = '2026-07-27'
def get_model(name):
return {
'sale.sale': sale_model,
'purchase.purchase': purchase_model,
'sale.line': sale_line_model,
'purchase.line': purchase_line_model,
'lot.lot': lot_model,
'ir.date': date_model,
}[name]
with patch('trytond.modules.purchase_trade.service.Pool') as PoolMock, \
patch.object(
ContractFactory, '_create_lot') as create_lot:
PoolMock.return_value.get.side_effect = get_model
created = ContractFactory.create_mirror_contract(
header, mirror_lines, type_='Sale')
self.assertEqual(created, created_contracts[0])
self.assertEqual(len(created_contracts), 1)
self.assertEqual(len(created_lines), 2)
self.assertEqual(
[line.product for line in created_lines],
[product_a, product_b])
self.assertEqual(
[line.quantity for line in created_lines],
[Decimal('100.00000'), Decimal('200.00000')])
self.assertEqual(
[line.sale for line in created_lines],
[301, 301])
self.assertEqual(create_lot.call_count, 2)
lot_model.assert_lines_quantity_consistency.assert_called_once()
def test_contract_factory_create_mirror_rejects_removed_line(self):
'create mirror must keep one wizard line per selected open quantity'
header = Mock(selected_source_count=2, selected_source_ids='1,2')
line = Mock()
with self.assertRaises(UserError):
ContractFactory.create_mirror_contract(
header, [line], type_='Sale')
def test_custom_duplicate_resets_finished_line(self):
'custom duplicate keeps duplicated lots visible in lots management'
line = Mock(

View File

@@ -0,0 +1,14 @@
<tree editable="1">
<field name="source_lqt"/>
<field name="source_lot"/>
<field name="product"/>
<field name="price"/>
<field name="currency_unit"/>
<field name="quantity"/>
<field name="unit"/>
<field name="price_type"/>
<field name="premium"/>
<field name="del_period"/>
<field name="from_del"/>
<field name="to_del"/>
</tree>

View File

@@ -0,0 +1,34 @@
<form col="4">
<label name="type"/>
<field name="type"/>
<label name="selected_source_count"/>
<field name="selected_source_count"/>
<field name="selected_source_ids" invisible="1"/>
<newline/>
<label name="party"/>
<field name="party"/>
<label name="currency"/>
<field name="currency"/>
<newline/>
<label name="payment_term"/>
<field name="payment_term"/>
<label name="incoterm"/>
<field name="incoterm"/>
<newline/>
<label name="reference"/>
<field name="reference"/>
<label name="crop"/>
<field name="crop"/>
<newline/>
<label name="from_location"/>
<field name="from_location"/>
<label name="to_location"/>
<field name="to_location"/>
<newline/>
<label name="tol_min"/>
<field name="tol_min"/>
<label name="tol_max"/>
<field name="tol_max"/>
<newline/>
<field name="lines" colspan="4"/>
</form>

View File

@@ -4,6 +4,7 @@ this repository contains the full copyright notices and license terms. -->
<data>
<xpath expr="/form/group[@id='hd'][2]" position="replace"/>
<xpath expr="/form/group[@id='buttons']" position="replace"/>
<xpath expr="/form/group[@id='links']" position="replace"/>
<xpath expr="/form/field[@name='party_lang']" position="before">
<field name="allow_modification_after_validation" invisible="1"/>
</xpath>

View File

@@ -4,6 +4,7 @@ this repository contains the full copyright notices and license terms. -->
<data>
<xpath expr="/form/group[@id='hd'][2]" position="replace"/>
<xpath expr="/form/group[@id='buttons']" position="replace"/>
<xpath expr="/form/group[@id='links']" position="replace"/>
<xpath expr="/form/field[@name='party_lang']" position="before">
<field name="allow_modification_after_validation" invisible="1"/>
</xpath>