Files
tradon/modules/purchase_trade/service.py
2026-04-02 11:33:49 +02:00

330 lines
12 KiB
Python

# -*- coding: utf-8 -*-
from decimal import Decimal
import logging
from trytond.pool import Pool
from trytond.transaction import Transaction
from trytond.exceptions import UserError
logger = logging.getLogger(__name__)
class ContractFactory:
"""
Factory métier pour créer des Purchase depuis Sale
ou des Sale depuis Purchase.
Compatible :
- Wizard (n contrats)
- Appel direct depuis un modèle (1 contrat)
"""
@classmethod
def create_contracts(cls, contracts, *, type_, ct):
"""
:param contracts: iterable de contracts (wizard lines)
:param type_: 'Purchase' ou 'Sale'
:param ct: objet contenant le contexte (lot, product, unit, matched...)
:return: liste des contracts créés
"""
pool = Pool()
Sale = pool.get('sale.sale')
Purchase = pool.get('purchase.purchase')
SaleLine = pool.get('sale.line')
PurchaseLine = pool.get('purchase.line')
Date = pool.get('ir.date')
created = []
sources = cls._get_sources(ct, type_)
base_contract = cls._get_base_contract(sources, ct, type_)
for c in contracts:
contract = Purchase() if type_ == 'Purchase' else Sale()
# ---------- CONTRACT ----------
parts = c.currency_unit.split("_")
contract.currency = int(parts[0]) or 1
contract.party = c.party
contract.crop = c.crop
contract.tol_min = c.tol_min
contract.tol_max = c.tol_max
contract.payment_term = c.payment_term
contract.reference = c.reference
contract.from_location = c.from_location
contract.to_location = c.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_locations(contract, base_contract, type_)
cls._apply_party_data(contract, c.party, type_)
cls._apply_payment_term(contract, c.party, type_)
if type_ == 'Sale':
contract.product_origin = getattr(base_contract, 'product_origin', None)
contract.incoterm = c.incoterm
if c.party.addresses:
contract.invoice_address = c.party.addresses[0]
if type_ == 'Sale':
contract.shipment_address = c.party.addresses[0]
contract.save()
line_sources = cls._get_line_sources(c, sources, ct)
for source in line_sources:
line = PurchaseLine() if type_ == 'Purchase' else SaleLine()
# ---------- LINE ----------
line.quantity = source['quantity']
line.quantity_theorical = source['quantity']
line.product = ct.product
line.unit = ct.unit
line.price_type = c.price_type
line.created_by_code = ct.matched
line.premium = Decimal(0)
if type_ == 'Purchase':
line.purchase = contract.id
else:
line.sale = contract.id
cls._apply_price(line, c, parts)
cls._apply_delivery(line, c, source)
line.save()
logger.info("CREATE_ID:%s", contract.id)
logger.info("CREATE_LINE_ID:%s", line.id)
if ct.matched:
cls._create_lot(line, c, source, type_)
created.append(contract)
return created
# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------
@staticmethod
def _apply_locations(contract, base, type_):
if not (base.from_location and base.to_location):
return
if type_ == 'Purchase':
contract.to_location = base.from_location
else:
contract.from_location = base.to_location
if (base.from_location.type == 'supplier'
and base.to_location.type == 'customer'):
contract.from_location = base.from_location
contract.to_location = base.to_location
@staticmethod
def _apply_party_data(contract, party, type_):
if party.wb:
contract.wb = party.wb
if party.association:
contract.association = party.association
@staticmethod
def _apply_payment_term(contract, party, type_):
if type_ == 'Purchase' and party.supplier_payment_term:
contract.payment_term = party.supplier_payment_term
elif type_ == 'Sale' and party.customer_payment_term:
contract.payment_term = party.customer_payment_term
@staticmethod
def _apply_price(line, c, parts):
if int(parts[0]) == 0:
line.enable_linked_currency = True
line.linked_currency = 1
line.linked_unit = int(parts[1])
line.linked_price = c.price
line.unit_price = line.get_price_linked_currency()
else:
line.unit_price = c.price if c.price else Decimal(0)
@staticmethod
def _apply_delivery(line, c, source):
source_line = source.get('trade_line')
if source.get('use_source_delivery') and source_line:
line.del_period = getattr(source_line, 'del_period', None)
line.from_del = getattr(source_line, 'from_del', None)
line.to_del = getattr(source_line, 'to_del', None)
return
line.del_period = c.del_period
line.from_del = c.from_del
line.to_del = c.to_del
@staticmethod
def _normalize_quantity(quantity):
return abs(Decimal(str(quantity or 0))).quantize(Decimal('0.00001'))
@classmethod
def _get_base_contract(cls, sources, ct, type_):
if sources:
source_lot = sources[0]['lot']
return (
source_lot.sale_line.sale
if type_ == 'Purchase'
else source_lot.line.purchase
)
return (
ct.lot.sale_line.sale
if type_ == 'Purchase'
else ct.lot.line.purchase
)
@classmethod
def _get_sources(cls, ct, type_):
pool = Pool()
LotQt = pool.get('lot.qt')
context = Transaction().context or {}
active_ids = context.get('active_ids') or []
sources = []
if active_ids:
for record_id in active_ids:
if record_id < 10000000:
continue
lqt = LotQt(record_id - 10000000)
lot = lqt.lot_p or lqt.lot_s
if not lot:
continue
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(lqt.lot_quantity),
'shipment_origin': lqt.lot_shipment_origin,
})
elif getattr(ct, 'lot', None):
lot = ct.lot
trade_line = (
lot.sale_line if type_ == 'Purchase' else lot.line
)
sources.append({
'lqt': None,
'lot': lot,
'trade_line': trade_line,
'quantity': cls._normalize_quantity(getattr(ct, 'quantity', 0)),
'shipment_origin': cls._get_shipment_origin(ct),
})
cls._validate_sources(sources, type_)
return sources
@classmethod
def _validate_sources(cls, sources, type_):
if not sources:
return
first_line = sources[0]['trade_line']
for source in sources[1:]:
line = source['trade_line']
if bool(getattr(line, 'sale', None)) != bool(getattr(first_line, 'sale', None)):
raise UserError('Selected lots must all come from the same side.')
if getattr(line.product, 'id', None) != getattr(first_line.product, 'id', None):
raise UserError('Selected lots must share the same product.')
if getattr(line.unit, 'id', None) != getattr(first_line.unit, 'id', None):
raise UserError('Selected lots must share the same unit.')
@classmethod
def _get_line_sources(cls, contract_detail, sources, ct):
if not ct.matched or len(sources) <= 1:
quantity = cls._normalize_quantity(contract_detail.quantity)
source = sources[0] if sources else {
'lot': getattr(ct, 'lot', None),
'trade_line': None,
'shipment_origin': cls._get_shipment_origin(ct),
}
return [{
**source,
'quantity': quantity,
'use_source_delivery': False,
}]
selected_total = sum(source['quantity'] for source in sources)
requested = cls._normalize_quantity(contract_detail.quantity)
if requested != selected_total:
raise UserError(
'For multi-lot matched creation, quantity must equal the total selected open quantity.'
)
return [{
**source,
'use_source_delivery': True,
} for source in sources]
# -------------------------------------------------------------------------
# LOT / MATCHING (repris tel quel du wizard)
# -------------------------------------------------------------------------
@classmethod
def _create_lot(cls, line, c, source, type_):
pool = Pool()
Lot = pool.get('lot.lot')
LotQtHist = pool.get('lot.qt.hist')
LotQtType = pool.get('lot.qt.type')
lot = Lot()
if type_ == 'Purchase':
lot.line = line.id
else:
lot.sale_line = line.id
lot.lot_qt = None
lot.lot_unit = None
lot.lot_unit_line = line.unit
lot.lot_quantity = round(line.quantity, 5)
lot.lot_gross_quantity = None
lot.lot_status = 'forecast'
lot.lot_type = 'virtual'
lot.lot_product = line.product
lqtt = LotQtType.search([('sequence', '=', 1)])
if lqtt:
lqh = LotQtHist()
lqh.quantity_type = lqtt[0]
lqh.quantity = round(lot.lot_quantity, 5)
lqh.gross_quantity = round(lot.lot_quantity, 5)
lot.lot_hist = [lqh]
lot.save()
vlot = source['lot']
shipment_origin = source.get('shipment_origin')
qt = source['quantity']
if type_ == 'Purchase':
if not lot.updateVirtualPart(qt, shipment_origin, vlot):
lot.createVirtualPart(qt, shipment_origin, vlot)
# Decrease forecasted virtual part non matched
lot.updateVirtualPart(-qt, shipment_origin, vlot, 'only sale')
else:
if not vlot.updateVirtualPart(qt, shipment_origin, lot):
vlot.createVirtualPart(qt, shipment_origin, lot)
# Decrease forecasted virtual part non matched
vlot.updateVirtualPart(-qt, shipment_origin, None)
@staticmethod
def _get_shipment_origin(ct):
if ct.shipment_in:
return 'stock.shipment.in,%s' % ct.shipment_in.id
if ct.shipment_internal:
return 'stock.shipment.internal,%s' % ct.shipment_internal.id
if ct.shipment_out:
return 'stock.shipment.out,%s' % ct.shipment_out.id
return None