Create mirror action
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'ajouter, supprimer ou remplacer les lignes sources selectionnees; le serveur revalide ces invariants a la creation.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
14
modules/purchase_trade/view/mirror_line_tree.xml
Normal file
14
modules/purchase_trade/view/mirror_line_tree.xml
Normal 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>
|
||||
34
modules/purchase_trade/view/mirror_start_form.xml
Normal file
34
modules/purchase_trade/view/mirror_start_form.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user