Files
tradon/modules/purchase_trade/purchase.py

3656 lines
139 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
from functools import wraps
from trytond.model import ModelSingleton, ModelSQL, ModelView, Workflow, fields
from trytond.i18n import gettext
from trytond.report import Report
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval, Id, If, PYSONEncoder
from trytond.model import (ModelSQL, ModelView)
from trytond.tools import (cursor_dict, is_full_text, lstrip_wildcard)
from trytond.transaction import Transaction, inactive_records
from decimal import getcontext, Decimal, ROUND_HALF_UP
from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr
from sql.conditionals import Case, Coalesce
from sql import Column, Literal
from sql.functions import CurrentTimestamp, DateTrunc, Abs
from trytond.wizard import Button, StateTransition, StateView, Wizard, StateAction
from itertools import chain, groupby
from operator import itemgetter
import datetime
import logging
import json
import re
import jwt
from collections import defaultdict
from trytond.exceptions import UserWarning, UserError
from trytond.modules.purchase_trade.numbers_to_words import quantity_to_words, amount_to_currency_words, format_date_en
from trytond.modules.purchase_trade.company_defaults import (
add_itsa_analytic_dimension_defaults,
default_itsa_analytic_dimension_assignments, default_itsa_currency,
default_itsa_unit, is_itsa_company, record_id)
import requests
import io
logger = logging.getLogger(__name__)
TRIGGERS = [
('bldate', 'BL date'),
('invdate', 'Invoice date'),
('ctdate', 'Ct. date'),
('prdate', 'Pur. date'),
('cod', 'COD date'),
('border', 'Border crossing date'),
('pump', 'Pump date'),
('discharge', 'Discharge NOR'),
('arrival', 'Arrival date'),
('delmonth', 'Delivery month'),
('deldate', 'Delivery date'),
]
class DocType(ModelSQL,ModelView):
"Document Type"
__name__ = 'document.type'
name = fields.Char('Name')
class ContractDocumentType(ModelSQL):
"Contract - Document Type"
__name__ = 'contract.document.type'
doc_type = fields.Many2One('document.type', 'Document Type')
purchase = fields.Many2One('purchase.purchase', "Purchase")
class DocTemplate(ModelSQL,ModelView):
"Documents Template"
__name__ = 'doc.template'
name = fields.Char('Name')
type = fields.Many2Many('doc.type.template','template','type',"Document Type")
class DocTypeTemplate(ModelSQL):
"Template - Document Type"
__name__ = 'doc.type.template'
template = fields.Many2One('doc.template')
type = fields.Many2One('document.type')
class PurchaseStrategy(ModelSQL):
"Purchase - Document Type"
__name__ = 'purchase.strategy'
line = fields.Many2One('purchase.line', 'Purchase Line')
strategy = fields.Many2One('mtm.strategy', "Strategy")
class Estimated(metaclass=PoolMeta):
"Estimated date"
__name__ = 'pricing.estimated'
shipment_in = fields.Many2One('stock.shipment.in')
shipment_out = fields.Many2One('stock.shipment.out')
shipment_internal = fields.Many2One('stock.shipment.internal')
purchase = fields.Many2One('purchase.purchase',"Purchase")
line = fields.Many2One('purchase.line',"Line")
class Currency(metaclass=PoolMeta):
"Currency"
__name__ = 'currency.currency'
concatenate = fields.Boolean("Concatenate")
@classmethod
def default_concatenate(cls):
return False
class Unit(metaclass=PoolMeta):
"Unit"
__name__ = 'product.uom'
concatenate = fields.Boolean("Concatenate")
@classmethod
def default_concatenate(cls):
return False
class FeeLots(metaclass=PoolMeta):
"Fee lots"
__name__ = 'fee.lots'
line = fields.Many2One('purchase.line',"Line", ondelete='CASCADE')
class Component(metaclass=PoolMeta):
"Component"
__name__ = 'pricing.component'
line = fields.Many2One('purchase.line',"Line")
quota = fields.Function(fields.Numeric("Quota",digits='unit'),'get_quota_purchase')
unit = fields.Function(fields.Many2One('product.uom',"Unit"),'get_unit_purchase')
def getDelMonthDatePurchase(self):
PM = Pool().get('product.month')
if self.line.del_period:
pm = PM(self.line.del_period)
if pm:
return pm.beg_date
def getEstimatedTriggerPurchase(self,t):
if t == 'delmonth':
return self.getDelMonthDatePurchase()
if self.line and hasattr(self.line, 'get_date'):
trigger_date = self.line.get_date(t)
if trigger_date:
return trigger_date
PE = Pool().get('pricing.estimated')
Date = Pool().get('ir.date')
pe = PE.search([('line','=',self.line),('trigger','=',t)])
if pe:
return pe[0].estimated_date
else:
return Date.today()
def get_unit_purchase(self, name):
if self.line:
return self.line.unit
def get_quota_purchase(self, name):
if self.line:
quantity = getattr(self.line, 'quantity_theorical', None)
if quantity is None:
quantity = getattr(self.line, 'quantity', None)
if quantity is not None:
nbdays = self.nbdays if self.nbdays and self.nbdays > 0 else 1
return round(Decimal(quantity) / nbdays, 5)
class Pricing(metaclass=PoolMeta):
"Pricing"
__name__ = 'pricing.pricing'
line = fields.Many2One('purchase.line',"Lines")
unit = fields.Function(fields.Many2One('product.uom',"Unit"),'get_unit_purchase')
def get_unit_purchase(self,name):
if self.line:
return self.line.unit
def get_eod_price_purchase(self):
return self._weighted_average_price(
self.fixed_qt,
self.fixed_qt_price,
self.unfixed_qt,
self.unfixed_qt_price,
)
class Summary(ModelSQL,ModelView):
"Pricing summary"
__name__ = 'purchase.pricing.summary'
line = fields.Many2One('purchase.line',"Lines")
price_component = fields.Many2One('pricing.component',"Component")
quantity = fields.Numeric("Qt",digits=(1,5))
fixed_qt = fields.Numeric("Fixed qt",digits=(1,5))
unfixed_qt = fields.Numeric("Unfixed qt",digits=(1,5))
price = fields.Numeric("Price",digits=(1,4))
progress = fields.Float("Fix. progress")
ratio = fields.Numeric("Ratio")
def get_name(self):
if self.price_component:
return self.price_component.get_rec_name()
return ""
def get_last_price(self):
Date = Pool().get('ir.date')
if self.price_component:
pc = Pool().get('pricing.component')(self.price_component)
if pc.price_index:
PI = Pool().get('price.price')
pi = PI(pc.price_index)
return pi.get_price(Date.today(),self.line.unit,self.line.purchase.currency,True)
@classmethod
def table_query(cls):
PurchasePricing = Pool().get('pricing.pricing')
pp = PurchasePricing.__table__()
PurchaseComponent = Pool().get('pricing.component')
pc = PurchaseComponent.__table__()
#wh = Literal(True)
context = Transaction().context
group_pnl = context.get('group_pnl')
if group_pnl:
return None
query = pp.join(pc,'LEFT',condition=pp.price_component == pc.id).select(
Literal(0).as_('create_uid'),
CurrentTimestamp().as_('create_date'),
Literal(None).as_('write_uid'),
Literal(None).as_('write_date'),
Literal(None).as_('sale_line'),
Max(pp.id).as_('id'),
pp.line.as_('line'),
pp.price_component.as_('price_component'),
Max(pp.fixed_qt+pp.unfixed_qt).as_('quantity'),
Max(pp.fixed_qt).as_('fixed_qt'),
(Min(pp.unfixed_qt)).as_('unfixed_qt'),
Max(Case((pp.last, pp.eod_price),else_=0)).as_('price'),
(Max(pp.fixed_qt)/Max(pp.fixed_qt+pp.unfixed_qt)).as_('progress'),
Max(pc.ratio).as_('ratio'),
#where=wh,
group_by=[pp.line,pp.price_component])
return query
class StockLocation(metaclass=PoolMeta):
__name__ = 'stock.location'
lat = fields.Numeric("Latitude")
lon = fields.Numeric("Longitude")
class PurchaseCertification(ModelSQL,ModelView):
"Certification"
__name__ = 'purchase.certification'
name = fields.Char("Name")
class PurchaseCertificationWeightBasis(ModelSQL,ModelView):
"Weight basis"
__name__ = 'purchase.weight.basis'
name = fields.Char("Name")
qt_type = fields.Many2One('lot.qt.type',"Associated type to final invoice")
description = fields.Char("Description")
class PurchaseAssociation(ModelSQL,ModelView):
"Association"
__name__ = 'purchase.association'
name = fields.Char("Name")
party = fields.Many2One('party.party',"Party")
rule = fields.Text("Description")
class PurchaseCrop(ModelSQL,ModelView):
"Crop"
__name__ = 'purchase.crop'
name = fields.Char("Name")
class Purchase(metaclass=PoolMeta):
__name__ = 'purchase.purchase'
@classmethod
def __setup__(cls):
super().__setup__()
cls._transitions.discard(('confirmed', 'processing'))
cls.lines.states['readonly'] = (
(Eval('state') != 'draft')
& ((Eval('state') != 'quotation')
| ~Eval('allow_modification_after_validation')))
cls._buttons['process'] = {
'invisible': True,
'depends': ['state'],
}
@classmethod
@ModelView.button
@Workflow.transition('confirmed')
def confirm(cls, purchases):
pool = Pool()
Line = pool.get('purchase.line')
cls.set_purchase_date(purchases)
lines = list(sum((p.lines for p in purchases), ()))
Line._validate(lines, ['unit_price'])
cls.store_cache(purchases)
@classmethod
@ModelView.button
def process(cls, purchases):
purchases = [p for p in purchases if p.state != 'confirmed']
if purchases:
super().process(purchases)
@classmethod
def create(cls, vlist):
for values in vlist:
add_itsa_analytic_dimension_defaults(values)
return super().create(vlist)
@classmethod
def default_analytic_dimensions(cls):
default = getattr(super(), 'default_analytic_dimensions', None)
values = default() if default else []
return values or default_itsa_analytic_dimension_assignments()
btb = fields.Many2One('back.to.back',"Back to back")
charter_conditions = fields.One2Many(
'charter.condition', 'purchase', "Charter Conditions")
contract_template = fields.Many2One(
'contract.template', "Contract Template",
domain=[
('direction', 'in', ['both', 'purchase']),
('active', '=', True),
])
contract_clauses = fields.One2Many(
'contract.clause.selection', 'purchase', "Contract Clauses")
bank_accounts = fields.Function(
fields.Many2Many('bank.account', None, None, "Bank Accounts"),
'on_change_with_bank_accounts')
bank_account = fields.Many2One(
'bank.account', "Supplier Bank Account",
domain=[('id', 'in', Eval('bank_accounts', []))],
depends=['bank_accounts'])
our_bank_account = fields.Many2One(
'bank.account', "Our Bank Account")
from_location = fields.Many2One('stock.location', 'From location', required=True,domain=[('type', "!=", 'customer')])
to_location = fields.Many2One('stock.location', 'To location', required=True,domain=[('type', "!=", 'supplier')])
first_line_product = fields.Function(
fields.Many2One('product.product', "Product"),
'get_first_line_product')
first_line_price_type = fields.Function(fields.Selection([
('cash', 'Cash Price'),
('priced', 'Priced'),
('basis', 'Basis'),
('efp', 'EFP'),
], "Price Type"), 'get_first_line_price_type')
first_line_del_period = fields.Function(
fields.Many2One('product.month', "Shipment Period"),
'get_first_line_del_period')
first_line_from_del = fields.Function(
fields.Date("Delivery From"), 'get_first_line_from_del')
first_line_to_del = fields.Function(
fields.Date("Delivery To"), 'get_first_line_to_del')
shipment_in = fields.Many2One('stock.shipment.in','Purchases')
broker = fields.Many2One('party.party',"Broker",domain=[('categories.parent', 'child_of', [4])])
tol_min = fields.Numeric("Tol - in %", required=True)
tol_max = fields.Numeric("Tol + in %", required=True)
tol_min_qt = fields.Numeric("Tol -")
tol_max_qt = fields.Numeric("Tol +")
tolerance_used = fields.Function(
fields.Numeric("Tolerance used"), 'get_tolerance_used')
tolerance_option = fields.Selection([
('', ''),
('buyer_option', "Buyer's option"),
('seller_option', "Seller's option"),
], "Tolerance option")
tolerance_min = fields.Function(
fields.Numeric("Tolerance min"), 'get_tolerance_min')
tolerance_max = fields.Function(
fields.Numeric("Tolerance max"), 'get_tolerance_max')
certif = fields.Many2One('purchase.certification',"Certification", required=True,states={'invisible': Eval('company_visible'),})
wb = fields.Many2One('purchase.weight.basis',"Weight basis", required=True)
association = fields.Many2One('purchase.association',"Association", required=True,states={'invisible': Eval('company_visible'),})
crop = fields.Many2One('purchase.crop',"Crop",states={'invisible': Eval('company_visible'),})
pnl = fields.One2Many('valuation.valuation.dyn', 'r_purchase', 'Pnl',states={'invisible': ~Eval('group_pnl'),})
pnl_ = fields.One2Many('valuation.valuation.line', 'purchase', 'Pnl',states={'invisible': Eval('group_pnl'),})
pnl_graph = fields.One2Many(
'purchase.pnl.graph', 'purchase', 'Pnl graph')
derivatives = fields.One2Many('derivative.derivative', 'purchase', 'Derivative')
plans = fields.One2Many('workflow.plan','purchase',"Execution plans")
forex = fields.One2Many('forex.cover.physical.contract','contract',"Forex",readonly=True)
plan = fields.Many2One('workflow.plan',"Name")
estimated_date = fields.One2Many('pricing.estimated','purchase',"Estimated date")
group_pnl = fields.Boolean("Group Pnl")
viewer = fields.Function(fields.Text(""),'get_viewer')
execution_summary_viewer = fields.Function(
fields.Text(""), 'get_execution_summary_viewer')
doc_template = fields.Many2One('doc.template',"Template")
required_documents = fields.Many2Many(
'contract.document.type', 'purchase', 'doc_type', 'Required Documents')
analytic_dimensions = fields.One2Many(
'analytic.dimension.assignment',
'purchase',
'Analytic Dimensions'
)
allow_modification_after_validation = fields.Function(
fields.Boolean("Autorise modification after validation"),
'on_change_with_allow_modification_after_validation')
trader = fields.Many2One(
'party.party', "Trader",
domain=[('categories.name', '=', 'TRADER')])
operator = fields.Many2One(
'party.party', "Operator",
domain=[('categories.name', '=', 'OPERATOR')])
our_reference = fields.Char("Our Reference")
company_visible = fields.Function(
fields.Boolean("Visible"), 'on_change_with_company_visible')
lc_date = fields.Date("LC date")
product_origin = fields.Char(
"Origin", states={'invisible': ~Eval('company_visible'),})
@fields.depends('company', '_parent_company.party')
def on_change_with_company_visible(self, name=None):
return bool(
self.company and self.company.party
and self.company.party.name in {'MELYA', 'ITSA'})
def on_change_with_allow_modification_after_validation(self, name=None):
Configuration = Pool().get('purchase.configuration')
configurations = Configuration.search([], limit=1)
return bool(
configurations
and configurations[0].allow_modification_after_validation)
def _get_default_bank_account(self):
if not self.party or not self.party.bank_accounts:
return None
party_bank_accounts = list(self.party.bank_accounts)
if self.currency:
for account in party_bank_accounts:
if account.currency == self.currency:
return account
return party_bank_accounts[0]
def _get_default_our_bank_account(self):
if (not self.company or not self.company.party
or not self.company.party.bank_accounts):
return None
company_bank_accounts = list(self.company.party.bank_accounts)
if self.currency:
for account in company_bank_accounts:
if account.currency == self.currency:
return account
return company_bank_accounts[0]
@fields.depends('party', '_parent_party.bank_accounts')
def on_change_with_bank_accounts(self, name=None):
if self.party and self.party.bank_accounts:
return [account.id for account in self.party.bank_accounts]
return []
@fields.depends(
'company', 'party', 'invoice_party', 'payment_term', 'lines',
'bank_account', 'our_bank_account', '_parent_party.bank_accounts',
'_parent_company.party')
def on_change_party(self):
super().on_change_party()
self.bank_account = self._get_default_bank_account()
self.our_bank_account = self._get_default_our_bank_account()
@fields.depends(
'party', 'company', 'currency', '_parent_party.bank_accounts',
'_parent_company.party')
def on_change_currency(self):
self.bank_account = self._get_default_bank_account()
self.our_bank_account = self._get_default_our_bank_account()
@fields.depends('company', 'currency', 'our_bank_account',
'_parent_company.party')
def on_change_company(self):
previous_currency = self.currency
super().on_change_company()
if (is_itsa_company(self.company)
and (not previous_currency
or record_id(self.currency)
== record_id(getattr(self.company, 'currency', None))
or record_id(previous_currency)
== record_id(getattr(self.company, 'currency', None)))):
currency = default_itsa_currency()
if currency:
self.currency = currency
self.our_bank_account = self._get_default_our_bank_account()
@classmethod
def default_currency(cls, **pattern):
company = pattern.get('company')
if company and not hasattr(company, 'party'):
company = Pool().get('company.company')(company)
if is_itsa_company(company):
currency = default_itsa_currency()
if currency:
return currency
default = getattr(super(), 'default_currency', None)
if default:
return default(**pattern)
@classmethod
def default_wb(cls):
WB = Pool().get('purchase.weight.basis')
wb = WB.search(['id','>',0])
if wb:
return wb[0].id
@classmethod
def default_certif(cls):
Certification = Pool().get('purchase.certification')
certification = Certification.search(['id','>',0])
if certification:
return certification[0].id
@classmethod
def default_association(cls):
Association = Pool().get('purchase.association')
association = Association.search(['id','>',0])
if association:
return association[0].id
@classmethod
def default_tol_min(cls):
return 0
@classmethod
def default_tol_max(cls):
return 0
@classmethod
def default_tolerance_option(cls):
return ''
def _line_quantity_in_unit(self, quantity, from_unit, to_unit):
if not from_unit or not to_unit or from_unit == to_unit:
return Decimal(str(quantity or 0))
Uom = Pool().get('product.uom')
factor = None
rate = None
if from_unit.category.id != to_unit.category.id:
factor = 1
rate = 1
return Decimal(str(Uom.compute_qty(
from_unit, float(quantity or 0), to_unit, True, factor, rate)))
def _line_lot_quantity_for_tolerance(self, line):
LotQt = Pool().get('lot.qt')
quantity = Decimal(0)
unit = getattr(line, 'unit', None)
physical_lots = [
lot for lot in getattr(line, 'lots', None) or []
if getattr(lot, 'lot_type', None) == 'physic']
if physical_lots:
return sum(
Decimal(str(
lot.get_current_quantity_converted(unit=unit) or 0))
for lot in physical_lots)
for lot in getattr(line, 'lots', None) or []:
if getattr(lot, 'lot_type', None) != 'virtual':
continue
for lqt in LotQt.search([('lot_p', '=', lot.id)]):
quantity += self._line_quantity_in_unit(
getattr(lqt, 'lot_quantity', 0),
getattr(lqt, 'lot_unit', None),
unit)
return quantity
def _tolerance_totals(self):
theoretical = Decimal(0)
actual = Decimal(0)
for line in self.lines or []:
if getattr(line, 'type', None) != 'line':
continue
theoretical += Decimal(str(
getattr(line, 'quantity_theorical', None)
or getattr(line, 'quantity', None)
or 0))
if hasattr(line, '_tolerance_targeted_quantity'):
actual += line._tolerance_targeted_quantity()
else:
actual += self._line_lot_quantity_for_tolerance(line)
return theoretical, actual
def get_tolerance_used(self, name):
theoretical, actual = self._tolerance_totals()
if not theoretical:
return Decimal(0)
return round(
((actual - theoretical) / theoretical) * Decimal(100), 5)
def get_tolerance_min(self, name):
return -Decimal(str(self.tol_min or 0))
def get_tolerance_max(self, name):
return Decimal(str(self.tol_max or 0))
@staticmethod
def _has_matched_physical_lots(purchase):
for line in purchase.lines or []:
for lot in line.lots or []:
if (
getattr(lot, 'lot_type', None) == 'physic'
and getattr(lot, 'line', None)
and getattr(lot, 'sale_line', None)):
return True
return False
def _get_first_trade_line(self):
for line in self.lines or []:
if getattr(line, 'type', None) == 'line':
return line
@staticmethod
def _record_id(record):
return getattr(record, 'id', record) if record else None
def get_first_line_product(self, name):
line = self._get_first_trade_line()
return self._record_id(getattr(line, 'product', None))
def get_first_line_price_type(self, name):
line = self._get_first_trade_line()
return getattr(line, 'price_type', None) if line else None
def get_first_line_del_period(self, name):
line = self._get_first_trade_line()
return self._record_id(getattr(line, 'del_period', None))
def get_first_line_from_del(self, name):
line = self._get_first_trade_line()
return getattr(line, 'from_del', None) if line else None
def get_first_line_to_del(self, name):
line = self._get_first_trade_line()
return getattr(line, 'to_del', None) if line else None
@classmethod
def delete(cls, purchases):
for purchase in purchases:
if cls._has_matched_physical_lots(purchase):
raise UserError(
"You cannot delete a purchase matched to a sale")
super().delete(purchases)
@property
def report_terms(self):
if self.lines:
return self.lines[0].note
else:
return ''
@property
def report_qt(self):
if self.lines:
return quantity_to_words(self.lines[0].quantity)
else:
return ''
@property
def report_price(self):
if self.lines:
if self.lines[0].price_type == 'priced':
return amount_to_currency_words(self.lines[0].unit_price)
elif self.lines[0].price_type == 'basis':
return amount_to_currency_words(self.lines[0].unit_price)
else:
return ''
@staticmethod
def _format_report_number(value, digits='0.0000',
strip_trailing_zeros=True):
value = Decimal(str(value or 0)).quantize(Decimal(digits))
text = format(value, 'f')
if strip_trailing_zeros:
text = text.rstrip('0').rstrip('.')
return text or '0'
def _get_report_lines(self):
return [
line for line in self.lines or []
if getattr(line, 'type', None) == 'line']
def _get_report_first_line(self):
lines = self._get_report_lines()
return lines[0] if lines else None
@property
def report_commission_invoice_number(self):
return self.reference or self.full_number or self.number or ''
@property
def report_commission_contract_number(self):
return self.full_number or self.number or ''
def _get_report_commission_fee(self):
fallback = None
for line in self._get_report_lines():
for fee in getattr(line, 'fees', []) or []:
if fallback is None:
fallback = fee
product = getattr(fee, 'product', None)
product_name = (
getattr(product, 'rec_name', None)
or getattr(product, 'name', None) or '')
if 'commission' in product_name.lower():
return fee
return fallback
def _get_report_commission_shipment(self):
for line in self._get_report_lines():
for lot in getattr(line, 'lots', []) or []:
shipment = (
getattr(lot, 'lot_shipment_in', None)
or getattr(lot, 'lot_shipment_out', None)
or getattr(lot, 'lot_shipment_internal', None))
if shipment:
return shipment
@property
def report_commission_bl_number(self):
shipment = self._get_report_commission_shipment()
return getattr(shipment, 'bl_number', None) or ''
@property
def report_commission_bl_date(self):
shipment = self._get_report_commission_shipment()
return getattr(shipment, 'bl_date', None)
@property
def report_commission_vessel(self):
shipment = self._get_report_commission_shipment()
vessel = getattr(shipment, 'vessel', None) if shipment else None
return (
getattr(vessel, 'vessel_name', None)
or getattr(shipment, 'vessel_name', None)
or '')
@property
def report_commission_quantity(self):
return sum(
Decimal(str(getattr(line, 'quantity', 0) or 0))
for line in self._get_report_lines())
@property
def report_commission_quantity_display(self):
return self._format_report_number(
self.report_commission_quantity, digits='0.01',
strip_trailing_zeros=False)
@property
def report_commission_quantity_unit_upper(self):
line = self._get_report_first_line()
unit = getattr(line, 'unit', None) if line else None
return (getattr(unit, 'rec_name', None) or '').upper()
@staticmethod
def _find_report_unit(name):
Uom = Pool().get('product.uom')
units = Uom.search([
'OR',
('name', '=', name),
('symbol', '=', name),
], limit=1)
return units[0] if units else None
@property
def report_commission_lbs_display(self):
line = self._get_report_first_line()
unit = getattr(line, 'unit', None) if line else None
lbs_unit = self._find_report_unit('LBS') or self._find_report_unit('LB')
if not unit or not lbs_unit:
return ''
Uom = Pool().get('product.uom')
converted = Uom.compute_qty(
unit, float(self.report_commission_quantity), lbs_unit) or 0
return self._format_report_number(
Decimal(str(converted)), digits='0.01')
@property
def report_commission_base_amount_display(self):
return self._format_report_number(
self.untaxed_amount or self.total_amount or 0,
digits='0.01', strip_trailing_zeros=False)
@property
def report_commission_currency_name(self):
return getattr(self.currency, 'name', None) or getattr(
self.currency, 'code', None) or ''
@property
def report_commission_invoice_line(self):
return ' '.join(part for part in [
'Our invoice N.',
self.report_commission_invoice_number,
self.report_commission_currency_name,
self.report_commission_base_amount_display,
] if part)
@property
def report_commission_rate_line(self):
fee = self._get_report_commission_fee()
base = self.report_commission_base_amount_display
currency = self.report_commission_currency_name
if fee:
price = self._format_report_number(
getattr(fee, 'price', 0) or 0, digits='0.0000')
if getattr(fee, 'mode', None) in {'pprice', 'rate', 'pcost'}:
return ' '.join(part for part in [
price, '% on', base, currency,
] if part)
fee_currency = getattr(fee, 'currency', None)
fee_unit = getattr(fee, 'unit', None)
return ' '.join(part for part in [
price,
getattr(fee_currency, 'name', None) or getattr(
fee_currency, 'code', None) or '',
'/',
(getattr(fee_unit, 'rec_name', None) or '').upper(),
] if part)
return ' '.join(part for part in ['0 % on', base, currency] if part)
@property
def report_commission_secondary_rate_line(self):
fee = self._get_report_commission_fee()
if not fee or not getattr(fee, 'enable_linked_currency', False):
return ''
currency = getattr(fee, 'linked_currency', None)
unit = getattr(fee, 'linked_unit', None)
return ' '.join(part for part in [
self._format_report_number(
getattr(fee, 'linked_price', 0) or 0, digits='0.0000'),
getattr(currency, 'name', None) or getattr(currency, 'code', None) or '',
'/',
(getattr(unit, 'rec_name', None) or '').upper(),
] if part)
@property
def report_commission_total_display(self):
fee = self._get_report_commission_fee()
if fee and hasattr(fee, 'get_amount'):
amount = fee.get_amount()
else:
amount = self.total_amount or 0
return self._format_report_number(
abs(Decimal(str(amount or 0))), digits='0.01',
strip_trailing_zeros=False)
@property
def report_delivery(self):
del_date = 'PROMPT'
if self.lines:
if self.lines[0].estimated_date:
delivery_date = [dd.estimated_date for dd in self.lines[0].estimated_date if dd.trigger=='deldate']
if delivery_date:
del_date = delivery_date[0]
if del_date:
del_date = format_date_en(del_date)
return del_date
@property
def report_delivery_period_description(self):
if self.lines and self.lines[0].del_period:
return self.lines[0].del_period.description or ''
return ''
@property
def report_payment_date(self):
if self.lines:
if self.lc_date:
return format_date_en(self.lc_date)
Date = Pool().get('ir.date')
payment_date = self.lines[0].sale.payment_term.lines[0].get_date(Date.today(),self.lines[0])
if payment_date:
payment_date = format_date_en(payment_date)
return payment_date
@property
def report_shipment(self):
if self.lines:
if len(self.lines[0].lots)>1:
shipment = self.lines[0].lots[1].lot_shipment_in
lot = self.lines[0].lots[1].lot_name
if shipment:
info = 'B/L ' + shipment.bl_number
if shipment.container and shipment.container[0].container_no:
id = 1
for cont in shipment.container:
if id == 1:
info += ' Container(s)'
if cont.container_no:
info += ' ' + cont.container_no
else:
info += ' unnamed'
id += 1
info += ' (LOT ' + lot + ')'
if shipment.note:
info += ' ' + shipment.note
return info
else:
return ''
@classmethod
def default_viewer(cls):
country_start = "Zobiland"
data = {
"highlightedCountryName": country_start
}
return "d3:" + json.dumps(data)
def get_viewer(self, name=None):
country_start = ''
dep_name = ''
arr_name = ''
departure = ''
arrival = ''
if self.party and self.party.addresses:
if self.party.addresses[0].country:
country_start = self.party.addresses[0].country.name
if self.from_location:
lat_from = self.from_location.lat
lon_from = self.from_location.lon
dep_name = self.from_location.name
departure = { "name":dep_name,"lat": str(lat_from), "lon": str(lon_from) }
if self.to_location:
lat_to = self.to_location.lat
lon_to = self.to_location.lon
arr_name = self.to_location.name
arrival = { "name":arr_name,"lat": str(lat_to), "lon": str(lon_to) }
data = {
"highlightedCountryNames": [{"name":country_start}],
"routePoints": [
{ "lon": -46.3, "lat": -23.9 },
{ "lon": -30.0, "lat": -20.0 },
{ "lon": -30.0, "lat": 0.0 },
{ "lon": -6.0, "lat": 35.9 },
{ "lon": 15.0, "lat": 38.0 },
{ "lon": 29.0, "lat": 41.0 }
],
"boats": [
# {"name": "CARIBBEAN 1",
# "imo": "1234567",
# "lon": -30.0,
# "lat": 0.0,
# "status": "En route",
# "links": [
# { "text": "Voir sur VesselFinder", "url": "https://www.vesselfinder.com" },
# { "text": "Détails techniques", "url": "https://example.com/tech" }
# ],
# "actions": [
# { "type": "track", "id": "123", "label": "Suivre ce bateau" },
# { "type": "details", "id": "123", "label": "Voir détails" }
# ]}
],
"cottonStocks": [
# { "name":"Mali","lat": 12.65, "lon": -8.0, "amount": 300 },
# { "name":"Egypte","lat": 30.05, "lon": 31.25, "amount": 500 },
# { "name":"Irak","lat": 33.0, "lon": 44.0, "amount": 150 }
],
"departures": [departure],
"arrivals": [arrival]
}
return "d3:" + json.dumps(data)
def _execution_summary_quantity(self, value):
if value is None:
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def get_execution_summary_viewer(self, name=None):
LotQt = Pool().get('lot.qt')
rows = []
totals = defaultdict(float)
for line in (self.lines or []):
if getattr(line, 'type', 'line') != 'line':
continue
quantity = self._execution_summary_quantity(
getattr(line, 'quantity_theorical', None)
or getattr(line, 'quantity', None))
matched = 0.0
shipped = 0.0
lots_count = 0
shipped_routes = []
line_lots = list(getattr(line, 'lots', None) or [])
lot_ids = [lot.id for lot in line_lots if getattr(lot, 'id', None)]
lot_qts = LotQt.search([('lot_p', 'in', lot_ids)]) if lot_ids else []
for lqt in lot_qts:
lots_count += 1
lot_quantity = self._execution_summary_quantity(
getattr(lqt, 'lot_quantity', None)
or getattr(lqt, 'lot_qt', None))
if getattr(lqt, 'lot_s', None):
matched += lot_quantity
shipment = (
getattr(lqt, 'lot_shipment_in', None)
or getattr(lqt, 'lot_shipment_internal', None)
or getattr(lqt, 'lot_shipment_out', None))
if shipment:
shipped += lot_quantity
route_from = getattr(
getattr(shipment, 'from_location', None),
'rec_name', None)
route_to = getattr(
getattr(shipment, 'to_location', None),
'rec_name', None)
route_from = route_from or getattr(
getattr(self, 'from_location', None), 'rec_name', None)
route_to = route_to or getattr(
getattr(self, 'to_location', None), 'rec_name', None)
if route_from or route_to:
shipped_routes.append(
'%s > %s' % (route_from or '-', route_to or '-'))
if not lot_qts:
for lot in line_lots:
lots_count += 1
lot_quantity = self._execution_summary_quantity(
getattr(lot, 'lot_qt', None))
if getattr(lot, 'lot_s', None) or getattr(lot, 'sale_line', None):
matched += lot_quantity
shipment = (
getattr(lot, 'lot_shipment_in', None)
or getattr(lot, 'lot_shipment_internal', None)
or getattr(lot, 'lot_shipment_out', None))
if shipment:
shipped += lot_quantity
route_from = getattr(
getattr(shipment, 'from_location', None),
'rec_name', None)
route_to = getattr(
getattr(shipment, 'to_location', None),
'rec_name', None)
route_from = route_from or getattr(
getattr(self, 'from_location', None),
'rec_name', None)
route_to = route_to or getattr(
getattr(self, 'to_location', None),
'rec_name', None)
if route_from or route_to:
shipped_routes.append(
'%s > %s' % (route_from or '-', route_to or '-'))
unit = getattr(getattr(line, 'unit', None), 'rec_name', '') or ''
product = getattr(getattr(line, 'product', None), 'rec_name', '') or ''
route = (shipped_routes[0] if shipped_routes else '%s > %s' % (
getattr(getattr(self, 'from_location', None), 'rec_name', None)
or '-',
getattr(getattr(self, 'to_location', None), 'rec_name', None)
or '-'))
rows.append({
'product': product,
'quantity': quantity,
'matched': matched,
'shipped': shipped,
'open': max(quantity - shipped, 0.0),
'unit': unit,
'lots': lots_count,
'route': route,
})
totals['quantity'] += quantity
totals['matched'] += matched
totals['shipped'] += shipped
totals['open'] += max(quantity - shipped, 0.0)
totals['lines'] += 1
data = {
'type': 'execution-summary',
'unit': rows[0]['unit'] if rows else '',
'totals': dict(totals),
'rows': rows[:4],
}
return "d3:" + json.dumps(data)
def getLots(self):
if self.lines:
if self.lines.lots:
return [l for l in self.lines.lots]
@fields.depends('party','from_location','to_location')
def on_change_with_viewer(self):
return self.get_viewer()
@fields.depends('doc_template','required_documents')
def on_change_with_required_documents(self):
if self.doc_template:
return self.doc_template.type
@classmethod
def _check_lines_delivery_period_values(cls, values):
line_commands = values.get('lines') or []
if not line_commands:
return
Line = Pool().get('purchase.line')
pending_values = {}
for command in line_commands:
action = command[0]
if action == 'create':
for line_values in command[1]:
Line._check_delivery_period_values([Line()], line_values)
elif action == 'write':
actions = iter(command[1:])
for line_ids, line_values in zip(actions, actions):
for line_id in line_ids:
pending_values.setdefault(line_id, {}).update(
line_values)
elif action == 'add':
for line_id in command[1]:
pending_values.setdefault(line_id, {})
for line_id, line_values in pending_values.items():
Line._check_delivery_period_values(
Line.browse([line_id]), line_values)
@classmethod
def write(cls, *args):
actions = iter(args)
args = []
for purchases, values in zip(actions, actions):
cls._check_lines_delivery_period_values(values)
args.extend((purchases, values))
super().write(*args)
@classmethod
def copy(cls, purchases, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('pnl', None)
default.setdefault('derivatives', None)
default.setdefault('pnl_', None)
default.setdefault('plans', None)
return super().copy(purchases, default=default)
@classmethod
def validate(cls, purchases):
super(Purchase, cls).validate(purchases)
Line = Pool().get('purchase.line')
Date = Pool().get('ir.date')
for purchase in purchases:
for line in purchase.lines:
if not line.quantity_theorical and line.quantity > 0:
line.quantity_theorical = (
Decimal(str(line.quantity))
.quantize(Decimal("0.00001"))
)
Line.save([line])
#compute pnl
Pnl = Pool().get('valuation.valuation')
Pnl.generate(line)
if line.quantity_theorical:
OpenPosition = Pool().get('open.position')
# OpenPosition.create_from_purchase_line(line)
#line unit_price calculation
if line.price_type == 'basis' and line.lots: #line.price_pricing and line.price_components and
previous_linked_price = line.linked_price
line.sync_linked_price_from_basis()
unit_price = line.get_basis_price()
logger.info("VALIDATEPURCHASE:%s",unit_price)
if unit_price != line.unit_price or line.linked_price != previous_linked_price:
line.unit_price = unit_price
logger.info("VALIDATEPURCHASE2:%s",line.unit_price)
Line.save([line])
if line.price_type == 'efp':
if line.derivatives:
for d in line.derivatives:
line.unit_price = round(Decimal(d.price_index.get_price(Date.today(),line.unit,line.currency,True)),4)
logger.info("EFP_PRICE:%s",line.unit_price)
Line.save([line])
def _contract_clause_values_from_template(self, direction):
values = []
template = getattr(self, 'contract_template', None)
if not template:
return values
for template_line in sorted(
template.lines or [], key=lambda line: line.sequence or 0):
clause = template_line.clause
if not clause or not clause.matches_contract(self, direction):
continue
values.append(ContractClauseSelection(
sequence=template_line.sequence,
section=template_line.section or clause.category,
clause=clause,
selected=not template_line.optional,
optional=template_line.optional,
))
return values
@fields.depends('contract_template', 'contract_clauses', 'incoterm',
'payment_term', 'lines')
def on_change_contract_template(self):
if self.contract_template:
self.contract_clauses = self._contract_clause_values_from_template(
'purchase')
class PriceComposition(ModelSQL,ModelView):
"Price Composition"
__name__ = 'price.composition'
line = fields.Many2One('purchase.line',"Purchase line")
component = fields.Char("Component")
price = fields.Numeric("Unit Price")
class PremiumComposition(ModelSQL, ModelView):
"Premium Composition"
__name__ = 'premium.composition'
line = fields.Many2One('purchase.line', "Purchase line", ondelete='CASCADE')
type = fields.Many2One('price.fixtype', "Type")
premium = fields.Numeric("Premium/Discount", digits=(16, 6))
currency = fields.Many2One('currency.currency', "Curr.")
reference = fields.Text("Reference")
base_amount = fields.Function(
fields.Numeric("Base Amt", digits=(16, 6)), 'get_base_amount')
def _get_owner_line(self):
return getattr(self, 'line', None) or getattr(self, 'sale_line', None)
def _get_contract(self):
line = self._get_owner_line()
if line:
return getattr(line, 'purchase', None) or getattr(line, 'sale', None)
def _get_contract_currency(self):
contract = self._get_contract()
return getattr(contract, 'currency', None)
def _get_contract_date(self):
contract = self._get_contract()
return (
getattr(contract, 'purchase_date', None)
or getattr(contract, 'sale_date', None))
def get_base_amount(self, name=None):
amount = Decimal(str(self.premium or 0))
currency = getattr(self, 'currency', None)
contract_currency = self._get_contract_currency()
if not currency or not contract_currency or currency == contract_currency:
return round(amount, 6)
Currency = Pool().get('currency.currency')
contract_date = self._get_contract_date()
if contract_date:
with Transaction().set_context(date=contract_date):
return round(Currency.compute(
currency, amount, contract_currency), 6)
return round(Currency.compute(currency, amount, contract_currency), 6)
CONTRACT_CLAUSE_CATEGORIES = [
(None, ''),
('payment', 'Payment'),
('delivery', 'Delivery'),
('transport', 'Transport'),
('documents', 'Documents'),
('quality', 'Quality'),
('quantity', 'Quantity'),
('price', 'Price'),
('insurance', 'Insurance'),
('title_transfer', 'Title Transfer'),
('charter', 'Charter / Demurrage'),
('tax', 'Tax'),
('sanctions', 'Sanctions'),
('force_majeure', 'Force Majeure'),
('law', 'Law / Arbitration'),
('other', 'Other'),
]
class ContractClause(ModelSQL, ModelView):
"Contract Clause"
__name__ = 'contract.clause'
name = fields.Char("Name", required=True)
category = fields.Selection(CONTRACT_CLAUSE_CATEGORIES, "Category")
direction = fields.Selection([
('both', 'Purchase & Sale'),
('purchase', 'Purchase'),
('sale', 'Sale'),
], "Direction", required=True)
active = fields.Boolean("Active")
version = fields.Char("Version")
language = fields.Char("Language")
source = fields.Char("Source")
text = fields.Text("Text", required=True)
incoterm_code = fields.Char("Incoterm")
product = fields.Many2One('product.product', "Product")
payment_term = fields.Many2One(
'account.invoice.payment_term', "Payment Term")
transport_type = fields.Selection([
(None, ''),
('vessel', 'Vessel'),
('truck', 'Truck'),
('other', 'Other'),
], "Transport Type")
cargo_mode = fields.Selection([
(None, ''),
('bulk', 'Bulk'),
('container', 'Container'),
('tanker', 'Tanker'),
], "Cargo Mode")
variables = fields.Function(
fields.Char("Variables"), 'get_variables')
@staticmethod
def default_active():
return True
@staticmethod
def default_direction():
return 'both'
def get_variables(self, name=None):
variables = sorted(set(re.findall(
r'\[([A-Za-z0-9_]+)\]', self.text or '')))
return ', '.join(variables)
def matches_contract(self, contract, direction):
if self.direction not in ('both', direction):
return False
if (self.incoterm_code
and getattr(getattr(contract, 'incoterm', None), 'code', None)
!= self.incoterm_code):
return False
if (self.payment_term
and getattr(getattr(contract, 'payment_term', None), 'id', None)
!= self.payment_term.id):
return False
if self.product:
products = {
getattr(getattr(line, 'product', None), 'id', None)
for line in getattr(contract, 'lines', []) or []
if getattr(line, 'type', 'line') == 'line'
}
if self.product.id not in products:
return False
if (self.transport_type
and getattr(contract, 'transport_type', None)
!= self.transport_type):
return False
if self.cargo_mode and getattr(contract, 'cargo_mode', None) != self.cargo_mode:
return False
return True
class ContractTemplate(ModelSQL, ModelView):
"Contract Template"
__name__ = 'contract.template'
name = fields.Char("Name", required=True)
direction = fields.Selection([
('both', 'Purchase & Sale'),
('purchase', 'Purchase'),
('sale', 'Sale'),
], "Direction", required=True)
active = fields.Boolean("Active")
incoterm_code = fields.Char("Incoterm")
product = fields.Many2One('product.product', "Product")
payment_term = fields.Many2One(
'account.invoice.payment_term', "Payment Term")
transport_type = fields.Selection([
(None, ''),
('vessel', 'Vessel'),
('truck', 'Truck'),
('other', 'Other'),
], "Transport Type")
cargo_mode = fields.Selection([
(None, ''),
('bulk', 'Bulk'),
('container', 'Container'),
('tanker', 'Tanker'),
], "Cargo Mode")
lines = fields.One2Many(
'contract.template.line', 'template', "Clauses")
notes = fields.Text("Notes")
@staticmethod
def default_active():
return True
@staticmethod
def default_direction():
return 'both'
def matches_contract(self, contract, direction):
if self.direction not in ('both', direction):
return False
if (self.incoterm_code
and getattr(getattr(contract, 'incoterm', None), 'code', None)
!= self.incoterm_code):
return False
if (self.payment_term
and getattr(getattr(contract, 'payment_term', None), 'id', None)
!= self.payment_term.id):
return False
if self.product:
products = {
getattr(getattr(line, 'product', None), 'id', None)
for line in getattr(contract, 'lines', []) or []
if getattr(line, 'type', 'line') == 'line'
}
if self.product.id not in products:
return False
if (self.transport_type
and getattr(contract, 'transport_type', None)
!= self.transport_type):
return False
if self.cargo_mode and getattr(contract, 'cargo_mode', None) != self.cargo_mode:
return False
return True
class ContractTemplateLine(ModelSQL, ModelView):
"Contract Template Line"
__name__ = 'contract.template.line'
template = fields.Many2One(
'contract.template', "Template", required=True, ondelete='CASCADE')
sequence = fields.Integer("Sequence")
section = fields.Selection(CONTRACT_CLAUSE_CATEGORIES, "Section")
clause = fields.Many2One('contract.clause', "Clause", required=True)
optional = fields.Boolean("Optional")
@staticmethod
def default_sequence():
return 10
class ContractClauseSelection(ModelSQL, ModelView):
"Contract Clause Selection"
__name__ = 'contract.clause.selection'
purchase = fields.Many2One(
'purchase.purchase', "Purchase", ondelete='CASCADE')
sequence = fields.Integer("Sequence")
section = fields.Selection(CONTRACT_CLAUSE_CATEGORIES, "Section")
clause = fields.Many2One('contract.clause', "Clause", required=True)
selected = fields.Boolean("Selected")
optional = fields.Boolean("Optional")
text_override = fields.Text("Override")
rendered_text = fields.Function(
fields.Text("Rendered Text"), 'get_rendered_text')
@staticmethod
def default_sequence():
return 10
@staticmethod
def default_selected():
return True
def _get_contract(self):
return getattr(self, 'purchase', None) or getattr(self, 'sale', None)
def _get_render_context(self):
contract = self._get_contract()
if not contract:
return {}
lines = [
line for line in getattr(contract, 'lines', []) or []
if getattr(line, 'type', 'line') == 'line']
products = ', '.join(filter(None, [
getattr(getattr(line, 'product', None), 'rec_name', None)
for line in lines]))
quantities = ', '.join(filter(None, [
str(getattr(line, 'quantity', '') or '')
for line in lines]))
units = ', '.join(filter(None, [
getattr(getattr(line, 'unit', None), 'rec_name', None)
for line in lines]))
return {
'Contract_Number': (
getattr(contract, 'reference', None)
or getattr(contract, 'number', None) or ''),
'reference': getattr(contract, 'reference', None) or '',
'party': getattr(getattr(contract, 'party', None), 'rec_name', None) or '',
'company': getattr(getattr(contract, 'company', None), 'rec_name', None) or '',
'currency': getattr(getattr(contract, 'currency', None), 'rec_name', None) or '',
'incoterm': getattr(getattr(contract, 'incoterm', None), 'code', None) or '',
'incoterm_location': getattr(getattr(contract, 'incoterm_location', None), 'rec_name', None) or '',
'payment_term': getattr(getattr(contract, 'payment_term', None), 'rec_name', None) or '',
'product': products,
'quantity': quantities,
'unit': units,
'product_origin': getattr(contract, 'product_origin', None) or '',
'del_from': str(getattr(contract, 'del_from', '') or ''),
'del_to': str(getattr(contract, 'del_to', '') or ''),
}
def get_rendered_text(self, name=None):
text = self.text_override or getattr(self.clause, 'text', '') or ''
context = self._get_render_context()
def replace(match):
key = match.group(1)
return str(context.get(key, match.group(0)))
return re.sub(r'\[([A-Za-z0-9_]+)\]', replace, text)
class AssayImporter:
def __init__(self):
pool = Pool()
self.AssayLine = pool.get('assay.line')
self.Element = pool.get('assay.element')
self.Unit = pool.get('assay.unit')
# -----------------------------
# PUBLIC ENTRYPOINT
# -----------------------------
def import_from_json(self, data: dict, assay):
self._update_assay(data,assay)
lines = self._create_lines(data, assay)
self.AssayLine.save(lines)
return assay
# -----------------------------
# HEADER
# -----------------------------
def _update_assay(self, data, assay):
Party = Pool().get('party.party')
metadata = data.get('document_metadata', {})
assay.reference = metadata.get('report_reference')
assay.date = self._parse_date(metadata.get('issue_date'))
assay.type = self._map_type(metadata.get('status'))
assay.status = 'draft'
assay.lab = Party.getPartyByName(metadata.get('lab_name'))
assay.save()
return assay
# -----------------------------
# LINES
# -----------------------------
def _create_lines(self, data, assay):
lines = []
# assays
for item in data.get('assays', []):
lines.append(self._build_line(item, assay, category='assay'))
# penalties
for item in data.get('penalties', []):
lines.append(self._build_line(item, assay, category='penalty'))
# moisture
moisture = data.get('weights_and_moisture', {}).get('moisture')
if moisture and moisture.get('value') is not None:
lines.append(self._build_line({
"element": "H2O",
"value": moisture.get('value'),
"unit": moisture.get('unit')
}, assay, category='moisture'))
return lines
# -----------------------------
# LINE BUILDER
# -----------------------------
def _build_line(self, item, assay, category):
line = self.AssayLine()
line.assay = assay
line.element = self._get_or_create_element(item.get('element'))
line.value = self._safe_float(item.get('value'))
line.unit = self._get_unit(item.get('unit'))
line.category = category
line.method = item.get('method')
line.is_payable = item.get('is_payable', False)
return line
# -----------------------------
# HELPERS
# -----------------------------
def _get_or_create_element(self, code):
if not code:
return None
elements = self.Element.search([('name', '=', code)])
if elements:
return elements[0]
# auto-create (optionnel mais pratique)
element = self.Element()
element.name = code
element.save()
return element
def _get_unit(self, unit_name):
if not unit_name:
return None
units = self.Unit.search([('symbol', '=', unit_name)])
if units:
return units[0]
return None # ou lever une erreur selon ton besoin
def _parse_date(self, date_str):
if not date_str:
return None
formats = [
"%Y-%m-%d", # 2025-02-28
"%d-%b-%Y", # 28-Feb-2025
"%d-%B-%Y", # 28-February-2025
"%d/%m/%Y", # 28/02/2025
]
for fmt in formats:
try:
return datetime.datetime.strptime(date_str, fmt).date()
except Exception:
continue
return None
def _parse_date_(self, date_str):
if not date_str:
return None
try:
return datetime.datetime.strptime(date_str, "%Y-%m-%d").date()
except Exception:
return None
def _safe_float(self, value):
try:
return float(value)
except Exception:
return None
def _map_type(self, status):
if not status:
return 'provisional'
status = status.lower()
if 'final' in status:
return 'final'
if 'umpire' in status:
return 'umpire'
return 'provisional'
class AssayUnit(ModelSQL, ModelView):
'Assay Unit'
__name__ = 'assay.unit'
_rec_name = 'symbol'
name = fields.Char('Name') # Percent, g/t, ppm
symbol = fields.Char('Symbol') # %, g/t, ppm
dimension = fields.Selection([
('mass_fraction', 'Mass Fraction'),
('grade', 'Grade'),
('trace', 'Trace'),
])
class Assay(ModelSQL, ModelView):
"Assay"
__name__ = 'assay.assay'
line = fields.Many2One('purchase.line',"Purchase Line")
reference = fields.Char("Reference")
date = fields.Date("Analysis Date")
type = fields.Selection([
(None, ''),
('provisional', 'Provisional'),
('final', 'Final'),
('umpire', 'Umpire'),
], "Type")
status = fields.Selection([
(None, ''),
('draft', 'Draft'),
('validated', 'Validated'),
], "Status")
lab = fields.Many2One('party.party',"Laboratory")
lines = fields.One2Many(
'assay.line', 'assay', "Assay Lines"
)
analysis = fields.Many2One('document.incoming',"Analysis")
class AssayLine(ModelSQL, ModelView):
"Assay Line"
__name__ = 'assay.line'
assay = fields.Many2One('assay.assay', "Assay")
element = fields.Many2One('assay.element', "Element")
value = fields.Numeric("Value")
unit = fields.Many2One('assay.unit', "Unit")
category = fields.Selection([
('assay', 'Assay'),
('penalty', 'Penalty'),
('moisture', 'Moisture'),
], "Category")
method = fields.Char("Method")
is_payable = fields.Boolean("Payable")
class AssayElement(ModelSQL, ModelView):
"Assay Element"
__name__ = 'assay.element'
name = fields.Char("Code") # Cu, Au, As
description = fields.Char("Description")
default_unit = fields.Many2One('product.uom', "Default Unit")
type = fields.Selection([
(None, ''),
('metal', 'Metal'),
('impurity', 'Impurity'),
], "Type")
class PayableRule(ModelSQL, ModelView):
"Payable Rule"
__name__ = 'payable.rule'
_rec_name = 'name'
name = fields.Char("Name")
element = fields.Many2One('assay.element', "Element")
payable_percent = fields.Numeric("Payable %")
deduction_value = fields.Numeric("Deduction Value")
deduction_unit = fields.Many2One('assay.unit',"Unit")
payable_method = fields.Selection([
('percent', 'Fixed %'),
('grade_minus', 'Grade minus deduction'),
('min_of_both', 'Min(% of grade, grade - deduction)'),
], "Method")
min_payable = fields.Numeric("Floor (min payable)")
def compute_payable_quantity(self, grade):
"""
Retourne la quantité payable dans l'unité du grade.
grade : Decimal (ex: Decimal('26.862'))
"""
grade = Decimal(str(grade))
if self.payable_method == 'percent':
result = grade * self.payable_percent / Decimal(100)
elif self.payable_method == 'grade_minus':
result = grade - self.deduction_value
elif self.payable_method == 'min_of_both':
by_percent = grade * self.payable_percent / Decimal(100)
by_deduction = grade - self.deduction_value
if self.deduction_unit.symbol == 'g/t':
result = min(by_percent/Decimal(10000), by_deduction/Decimal(10000))
else:
result = min(by_percent, by_deduction)
if self.min_payable is not None:
result = max(result, self.min_payable)
return result
class PenaltyRuleTier(ModelSQL, ModelView):
"Penalty Rule Tier"
__name__ = 'penalty.rule.tier'
rule = fields.Many2One('penalty.rule', "Rule", ondelete='CASCADE')
threshold_from = fields.Numeric("From")
threshold_to = fields.Numeric("To") # None = pas de plafond
threshold_unit = fields.Many2One('assay.unit', "Unit")
deduction_per_unit = fields.Numeric("Deduction / unit")
penalty_value = fields.Numeric("Penalty Value (USD/DMT)")
mode = fields.Selection([
('excess', 'Excess above threshold'),
('full', 'Full grade in tier'),
('min_or_both', 'Min(grade, cap) — full tier amount'),
], "Mode")
@classmethod
def default_mode(cls):
return 'excess'
def compute_tier_penalty(self, grade):
grade = Decimal(str(grade))
if grade <= self.threshold_from:
return Decimal(0)
if self.mode == 'excess':
# Ton comportement actuel : on paye seulement l'excès au-dessus du seuil bas
# Ex Codelco : As=0,7% → palier 0,2-0,5% donne 0,3% d'excès
excess_top = grade if self.threshold_to is None else min(grade, self.threshold_to)
taxable = excess_top - self.threshold_from
elif self.mode == 'full':
# Pénalité sur toute la teneur dans ce palier dès déclenchement
# Ex : As=0,7% → palier 0,5-1,0% donne 0,7% entier (pas 0,2%)
taxable = grade if self.threshold_to is None else min(grade, self.threshold_to)
elif self.mode == 'min_or_both':
# Pénalité sur min(grade, plafond) — utile quand le contrat dit
# "si As dépasse X%, pénalité sur la tranche entière jusqu'à Y%"
# Ex vente fichier : palier 0,3-0,9%, As=0,6% → taxable = min(0,6%, 0,9%) = 0,6%
cap = self.threshold_to if self.threshold_to is not None else grade
taxable = min(grade, cap)
return (taxable * self.penalty_value).quantize(Decimal('0.01'))
class PenaltyRule(ModelSQL, ModelView):
"Penalty Rule"
__name__ = 'penalty.rule'
name = fields.Char("Name")
element = fields.Many2One('assay.element', "Element")
tiers = fields.One2Many('penalty.rule.tier', 'rule', "Tiers")
def compute_penalty(self, grade):
"""
Retourne la pénalité totale USD en cumulant tous les paliers traversés.
grade : Decimal teneur brute de l'élément
dry_weight_dmt: Decimal poids sec en DMT
"""
grade = Decimal(str(grade))
total = Decimal(0)
for tier in self.tiers:
total += tier.compute_tier_penalty(grade)
return total.quantize(Decimal('0.01'))
class ConcentrateTerm(ModelSQL, ModelView):
"Concentrate Term"
__name__ = 'concentrate.term'
line = fields.Many2One(
'purchase.line', "Line",
ondelete='CASCADE'
)
element = fields.Many2One('assay.element',"Element")
component = fields.Many2One(
'pricing.component',
"Price Component",
domain=[
('id', 'in', Eval('line_component')),
],
depends=['line_component']
)
line_component = fields.Function(fields.One2Many('pricing.component','',"Component"),'on_change_with_line_component')
manual_price = fields.Numeric(
"Price",
digits=(16, 2)
)
currency = fields.Many2One('currency.currency',"Curr")
unit = fields.Many2One('product.uom',"Unit")
payable_rule = fields.Many2One(
'payable.rule',"Payable Rule"
)
penalty_rules = fields.Many2One(
'penalty.rule',
"Penalties"
)
valid_from = fields.Date("Valid From")
valid_to = fields.Date("Valid To")
@fields.depends('component')
def on_change_with_line_component(self, name):
PC = Pool().get('pricing.component')
return PC.search(['line','=',self.line])
class QualityAnalysis(ModelSQL,ModelView):
"Quality Analysis"
__name__ = 'quality.analysis'
line = fields.Many2One('purchase.line',"Purchase Line")
reference = fields.Char("Reference")
date = fields.Date("Analysis date")
attributes = fields.Dict(
'product.attribute', 'Attributes',
domain=[
('sets', '=', Eval('attribute_set')),
],
states={
'readonly': ~Eval('attribute_set'),
},
depends=['product', 'attribute_set'],
help="Add attributes to the variant."
)
product = fields.Function(
fields.Many2One('product.product', "Product"),
'on_change_with_product'
)
attribute_set = fields.Function(
fields.Many2One('product.attribute.set', "Attribute Set"),
'on_change_with_attribute_set'
)
attributes_name = fields.Function(
fields.Char("Details"),
'on_change_with_attributes_name'
)
last_analysis_pricing = fields.Boolean("Used for pricing")
@fields.depends('product')
def on_change_with_attribute_set(self, name=None):
if self.product and self.product.template and self.product.template.attribute_set:
return self.product.template.attribute_set.id
@fields.depends('line')
def on_change_with_product(self, name=None):
if self.line:
return self.line.product
@fields.depends('product', 'attributes')
def on_change_with_attributes_name(self, name=None):
if not self.product or not self.product.attribute_set or not self.attributes:
return
def key(attribute):
return getattr(attribute, 'sequence', attribute.name)
values = []
for attribute in sorted(self.product.attribute_set.attributes, key=key):
if attribute.name in self.attributes:
value = self.attributes[attribute.name]
values.append(gettext(
'product_attribute.msg_label_value',
label=attribute.string,
value=attribute.format(value)
))
return " | ".join(filter(None, values))
class Line(metaclass=PoolMeta):
__name__ = 'purchase.line'
@classmethod
def __setup__(cls):
super().__setup__()
cls.quantity.readonly = True
def _get_service_fee_invoice_lines(self, invoice_line, lot):
Fee = Pool().get('fee.fee')
fee = Fee.search(['purchase', '=', self.purchase.id])
if not fee:
return [invoice_line]
fee = fee[0]
invoice_line.fee = fee
fee.warn_percent_price_partial_lots()
if fee.mode == 'lumpsum':
invoice_line.quantity = 1
elif fee.mode == 'pprice':
invoice_line.quantity = 1
elif fee.mode == 'ppack':
invoice_line.quantity = fee.quantity
elif (
fee.mode == 'perqt'
and getattr(fee, 'add_padding', False)
and not Transaction().context.get(
'_purchase_trade_ignore_fee_padding')):
invoice_line.quantity = fee.get_billing_quantity()
else:
invoice_line.quantity = fee.get_fee_lots_qt()
if getattr(fee, 'state', None) != 'invoiced':
return [invoice_line]
previous_line = self._get_last_fee_invoice_line(fee, lot)
if not self._fee_invoice_line_changed(previous_line, invoice_line):
return []
reversal_line = self._get_fee_reversal_invoice_line(
previous_line, self)
if reversal_line:
return [reversal_line, invoice_line]
return [invoice_line]
@staticmethod
def _is_empty_quantity(quantity):
if quantity in (None, ''):
return True
return Decimal(str(quantity or 0)) == Decimal(0)
@staticmethod
def _has_physical_lot(line):
return any(
getattr(lot, 'lot_type', None) == 'physic'
for lot in (getattr(line, 'lots', None) or []))
@classmethod
def _sync_initial_quantity_from_theorical(cls, line):
if cls._has_physical_lot(line):
return False
if not cls._is_empty_quantity(getattr(line, 'quantity', None)):
return False
quantity = getattr(line, 'quantity_theorical', None)
if cls._is_empty_quantity(quantity):
return False
line.quantity = Decimal(str(quantity)).quantize(Decimal("0.00001"))
return True
@classmethod
def _sync_quantity_counter_from_theorical(cls, line):
if cls._has_physical_lot(line):
return False
quantity = getattr(line, 'quantity_theorical', None)
if cls._is_empty_quantity(quantity):
quantity = Decimal(0)
else:
quantity = Decimal(str(quantity)).quantize(Decimal("0.00001"))
current = getattr(line, 'quantity', None)
try:
current = Decimal(str(current or 0)).quantize(Decimal("0.00001"))
except Exception:
current = Decimal(0)
if current == quantity:
return False
line.quantity = quantity
return True
@classmethod
def _set_initial_quantity_values(cls, values):
if 'quantity_theorical' not in values:
return
quantity = values.get('quantity_theorical')
if cls._is_empty_quantity(quantity):
return
quantity = Decimal(str(quantity)).quantize(Decimal("0.00001"))
if cls._is_empty_quantity(values.get('quantity')):
values['quantity'] = quantity
if cls._is_empty_quantity(values.get('targeted_qt')):
values['targeted_qt'] = quantity
@classmethod
def _set_quantity_counter_values_from_theorical(cls, records, values):
if 'quantity_theorical' not in values:
return
if any(cls._has_physical_lot(record) for record in records):
return
quantity = values.get('quantity_theorical')
if cls._is_empty_quantity(quantity):
quantity = Decimal(0)
else:
quantity = Decimal(str(quantity)).quantize(Decimal("0.00001"))
values['quantity'] = quantity
if 'targeted_qt' not in values:
values['targeted_qt'] = quantity
@classmethod
def _fresh_lines_for_quantity_consistency(cls, lines):
try:
return [cls(line.id) for line in lines if getattr(line, 'id', None)]
except Exception:
return lines
@classmethod
def default_pricing_rule(cls):
try:
Configuration = Pool().get('purchase_trade.configuration')
except KeyError:
return ''
configurations = Configuration.search([], limit=1)
if configurations:
return configurations[0].pricing_rule or ''
return ''
@staticmethod
def _has_invalid_delivery_period(line):
return (
bool(line.from_del)
and bool(line.to_del)
and line.from_del > line.to_del)
@classmethod
def _check_delivery_period_values(cls, lines, values=None):
values = values or {}
for line in lines:
from_del = values.get('from_del', getattr(line, 'from_del', None))
to_del = values.get('to_del', getattr(line, 'to_del', None))
if from_del and to_del and from_del > to_del:
raise UserError(
"Shipment period From date must be before To date.")
@classmethod
def _estimated_bl_relation_field(cls):
return 'line'
@classmethod
def _has_estimated_bl_date(cls, estimated_dates):
return any(
getattr(estimated, 'trigger', None) == 'bldate'
for estimated in (estimated_dates or []))
@classmethod
def _values_have_estimated_bl_date(cls, values):
for command in values.get('estimated_date') or []:
action = command[0]
if action == 'create':
if any(
estimated.get('trigger') == 'bldate'
for estimated in command[1]):
return True
elif action == 'write':
actions = iter(command[1:])
for _estimated_ids, estimated_values in zip(actions, actions):
if estimated_values.get('trigger') == 'bldate':
return True
return False
@classmethod
def _delivery_period_from_values(cls, values):
period = values.get('del_period')
if period and not hasattr(period, 'beg_date'):
period = Pool().get('product.month')(period)
return period
@classmethod
def _default_estimated_bl_date(cls, values):
period = cls._delivery_period_from_values(values)
return getattr(period, 'beg_date', None)
@classmethod
def _set_default_estimated_bl_date_values(cls, values):
if cls._values_have_estimated_bl_date(values):
return
estimated_date = cls._default_estimated_bl_date(values)
if not estimated_date:
return
if values.get('estimated_date') is None:
values['estimated_date'] = []
elif isinstance(values['estimated_date'], tuple):
values['estimated_date'] = list(values['estimated_date'])
values['estimated_date'].append(('create', [{
'trigger': 'bldate',
'estimated_date': estimated_date,
}]))
@classmethod
def _create_missing_estimated_bl_dates(cls, lines):
Estimated = Pool().get('pricing.estimated')
values = []
relation_field = cls._estimated_bl_relation_field()
for line in lines:
if cls._has_estimated_bl_date(getattr(line, 'estimated_date', None)):
continue
del_period = getattr(line, 'del_period', None)
estimated_date = getattr(del_period, 'beg_date', None)
if not estimated_date:
continue
values.append({
relation_field: line.id,
'trigger': 'bldate',
'estimated_date': estimated_date,
})
if values:
Estimated.create(values)
@classmethod
def create(cls, vlist):
regenerate_valuation = any(
cls._should_regenerate_valuation(values) for values in vlist)
for values in vlist:
cls._check_delivery_period_values([cls()], values)
cls._set_default_estimated_bl_date_values(values)
cls._set_initial_quantity_values(values)
lines = super().create(vlist)
if not Transaction().context.get('_purchase_trade_skip_fee_rules'):
Pool().get('fee.rule').apply_to_lines(
lines, 'purchase_line', auto_only=True)
if (regenerate_valuation
and not Transaction().context.get(
'_purchase_trade_skip_valuation_regeneration')):
Valuation = Pool().get('valuation.valuation')
with Transaction().set_context(
_purchase_trade_skip_valuation_regeneration=True):
Valuation.regenerate_for_purchase_lines(lines)
return lines
@classmethod
@ModelView.button
def apply_default_fees(cls, lines):
Pool().get('fee.rule').apply_to_lines(lines, 'purchase_line')
def _check_delivery_period(self):
if self._has_invalid_delivery_period(self):
raise UserError(
"Shipment period From date must be before To date.")
@fields.depends('from_del', 'to_del')
def on_change_from_del(self):
self._check_delivery_period()
@fields.depends('from_del', 'to_del')
def on_change_to_del(self):
self._check_delivery_period()
quantity_theorical = fields.Numeric("Contractual Qt", digits='unit', readonly=False)
price_type = fields.Selection([
('cash', 'Cash Price'),
('priced', 'Priced'),
('basis', 'Basis'),
('efp', 'EFP'),
], 'Price type')
progress = fields.Function(fields.Float("Fix. progress",
states={
'invisible': Eval('price_type') != 'basis',
}),'get_progress')
del_period = fields.Many2One('product.month',"Shipment Period")
from_del = fields.Date("From")
to_del = fields.Date("To")
period_at = fields.Selection([
(None, ''),
('laycan', 'Laycan'),
('loading', 'Loading'),
('discharge', 'Discharge'),
('crossing_border', 'Crossing Border'),
('title_transfer', 'Title transfer'),
('arrival', 'Arrival'),
],"Period at")
concentration = fields.Numeric("Concentration")
price_components = fields.One2Many('pricing.component','line',"Components")
price_pricing = fields.One2Many('pricing.pricing','line',"Pricing")
price_summary = fields.One2Many('purchase.pricing.summary','line',"Summary")
premium_decomposition = fields.One2Many(
'premium.composition', 'line', "Premium decomposition")
charter_conditions = fields.One2Many(
'charter.condition', 'purchase_line', "Charter Conditions")
estimated_date = fields.One2Many('pricing.estimated','line',"Estimated date")
optional = fields.One2Many('optional.scenario','line',"Optionals Scenarios")
lots = fields.One2Many('lot.lot','line',"Lots",readonly=True)
purchase_line = fields.Many2One('purchase.line',"Lines")
fees = fields.One2Many('fee.fee', 'line', 'Fees')#, filter=[('product.type', '=', 'service')])
derivatives = fields.One2Many('derivative.derivative','line',"Derivatives")
mtm = fields.Many2Many('purchase.strategy', 'line', 'strategy', 'Mtm Strategy')
tol_min = fields.Numeric("Tol - in %",states={
'readonly': (Eval('inherit_tol')),
})
tol_max = fields.Numeric("Tol + in %",states={
'readonly': (Eval('inherit_tol')),
})
tol_min_qt = fields.Numeric("Tol -",states={
'readonly': (Eval('inherit_tol')),
})
tol_max_qt = fields.Numeric("Tol +",states={
'readonly': (Eval('inherit_tol')),
})
inherit_tol = fields.Boolean("Inherit tolerance")
tol_min_v = fields.Function(fields.Numeric("Qt min"),'get_tol_min')
tol_max_v = fields.Function(fields.Numeric("Qt max"),'get_tol_max')
tolerance_used = fields.Function(
fields.Numeric("Tolerance used"), 'get_tolerance_used')
tolerance_min = fields.Function(
fields.Numeric("Tolerance min"), 'get_tolerance_min')
tolerance_max = fields.Function(
fields.Numeric("Tolerance max"), 'get_tolerance_max')
targeted_qt = fields.Numeric("Targeted Qt", digits='unit')
# certification = fields.Selection([
# (None, ''),
# ('bci', 'BCI'),
# ],"Certification",states={'readonly': (Eval('inherit_cer')),})
certif = fields.Many2One('purchase.certification',"Certification",states={'readonly': (Eval('inherit_cer')),})
inherit_cer = fields.Boolean("Inherit certification")
enable_linked_currency = fields.Boolean("Linked currencies")
linked_price = fields.Numeric("Price", digits='unit',states={
'invisible': (~Eval('enable_linked_currency')),
'required': Eval('enable_linked_currency'),
'readonly': Eval('price_type') == 'basis',
}, depends=['enable_linked_currency', 'price_type'])
linked_currency = fields.Many2One('currency.linked',"Currency",states={
'invisible': (~Eval('enable_linked_currency')),
'required': Eval('enable_linked_currency'),
}, depends=['enable_linked_currency'])
linked_unit = fields.Many2One('product.uom', 'Unit',states={
'invisible': (~Eval('enable_linked_currency')),
'required': Eval('enable_linked_currency'),
}, depends=['enable_linked_currency'])
premium = fields.Function(
fields.Numeric("Premium/Discount", digits='unit', readonly=True),
'get_premium', setter='set_premium')
fee_ = fields.Many2One('fee.fee',"Fee")
pricing_rule = fields.Text("Pricing description")
parent_trader = fields.Function(
fields.Many2One('party.party', "Trader"), 'get_parent_field')
parent_operator = fields.Function(
fields.Many2One('party.party', "Operator"), 'get_parent_field')
parent_broker = fields.Function(
fields.Many2One('party.party', "Broker"), 'get_parent_field')
parent_contract_template = fields.Function(
fields.Many2One('contract.template', "Contract Template"),
'get_parent_field')
parent_our_reference = fields.Function(
fields.Char("Our Reference"), 'get_parent_field')
parent_lc_date = fields.Function(
fields.Date("LC date"), 'get_parent_field')
parent_product_origin = fields.Function(
fields.Char("Origin"), 'get_parent_field')
price_component_count = fields.Function(
fields.Integer("Components"), 'get_relation_count')
pricing_count = fields.Function(
fields.Integer("Pricing"), 'get_relation_count')
price_summary_count = fields.Function(
fields.Integer("Summary"), 'get_relation_count')
premium_decomposition_count = fields.Function(
fields.Integer("Premium lines"), 'get_relation_count')
mtm_count = fields.Function(
fields.Integer("MTM"), 'get_relation_count')
fee_count = fields.Function(
fields.Integer("Fees"), 'get_relation_count')
lot_count = fields.Function(
fields.Integer("Lots"), 'get_relation_count')
derivative_count = fields.Function(
fields.Integer("Derivatives"), 'get_relation_count')
estimated_date_count = fields.Function(
fields.Integer("Estimated dates"), 'get_relation_count')
charter_condition_count = fields.Function(
fields.Integer("Charter conditions"), 'get_relation_count')
_parent_field_map = {
'parent_trader': 'trader',
'parent_operator': 'operator',
'parent_broker': 'broker',
'parent_contract_template': 'contract_template',
'parent_our_reference': 'our_reference',
'parent_lc_date': 'lc_date',
'parent_product_origin': 'product_origin',
}
_relation_count_map = {
'price_component_count': 'price_components',
'pricing_count': 'price_pricing',
'price_summary_count': 'price_summary',
'premium_decomposition_count': 'premium_decomposition',
'mtm_count': 'mtm',
'fee_count': 'fees',
'lot_count': 'lots',
'derivative_count': 'derivatives',
'estimated_date_count': 'estimated_date',
'charter_condition_count': 'charter_conditions',
}
def get_parent_field(self, name=None):
field_name = self._parent_field_map.get(name)
if not field_name or not self.purchase:
return None
value = getattr(self.purchase, field_name, None)
if hasattr(value, 'id'):
return value.id
return value
def get_relation_count(self, name=None):
field_name = self._relation_count_map.get(name)
if not field_name:
return 0
return len(getattr(self, field_name, None) or [])
@classmethod
def default_unit(cls):
if is_itsa_company():
unit = default_itsa_unit()
if unit:
return unit
default = getattr(super(), 'default_unit', None)
if default:
return default()
@fields.depends('product', 'unit', 'purchase',
'_parent_purchase.company')
def on_change_product(self):
previous_unit = self.unit
parent_on_change = getattr(super(), 'on_change_product', None)
if parent_on_change:
parent_on_change()
if (self.purchase and is_itsa_company(self.purchase.company)
and (not previous_unit
or record_id(previous_unit) == default_itsa_unit())):
unit = default_itsa_unit()
if unit:
self.unit = unit
attributes = fields.Dict(
'product.attribute', 'Attributes',
domain=[
('sets', '=', Eval('attribute_set')),
],
states={
'readonly': ~Eval('attribute_set'),
},
depends=['product', 'attribute_set'],
help="Add attributes to the variant."
)
attribute_set = fields.Function(
fields.Many2One('product.attribute.set', "Attribute Set"),
'on_change_with_attribute_set'
)
attributes_name = fields.Function(
fields.Char("Attributes Name"),
'on_change_with_attributes_name'
)
finished = fields.Boolean("Mark as finished")
quality_analysis = fields.One2Many('quality.analysis','line',"Quality analysis")
assays = fields.One2Many('assay.assay','line',"Assays")
terms = fields.One2Many('concentrate.term','line',"Terms")
term = fields.Many2One('document.incoming',"Contract")
update_pricing = fields.Boolean("Update pricing")
assay_state = fields.Selection([
(None, ''),
('provisional', 'Provisional'),
('final', 'Final'),
('umpire', 'Umpire'),
], "Type")
@classmethod
def default_finished(cls):
return False
@property
def report_fixing_rule(self):
pricing_rule = ''
if self.pricing_rule:
pricing_rule = self.pricing_rule
return pricing_rule
@fields.depends('product')
def on_change_with_attribute_set(self, name=None):
if self.product and self.product.template and self.product.template.attribute_set:
return self.product.template.attribute_set.id
@fields.depends(
'product', 'attributes',
'coffee_origin', 'coffee_type', 'coffee_process', 'coffee_variety',
'coffee_crop_year', 'coffee_screen_size', 'coffee_moisture_max',
'coffee_defect_max', 'coffee_cup_score_min')
def on_change_with_attributes_name(self, name=None):
coffee_attributes_name = getattr(
self, '_coffee_attributes_name', lambda: None)()
if coffee_attributes_name:
return coffee_attributes_name
if not self.product or not self.product.attribute_set or not self.attributes:
return
def key(attribute):
return getattr(attribute, 'sequence', attribute.name)
values = []
for attribute in sorted(self.product.attribute_set.attributes, key=key):
if attribute.name in self.attributes:
value = self.attributes[attribute.name]
values.append(gettext(
'product_attribute.msg_label_value',
label=attribute.string,
value=attribute.format(value)
))
return " | ".join(filter(None, values))
@fields.depends(
'quantity_theorical', 'quantity', 'lots', 'targeted_qt',
methods=['_recompute_trade_price_fields'])
def on_change_quantity_theorical(self):
if self._sync_quantity_counter_from_theorical(self):
self._recompute_trade_price_fields()
self.targeted_qt = self._tolerance_theoretical_quantity()
@classmethod
def default_price_type(cls):
return 'priced'
@classmethod
def default_inherit_tol(cls):
return True
@classmethod
def default_enable_linked_currency(cls):
return False
@classmethod
def default_inherit_cer(cls):
return True
def get_matched_lines(self):
if self.lots:
LotQt = Pool().get('lot.qt')
return LotQt.search([('lot_p','=',self.lots[0].id),('lot_s','>',0)])
def get_date(self,trigger_event):
if trigger_event == 'bldate':
real_bl_date = self._get_real_bl_date()
if real_bl_date:
return real_bl_date
trigger_date = None
if self.estimated_date:
trigger_date = [d.estimated_date for d in self.estimated_date if d.trigger == trigger_event]
trigger_date = trigger_date[0] if trigger_date else None
return trigger_date
def _get_real_bl_date(self):
bl_dates = []
for lot in self.lots or []:
shipment = getattr(lot, 'lot_shipment_in', None)
bl_date = getattr(shipment, 'bl_date', None)
if bl_date:
bl_dates.append(bl_date)
if not bl_dates:
for match in self.get_matched_lines() or []:
shipment = getattr(match, 'lot_shipment_in', None)
bl_date = getattr(shipment, 'bl_date', None)
if bl_date:
bl_dates.append(bl_date)
# Multiple BL dates on one line should not happen; use the first one
# for now and revisit when partial shipments need split maturity/pricing.
return bl_dates[0] if bl_dates else None
def get_tol_min(self,name):
if self.inherit_tol:
if self.purchase.tol_min and self.quantity_theorical:
return round((1-(self.purchase.tol_min/100))*Decimal(self.quantity_theorical),3)
else:
if self.tol_min and self.quantity_theorical:
return round((1-(self.tol_min/100))*Decimal(self.quantity_theorical),3)
def get_tol_max(self,name):
if self.inherit_tol:
if self.purchase.tol_max and self.quantity_theorical:
return round((1+(self.purchase.tol_max/100))*Decimal(self.quantity_theorical),3)
else:
if self.tol_max and self.quantity_theorical:
return round((1+(self.tol_max/100))*Decimal(self.quantity_theorical),3)
def _line_quantity_in_unit(self, quantity, from_unit, to_unit):
if not from_unit or not to_unit or from_unit == to_unit:
return Decimal(str(quantity or 0))
Uom = Pool().get('product.uom')
factor = None
rate = None
if from_unit.category.id != to_unit.category.id:
factor = 1
rate = 1
return Decimal(str(Uom.compute_qty(
from_unit, float(quantity or 0), to_unit, True, factor, rate)))
def _tolerance_theoretical_quantity(self):
return Decimal(str(
getattr(self, 'quantity_theorical', None)
or getattr(self, 'quantity', None)
or 0))
def _tolerance_actual_quantity(self):
LotQt = Pool().get('lot.qt')
unit = getattr(self, 'unit', None)
physical_lots = [
lot for lot in getattr(self, 'lots', None) or []
if getattr(lot, 'lot_type', None) == 'physic']
if physical_lots:
return sum(
Decimal(str(
lot.get_current_quantity_converted(unit=unit) or 0))
for lot in physical_lots)
quantity = Decimal(0)
for lot in getattr(self, 'lots', None) or []:
if getattr(lot, 'lot_type', None) != 'virtual':
continue
for lqt in LotQt.search([('lot_p', '=', lot.id)]):
quantity += self._line_quantity_in_unit(
getattr(lqt, 'lot_quantity', 0),
getattr(lqt, 'lot_unit', None),
unit)
return quantity
def _tolerance_targeted_quantity(self):
targeted = getattr(self, 'targeted_qt', None)
if not self._is_empty_quantity(targeted):
return Decimal(str(targeted))
return self._tolerance_theoretical_quantity()
@fields.depends('targeted_qt', 'quantity_theorical', 'quantity')
def get_tolerance_used(self, name):
theoretical = self._tolerance_theoretical_quantity()
if not theoretical:
return Decimal(0)
return round(
((self._tolerance_targeted_quantity() - theoretical)
/ theoretical) * Decimal(100), 5)
def get_tolerance_min(self, name):
purchase = getattr(self, 'purchase', None)
if self.inherit_tol and purchase:
return -Decimal(str(purchase.tol_min or 0))
return -Decimal(str(self.tol_min or 0))
def get_tolerance_max(self, name):
purchase = getattr(self, 'purchase', None)
if self.inherit_tol and purchase:
return Decimal(str(purchase.tol_max or 0))
return Decimal(str(self.tol_max or 0))
def check_targeted_qt_tolerance(self):
theoretical = self._tolerance_theoretical_quantity()
if not theoretical:
return
if self._is_empty_quantity(getattr(self, 'targeted_qt', None)):
self.targeted_qt = theoretical
targeted = self._tolerance_targeted_quantity()
tol_min = -self.get_tolerance_min('tolerance_min')
tol_max = self.get_tolerance_max('tolerance_max')
min_qt = theoretical * (Decimal(1) - tol_min / Decimal(100))
max_qt = theoretical * (Decimal(1) + tol_max / Decimal(100))
if targeted < min_qt:
raise UserError(
"Targeted Qt is below the tolerance minimum.")
if targeted > max_qt:
raise UserError(
"Targeted Qt exceeds the tolerance maximum.")
def get_progress(self,name):
PS = Pool().get('purchase.pricing.summary')
ps = PS.search(['line','=',self.id])
if ps:
if not self.price_components:
manual = [e for e in ps if not e.price_component]
if manual:
return manual[0].progress or 0
return sum((e.progress if e.progress else 0) * (e.ratio if e.ratio else 0) / 100 for e in ps)
def getVirtualLot(self):
if self.lots:
return [l for l in self.lots if l.lot_type=='virtual'][0]
def _get_linked_unit_factor(self):
if not (self.enable_linked_currency and self.linked_currency):
return None
factor = Decimal(self.linked_currency.factor or 0)
if not factor:
return None
unit_factor = Decimal(1)
if self.linked_unit:
source_unit = getattr(self, 'unit', None)
if not source_unit and self.product:
source_unit = self.product.purchase_uom or self.product.default_uom
if not source_unit:
return factor
Uom = Pool().get('product.uom')
unit_factor = Decimal(str(
Uom.compute_qty(source_unit, float(1), self.linked_unit) or 0))
return factor * unit_factor
def _linked_to_line_price(self, price):
factor = self._get_linked_unit_factor()
price = Decimal(price or 0)
if not factor:
return price
return round(price * factor, 4)
def _line_to_linked_price(self, price):
factor = self._get_linked_unit_factor()
price = Decimal(price or 0)
if not factor:
return price
return round(price / factor, 4)
def get_premium(self, name=None):
return round(sum(
Decimal(str(getattr(premium, 'base_amount', None)
if getattr(premium, 'base_amount', None) is not None
else premium.get_base_amount('base_amount') or 0))
for premium in (self.premium_decomposition or [])), 4)
def get_effective_charter_conditions(self):
if self.charter_conditions:
return list(self.charter_conditions)
purchase = getattr(self, 'purchase', None)
return list(getattr(purchase, 'charter_conditions', None) or [])
@classmethod
def set_premium(cls, lines, name, value):
Premium = Pool().get('premium.composition')
value = Decimal(str(value or 0))
for line in lines:
existing = Premium.search([('line', '=', line.id)])
if existing:
Premium.delete(existing)
if value:
Premium.create([{
'line': line.id,
'premium': value,
'currency': (
line.purchase.currency.id
if line.purchase and line.purchase.currency else None),
}])
def _get_premium_price(self):
return Decimal(self.premium or 0)
def _get_amount_quantity(self):
if getattr(self, 'finished', False):
weight_basis_quantity = self._get_weight_basis_quantity()
if weight_basis_quantity is not None:
return weight_basis_quantity
quantity = getattr(self, 'quantity', None)
if quantity is None:
quantity = getattr(self, 'quantity_theorical', None)
return quantity
quantity = getattr(self, 'quantity_theorical', None)
if quantity is None:
quantity = getattr(self, 'quantity', None)
return quantity
def _get_weight_basis_quantity(self):
physical_lots = [
lot for lot in (getattr(self, 'lots', None) or [])
if getattr(lot, 'lot_type', None) == 'physic']
if not physical_lots:
return None
wb = getattr(getattr(self, 'purchase', None), 'wb', None)
qt_type = getattr(wb, 'qt_type', None)
if not qt_type:
return None
state_id = getattr(qt_type, 'id', qt_type)
quantity = Decimal(0)
for lot in physical_lots:
hist_types = [
getattr(hist, 'quantity_type', None)
for hist in (getattr(lot, 'lot_hist', None) or [])]
hist_type_ids = [getattr(hist_type, 'id', hist_type)
for hist_type in hist_types]
if qt_type not in hist_types and state_id not in hist_type_ids:
return None
quantity += Decimal(str(
lot.get_current_quantity_converted(state_id, self.unit) or 0))
return quantity
def _get_basis_component_price(self):
price = Decimal(0)
terms = getattr(self, 'terms', None)
if terms:
for t in terms:
price += (t.manual_price if t.manual_price else Decimal(0))
else:
if not self.price_components:
PP = Pool().get('purchase.pricing.summary')
pp = PP.search([
('line', '=', self.id),
('price_component', '=', None),
], limit=1)
if pp:
return round(Decimal(pp[0].price or 0), 4)
for pc in self.price_components:
PP = Pool().get('purchase.pricing.summary')
pp = PP.search([('price_component','=',pc.id),('line','=',self.id)])
if pp:
price += pp[0].price * (pc.ratio / 100)
return round(price,4)
def get_basis_price(self):
return round(self._get_basis_component_price(), 4)
def sync_linked_price_from_basis(self):
if self.enable_linked_currency and self.linked_currency:
self.linked_price = self._line_to_linked_price(
self._get_basis_component_price())
def get_price(self,lot_premium=0):
return round(
Decimal(self.unit_price or 0)
+ Decimal(lot_premium or 0),
4)
def get_price_linked_currency(self,lot_premium=0):
return round(
self._linked_to_line_price(
Decimal(self.linked_price or 0)
+ Decimal(lot_premium or 0)),
4)
@fields.depends('id','unit','quantity','unit_price','price_pricing','price_type','price_components','estimated_date','lots','fees','enable_linked_currency','linked_price','linked_currency','linked_unit','premium_decomposition')
def on_change_with_unit_price(self, name=None):
Date = Pool().get('ir.date')
logger.info("ONCHANGEUNITPRICE:%s",self.unit_price)
if self.price_type == 'basis':
self.sync_linked_price_from_basis()
price = self.get_basis_price()
logger.info("ONCHANGEUNITPRICE_IN:%s",price)
return price
if self.enable_linked_currency and self.linked_price and self.linked_currency and self.price_type == 'priced':
return self.get_price_linked_currency()
if self.price_type == 'efp':
if hasattr(self, 'derivatives') and self.derivatives:
for d in self.derivatives:
return round(d.price_index.get_price(Date.today(),self.unit,self.purchase.currency,True),4)
return self.get_price()
@fields.depends(
'type', 'quantity', 'quantity_theorical', 'finished',
'unit_price', 'unit', 'product', 'lots',
'purchase', '_parent_purchase.currency', '_parent_purchase.wb',
'premium_decomposition', 'enable_linked_currency',
'linked_currency', 'linked_unit')
def on_change_with_amount(self):
if self.type == 'line' and self.unit_price is not None:
quantity = self._get_amount_quantity()
if quantity is None:
return Decimal(0)
currency = self.purchase.currency if self.purchase else None
amount = Decimal(str(quantity)) * (
Decimal(self.unit_price or 0) + self._get_premium_price())
if currency:
return currency.round(amount)
return amount
return Decimal(0)
@fields.depends(
'unit', 'product', 'price_type', 'enable_linked_currency',
'linked_currency', 'linked_unit', 'linked_price',
'premium_decomposition',
methods=['on_change_with_unit_price', 'on_change_with_amount'])
def _recompute_trade_price_fields(self):
self.unit_price = self.on_change_with_unit_price()
self.amount = self.on_change_with_amount()
@fields.depends(methods=['_recompute_trade_price_fields'])
def on_change_premium(self):
self._recompute_trade_price_fields()
@fields.depends(methods=['_recompute_trade_price_fields'])
def on_change_premium_decomposition(self):
self._recompute_trade_price_fields()
@fields.depends(methods=['_recompute_trade_price_fields'])
def on_change_price_type(self):
self._recompute_trade_price_fields()
@fields.depends(methods=['_recompute_trade_price_fields'])
def on_change_enable_linked_currency(self):
self._recompute_trade_price_fields()
@fields.depends(methods=['_recompute_trade_price_fields'])
def on_change_linked_price(self):
self._recompute_trade_price_fields()
@fields.depends(methods=['_recompute_trade_price_fields'])
def on_change_linked_currency(self):
self._recompute_trade_price_fields()
@fields.depends(methods=['_recompute_trade_price_fields'])
def on_change_linked_unit(self):
self._recompute_trade_price_fields()
@classmethod
def _valuation_regeneration_fields(cls):
return {
'quantity', 'quantity_theorical', 'unit', 'unit_price',
'price_type', 'premium', 'linked_price', 'linked_currency',
'linked_unit', 'price_pricing', 'price_components', 'derivatives',
'fees', 'lots', 'product', 'currency',
}
@classmethod
def _should_regenerate_valuation(cls, values):
return bool(cls._valuation_regeneration_fields() & set(values))
@classmethod
def _sync_virtual_lot_packing(cls, line):
Lot = Pool().get('lot.lot')
if not getattr(line, 'lots', None):
line = cls(line.id)
virtual_lots = [
lot for lot in (line.lots or [])
if getattr(lot, 'lot_type', None) == 'virtual']
if not virtual_lots:
return
lot = virtual_lots[0]
packing_count = getattr(line, 'coffee_packing_count', None)
packing_unit = getattr(line, 'coffee_packing_unit', None)
lot_unit_id = getattr(lot.lot_unit, 'id', lot.lot_unit)
packing_unit_id = getattr(packing_unit, 'id', packing_unit)
if lot.lot_qt == packing_count and lot_unit_id == packing_unit_id:
return
lot.lot_qt = packing_count
lot.lot_unit = packing_unit
Lot.save([lot])
@classmethod
def write(cls, *args):
actions = iter(args)
args = []
valuation_line_ids = set()
for records, values in zip(actions, actions):
cls._check_delivery_period_values(records, values)
if cls._should_regenerate_valuation(values):
valuation_line_ids.update(record.id for record in records)
args.extend((records, values))
# Agents:
# Ici on gère la variation éventuelle de la théorical quantity après création du contrat
# Si delta > 0 on met à jour le lot virtual qui est toujours unique pour une purchase line
# mais aussi la table lot_qt dont le lot_p est ce lot virtuel (ajuster lot_quantity)
# si il n'existe aucun lot_qt non shippé (tous les lot_shipments à None) et aucun non matché (lot_s à None)
# alors il faut créer un nouveau lot_qt non shippé et non matché avec le delta
# Si delta négatif alors on decrease si c'est possible le lot_qt non shippé non matché et s'il n'y en a pas on envoie un
# message d'erreur 'Please unlink or unmatch lot'
old_values = {}
for records, values in zip(args[::2], args[1::2]):
if 'quantity_theorical' in values:
for record in records:
old_values[record.id] = record.quantity_theorical
cls._set_quantity_counter_values_from_theorical(
records, values)
super().write(*args)
lines = sum(args[::2], [])
for line in lines:
if line.id in old_values:
new = Decimal(line.quantity_theorical or 0)
virtual_lots = [lot for lot in (line.lots or []) if lot.lot_type == 'virtual']
if not virtual_lots:
continue
vlot = virtual_lots[0]
# Initializing quantity_theorical after virtual-lot creation
# must not add the line quantity a second time.
old = old_values[line.id]
baseline = (
Decimal(old)
if old is not None
else Decimal(vlot.get_current_quantity_converted() or 0)
)
delta = new - baseline
if delta:
physical_quantity = sum(
Decimal(lot.get_current_quantity_converted() or 0)
for lot in (line.lots or [])
if lot.lot_type == 'physic')
target_quantity = round(new - physical_quantity, 5)
if target_quantity < 0:
raise UserError("Please unlink or unmatch lot")
cls._sync_open_lot_quantity(line, vlot, target_quantity)
for fee in line.fees or []:
fee.sync_quantity_from_lots()
fee.adjust_purchase_values()
Pool().get('lot.lot').assert_lines_quantity_consistency(
cls._fresh_lines_for_quantity_consistency(lines))
cls._create_missing_estimated_bl_dates(lines)
if not Transaction().context.get('_purchase_trade_skip_fee_rules'):
Pool().get('fee.rule').apply_to_lines(
lines, 'purchase_line', auto_only=True)
if (valuation_line_ids
and not Transaction().context.get(
'_purchase_trade_skip_valuation_regeneration')):
Valuation = Pool().get('valuation.valuation')
with Transaction().set_context(
_purchase_trade_skip_valuation_regeneration=True):
Valuation.regenerate_for_purchase_lines(
cls.browse(list(valuation_line_ids)))
@classmethod
def _sync_open_lot_quantity(cls, line, vlot, target_quantity):
Lot = Pool().get('lot.lot')
LotQt = Pool().get('lot.qt')
free_domain = [
('lot_p', '=', vlot.id),
('lot_s', '=', None),
('lot_shipment_in', '=', None),
('lot_shipment_internal', '=', None),
('lot_shipment_out', '=', None),
]
free_lqts = LotQt.search(free_domain)
allocated_lqts = LotQt.search([
('lot_p', '=', vlot.id),
[
'OR',
('lot_s', '!=', None),
('lot_shipment_in', '!=', None),
('lot_shipment_internal', '!=', None),
('lot_shipment_out', '!=', None),
],
])
allocated_quantity = sum(
Decimal(lqt.lot_quantity or 0) for lqt in allocated_lqts)
free_quantity = round(target_quantity - allocated_quantity, 5)
logger.info(
"PURCHASE_QTY_SYNC line=%s vlot=%s target=%s allocated=%s "
"free=%s free_lqts=%s",
getattr(line, 'id', None),
getattr(vlot, 'id', None),
target_quantity,
allocated_quantity,
free_quantity,
[getattr(lqt, 'id', None) for lqt in free_lqts])
if free_quantity < 0:
raise UserError("Please unlink or unmatch lot")
if free_lqts:
lqt = free_lqts[0]
logger.info(
"PURCHASE_QTY_SYNC_FREE_BEFORE lqt=%s quantity=%s new=%s",
getattr(lqt, 'id', None),
getattr(lqt, 'lot_quantity', None),
free_quantity)
lqt.lot_quantity = free_quantity
LotQt.save([lqt])
logger.info(
"PURCHASE_QTY_SYNC_FREE_AFTER lqt=%s quantity=%s",
getattr(lqt, 'id', None),
getattr(LotQt(getattr(lqt, 'id')), 'lot_quantity', None))
elif free_quantity > 0:
lqt = LotQt()
lqt.lot_p = vlot.id
lqt.lot_s = None
lqt.lot_quantity = free_quantity
lqt.lot_unit = line.unit
LotQt.save([lqt])
current_quantity = round(
Decimal(vlot.get_current_quantity_converted() or 0), 5)
if current_quantity != target_quantity:
vlot.set_current_quantity(target_quantity, target_quantity, 1)
Lot.save([vlot])
@classmethod
def copy(cls, lines, default=None):
if default is None:
default = {}
else:
default = default.copy()
default.setdefault('lots', None)
default.setdefault('quantity', Decimal(0))
default.setdefault('quantity_theorical', None)
default.setdefault('price_pricing', None)
return super().copy(lines, default=default)
@classmethod
def delete(cls, lines):
pool = Pool()
LotQt = pool.get('lot.qt')
Valuation = pool.get('valuation.valuation')
OpenPosition = pool.get('open.position')
for line in lines:
if line.lots:
vlot_p = line.lots[0].getVlot_p()
lqts = LotQt.search([('lot_p','=',vlot_p.id),('lot_s','!=',None),('lot_quantity','>',0)])
if lqts:
raise UserError("You cannot delete matched sale")
return
lqts = LotQt.search([('lot_p','=',vlot_p.id)])
LotQt.delete(lqts)
valuations = Valuation.search([('lot','in',line.lots)])
if valuations:
Valuation.delete(valuations)
# op = OpenPosition.search(['line','=',line.id])
# if op:
# OpenPosition.delete(op)
super(Line, cls).delete(lines)
@classmethod
def validate(cls, lines):
super(Line, cls).validate(lines)
for line in lines:
if cls._has_invalid_delivery_period(line):
raise UserError(
"Shipment period From date must be before To date.")
if line.price_components:
for pc in line.price_components:
if pc.triggers:
for tr in pc.triggers:
line.check_from_to(tr)
line.check_pricing()
line.check_targeted_qt_tolerance()
#no lot need to create one with line quantity
if not line.created_by_code:
if cls._sync_initial_quantity_from_theorical(line):
cls.save([line])
if (not line.lots and line.product.type != 'service'
and line.quantity_theorical
and Decimal(str(line.quantity_theorical or 0)) != Decimal(0)):
FeeLots = Pool().get('fee.lots')
LotQtHist = Pool().get('lot.qt.hist')
LotQtType = Pool().get('lot.qt.type')
Lot = Pool().get('lot.lot')
lot = Lot()
lot.line = line.id
lot.lot_qt = None
lot.lot_unit = None
lot.lot_unit_line = line.unit
lot.lot_quantity = round(
Decimal(str(line.quantity_theorical)), 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 = lot.lot_quantity
logger.info("PURCHASE_VALIDATE:%s",lot.lot_quantity)
lqh.gross_quantity = lot.lot_quantity
lot.lot_hist = [lqh]
if Decimal(str(line.quantity_theorical or 0)) > 0:
Lot.save([lot])
#check if fees need to be updated
if line.fees:
for fee in line.fees:
fl_check = FeeLots.search([('fee','=',fee.id),('lot','=',lot.id),('line','=',line.id)])
if not fl_check:
fl = FeeLots()
fl.fee = fee.id
fl.lot = lot.id
fl.line = line.id
FeeLots.save([fl])
cls._sync_virtual_lot_packing(line)
if line.fee_:
if not line.fee_.purchase:
Fee = Pool().get('fee.fee')
f = Fee(line.fee_)
f.purchase = line.purchase
Fee.save([f])
if line.assays:
for assay in line.assays:
if not assay.lines and assay.analysis:
file_data = assay.analysis.data or b""
logger.info(f"File size: {len(file_data)} bytes")
file_name = assay.analysis.name or "document"
response = requests.post(
"http://62.72.36.116:8006/ocr-parse-assay",
files={"file": (file_name, io.BytesIO(file_data))}
)
response.raise_for_status()
f = response.json()
logger.info("RUN_OCR_RESPONSE:%s", f)
parsed_data_str = f.get("parsed_data") # string JSON venant de ton endpoint
if parsed_data_str:
if isinstance(parsed_data_str, str):
data = json.loads(parsed_data_str)
else:
data = parsed_data_str or {}
else:
data = {} # fallback si aucune donnée
importer = AssayImporter()
importer.import_from_json(data, assay)
logger.info("Updated assay:%s", assay.id)
def check_from_to(self,tr):
if tr.pricing_period:
date_from,date_to, d, include, dates = tr.getDateWithEstTrigger(1)
if date_from:
tr.from_p = date_from.date()
if date_to:
tr.to_p = date_to.date()
if tr.application_period:
date_from,date_to, d, include, dates = tr.getDateWithEstTrigger(2)
if date_from:
tr.from_a = date_from.date()
if date_to:
tr.to_a = date_to.date()
TR = Pool().get('pricing.trigger')
TR.save([tr])
def get_element_grade(self, state, element):
if self.assays:
for assay in self.assays:
if assay.type == state:
for line in assay.lines:
if line.element == element:
return line.value
def check_pricing(self):
if self.terms and self.update_pricing:
Concentrate = Pool().get('concentrate.term')
for t in self.terms:
grade = self.get_element_grade(self.assay_state,t.element)
logger.info("GRADE:%s",grade)
if grade != None:
payable_price = Decimal(0)
penalty_price = Decimal(0)
if t.penalty_rules:
penalty_price = t.penalty_rules.compute_penalty(grade)
if t.component:
cp = [c for c in self.price_summary if c.price_component == t.component]
if cp:
cp = cp[0]
price = Decimal(cp.get_last_price())
logger.info("PRICE:%s",price)
if t.payable_rule:
payable_price = t.payable_rule.compute_payable_quantity(grade) * price / Decimal(100)
t.manual_price = round(payable_price - penalty_price,2)
t.currency = self.purchase.currency
t.unit = self.unit
Concentrate.save([t])
if self.price_components:
for pc in self.price_components:
if not pc.auto:
Pricing = Pool().get('pricing.pricing')
pricings = Pricing.search(['price_component','=',pc.id],order=[('pricing_date', 'ASC')])
if pricings:
Pricing._sync_manual_values(pricings)
Pricing._sync_manual_last(pricings)
Pricing._sync_eod_price(pricings)
if pc.triggers and pc.auto:
prDate = []
prPrice = []
apDate = []
apPrice = []
calendar = pc.get_calendar()
for t in pc.triggers:
prD, prP = t.getPricingListDates(calendar)
apD, apP = t.getApplicationListDates(calendar)
prDate.extend(prD)
prPrice.extend(prP)
apDate.extend(apD)
apPrice.extend(apP)
if pc.quota:
prPrice = self.get_avg(prPrice)
self.generate_pricing(pc,apDate,prPrice)
def get_avg(self,lprice):
l = len(lprice)
if l > 0 :
cumulprice = Decimal(0)
i = 1
for p in lprice:
if i > 1:
p['avg_minus_1'] = cumulprice / (i-1)
p['price'] = Decimal(str(p['price'] or 0))
cumulprice += p['price']
p['avg'] = cumulprice / i
i += 1
return lprice
def getnearprice(self,pl,d,t,max_date=None):
if pl:
pl_sorted = sorted(pl, key=lambda x: x['date'])
pminus = pl_sorted[0]
if not max_date:
max_date = d.date()
for p in pl_sorted:
if p['date'].date() == d.date():
if p['isAvg'] and t == 'avg':
return p[t]
if not p['isAvg'] and t == 'avg':
return p['price']
elif p['date'].date() > d.date():
if pminus != p:
return pminus[t]
else:
return Decimal(0)
pminus = p
return pl_sorted[len(pl)-1][t]
return Decimal(0)
def _get_pricing_base_quantity(self):
quantity = self.quantity_theorical
if quantity is None:
quantity = self.quantity
return Decimal(str(quantity or 0))
def generate_pricing(self,pc,dl,pl):
Pricing = Pool().get('pricing.pricing')
pricing = Pricing.search(['price_component','=',pc.id])
if pricing:
Pricing.delete(pricing)
base_quantity = self._get_pricing_base_quantity()
cumul_qt = 0
index = 0
dl_sorted = sorted(dl)
for d in dl_sorted:
if pc.pricing_date and d.date() > pc.pricing_date:
break
p = Pricing()
p.line = self.id
p.pricing_date = d.date()
p.price_component = pc.id
p.quantity = round(Decimal(pc.quota),5)
price = round(Decimal(self.getnearprice(pl,d,'price')),4)
p.settl_price = price
if price > 0:
cumul_qt += pc.quota
p.fixed_qt = round(Decimal(cumul_qt),5)
p.fixed_qt_price = round(Decimal(self.getnearprice(pl,d,'avg')),4)
#p.fixed_qt_price = p.get_fixed_price()
if p.fixed_qt_price == 0:
p.fixed_qt_price = round(Decimal(self.getnearprice(pl,d,'avg_minus_1')),4)
p.unfixed_qt = round(base_quantity - Decimal(cumul_qt),5)
if p.unfixed_qt < 0.001:
p.unfixed_qt = Decimal(0)
p.fixed_qt = base_quantity
if price > 0:
p.unfixed_qt_price = price
else:
pr = Decimal(pc.get_price(
p.pricing_date, self.unit, self.purchase.currency, True))
pr = round(pr,4)
p.unfixed_qt_price = pr
p.eod_price = p.get_eod_price_purchase()
if (index == len(dl)-1) or (pc.pricing_date and (index < len(dl)-1 and dl_sorted[index+1].date() > pc.pricing_date)):
p.last = True
Pricing.save([p])
index += 1
# @classmethod
# def view_attributes(cls):
# return super().view_attributes() + [
# ('/tree/field[@name="quantity"]', 'visual',
# If(Eval('quantity') & (Eval('quantity', 0) > 0),'success','danger')),
# ]
class GoToBi(Wizard):
__name__ = 'purchase.bi'
start_state = 'bi'
bi = StateAction('purchase_trade.url_bi')
def do_bi(self, action):
Configuration = Pool().get('gr.configuration')
config = Configuration.search(['id','>',0])[0]
ct_number = self.records[0].number
action['url'] = config.bi + '/dashboard/6-pnl?lot=&product=&purchase='+ ct_number + '&sale='
return action, {}
class PurchaseAllocationsWizard(Wizard):
'Open Allocations report from Purchase without modal'
__name__ = 'purchase.allocations.wizard'
start_state = 'open_report'
open_report = StateAction('purchase_trade.act_lot_report_form')
def do_open_report(self, action):
purchase_id = Transaction().context.get('active_id')
if not purchase_id:
raise ValueError("No active purchase ID in context")
action['context_model'] = 'lot.context'
action['pyson_context'] = PYSONEncoder().encode({
'purchase': purchase_id,
})
return action, {}
class PurchasePnlGraph(ModelSQL, ModelView):
"Purchase Pnl graph"
__name__ = 'purchase.pnl.graph'
purchase = fields.Many2One('purchase.purchase', "Purchase")
valuation_date = fields.Date("Valuation Date")
pnl = fields.Numeric("Pnl", digits=(16, 2))
mtm = fields.Numeric("Mtm", digits=(16, 2))
@classmethod
def __setup__(cls):
super().__setup__()
cls._order.insert(0, ('valuation_date', 'ASC'))
@classmethod
def table_query(cls):
Valuation = Pool().get('valuation.valuation')
PurchaseLine = Pool().get('purchase.line')
val = Valuation.__table__()
line = PurchaseLine.__table__()
purchase = Coalesce(val.purchase, line.purchase)
return val.join(
line, 'LEFT', condition=val.line == line.id).select(
Literal(0).as_('create_uid'),
CurrentTimestamp().as_('create_date'),
Literal(None).as_('write_uid'),
Literal(None).as_('write_date'),
Max(val.id).as_('id'),
purchase.as_('purchase'),
val.date.as_('valuation_date'),
Sum(Coalesce(val.pnl, 0)).as_('pnl'),
Sum(Coalesce(val.mtm, 0)).as_('mtm'),
where=(purchase != None),
group_by=[purchase, val.date],
order_by=[val.date.asc])
class PurchaseInvoiceReport(
ModelSQL, ModelView):
"Purchase invoices"
__name__ = 'purchase.invoice.report'
r_supplier = fields.Many2One('party.party',"Supplier")
r_purchase = fields.Many2One('purchase.purchase', "Purchase")
r_line = fields.Many2One('purchase.line',"Line")
r_lot = fields.Many2One('lot.lot',"Lot")
r_product = fields.Many2One('product.product', "Product")
r_pur_invoice = fields.Many2One('account.invoice',"Invoice")
r_inv_date = fields.Date("Inv. date")
r_pur_payment = fields.Many2Many('account.invoice-account.move.line','invoice', 'line', string='Payment')
r_invoice_amount = fields.Numeric("Amount",digits=(1,2))
r_payment_amount = fields.Numeric("Paid",digits=(1,2))
r_left_amount = fields.Numeric("Left",digits=(1,2))
r_curr = fields.Many2One('currency.currency',"Curr")
r_reconciliation = fields.Integer("Reconciliation")
r_move = fields.Many2One('account.move',"Move")
r_status = fields.Selection([
('not', 'Not'),
('paid', 'Paid'),
('partial', 'Partially'),
], 'Status')
@classmethod
def table_query(cls):
pool = Pool()
Invoice = pool.get('account.invoice')
InvoiceLine = pool.get('account.invoice.line')
PurchaseLine = pool.get('purchase.line')
Purchase = pool.get('purchase.purchase')
Party = pool.get('party.party')
InvoicePayment = pool.get('account.invoice-account.move.line')
MoveLine = pool.get('account.move.line')
Move = pool.get('account.move')
Currency = pool.get('currency.currency')
Lot = pool.get('lot.lot')
ai = Invoice.__table__()
ail = InvoiceLine.__table__()
pl = PurchaseLine.__table__()
pu = Purchase.__table__()
pa = Party.__table__()
aiaml = InvoicePayment.__table__()
aml = MoveLine.__table__()
lot = Lot.__table__()
cu = Currency.__table__()
mo = Move.__table__()
context = Transaction().context
supplier = context.get('supplier')
purchase = context.get('purchase')
asof = context.get('asof')
todate = context.get('todate')
state = context.get('state')
wh = Literal(True)
wh &= lot.lot_type == 'physic'
wh &= ai.type == 'in'
if supplier:
wh &= (ai.party == supplier)
if purchase:
wh &= (pu.id == purchase)
if asof and todate:
wh &= (ai.invoice_date >= asof) & (ai.invoice_date <= todate)
query = (
lot
.join(ail, 'LEFT', condition=ail.lot == lot.id)
.join(ai, 'LEFT', condition=ai.id == ail.invoice)
.join(pl, 'LEFT', condition=pl.id == lot.line)
.join(pu, 'LEFT', condition=pl.purchase == pu.id)
.join(pa, 'LEFT', condition=ai.party == pa.id)
.join(cu, 'LEFT', condition=cu.id == ail.currency)
.select(
Literal(0).as_('create_uid'),
CurrentTimestamp().as_('create_date'),
Literal(0).as_('write_uid'),
Literal(0).as_('write_date'),
Max(ail.id).as_('id'),
Max(pa.id).as_('r_supplier'),
pu.id.as_('r_purchase'),
pl.id.as_('r_line'),
Max(ail.product).as_('r_product'),
Max(lot.id).as_('r_lot'),
ai.id.as_('r_pur_invoice'),
Max(ai.invoice_date).as_('r_inv_date'),
Sum(ail.quantity*ail.unit_price).as_('r_invoice_amount'),
Max(cu.id).as_('r_curr'),
where=wh,
group_by=[pu.id,pl.id,ai.id]
)
)
query_alias = query
left = Case((Abs(Sum(aml.amount_second_currency))>0,(Max(query_alias.r_invoice_amount)-Sum(aml.amount_second_currency))),else_=(Max(query_alias.r_invoice_amount)-(Sum(aml.debit)-Sum(aml.credit))))
status = Case((left==0, 'paid'),else_=Case((left<Max(query_alias.r_invoice_amount),'partial'),else_='not'))
wh = Literal(True)
if state != 'all':
wh &= status == state
query2 = (
query_alias
.join(aiaml, 'LEFT', condition=(aiaml.invoice == query_alias.r_pur_invoice))
.join(aml, 'LEFT', condition=aml.id == aiaml.line)
.join(mo, 'LEFT', condition=mo.id == aml.move)
.select(
Literal(0).as_('create_uid'),
CurrentTimestamp().as_('create_date'),
Literal(0).as_('write_uid'),
Literal(0).as_('write_date'),
Max(query_alias.id).as_('id'),
Max(query_alias.r_supplier).as_('r_supplier'),
query_alias.r_purchase.as_('r_purchase'),
query_alias.r_line.as_('r_line'),
Max(query_alias.r_product).as_('r_product'),
Max(query_alias.r_lot).as_('r_lot'),
query_alias.r_pur_invoice.as_('r_pur_invoice'),
Max(query_alias.r_inv_date).as_('r_inv_date'),
Max(query_alias.r_invoice_amount).as_('r_invoice_amount'),
Sum(aml.amount_second_currency).as_('r_payment_amount'),
left.as_('r_left_amount'),
status.as_('r_status'),
Max(query_alias.r_curr).as_('r_curr'),
Max(aml.reconciliation).as_('r_reconciliation'),
Max(mo.id).as_('r_move'),
Max(aml.id).as_('r_pur_payment'),
where=wh,
group_by=[query_alias.r_purchase,query_alias.r_line,query_alias.r_pur_invoice]
))
return query2
class PurchaseInvoiceContext(ModelView):
"Purchase Invoice Context"
__name__ = 'purchase.invoice.context'
asof = fields.Date("As of")
todate = fields.Date("To")
supplier = fields.Many2One('party.party',"Supplier")
purchase = fields.Many2One('purchase.purchase', "Purchase")
state = fields.Selection([
('all', 'All'),
('not', 'Not'),
('paid', 'Paid'),
('partial', 'Partially')
], 'State')
@classmethod
def default_asof(cls):
pool = Pool()
Date = pool.get('ir.date')
return Date.today().replace(day=1,month=1,year=1999)
@classmethod
def default_todate(cls):
pool = Pool()
Date = pool.get('ir.date')
return Date.today()
@classmethod
def default_state(cls):
return 'all'
class InvoicePayment(Wizard):
'Payments'
__name__ = 'purchase.invoice.payment'
start_state = 'open_'
open_ = StateAction('account.act_move_line_form')
def do_open_(self, action):
if self.record.r_pur_invoice:
Payment = Pool().get('account.invoice-account.move.line')
payments = Payment.search(['invoice','=',self.record.r_pur_invoice.id])
if payments:
lines = [e.line.id for e in payments]
return action, {'res_id': lines}
class PnlReport(Wizard):
'Pnl report'
__name__ = 'pnl.report'
start = StateAction('purchase_trade.act_pnl_bi')
def do_start(self, action):
pool = Pool()
# action['views'].reverse()
return action, {'res_id': [1]}
class PnlBI(ModelSingleton,ModelSQL, ModelView):
'Pnl BI'
__name__ = 'pnl.bi'
input = fields.Text("BI")
metabase = fields.Function(fields.Text(""),'get_bi')
def get_bi(self,name=None):
Configuration = Pool().get('gr.configuration')
config = Configuration.search(['id','>',0])[0]
payload = {
"resource": {"dashboard": config.pnl_id},
"params": {},
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=30),
}
token = jwt.encode(payload, config.payload, algorithm="HS256")
logger.info("TOKEN:%s",token)
if config.dark:
url = f"metabase:{config.bi}/embed/dashboard/{token}#theme=night&bordered=true&titled=true"
else:
url = f"metabase:{config.bi}/embed/dashboard/{token}#bordered=true&titled=true"
return url
class PositionReport(Wizard):
'Position report'
__name__ = 'position.report'
start = StateAction('purchase_trade.act_position_bi')
def do_start(self, action):
pool = Pool()
# action['views'].reverse()
return action, {'res_id': [1]}
class PositionBI(ModelSingleton,ModelSQL, ModelView):
'Position BI'
__name__ = 'position.bi'
input = fields.Text("BI")
metabase = fields.Function(fields.Text(""),'get_bi')
def get_bi(self,name=None):
Configuration = Pool().get('gr.configuration')
config = Configuration.search(['id','>',0])[0]
payload = {
"resource": {"dashboard": config.position_id},
"params": {},
"exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=30),
}
token = jwt.encode(payload, config.payload, algorithm="HS256")
logger.info("TOKEN:%s",token)
if config.dark:
url = f"metabase:{config.bi}/embed/dashboard/{token}#theme=night&bordered=true&titled=true"
else:
url = f"metabase:{config.bi}/embed/dashboard/{token}#bordered=true&titled=true"
return url