# 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, 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 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 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 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") 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() 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)) * Max(pc.ratio / 100)).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' btb = fields.Many2One('back.to.back',"Back to back") bank_accounts = fields.Function( fields.Many2Many('bank.account', None, None, "Bank Accounts"), 'on_change_with_bank_accounts') bank_account = fields.Many2One( 'bank.account', "Bank Account", domain=[('id', 'in', Eval('bank_accounts', []))], depends=['bank_accounts']) 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')]) 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 +") 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'),}) 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') 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' ) 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") @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 == 'MELYA') 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] @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', '_parent_party.bank_accounts') def on_change_party(self): super().on_change_party() self.bank_account = self._get_default_bank_account() @fields.depends('party', 'currency', '_parent_party.bank_accounts') def on_change_currency(self): self.bank_account = self._get_default_bank_account() @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 @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 '' @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 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 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]) class PriceComposition(ModelSQL,ModelView): "Price Composition" __name__ = 'price.composition' line = fields.Many2One('purchase.line',"Purchase line") component = fields.Char("Component") price = fields.Numeric("Price") 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 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 '' 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',"Delivery 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") 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') # 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.Numeric("Premium/Discount",digits='unit') fee_ = fields.Many2One('fee.fee',"Fee") pricing_rule = fields.Text("Pricing description") 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') 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)) @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): 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_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 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_price(self): premium = Decimal(self.premium or 0) if not premium: return Decimal(0) if self.enable_linked_currency and self.linked_currency: return self._linked_to_line_price(premium) return premium def _get_basis_component_price(self): price = Decimal(0) if self.terms: for t in self.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') 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', 'unit_price', 'unit', 'product', 'purchase', '_parent_purchase.currency', 'premium', 'enable_linked_currency', 'linked_currency', 'linked_unit') def on_change_with_amount(self): if (self.type == 'line' and self.quantity is not None and self.unit_price is not None): currency = self.purchase.currency if self.purchase else None amount = Decimal(str(self.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', 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_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 write(cls, *args): # 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' Lot = Pool().get('lot.lot') LotQt = Pool().get('lot.qt') 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 super().write(*args) lines = sum(args[::2], []) for line in lines: if line.id in old_values: old = Decimal(old_values[line.id] or 0) new = Decimal(line.quantity_theorical or 0) delta = new - old if delta > 0: virtual_lots = [lot for lot in (line.lots or []) if lot.lot_type == 'virtual'] if not virtual_lots: continue vlot = virtual_lots[0] new_qty = round(Decimal(vlot.get_current_quantity_converted() or 0) + delta, 5) vlot.set_current_quantity(new_qty, new_qty, 1) Lot.save([vlot]) lqts = LotQt.search([ ('lot_p', '=', vlot.id), ('lot_s', '=', None), ('lot_shipment_in', '=', None), ('lot_shipment_internal', '=', None), ('lot_shipment_out', '=', None), ]) if lqts: lqt = lqts[0] lqt.lot_quantity = round(Decimal(lqt.lot_quantity or 0) + delta, 5) LotQt.save([lqt]) else: lqt = LotQt() lqt.lot_p = vlot.id lqt.lot_s = None lqt.lot_quantity = round(delta, 5) lqt.lot_unit = line.unit LotQt.save([lqt]) elif delta < 0: virtual_lots = [lot for lot in (line.lots or []) if lot.lot_type == 'virtual'] if not virtual_lots: continue vlot = virtual_lots[0] decrease = abs(delta) lqts = LotQt.search([ ('lot_p', '=', vlot.id), ('lot_s', '=', None), ('lot_shipment_in', '=', None), ('lot_shipment_internal', '=', None), ('lot_shipment_out', '=', None), ]) if (not lqts or Decimal(lqts[0].lot_quantity or 0) < decrease): raise UserError("Please unlink or unmatch lot") new_qty = round(Decimal(vlot.get_current_quantity_converted() or 0) - decrease, 5) vlot.set_current_quantity(new_qty, new_qty, 1) Lot.save([vlot]) lqt = lqts[0] lqt.lot_quantity = round(Decimal(lqt.lot_quantity or 0) - decrease, 5) LotQt.save([lqt]) @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 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() #no lot need to create one with line quantity if not line.created_by_code: if not line.lots and line.product.type != 'service' and line.quantity != 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(line.quantity),5) lot.lot_gross_quantity = None lot.lot_status = 'forecast' lot.lot_type = 'virtual' lot.lot_product = line.product lqtt = LotQtType.search([('sequence','=',1)]) if lqtt: lqh = LotQtHist() lqh.quantity_type = lqtt[0] lqh.quantity = lot.lot_quantity logger.info("PURCHASE_VALIDATE:%s",lot.lot_quantity) lqh.gross_quantity = lot.lot_quantity lot.lot_hist = [lqh] if line.quantity > 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]) 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 = [] for t in pc.triggers: prD, prP = t.getPricingListDates(pc.calendar) apD, apP = t.getApplicationListDates(pc.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 = float(0) i = 1 for p in lprice: if i > 1: p['avg_minus_1'] = cumulprice / (i-1) 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.price_index.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 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',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