Files
tradon/modules/purchase_trade/fee.py

1966 lines
79 KiB
Python
Executable File

from functools import wraps
from trytond.model import fields
from trytond.report import Report
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval, Id, If
from trytond.model import (ModelSQL, ModelView)
from trytond.tools import is_full_text, lstrip_wildcard
from trytond.transaction import Transaction, inactive_records
from decimal import getcontext, Decimal, ROUND_UP, ROUND_HALF_UP
from sql.aggregate import Count, Max, Min, Sum, Avg, BoolOr
from sql.conditionals import Case, Coalesce
from sql import Column, Literal, Null
from sql.functions import CurrentTimestamp, DateTrunc
from trytond.wizard import Button, StateTransition, StateView, Wizard
from itertools import chain, groupby
from operator import itemgetter
import datetime
import logging
from collections import defaultdict
from trytond.exceptions import UserWarning, UserError
from trytond.modules.account.exceptions import PeriodNotFoundError
from trytond.modules.purchase_trade.company_defaults import (
default_itsa_currency, default_itsa_unit, is_itsa_company)
logger = logging.getLogger(__name__)
def filter_state(state):
def filter(func):
@wraps(func)
def wrapper(cls, fees):
fees = [f for f in fees if f.state == state]
return func(cls, fees)
return wrapper
return filter
class Fee(ModelSQL,ModelView):
"Fee"
__name__ = 'fee.fee'
line = fields.Many2One('purchase.line',"Line", ondelete='CASCADE')
source_rule = fields.Many2One('fee.rule', "Source Rule", readonly=True)
generated_by_rule = fields.Boolean("Generated by Rule", readonly=True)
shipment_in = fields.Many2One('stock.shipment.in', ondelete='CASCADE')
shipment_out = fields.Many2One('stock.shipment.out', ondelete='CASCADE')
shipment_internal = fields.Many2One(
'stock.shipment.internal', ondelete='CASCADE')
currency = fields.Many2One('currency.currency',"Currency", states={
'required': ~Eval('enable_linked_currency'),
}, depends=['enable_linked_currency'])
supplier = fields.Many2One('party.party',"Supplier", required=True)
type = fields.Selection([
('budgeted', 'Budgeted'),
('ordered', 'Ordered'),
('actual', 'Actual'),
], "Type", required=True)
p_r = fields.Selection([
('pay', 'PAY'),
('rec', 'REC'),
], "P/R", required=True)
product = fields.Many2One('product.product',"Product", required=True, domain=[('type', '=', 'service')])
price = fields.Numeric("Price",digits=(1,4))
enable_linked_currency = fields.Boolean("Linked curr")
linked_price = fields.Numeric("Linked Price", digits='unit', states={
'invisible': ~Eval('enable_linked_currency'),
}, depends=['enable_linked_currency'])
linked_currency = fields.Many2One('currency.linked', "Linked curr",
states={
'invisible': ~Eval('enable_linked_currency'),
}, depends=['enable_linked_currency'])
linked_unit = fields.Many2One('product.uom', "Linked Unit",
states={
'invisible': ~Eval('enable_linked_currency'),
}, depends=['enable_linked_currency'])
mode = fields.Selection([
('lumpsum', 'Lump sum'),
('perqt', 'Per qt'),
('pprice', '% price'),
('rate', '% rate'),
('pcost', '% cost price'),
('ppack', 'Per packing'),
], 'Mode', required=True)
auto_calculation = fields.Boolean("Auto",states={'readonly': (Eval('mode') != 'ppack')})
inherit_qt = fields.Boolean("Inh Qt",states={'readonly': Eval('mode') != 'ppack'})
quantity = fields.Numeric("Qt",digits='unit',states={'readonly': (Eval('mode') != 'ppack') | Bool(Eval('auto_calculation'))})
add_padding = fields.Boolean("Add padding")
unit = fields.Many2One('product.uom',"Unit",domain=[
If(Eval('mode') == 'ppack',
('category', '=', Eval('packing_category')),
()),
],
states={
'readonly': (Bool(Eval('mode') != 'ppack') & Bool(Eval('mode') != 'perqt')),
},
depends=['mode', 'packing_category'])
packing_category = fields.Function(fields.Many2One('product.uom.category',"Packing Category"),'on_change_with_packing_category')
inherit_shipment = fields.Boolean("Inh Sh",states={
'invisible': (Eval('shipment_in')),
})
purchase = fields.Many2One('purchase.purchase',"Purchase", ondelete='CASCADE')
qt_state = fields.Many2One('lot.qt.type',"Qt State")
amount = fields.Function(fields.Numeric("Amount", digits='currency'),'get_amount')
fee_lots = fields.Function(fields.Many2Many('lot.lot', None, None, "Lots"),'get_lots')#, searcher='search_lots')
lots = fields.Many2Many('fee.lots', 'fee', 'lot',"Lots",domain=[('id', 'in', Eval('fee_lots',-1))] )
lots_cp = fields.Integer("Lots number")
state = fields.Selection([
('not invoiced', 'Not invoiced'),
('invoiced', 'Invoiced'),
], string='State', readonly=True)
fee_landed_cost = fields.Function(fields.Boolean("Inventory"),'get_landed_status')
inv = fields.Function(fields.Many2One('account.invoice',"Invoice"),'get_invoice')
dn_cn = fields.Many2One('account.invoice', "DN/CN", readonly=True)
weight_type = fields.Selection([
('net', 'Net'),
('brut', 'Gross'),
], string='W. type')
fee_date = fields.Date("Date")
@classmethod
def default_fee_date(cls):
Date = Pool().get('ir.date')
return Date.today()
@classmethod
def default_currency(cls):
if is_itsa_company():
currency = default_itsa_currency()
if currency:
return currency
@classmethod
def default_unit(cls):
if is_itsa_company():
unit = default_itsa_unit()
if unit:
return unit
@classmethod
def default_generated_by_rule(cls):
return False
@classmethod
def default_add_padding(cls):
return False
@classmethod
def default_qt_state(cls):
return None
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:
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_fee_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 _fee_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 _sync_price_from_linked(self):
if (self.enable_linked_currency and self.linked_currency
and self.linked_price is not None):
self.price = self._linked_to_fee_price(self.linked_price)
@fields.depends(
'enable_linked_currency', 'linked_currency', 'linked_unit',
'linked_price', 'price', 'unit')
def on_change_enable_linked_currency(self):
if self.enable_linked_currency and self.price is not None:
self.linked_price = self._fee_to_linked_price(self.price)
self._sync_price_from_linked()
@fields.depends(
'enable_linked_currency', 'linked_currency', 'linked_unit',
'linked_price', 'unit')
def on_change_linked_price(self):
self._sync_price_from_linked()
@fields.depends(
'enable_linked_currency', 'linked_currency', 'linked_unit',
'linked_price', 'unit')
def on_change_linked_currency(self):
self._sync_price_from_linked()
@fields.depends(
'enable_linked_currency', 'linked_currency', 'linked_unit',
'linked_price', 'unit')
def on_change_linked_unit(self):
self._sync_price_from_linked()
@fields.depends(
'enable_linked_currency', 'linked_currency', 'linked_unit',
'price', 'unit')
def on_change_price(self):
if (self.enable_linked_currency and self.linked_currency
and self.price is not None):
self.linked_price = self._fee_to_linked_price(self.price)
@fields.depends('mode','unit')
def on_change_with_packing_category(self, name=None):
UnitCategory = Pool().get('product.uom.category')
packing = UnitCategory.search(['name','=','Packing'])
if packing:
return packing[0]
@fields.depends('line','sale_line','shipment_in','lots','price','unit','auto_calculation','mode','_parent_line.unit','_parent_line.lots','_parent_sale_line.unit','_parent_sale_line.lots','_parent_shipment_in.id')
def on_change_with_quantity(self, name=None):
qt = None
unit = None
line = self.line
logger.info("ON_CHANGE_WITH_LINE:%s",line)
if not line:
line = self.sale_line
if line:
if line.lots:
qt = sum([e.get_current_quantity_converted(0,self.unit) for e in line.lots])
qt_ = sum([e.get_current_quantity_converted(0) for e in line.lots])
unit = line.lots[0].lot_unit or line.lots[0].lot_unit_line
logger.info("ON_CHANGE_WITH_QT0:%s",qt)
logger.info("ON_CHANGE_WITH_SI:%s",self.shipment_in)
if self.shipment_in:
Lot = Pool().get('lot.lot')
lots = Lot.search([('lot_shipment_in','=',self.shipment_in.id)])
logger.info("ON_CHANGE_WITH_LOTS:%s",lots)
if lots:
qt = sum([e.get_current_quantity_converted(0,self.unit) for e in lots])
qt_ = sum([e.get_current_quantity_converted(0) for e in lots])
unit = lots[0].lot_unit or lots[0].lot_unit_line
if not qt:
logger.info("ON_CHANGE_WITH_QT1:%s",qt)
LotQt = Pool().get('lot.qt')
if self.shipment_in:
lqts = LotQt.search(['lot_shipment_in','=',self.shipment_in.id])
if lqts:
qt = Decimal(lqts[0].lot_quantity)
qt_ = qt
unit = lqts[0].lot_unit
logger.info("ON_CHANGE_WITH_QT2:%s",qt)
if self.mode != 'ppack':
return qt
else:
if self.auto_calculation:
logger.info("AUTOCALCULATION:%s",qt)
logger.info("AUTOCALCULATION2:%s",qt_)
logger.info("AUTOCALCULATION3:%s",Decimal(unit.factor))
logger.info("AUTOCALCULATION4:%s",Decimal(self.unit.factor))
return (qt_ * Decimal(unit.factor) / Decimal(self.unit.factor)).to_integral_value(rounding=ROUND_UP)
@fields.depends('price','mode','_parent_line.lots','_parent_sale_line.lots','shipment_in')
def on_change_with_unit(self, name=None):
if self.mode != 'ppack' and self.mode != 'perqt':
line = self.line
if not line:
line = self.sale_line
if line:
if line.lots:
if len(line.lots) == 1:
return line.lots[0].lot_unit_line
else:
return line.lots[1].lot_unit_line
if self.shipment_in:
Lot = Pool().get('lot.lot')
lots = Lot.search([('lot_shipment_in','=',self.shipment_in.id)])
logger.info("ON_CHANGE_WITH_UNIT:%s",lots)
if lots:
return lots[0].lot_unit_line
else:
return self.unit
def get_lots(self, name):
logger.info("GET_LOTS_LINE:%s",self.line)
logger.info("GET_LOTS_SHIPMENT_IN:%s",self.shipment_in)
Lot = Pool().get('lot.lot')
if self.line:
return self.line.lots
if self.shipment_in:
LotQt = Pool().get('lot.qt')
lots = Lot.search([('lot_shipment_in','=',self.shipment_in.id)])
logger.info("LOTSDOMAIN:%s",lots)
domain_lots = []
seen = set()
for lot in lots:
lot_id = getattr(lot, 'id', None)
if lot_id not in seen:
domain_lots.append(lot)
seen.add(lot_id)
for lqt in LotQt.search(['lot_shipment_in','=',self.shipment_in.id]):
lot = getattr(lqt, 'lot_p', None)
lot_id = getattr(lot, 'id', None)
if lot and lot_id not in seen:
domain_lots.append(lot)
seen.add(lot_id)
if domain_lots:
return domain_lots
if self.shipment_internal:
return Lot.search([('lot_shipment_internal','=',self.shipment_internal.id)])
if self.shipment_out:
return Lot.search([('lot_shipment_out','=',self.shipment_out.id)])
return Lot.search(['id','>',0])
def get_cog(self,lot):
MoveLine = Pool().get('account.move.line')
Currency = Pool().get('currency.currency')
Date = Pool().get('ir.date')
AccountConfiguration = Pool().get('account.configuration')
account_configuration = AccountConfiguration(1)
Uom = Pool().get('product.uom')
ml = MoveLine.search([
('lot', '=', lot.id),
('fee', '=', self.id),
('account', '=', self.product.account_stock_in_used.id),
('origin', 'ilike', '%stock.move%'),
])
logger.info("GET_COG_FEE:%s",ml)
if ml:
return round(Decimal(sum([e.credit-e.debit for e in ml if e.description != 'Delivery fee'])),2)
return Decimal(0)
def get_non_cog(self,lot):
MoveLine = Pool().get('account.move.line')
Currency = Pool().get('currency.currency')
Date = Pool().get('ir.date')
AccountConfiguration = Pool().get('account.configuration')
account_configuration = AccountConfiguration(1)
Uom = Pool().get('product.uom')
ml = MoveLine.search([
('lot', '=', lot.id),
('fee', '=', self.id),
('account', '=', self.product.account_stock_in_used.id),
])
logger.info("GET_NON_COG_FEE:%s",ml)
if ml:
return round(Decimal(sum([e.credit-e.debit for e in ml])),2)
return Decimal(0)
@classmethod
def __setup__(cls):
super().__setup__()
cls._buttons.update({
'invoice': {},
})
@classmethod
def default_state(cls):
return 'not invoiced'
@classmethod
def default_p_r(cls):
return 'pay'
def get_unit(self, name=None):
FeeLots = Pool().get('fee.lots')
fl = FeeLots.search(['fee','=',self.id])
if fl:
if fl[0].lot.line:
return fl[0].lot.line.unit
if fl[0].lot.sale_line:
return fl[0].lot.sale_line.unit
@classmethod
@ModelView.button
def invoice(cls, fees):
Purchase = Pool().get('purchase.purchase')
FeeLots = Pool().get('fee.lots')
Warning = Pool().get('res.user.warning')
fees_to_invoice = []
for fee in fees:
was_invoiced = fee.state == 'invoiced'
if fee.state == 'invoiced':
warning_name = Warning.format(
"Fee already invoiced", [fee])
if Warning.check(warning_name):
raise UserWarning(
warning_name,
"This fee has already been invoiced. Continuing will "
"create a debit note or credit note by reversing the "
"previous fee invoice line and adding a new line with "
"the current fee values. Do you want to continue?")
fee.warn_percent_price_partial_lots()
fee.ensure_ordered_purchase()
if fee.purchase:
fl = FeeLots.search([('fee','=',fee.id)])
logger.info("PROCESS_FROM_FEE:%s",fl)
if was_invoiced:
with Transaction().set_context(
_purchase_trade_ignore_fee_padding=True):
fee.adjust_purchase_values()
invoices = Purchase._process_invoice(
[fee.purchase], [e.lot for e in fl], 'service')
else:
fee.adjust_purchase_values()
invoices = Purchase._process_invoice(
[fee.purchase], [e.lot for e in fl], 'service')
invoice = invoices.get(fee.purchase) if invoices else None
logger.info(
"PROCESS_FROM_FEE_RESULT: fee=%s purchase=%s invoice=%s "
"was_invoiced=%s",
getattr(fee, 'id', None), getattr(fee.purchase, 'id', None),
getattr(invoice, 'id', None), was_invoiced)
if not invoice:
if was_invoiced:
raise UserError(
"No debit note or credit note was created for "
"fee %s. The current fee values may be identical "
"to the last invoiced values." % getattr(
fee, 'id', None))
raise UserError(
"No invoice was created for fee %s. Please check "
"that the fee has linked lots and invoiceable values."
% getattr(fee, 'id', None))
if was_invoiced:
cls.write([fee], {'dn_cn': invoice.id})
fees_to_invoice.append(fee)
else:
raise UserError(
"Cannot invoice fee %s: no generated service purchase "
"is linked to this fee." % getattr(fee, 'id', None))
if fees_to_invoice:
cls.write(fees_to_invoice, {'state': 'invoiced',})
@classmethod
def default_type(cls):
return 'budgeted'
@classmethod
def default_weight_type(cls):
return 'brut'
def get_price_per_qt(self):
price = Decimal(0)
if self.mode == 'lumpsum':
if self.quantity:
if self.quantity > 0:
return round(self.price / self.quantity,4)
elif self.mode == 'perqt':
return self.price
elif self.mode == 'ppack':
unit = self.get_unit()
if unit and self.unit:
return round(self.price / Decimal(self.unit.factor) * Decimal(unit.factor),4)
elif self.mode == 'pprice' or self.mode == 'pcost':
if self.line and self.price:
return round(self.price * Decimal(self.line.unit_price) / 100,4)
if self.sale_line and self.price:
return round(self.price * Decimal(self.sale_line.unit_price) / 100,4)
if self.shipment_in:
StockMove = Pool().get('stock.move')
sm = StockMove.search(['shipment','=','stock.shipment.in,'+str(self.shipment_in.id)])
if sm:
if sm[0].lot:
return round(self.price * Decimal(sm[0].lot.get_lot_price()) / 100,4)
return price
def get_invoice(self,name):
InvoiceLine = Pool().get('account.invoice.line')
invoice_lines = InvoiceLine.search([
('fee', '=', self.id),
('quantity', '>', 0),
('invoice.state', '!=', 'cancelled'),
], order=[
('invoice.invoice_date', 'ASC'),
('invoice.id', 'ASC'),
('id', 'ASC'),
], limit=1)
if invoice_lines:
return invoice_lines[0].invoice
if self.purchase:
if self.purchase.invoices:
invoices = [
invoice for invoice in self.purchase.invoices
if getattr(invoice, 'state', None) != 'cancelled']
if invoices:
return sorted(
invoices,
key=lambda invoice: (
invoice.invoice_date or datetime.date.min,
invoice.id or 0))[0]
def get_landed_status(self,name):
if self.product:
return self.product.template.landed_cost
def get_quantity(self,name=None):
qt = self.get_fee_lots_qt()
if qt:
return qt
line = self.line or self.sale_line
if line and line.lots:
return sum(
Decimal(lot.get_current_quantity_converted(0, self.unit))
for lot in line.lots)
LotQt = Pool().get('lot.qt')
if self.shipment_in:
lqts = LotQt.search(['lot_shipment_in','=',self.shipment_in.id])
if lqts:
return Decimal(lqts[0].lot_quantity)
def _get_effective_fee_lots(self):
FeeLots = Pool().get('fee.lots')
fee_lots = FeeLots.search([('fee', '=', self.id)])
lots = [fee_lot.lot for fee_lot in fee_lots if fee_lot.lot]
return self._select_effective_lots(lots)
@staticmethod
def _select_effective_lots(lots):
physical_lots = [
lot for lot in lots
if getattr(lot, 'lot_type', None) == 'physic'
]
if physical_lots:
return physical_lots
return [
lot for lot in lots
if getattr(lot, 'lot_type', None) == 'virtual'
]
@staticmethod
def _record_key(record):
return getattr(record, 'id', None) or id(record)
def _get_effective_line_lots(self):
line = self.line or self.sale_line
return self._select_effective_lots(
list(getattr(line, 'lots', []) or []))
def percent_price_lots_cover_line(self):
if self.mode != 'pprice':
return True
if getattr(self, 'add_padding', False):
return True
line_lots = self._get_effective_line_lots()
if not line_lots:
return True
fee_lots = self._get_effective_fee_lots()
fee_lot_keys = {self._record_key(lot) for lot in fee_lots}
return all(
self._record_key(lot) in fee_lot_keys
for lot in line_lots)
def warn_percent_price_partial_lots(self):
if self.percent_price_lots_cover_line():
return
Warning = Pool().get('res.user.warning')
warning_name = Warning.format(
"Partial % price fee lots", [self])
if Warning.check(warning_name):
raise UserWarning(
warning_name,
"This % price fee is linked to only part of the line lots. "
"The commission cannot be safely computed from a partial "
"quantity because lot quantities may change through BL, WR "
"or LR weighing states. Please link all lots to the fee or "
"review the fee amount manually before continuing.")
def is_effective_fee_lot(self, lot):
return bool(lot and lot in self._get_effective_fee_lots())
def _get_weight_basis_qt_type(self, lot=None):
if self.line and self.line.purchase:
return getattr(self.line.purchase.wb, 'qt_type', None)
if self.sale_line and self.sale_line.sale:
return getattr(self.sale_line.sale.wb, 'qt_type', None)
if lot and lot.line and lot.line.purchase:
return getattr(lot.line.purchase.wb, 'qt_type', None)
if lot and lot.sale_line and lot.sale_line.sale:
return getattr(lot.sale_line.sale.wb, 'qt_type', None)
@staticmethod
def _qt_type_sequence(qt_type):
sequence = getattr(qt_type, 'sequence', None)
if sequence is None:
return None
return Decimal(str(sequence))
@staticmethod
def _same_qt_type(left, right):
return (
getattr(left, 'id', left)
and getattr(left, 'id', left) == getattr(right, 'id', right))
def _target_qt_type_for_lot(self, lot):
weight_basis_qt_type = self._get_weight_basis_qt_type(lot)
target_qt_type = self.qt_state or weight_basis_qt_type
if not target_qt_type or not weight_basis_qt_type:
return target_qt_type
target_sequence = self._qt_type_sequence(target_qt_type)
weight_basis_sequence = self._qt_type_sequence(weight_basis_qt_type)
if (
target_sequence is not None
and weight_basis_sequence is not None
and target_sequence > weight_basis_sequence):
return weight_basis_qt_type
return target_qt_type
def _select_lot_qt_type(self, lot):
target_qt_type = self._target_qt_type_for_lot(lot)
if not target_qt_type:
return None
lot_hist = list(getattr(lot, 'lot_hist', []) or [])
for hist in lot_hist:
if self._same_qt_type(hist.quantity_type, target_qt_type):
return hist.quantity_type
target_sequence = self._qt_type_sequence(target_qt_type)
if target_sequence is None:
return None
previous = []
for hist in lot_hist:
hist_sequence = self._qt_type_sequence(hist.quantity_type)
if hist_sequence is not None and hist_sequence <= target_sequence:
previous.append((hist_sequence, hist.quantity_type))
if previous:
previous.sort(key=lambda item: item[0])
return previous[-1][1]
return None
def _get_lot_fee_quantity(self, lot):
state = self._select_lot_qt_type(lot)
target_qt_type = self._target_qt_type_for_lot(lot)
if target_qt_type and not state:
return None
state_id = getattr(state, 'id', 0) if state else 0
if self.weight_type == 'brut':
return Decimal(
lot.get_current_gross_quantity_converted(state_id, self.unit)
or 0)
return Decimal(
lot.get_current_quantity_converted(state_id, self.unit) or 0)
def _get_effective_fee_lots_quantity(self):
lots = self._get_effective_fee_lots()
if not lots:
return None
if self.mode == 'ppack':
packing_qty = sum(
Decimal(str(getattr(lot, 'lot_qt', 0) or 0))
for lot in lots)
if packing_qty:
return packing_qty
return self.quantity
quantities = [self._get_lot_fee_quantity(lot) for lot in lots]
if any(quantity is None for quantity in quantities):
return None
return sum(quantities)
def _convert_padding_quantity(self, quantity, from_unit):
quantity = Decimal(str(quantity or 0))
if not quantity:
return Decimal(0)
to_unit = getattr(self, 'unit', None)
if not from_unit or not to_unit or from_unit == to_unit:
return quantity
Uom = Pool().get('product.uom')
return Decimal(str(
Uom.compute_qty(from_unit, float(quantity), to_unit) or 0))
def _get_lot_padding_quantity(self, lot):
padding = Decimal(str(
getattr(lot, 'sale_invoice_padding', 0) or 0))
if not padding:
return Decimal(0)
source_line = (
getattr(self, 'sale_line', None)
or getattr(lot, 'sale_line', None)
or getattr(self, 'line', None)
or getattr(lot, 'line', None))
source_unit = getattr(source_line, 'unit', None)
if not source_unit:
source_unit = getattr(lot, 'lot_unit_line', None)
return self._convert_padding_quantity(padding, source_unit)
def get_padding_quantity(self):
if Transaction().context.get('_purchase_trade_ignore_fee_padding'):
return Decimal(0)
if getattr(self, 'state', None) == 'invoiced':
return Decimal(0)
if not getattr(self, 'add_padding', False):
return Decimal(0)
return sum(
self._get_lot_padding_quantity(lot)
for lot in self._get_effective_fee_lots())
def get_billing_quantity(self):
quantity = self._get_effective_fee_lots_quantity()
if quantity is None:
quantity = self.quantity
if quantity is None:
quantity = self.get_quantity()
return Decimal(quantity or 0) + self.get_padding_quantity()
def _get_percent_price_unit_price(self):
line = self.line or self.sale_line
if line:
return Decimal(str(getattr(line, 'unit_price', 0) or 0))
lots = self._get_effective_fee_lots()
lot = lots[0] if lots else None
if lot:
if getattr(lot, 'line', None):
return Decimal(str(getattr(lot.line, 'unit_price', 0) or 0))
if getattr(lot, 'sale_line', None):
return Decimal(str(
getattr(lot.sale_line, 'unit_price', 0) or 0))
return Decimal(0)
def _get_padding_percent_price_amount(self):
return (
self.get_billing_quantity()
* self._get_percent_price_unit_price())
def sync_quantity_from_lots(self):
if self.mode == 'ppack' and self.auto_calculation:
self.assert_quantity_consistency()
return
quantity = self._get_effective_fee_lots_quantity()
if quantity is None:
self.assert_quantity_consistency()
return
quantity = round(Decimal(quantity), 5)
if self.mode == 'ppack':
quantity = quantity.to_integral_value(rounding=ROUND_UP)
if Decimal(self.quantity or 0) != quantity:
self.quantity = quantity
with Transaction().set_context(_purchase_trade_skip_fee_sync=True):
self.__class__.save([self])
self.assert_quantity_consistency()
def assert_quantity_consistency(self):
if self.mode == 'ppack':
return
expected = self._get_effective_fee_lots_quantity()
if expected is None:
if self._get_effective_fee_lots():
raise UserError(
"Fee quantity consistency error on fee %s: no usable "
"quantity state found on linked lots" % (
getattr(self, 'id', None),))
return
expected = round(Decimal(expected), 5)
observed = round(Decimal(self.quantity or 0), 5)
diff = observed - expected
if diff:
raise UserError(
"Fee quantity consistency error on fee %s: fee quantity "
"must equal linked lots quantity (observed=%s, expected=%s, "
"diff=%s)" % (
getattr(self, 'id', None), observed, expected, diff))
@classmethod
def assert_quantities_consistency(cls, fees):
for fee in fees:
fee.assert_quantity_consistency()
def _get_amount_quantity(self):
quantity = self.quantity
if quantity is None:
quantity = self.get_quantity()
return Decimal(quantity or 0)
@staticmethod
def _unsigned_amount(amount):
if amount is None:
return None
return abs(Decimal(amount))
def get_amount(self,name=None):
sign = Decimal(1)
if self.price:
# if self.p_r:
# if self.p_r == 'pay':
# sign = -1
if self.mode == 'lumpsum':
return self._unsigned_amount(self.price * sign)
elif self.mode == 'ppack':
return self._unsigned_amount(
round(self.price * self.quantity,2))
elif self.mode == 'rate':
if self.line:
if self.line.estimated_date:
est_lines = [dd for dd in self.line.estimated_date if dd.trigger == 'bldate']
est_line = est_lines[0] if est_lines else None
if est_line and est_line.fin_int_delta:
factor = (
Decimal(self.price) / Decimal(100)
* Decimal(est_line.fin_int_delta) / Decimal(360)
)
return self._unsigned_amount(round(
factor * self.line.unit_price
* self._get_amount_quantity() * sign,2))
if self.sale_line:
if self.sale_line.estimated_date:
est_lines = [
dd for dd in self.sale_line.estimated_date
if dd.trigger == 'bldate'
]
est_line = est_lines[0] if est_lines else None
if est_line and est_line.fin_int_delta:
factor = (
Decimal(self.price) / Decimal(100)
* Decimal(est_line.fin_int_delta) / Decimal(360)
)
return self._unsigned_amount(round(
factor * self.sale_line.unit_price
* self._get_amount_quantity() * sign, 2))
elif self.mode == 'perqt':
if self.add_padding:
return self._unsigned_amount(round(
self.get_billing_quantity() * self.price * sign, 2))
if self.shipment_in:
StockMove = Pool().get('stock.move')
sm = StockMove.search(['shipment','=','stock.shipment.in,'+str(self.shipment_in.id)])
if sm:
unique_lots = {e.lot for e in sm if e.lot}
return self._unsigned_amount(round(self.price * Decimal(sum([e.get_current_quantity_converted(0,self.unit) for e in unique_lots])) * sign,2))
LotQt = Pool().get('lot.qt')
lqts = LotQt.search(['lot_shipment_in','=',self.shipment_in.id])
if lqts:
return self._unsigned_amount(round(self.price * Decimal(lqts[0].lot_quantity) * sign,2))
return self._unsigned_amount(round((self.quantity if self.quantity else 0) * self.price * sign,2))
elif self.mode == 'pprice':
if self.add_padding:
return self._unsigned_amount(round(
self.price / 100
* self._get_padding_percent_price_amount()
* sign, 2))
if self.line:
if self.percent_price_lots_cover_line():
return self._unsigned_amount(round(
self.price / 100
* Decimal(self.line.amount or 0) * sign, 2))
return self._unsigned_amount(round(self.price / 100 * self.line.unit_price * (self.quantity if self.quantity else 0) * sign,2))
if self.sale_line:
if self.percent_price_lots_cover_line():
return self._unsigned_amount(round(
self.price / 100
* Decimal(self.sale_line.amount or 0) * sign, 2))
return self._unsigned_amount(round(self.price / 100 * self.sale_line.unit_price * (self.quantity if self.quantity else 0) * sign,2))
if self.shipment_in:
StockMove = Pool().get('stock.move')
sm = StockMove.search(['shipment','=','stock.shipment.in,'+str(self.shipment_in.id)])
if sm:
if sm[0].lot:
return self._unsigned_amount(round(self.price * Decimal(sum([e.lot.get_lot_price() for e in sm if e.lot])) / 100 * self.quantity * sign,2))
LotQt = Pool().get('lot.qt')
lqts = LotQt.search(['lot_shipment_in','=',self.shipment_in.id])
if lqts:
return self._unsigned_amount(round(self.price * Decimal(lqts[0].lot_p.get_lot_price()) / 100 * lqts[0].lot_quantity * sign,2))
@classmethod
def copy(cls, fees, default=None):
if default is None:
default = {}
else:
default = default.copy()
# Important : on vide le champ 'lots'
default.setdefault('lots', [])
return super().copy(fees, default=default)
def get_fee_lots_qt(self,state_id=0):
qt = Decimal(0)
lots = self._get_effective_fee_lots()
if lots:
if state_id:
qt = sum([
e.get_current_quantity_converted(state_id,self.unit)
for e in lots])
else:
qt = self._get_effective_fee_lots_quantity() or Decimal(0)
logger.info("GET_FEE_LOTS_QT:%s",qt)
return qt
def adjust_purchase_values(self):
Purchase = Pool().get('purchase.purchase')
PurchaseLine = Pool().get('purchase.line')
logger.info("ADJUST_PURCHASE_VALUES:%s",self)
if self.type == 'ordered' and self.purchase:
logger.info("ADJUST_PURCHASE_VALUES_QT:%s",self.purchase.lines[0].quantity)
if self.mode == 'lumpsum':
if self.amount != self.purchase.lines[0].unit_price:
self.purchase.lines[0].unit_price = self.amount
elif self.mode == 'ppack':
if self.amount != self.purchase.lines[0].amount:
self.purchase.lines[0].unit_price = self.price
self.purchase.lines[0].quantity = self.quantity
elif self.mode == 'pprice':
if self.amount != self.purchase.lines[0].unit_price:
self.purchase.lines[0].unit_price = self.amount
if self.purchase.lines[0].quantity != 1:
self.purchase.lines[0].quantity = 1
elif self.mode == 'perqt' and self.add_padding:
quantity = self.get_billing_quantity()
if self.price != self.purchase.lines[0].unit_price:
self.purchase.lines[0].unit_price = self.price
if quantity != self.purchase.lines[0].quantity:
self.purchase.lines[0].quantity = quantity
else:
if self.get_price_per_qt() != self.purchase.lines[0].unit_price:
self.purchase.lines[0].unit_price = self.get_price_per_qt()
if self.quantity != self.purchase.lines[0].quantity:
self.purchase.lines[0].quantity = self.quantity
if self.product != self.purchase.lines[0].product:
self.purchase.lines[0].product = self.product
PurchaseLine.save([self.purchase.lines[0]])
if self.supplier != self.purchase.party:
self.purchase.party = self.supplier
if self.currency != self.purchase.currency:
self.purchase.currency = self.currency
Purchase.save([self.purchase])
@classmethod
def _add_lot_pnl_lines(cls, lot, purchase_lines, sale_lines):
if not lot:
return
if getattr(lot, 'line', None):
purchase_lines[lot.line.id] = lot.line
if getattr(lot, 'sale_line', None):
sale_lines[lot.sale_line.id] = lot.sale_line
@classmethod
def _collect_pnl_lines(cls, fees=None, lots=None):
purchase_lines = {}
sale_lines = {}
for lot in lots or []:
cls._add_lot_pnl_lines(lot, purchase_lines, sale_lines)
for fee in fees or []:
if getattr(fee, 'line', None):
purchase_lines[fee.line.id] = fee.line
if getattr(fee, 'sale_line', None):
sale_lines[fee.sale_line.id] = fee.sale_line
for lot in getattr(fee, 'lots', None) or []:
cls._add_lot_pnl_lines(lot, purchase_lines, sale_lines)
shipment = getattr(fee, 'shipment_in', None)
if not shipment:
continue
for move in getattr(shipment, 'incoming_moves', None) or []:
cls._add_lot_pnl_lines(
getattr(move, 'lot', None), purchase_lines, sale_lines)
for lqt in getattr(shipment, 'lotqt', None) or []:
cls._add_lot_pnl_lines(
getattr(lqt, 'lot_p', None), purchase_lines, sale_lines)
cls._add_lot_pnl_lines(
getattr(lqt, 'lot_s', None), purchase_lines, sale_lines)
return purchase_lines.values(), sale_lines.values()
@classmethod
def _regenerate_fee_pnl(cls, fees=None, lots=None):
purchase_lines, sale_lines = cls._collect_pnl_lines(fees, lots)
cls._regenerate_fee_pnl_for_lines(purchase_lines, sale_lines)
@classmethod
def _regenerate_fee_pnl_for_lines(cls, purchase_lines, sale_lines):
purchase_lines = cls._unique_records(purchase_lines)
sale_lines = cls._unique_records(sale_lines)
if not purchase_lines and not sale_lines:
return
Valuation = Pool().get('valuation.valuation')
for line in purchase_lines:
Valuation.generate(line, valuation_type='fees')
for sale_line in sale_lines:
Valuation.generate_from_sale_line(
sale_line, valuation_type='fees')
@staticmethod
def _unique_records(records):
unique = {}
for record in records:
if not record:
continue
unique[getattr(record, 'id', id(record))] = record
return list(unique.values())
# @classmethod
# def validate(cls, fees):
# super(Fee, cls).validate(fees)
@classmethod
def _save_fee_accrual_or_warn(cls, account_move, fee, lot):
AccountMove = Pool().get('account.move')
if account_move:
AccountMove.save([account_move])
return
Warning = Pool().get('res.user.warning')
warning_name = Warning.format(
"Fee accrual not generated", [fee.product, lot])
if Warning.check(warning_name):
raise UserWarning(
warning_name,
"The fee can be saved, but the accrual entry could not be "
"generated because the stock accounting configuration or "
"accounting period is missing or incomplete. Do you want to "
"continue without creating the accrual entry?")
def _can_create_ordered_purchase(self):
return (
getattr(self, 'type', None) == 'ordered'
and not getattr(self, 'purchase', None)
and getattr(self, 'currency', None)
and getattr(self, 'price', None) is not None)
def _get_ordered_purchase_quantity_unit(self):
quantity = Decimal(0)
unit = None
lots = self._get_effective_fee_lots()
if lots:
quantity = sum(
Decimal(lot.get_current_quantity_converted() or 0)
for lot in lots)
first_lot = lots[0]
if getattr(first_lot, 'line', None):
unit = first_lot.line.unit
elif getattr(first_lot, 'sale_line', None):
unit = first_lot.sale_line.unit
elif getattr(self, 'quantity', None) is not None:
quantity = Decimal(self.quantity or 0)
unit = getattr(self, 'unit', None)
if self.mode == 'perqt' and getattr(self, 'add_padding', False):
quantity = self.get_billing_quantity()
return quantity, unit
def _get_ordered_purchase_locations(self):
if getattr(self, 'shipment_in', None):
return self.shipment_in.from_location, self.shipment_in.to_location
if getattr(self, 'line', None):
return self.line.purchase.from_location, self.line.purchase.to_location
return self.sale_line.sale.from_location, self.sale_line.sale.to_location
def _get_ordered_purchase_payment_term(self):
if getattr(self, 'shipment_in', None) and self.shipment_in.lotqt:
return self.shipment_in.lotqt[0].lot_p.line.purchase.payment_term
if getattr(self, 'line', None):
return self.line.purchase.payment_term
return self.sale_line.sale.payment_term
def _set_ordered_purchase_line_values(self, line, quantity, unit):
line.product = self.product
line.quantity = round(quantity, 5)
line.unit = unit
line.fee_ = self.id
if self.mode == 'pprice':
line.quantity = 1
line.unit_price = round(Decimal(self.amount or 0), 4)
return
if getattr(self, 'price', None) is not None:
fee_price = self.get_price_per_qt()
logger.info("GET_FEE_PRICE_PER_QT:%s", fee_price)
line.unit_price = round(Decimal(fee_price), 4)
if self.mode == 'lumpsum':
line.quantity = 1
line.unit_price = round(Decimal(self.amount or 0), 4)
elif self.mode == 'ppack':
line.unit_price = self.price
def _create_fee_accruals(self):
if self.sale_line:
return
FeeLots = Pool().get('fee.lots')
feelots = FeeLots.search(['fee','=',self.id])
for fl in feelots:
if self.product.template.landed_cost:
move = fl.lot.get_received_move()
if move:
Warning = Pool().get('res.user.warning')
warning_name = Warning.format("Lot ever received", [])
if Warning.check(warning_name):
raise UserWarning(warning_name,
"By clicking yes, an accrual for this fee will be created")
account_move = move._get_account_stock_move_fee(self)
self.__class__._save_fee_accrual_or_warn(
account_move, self, fl.lot)
else:
account_move = self._get_account_move_fee(fl.lot)
self.__class__._save_fee_accrual_or_warn(
account_move, self, fl.lot)
def ensure_ordered_purchase(self):
if not self._can_create_ordered_purchase():
return False
Purchase = Pool().get('purchase.purchase')
PurchaseLine = Pool().get('purchase.line')
quantity, unit = self._get_ordered_purchase_quantity_unit()
line = PurchaseLine()
self._set_ordered_purchase_line_values(line, quantity, unit)
purchase = Purchase()
purchase.lines = [line]
purchase.party = self.supplier
if purchase.party.addresses:
purchase.invoice_address = purchase.party.addresses[0]
purchase.currency = self.currency
purchase.line_type = 'service'
purchase.from_location, purchase.to_location = (
self._get_ordered_purchase_locations())
purchase.payment_term = self._get_ordered_purchase_payment_term()
Purchase.save([purchase])
self.purchase = purchase
with Transaction().set_context(_purchase_trade_skip_fee_sync=True):
self.__class__.save([self])
self._create_fee_accruals()
return True
@classmethod
def create(cls, vlist):
vlist = [x.copy() for x in vlist]
fees = super(Fee, cls).create(vlist)
for fee in fees:
qt_sh = Decimal(0)
qt_line = Decimal(0)
FeeLots = Pool().get('fee.lots')
Lots = Pool().get('lot.lot')
LotQt = Pool().get('lot.qt')
if fee.line:
for l in fee.line.lots:
if (l.lot_type == 'virtual' and len(fee.line.lots)==1) or (l.lot_type == 'physic' and len(fee.line.lots)>1):
fl = FeeLots()
fl.fee = fee.id
fl.lot = l.id
fl.line = l.line.id
FeeLots.save([fl])
qt_line += l.get_current_quantity_converted()
unit = l.line.unit
if fee.sale_line:
for l in fee.sale_line.lots:
if (l.lot_type == 'virtual' and len(fee.sale_line.lots)==1) or (l.lot_type == 'physic' and len(fee.sale_line.lots)>1):
fl = FeeLots()
fl.fee = fee.id
fl.lot = l.id
fl.sale_line = l.sale_line.id
FeeLots.save([fl])
qt_line += l.get_current_quantity_converted()
unit = l.sale_line.unit
if fee.shipment_in:
if fee.shipment_in.state == 'draft'or fee.shipment_in.state == 'started':
lots = Lots.search(['lot_shipment_in','=',fee.shipment_in.id])
if lots:
for l in lots:
fl = FeeLots()
fl.fee = fee.id
fl.lot = l.id
FeeLots.save([fl])
qt_sh += l.get_current_quantity_converted()
unit = l.line.unit
else:
lqts = LotQt.search(['lot_shipment_in','=',fee.shipment_in.id])
if lqts:
for l in lqts:
if l.lot_p:
exist = FeeLots.search([
('fee','=',fee.id),
('lot','=',l.lot_p.id),
])
if not exist:
fl = FeeLots()
fl.fee = fee.id
fl.lot = l.lot_p.id
FeeLots.save([fl])
qt_sh += l.lot_p.get_current_quantity_converted()
unit = l.lot_p.line.unit
else:
raise UserError("You cannot add fee on received shipment!")
logger.info("CREATE_PURHCASE_FOR_FEE_QT:%s", qt_line or qt_sh)
fee.ensure_ordered_purchase()
for fee in fees:
fee.sync_quantity_from_lots()
cls.assert_quantities_consistency(fees)
cls._regenerate_fee_pnl(fees=fees)
return fees
@classmethod
def write(cls, *args):
old_purchase_lines, old_sale_lines = cls._collect_pnl_lines(
fees=sum(args[::2], []))
old_purchase_lines = list(old_purchase_lines)
old_sale_lines = list(old_sale_lines)
super().write(*args)
if Transaction().context.get('_purchase_trade_skip_fee_sync'):
return
fees = sum(args[::2], [])
for fee in fees:
fee.sync_quantity_from_lots()
fee.ensure_ordered_purchase()
fee.adjust_purchase_values()
cls.assert_quantities_consistency(fees)
purchase_lines, sale_lines = cls._collect_pnl_lines(fees=fees)
cls._regenerate_fee_pnl_for_lines(
old_purchase_lines + list(purchase_lines),
old_sale_lines + list(sale_lines))
@classmethod
def delete(cls, fees):
purchase_lines, sale_lines = cls._collect_pnl_lines(fees=fees)
purchases = cls._get_generated_purchases_to_delete(fees)
super().delete(fees)
if purchases:
Purchase = Pool().get('purchase.purchase')
Purchase.delete(purchases)
cls._regenerate_fee_pnl_for_lines(purchase_lines, sale_lines)
@classmethod
def _get_generated_purchases_to_delete(cls, fees):
purchases = []
seen = set()
for fee in fees:
purchase = getattr(fee, 'purchase', None)
if not cls._is_generated_purchase_for_fee(fee, purchase):
continue
purchase_id = getattr(purchase, 'id', id(purchase))
if purchase_id in seen:
continue
seen.add(purchase_id)
purchases.append(purchase)
return purchases
@staticmethod
def _is_generated_purchase_for_fee(fee, purchase):
if getattr(fee, 'type', None) != 'ordered' or not purchase:
return False
if getattr(purchase, 'line_type', 'service') != 'service':
return False
lines = list(getattr(purchase, 'lines', []) or [])
if not lines:
return False
fee_id = getattr(fee, 'id', fee)
for line in lines:
line_fee = getattr(line, 'fee_', None)
if getattr(line_fee, 'id', line_fee) != fee_id:
return False
return True
def _get_account_move_fee(self,lot,in_out='in',amt = None):
pool = Pool()
AccountMove = pool.get('account.move')
Date = pool.get('ir.date')
Period = pool.get('account.period')
AccountConfiguration = pool.get('account.configuration')
if self.product.type != 'service':
return
today = Date.today()
company = lot.line.purchase.company if lot.line else lot.sale_line.sale.company
for date in [today]:
try:
period = Period.find(company, date=date, test_state=False)
except PeriodNotFoundError:
if date < today:
return
continue
break
else:
return
if period.state != 'open':
date = today
period = Period.find(company, date=date)
AccountMoveLine = pool.get('account.move.line')
Currency = pool.get('currency.currency')
move_line = AccountMoveLine()
move_line.lot = lot
move_line.fee = self
move_line.origin = None
move_line_ = AccountMoveLine()
move_line_.lot = lot
move_line_.fee = self
move_line_.origin = None
amount = amt if amt else self.amount
if self.currency != company.currency:
with Transaction().set_context(date=today):
amount_converted = amount
amount = Currency.compute(self.currency,
amount, company.currency)
move_line.second_currency = self.currency
if self.p_r == 'pay':
move_line.debit = amount
move_line.credit = Decimal(0)
move_line.account = self.product.account_stock_used if in_out == 'in' else self.product.account_cogs_used
if hasattr(move_line, 'second_currency') and move_line.second_currency:
move_line.amount_second_currency = amount_converted
move_line_.debit = Decimal(0)
move_line_.credit = amount
move_line_.account = self.product.account_stock_in_used if in_out == 'in' else self.product.account_stock_out_used
if hasattr(move_line_, 'second_currency') and move_line_.second_currency:
move_line_.amount_second_currency = -amount_converted
else:
move_line.debit = Decimal(0)
move_line.credit = amount
move_line.account = self.product.account_stock_used if in_out == 'in' else self.product.account_cogs_used
if hasattr(move_line, 'second_currency') and move_line.second_currency:
move_line.amount_second_currency = -amount_converted
move_line_.debit = amount
move_line_.credit = Decimal(0)
move_line_.account = self.product.account_stock_in_used if in_out == 'in' else self.product.account_stock_out_used
if hasattr(move_line_, 'second_currency') and move_line_.second_currency:
move_line_.amount_second_currency = amount_converted
logger.info("FEE_MOVELINES_1:%s",move_line)
logger.info("FEE_MOVELINES_2:%s",move_line_)
AccountJournal = Pool().get('account.journal')
journal = AccountJournal.search(['type','=','expense'])
if journal:
journal = journal[0]
description = None
description = 'Fee'
return AccountMove(
journal=journal,
period=period,
date=date,
origin=None,
description=description,
lines=[move_line,move_line_],
)
class FeeRule(ModelSQL, ModelView):
"Fee Rule"
__name__ = 'fee.rule'
name = fields.Char("Name", required=True)
active = fields.Boolean("Active")
sequence = fields.Integer("Sequence")
apply_on = fields.Selection([
('purchase_line', "Purchase Line"),
('sale_line', "Sale Line"),
], "Apply On", required=True)
auto_apply = fields.Boolean("Auto Apply")
party = fields.Many2One('party.party', "Counterparty")
party_category = fields.Many2One(
'party.category', "Counterparty Category")
line_product = fields.Many2One('product.product', "Line Product")
incoterm_code = fields.Char("Incoterm Code")
from_location = fields.Many2One('stock.location', "From Location")
to_location = fields.Many2One('stock.location', "To Location")
execution_plan = fields.Many2One('workflow.plan', "Execution Plan")
fee_product = fields.Many2One(
'product.product', "Fee Product", required=True,
domain=[('type', '=', 'service')])
supplier = fields.Many2One('party.party', "Supplier")
p_r = fields.Selection([
('pay', 'PAY'),
('rec', 'REC'),
], "P/R", required=True)
mode = fields.Selection([
('lumpsum', 'Lump sum'),
('perqt', 'Per qt'),
('pprice', '% price'),
('rate', '% rate'),
('pcost', '% cost price'),
('ppack', 'Per packing'),
], "Mode", required=True)
price = fields.Numeric("Price", digits=(16, 4))
quantity = fields.Numeric("Qt", digits='unit')
currency = fields.Many2One('currency.currency', "Currency")
unit = fields.Many2One('product.uom', "Unit")
qt_state = fields.Many2One('lot.qt.type', "Qt State")
weight_type = fields.Selection([
('net', 'Net'),
('brut', 'Gross'),
], "W. type")
@classmethod
def default_active(cls):
return True
@classmethod
def default_sequence(cls):
return 10
@classmethod
def default_auto_apply(cls):
return False
@classmethod
def default_p_r(cls):
return 'pay'
@classmethod
def default_mode(cls):
return 'lumpsum'
@classmethod
def default_weight_type(cls):
return 'brut'
@classmethod
def default_currency(cls):
if is_itsa_company():
currency = default_itsa_currency()
if currency:
return currency
@classmethod
def default_unit(cls):
if is_itsa_company():
unit = default_itsa_unit()
if unit:
return unit
@staticmethod
def _same_record(left, right):
return bool(
left and right
and getattr(left, 'id', left) == getattr(right, 'id', right))
@classmethod
def _category_matches(cls, category, target):
while target:
if cls._same_record(category, target):
return True
target = getattr(target, 'parent', None)
return False
@classmethod
def _party_has_category(cls, party, category):
if not category:
return True
if not party:
return False
return any(
cls._category_matches(category, party_category)
for party_category in (getattr(party, 'categories', []) or []))
@classmethod
def _rules_for_lines(cls, apply_on, auto_only=False):
domain = [
('active', '=', True),
('apply_on', '=', apply_on),
]
if auto_only:
domain.append(('auto_apply', '=', True))
return cls.search(domain, order=[('sequence', 'ASC'), ('id', 'ASC')])
def _matches_line(self, line, apply_on):
contract = getattr(line, 'purchase', None) or getattr(line, 'sale', None)
if not contract or self.apply_on != apply_on:
return False
checks = [
(self.party, getattr(contract, 'party', None)),
(self.line_product, getattr(line, 'product', None)),
(self.from_location, getattr(contract, 'from_location', None)),
(self.to_location, getattr(contract, 'to_location', None)),
]
if any(rule_value and not self._same_record(rule_value, actual)
for rule_value, actual in checks):
return False
if not self._party_has_category(
getattr(contract, 'party', None), self.party_category):
return False
if self.incoterm_code:
incoterm = getattr(contract, 'incoterm', None)
if (getattr(incoterm, 'code', '') or '').upper() != (
self.incoterm_code or '').upper():
return False
if self.execution_plan and not self._same_record(
self.execution_plan, getattr(contract, 'plan', None)):
return False
return True
def _existing_fee(self, line, apply_on):
Fee = Pool().get('fee.fee')
field_name = 'line' if apply_on == 'purchase_line' else 'sale_line'
existing = Fee.search([
('source_rule', '=', self.id),
(field_name, '=', line.id),
], limit=1)
return existing[0] if existing else None
def _fee_values(self, line, apply_on):
contract = getattr(line, 'purchase', None) or getattr(line, 'sale', None)
values = {
'source_rule': self.id,
'generated_by_rule': True,
'type': 'budgeted',
'p_r': self.p_r,
'product': self.fee_product.id,
'supplier': (
getattr(self.supplier, 'id', None)
or getattr(getattr(contract, 'party', None), 'id', None)),
'mode': self.mode,
'price': self.price if self.price is not None else Decimal(0),
'currency': (
getattr(self.currency, 'id', None)
or getattr(getattr(contract, 'currency', None), 'id', None)),
'unit': (
getattr(self.unit, 'id', None)
or getattr(getattr(line, 'unit', None), 'id', None)),
'quantity': (
self.quantity if self.quantity is not None
else getattr(line, 'quantity_theorical', None)
or getattr(line, 'quantity', None)),
'qt_state': getattr(self.qt_state, 'id', None),
'weight_type': self.weight_type,
}
values['line' if apply_on == 'purchase_line' else 'sale_line'] = line.id
return values
@classmethod
def apply_to_lines(cls, lines, apply_on, auto_only=False):
Fee = Pool().get('fee.fee')
rules = cls._rules_for_lines(apply_on, auto_only=auto_only)
created = []
for line in lines:
if not getattr(line, 'id', None):
continue
for rule in rules:
if not rule._matches_line(line, apply_on):
continue
if rule._existing_fee(line, apply_on):
continue
values = rule._fee_values(line, apply_on)
if not (
values.get('supplier') and values.get('currency')
and values.get('unit')):
logger.info(
"FEE_RULE_SKIPPED_MISSING_VALUES rule=%s line=%s",
rule.id, line.id)
continue
with Transaction().set_context(
_purchase_trade_skip_fee_rules=True):
created.extend(Fee.create([values]))
return created
class FeeLots(ModelSQL,ModelView):
"Fee lots"
__name__ = 'fee.lots'
fee = fields.Many2One('fee.fee',"Fee",required=True, ondelete='CASCADE')
lot = fields.Many2One('lot.lot',"Lot",required=True, ondelete='CASCADE')
@classmethod
def create(cls, vlist):
records = super().create(vlist)
Fee = Pool().get('fee.fee')
fee_ids = {
record.fee.id for record in records if getattr(record, 'fee', None)}
for fee in Fee.browse(list(fee_ids)):
fee.sync_quantity_from_lots()
fee.adjust_purchase_values()
Fee.assert_quantities_consistency(Fee.browse(list(fee_ids)))
Fee._regenerate_fee_pnl(
fees=Fee.browse(list(fee_ids)),
lots=[record.lot for record in records if getattr(record, 'lot', None)])
return records
@classmethod
def write(cls, *args):
old_lots = [
record.lot for record in sum(args[::2], [])
if getattr(record, 'lot', None)]
super().write(*args)
records = sum(args[::2], [])
Fee = Pool().get('fee.fee')
fee_ids = {
record.fee.id for record in records if getattr(record, 'fee', None)}
for fee in Fee.browse(list(fee_ids)):
fee.sync_quantity_from_lots()
fee.adjust_purchase_values()
Fee.assert_quantities_consistency(Fee.browse(list(fee_ids)))
Fee._regenerate_fee_pnl(
fees=Fee.browse(list(fee_ids)),
lots=old_lots + [
record.lot for record in records
if getattr(record, 'lot', None)])
@classmethod
def delete(cls, records):
Fee = Pool().get('fee.fee')
fee_ids = {
record.fee.id for record in records if getattr(record, 'fee', None)}
lots = [record.lot for record in records if getattr(record, 'lot', None)]
super().delete(records)
for fee in Fee.browse(list(fee_ids)):
fee.sync_quantity_from_lots()
fee.adjust_purchase_values()
Fee.assert_quantities_consistency(Fee.browse(list(fee_ids)))
Fee._regenerate_fee_pnl(
fees=Fee.browse(list(fee_ids)), lots=lots)
class FeeReport(
ModelSQL, ModelView):
"Fee Report"
__name__ = 'fee.report'
r_purchase_line = fields.Many2One('purchase.line', "Purchase line")
r_sale_line = fields.Many2One('sale.line', "Sale line")
r_shipment_in = fields.Many2One('stock.shipment.in', "Shipment in")
r_shipment_out = fields.Many2One('stock.shipment.out', "Shipment out")
r_shipment_internal = fields.Many2One('stock.shipment.internal', "Shipment internal")
r_fee_type = fields.Many2One('product.product', 'Fee type')
r_fee_counterparty = fields.Many2One('party.party', "Counterparty", required=True)
r_type = fields.Selection([
('ordered', 'Ordered'),
('budgeted', 'Budgeted'),
('actual', 'Actual')
], 'Type')
r_fee_paystatus = fields.Selection([
('pay', 'PAY'),
('rec', 'REC')
], 'Pay status')
r_mode = fields.Selection([
('lumpsum', 'Lump sum'),
('perqt', 'Per qt'),
('pprice', '% price'),
('pcost', '% cost price'),
], 'Mode', required=True)
r_fee_quantity = fields.Function(fields.Numeric("Qt",digits=(1,4)),'get_quantity')
r_fee_unit = fields.Function(fields.Many2One('product.uom',"Unit"),'get_unit')
r_purchase = fields.Many2One('purchase.purchase',"Purchase", ondelete='CASCADE')
r_sale = fields.Many2One('sale.sale',"Sale", ondelete='CASCADE')
r_fee_amount = fields.Function(fields.Numeric("Amount", digits=(1,4)),'get_amount')
r_inv = fields.Function(fields.Many2One('account.invoice',"Invoice"),'get_invoice')
r_dn_cn = fields.Function(fields.Many2One('account.invoice',"DN/CN"),
'get_dn_cn')
r_lot_invoice_status = fields.Function(fields.Selection([
('not', 'Not invoiced'),
('partial', 'Partially invoiced'),
('invoiced', 'Invoiced'),
], "Lot invoicing"), 'get_lot_invoice_status')
r_lot_payment_status = fields.Function(fields.Selection([
('not', 'Not paid'),
('partial', 'Partially paid'),
('paid', 'Paid'),
], "Lot payment"), 'get_lot_payment_status')
r_fee_payment_status = fields.Function(fields.Selection([
('not_paid', 'Not paid'),
('invoice_paid', 'Invoice paid'),
('dn_cn_paid', 'DN/CN paid'),
('all_paid', 'All paid'),
], "Fee payment"), 'get_fee_payment_status')
r_state = fields.Selection([
('not invoiced', 'Not invoiced'),
('invoiced', 'Invoiced'),
], string='State', readonly=True)
#r_fee_lots = fields.Function(fields.Many2Many('lot.lot', None, None, "Lots"),'get_lots')#, searcher='search_lots')
#r_lots = fields.Many2Many('fee.lots', 'fee', 'lot',"Lots",domain=[('id', 'in', Eval('r_fee_lots',[]))] )
r_fee_currency = fields.Many2One('currency.currency',"Currency")
r_fee_price = fields.Numeric("Price",digits=(1,4))
r_shipment_origin = fields.Function(
fields.Reference(
selection=[
("stock.shipment.in", "In"),
("stock.shipment.out", "Out"),
("stock.shipment.internal", "Internal"),
],
string="Shipment",
),
"get_shipment_origin",
)
def get_invoice(self,name):
Fee = Pool().get('fee.fee')
fee = Fee(self.id)
invoice = fee.get_invoice(name)
return getattr(invoice, 'id', invoice)
def get_dn_cn(self, name):
Fee = Pool().get('fee.fee')
fee = Fee(self.id)
dn_cn = getattr(fee, 'dn_cn', None)
return getattr(dn_cn, 'id', dn_cn)
@staticmethod
def _unique_invoices(invoices):
unique = []
keys = set()
for invoice in invoices:
if not invoice or getattr(invoice, 'state', None) == 'cancelled':
continue
key = getattr(invoice, 'id', None) or id(invoice)
if key not in keys:
keys.add(key)
unique.append(invoice)
return unique
@classmethod
def _lot_invoice_data(cls, fee):
lots = fee._get_effective_fee_lots()
invoices = []
invoiced_lots = 0
for lot in lots:
lot_invoices = []
for name in ('invoice_line', 'invoice_line_prov'):
line = getattr(lot, name, None)
if line:
lot_invoices.append(getattr(line, 'invoice', None))
lot_invoices = cls._unique_invoices(lot_invoices)
if lot_invoices:
invoiced_lots += 1
invoices.extend(lot_invoices)
return lots, invoiced_lots, cls._unique_invoices(invoices)
@staticmethod
def _invoice_payment_status(invoices):
if not invoices:
return 'not'
statuses = []
for invoice in invoices:
payment_lines = list(
getattr(invoice, 'payment_lines', None) or [])
if not payment_lines:
statuses.append('not')
elif (getattr(invoice, 'state', None) == 'paid'
or getattr(invoice, 'amount_to_pay', None) == 0):
statuses.append('paid')
else:
statuses.append('partial')
if all(status == 'paid' for status in statuses):
return 'paid'
if any(status != 'not' for status in statuses):
return 'partial'
return 'not'
def get_lot_invoice_status(self, name):
Fee = Pool().get('fee.fee')
lots, invoiced_lots, _ = self._lot_invoice_data(Fee(self.id))
if not lots or not invoiced_lots:
return 'not'
if invoiced_lots == len(lots):
return 'invoiced'
return 'partial'
def get_lot_payment_status(self, name):
Fee = Pool().get('fee.fee')
_, _, invoices = self._lot_invoice_data(Fee(self.id))
return self._invoice_payment_status(invoices)
@classmethod
def _fee_payment_status(cls, invoice, dn_cn):
invoice_paid = (
cls._invoice_payment_status(cls._unique_invoices([invoice]))
== 'paid')
dn_cn_paid = (
cls._invoice_payment_status(cls._unique_invoices([dn_cn]))
== 'paid')
if invoice_paid and dn_cn_paid:
return 'all_paid'
if invoice_paid:
return 'invoice_paid'
if dn_cn_paid:
return 'dn_cn_paid'
return 'not_paid'
def get_fee_payment_status(self, name):
Fee = Pool().get('fee.fee')
fee = Fee(self.id)
return self._fee_payment_status(
fee.get_invoice('inv'), getattr(fee, 'dn_cn', None))
def get_shipment_origin(self, name):
if self.r_shipment_in:
return 'stock.shipment.in,' + str(self.r_shipment_in.id)
elif self.r_shipment_out:
return 'stock.shipment.out,' + str(self.r_shipment_out.id)
elif self.r_shipment_internal:
return 'stock.shipment.internal,' + str(self.r_shipment_internal.id)
return None
# def get_lots(self, name):
# if self.r_purchase_line:
# return self.r_purchase_line.lots
def get_unit(self, name):
if self.r_purchase_line:
return self.r_purchase_line.unit
def get_quantity(self,name=None):
Fee = Pool().get('fee.fee')
fee = Fee(self.id)
return fee.get_quantity()
def get_amount(self,name=None):
Fee = Pool().get('fee.fee')
fee = Fee(self.id)
return fee.get_amount()
@classmethod
@ModelView.button
def invoice(cls, reports):
Fee = Pool().get('fee.fee')
Fee.invoice(Fee.browse([report.id for report in reports]))
@classmethod
def table_query(cls):
FeeReport = Pool().get('fee.fee')
fr = FeeReport.__table__()
FeeLots = Pool().get('fee.lots')
fl = FeeLots.__table__()
PurchaseLine = Pool().get('purchase.line')
pl = PurchaseLine.__table__()
SaleLine = Pool().get('sale.line')
sl = SaleLine.__table__()
Lot = Pool().get('lot.lot')
lot = Lot.__table__()
fee_lot_lines = (
fl
.join(lot, 'LEFT', condition=fl.lot == lot.id)
.join(pl, 'LEFT', condition=lot.line == pl.id)
.join(sl, 'LEFT', condition=lot.sale_line == sl.id)
.select(
fl.fee.as_('fee'),
Min(lot.line).as_('purchase_line'),
Min(pl.purchase).as_('purchase'),
Min(lot.sale_line).as_('sale_line'),
Min(sl.sale).as_('sale'),
group_by=[fl.fee]))
context = Transaction().context
party = context.get('party')
fee_type = context.get('fee_type')
purchase = context.get('purchase')
sale = context.get('sale')
shipment_in = context.get('shipment_in')
shipment_out = context.get('shipment_out')
shipment_internal = context.get('shipment_internal')
invoice_status = context.get('invoice_status')
asof = context.get('asof')
todate = context.get('todate')
wh = ((fr.create_date >= asof) & ((fr.create_date-datetime.timedelta(1)) <= todate))
if party:
wh &= (fr.fee_counterparty == party)
if fee_type:
wh &= (fr.fee_type == fee_type)
purchase_expr = fee_lot_lines.purchase
purchase_line_expr = fee_lot_lines.purchase_line
sale_expr = fee_lot_lines.sale
if purchase:
wh &= (purchase_expr == purchase)
if sale:
wh &= (sale_expr == sale)
if shipment_in:
wh &= (fr.shipment_in == shipment_in)
if invoice_status == 'not_invoiced':
wh &= (fr.state == 'not invoiced')
elif invoice_status == 'invoiced_without_dn_cn':
wh &= ((fr.state == 'invoiced') & (fr.dn_cn == Null))
elif invoice_status == 'invoiced_with_dn_cn':
wh &= ((fr.state == 'invoiced') & (fr.dn_cn != Null))
# if shipment_out:
# wh &= (fr.shipment_out == shipment_out)
query = fr.join(
fee_lot_lines, 'LEFT', condition=fr.id == fee_lot_lines.fee
).select(
Literal(0).as_('create_uid'),
CurrentTimestamp().as_('create_date'),
Literal(None).as_('write_uid'),
Literal(None).as_('write_date'),
fr.id.as_('id'),
purchase_line_expr.as_('r_purchase_line'),
fee_lot_lines.sale_line.as_('r_sale_line'),
fr.shipment_in.as_('r_shipment_in'),
Literal(None).as_('r_shipment_out'),
fr.shipment_internal.as_('r_shipment_internal'),
fr.product.as_('r_fee_type'),
fr.supplier.as_('r_fee_counterparty'),
fr.type.as_('r_type'),
fr.p_r.as_('r_fee_paystatus'),
fr.mode.as_('r_mode'),
fr.state.as_('r_state'),
purchase_expr.as_('r_purchase'),
sale_expr.as_('r_sale'),
#fr.amount.as_('r_fee_amount'),
fr.price.as_('r_fee_price'),
fr.currency.as_('r_fee_currency'),
#fr.fee_lots.as_('r_fee_lots'),
#fr.lots.as_('r_lots'),
where=wh)
return query
@classmethod
def search_rec_name(cls, name, clause):
_, operator, operand, *extra = clause
if operator.startswith('!') or operator.startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
code_value = operand
if operator.endswith('like') and is_full_text(operand):
code_value = lstrip_wildcard(operand)
return [bool_op,
('r_fee_type', operator, operand, *extra),
('r_fee_counterparty', operator, operand, *extra),
]
class FeeContext(ModelView):
"Fee Context"
__name__ = 'fee.context'
asof = fields.Date("As of")
todate = fields.Date("To")
party = fields.Many2One('party.party', "Counterparty")
fee_type = fields.Many2One('product.product', 'Fee type')
invoice_status = fields.Selection([
(None, ''),
('not_invoiced', 'Not invoiced'),
('invoiced_without_dn_cn', 'Invoiced without DN/CN'),
('invoiced_with_dn_cn', 'Invoiced with DN/CN'),
], "Invoice status")
purchase = fields.Many2One('purchase.purchase', "Purchase")
sale = fields.Many2One('sale.sale', "Sale")
shipment_in = fields.Many2One('stock.shipment.in',"Shipment In")
shipment_out = fields.Many2One('stock.shipment.out',"Shipment Out")
shipment_internal = fields.Many2One('stock.shipment.internal',"Shipment Internal")
@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()