Files
tradon/modules/sale_blanket_agreement/sale.py
2026-03-14 09:42:12 +00:00

827 lines
29 KiB
Python

# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import functools
from decimal import Decimal
from itertools import groupby
from sql import Null
from sql.functions import CharLength
from trytond.i18n import gettext
from trytond.model import (
ChatMixin, Index, ModelSQL, ModelView, Workflow, fields)
from trytond.modules.currency.fields import Monetary
from trytond.modules.product import price_digits, round_price
from trytond.modules.product.exceptions import UOMValidationError
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Date, Eval, Id, If
from trytond.transaction import Transaction
from trytond.wizard import Button, StateAction, StateView, Wizard
from .exceptions import (
BlanketAgreementClosingWarning, BlanketAgreementQuantityWarning)
def blanket_agreement_quantity_warning():
def decorator(func):
@functools.wraps(func)
def wrapper(cls, sales, *args, **kwargs):
pool = Pool()
Warning = pool.get('res.user.warning')
Lang = pool.get('ir.lang')
for sale in sales:
for line in sale.lines:
agreement_line = line.blanket_agreement_line
if not agreement_line:
continue
remaining_quantity = (
agreement_line.remainig_quantity_for_sale(line))
if (remaining_quantity is not None
and line.quantity > remaining_quantity):
warning_key = Warning.format(
'blanket_agreement_quantity_greater_remaining',
[line])
if Warning.check(warning_key):
lang = Lang.get()
raise BlanketAgreementQuantityWarning(
warning_key,
gettext('sale_blanket_agreement'
'.msg_quantity_greater_remaining',
line=line.rec_name,
remaining=lang.format_number_symbol(
remaining_quantity, line.unit),
agreement=agreement_line.rec_name))
return func(cls, sales, *args, **kwargs)
return wrapper
return decorator
class Configuration(metaclass=PoolMeta):
__name__ = 'sale.configuration'
blanket_agreement_sequence = fields.MultiValue(fields.Many2One(
'ir.sequence', "Blanket Agreement Sequence",
required=True,
domain=[
('company', 'in',
[Eval('context', {}).get('company', -1), None]),
('sequence_type', '=',
Id('sale_blanket_agreement',
'sequence_type_blanket_agreement')),
]))
@classmethod
def multivalue_model(cls, field):
pool = Pool()
if field == 'blanket_agreement_sequence':
return pool.get('sale.configuration.sequence')
return super().multivalue_model(field)
@classmethod
def default_blanket_agreement_sequence(cls, **pattern):
return cls.multivalue_model(
'blanket_agreement_sequence'
).default_blanket_agreement_sequence()
class ConfigurationSequence(metaclass=PoolMeta):
__name__ = 'sale.configuration.sequence'
blanket_agreement_sequence = fields.Many2One(
'ir.sequence', "Blanket Agreement Sequence", required=True,
domain=[
('company', 'in', [Eval('company', -1), None]),
('sequence_type', '=',
Id('sale_blanket_agreement',
'sequence_type_blanket_agreement')),
])
@classmethod
def default_blanket_agreement_sequence(cls):
pool = Pool()
ModelData = pool.get('ir.model.data')
try:
return ModelData.get_id(
'sale_blanket_agreement', 'sequence_blanket_agreement')
except KeyError:
return None
class BlanketAgreement(Workflow, ModelSQL, ModelView, ChatMixin):
__name__ = 'sale.blanket_agreement'
_rec_name = 'number'
company = fields.Many2One(
'company.company', "Company", required=True,
states={
'readonly': (
(Eval('state') != 'draft')
| Eval('lines', [0])
| Eval('customer', True)),
})
number = fields.Char("Number", readonly=True)
reference = fields.Char("Reference")
description = fields.Char(
"Description",
states={
'readonly': Eval('state') != 'draft',
})
customer = fields.Many2One(
'party.party', "Customer", required=True,
states={
'readonly': (
(Eval('state') != 'draft')
| (Eval('lines', [0]) & Eval('customer'))),
},
context={
'company': Eval('company', -1),
},
depends=['company'])
from_date = fields.Date(
"From Date",
domain=[
If(Eval('to_date') & Eval('from_date'),
('from_date', '<=', Eval('to_date')),
()),
],
states={
'readonly': Eval('state') != 'draft',
'required': ~Eval('state').in_(['draft', 'cancelled']),
})
to_date = fields.Date(
"To Date",
domain=[
If(Eval('from_date') & Eval('to_date'),
('to_date', '>=', Eval('from_date')),
()),
],
states={
'readonly': ~Eval('state').in_(['draft', 'running']),
'required': Eval('state') == 'closed',
})
currency = fields.Many2One(
'currency.currency', "Currency", required=True,
states={
'readonly': (
(Eval('state') != 'draft')
| (Eval('lines', [0]) & Eval('currency', 0))),
})
lines = fields.One2Many(
'sale.blanket_agreement.line', 'blanket_agreement', "Lines",
states={
'readonly': (
(Eval('state') != 'draft')
| ~Eval('customer'))
})
amount = fields.Function(Monetary(
"Amount", currency='currency', digits='currency'),
'on_change_with_amount')
state = fields.Selection([
('draft', "Draft"),
('running', "Running"),
('closed', "Closed"),
('cancelled', "Cancelled"),
], "State", readonly=True, required=True)
@classmethod
def __setup__(cls):
cls.number.search_unaccented = False
cls.reference.search_unaccented = False
super().__setup__()
t = cls.__table__()
cls._sql_indexes.update({
Index(t, (t.reference, Index.Similarity())),
Index(
t, (t.state, Index.Equality(cardinality='low')),
where=t.state.in_(['draft', 'running'])),
})
cls._order = [
('from_date', 'DESC NULLS FIRST'),
('id', 'DESC'),
]
cls._transitions |= set((
('draft', 'running'),
('draft', 'cancelled'),
('running', 'draft'),
('running', 'closed'),
('closed', 'running'),
('cancelled', 'draft'),
))
cls._buttons.update({
'cancel': {
'invisible': Eval('state') != 'draft',
'depends': ['state'],
},
'draft': {
'invisible': ~Eval('state').in_(['cancelled', 'running']),
'icon': 'tryton-undo',
'depends': ['state'],
},
'run': {
'invisible': (
(Eval('state') != 'draft')
& ~(Id('sale', 'group_sale_admin').in_(
Eval('context', {}).get('groups', []))
& (Eval('state') == 'closed'))),
'readonly': (~Eval('lines')
| (Eval('from_date', Date()) > Date())),
'icon': If(Eval('state') == 'closed',
'tryton-back',
'tryton-forward'),
'depends': ['state'],
},
'create_sale': {
'invisible': Eval('state') != 'running',
'depends': ['state'],
},
'close': {
'invisible': Eval('state') != 'running',
'depends': ['state'],
},
})
@classmethod
def default_company(cls):
return Transaction().context.get('company')
@classmethod
def order_number(cls, tables):
table, _ = tables[None]
return [
~((table.state == 'cancelled') & (table.number == Null)),
CharLength(table.number), table.number]
@classmethod
def default_currency(cls, **pattern):
pool = Pool()
Company = pool.get('company.company')
company = pattern.get('company')
if not company:
company = cls.default_company()
if company is not None and company >= 0:
return Company(company).currency.id
@fields.depends('company', 'customer', 'lines')
def on_change_customer(self):
if not self.lines:
self.currency = self.default_currency(
company=self.company.id if self.company else None)
if self.customer and self.customer.customer_currency:
self.currency = self.customer.customer_currency
@fields.depends('lines', 'currency')
def on_change_with_amount(self, name=None):
amount = sum(
(line.amount or Decimal(0) for line in self.lines),
Decimal(0))
if self.currency:
amount = self.currency.round(amount)
return amount
@classmethod
def default_state(cls):
return 'draft'
@property
def full_number(self):
return self.number
def get_rec_name(self, name):
items = []
if self.full_number:
items.append(self.full_number)
if self.reference:
items.append('[%s]' % self.reference)
if not items:
items.append('(%s)' % self.id)
return ' '.join(items)
@classmethod
def search_rec_name(cls, name, clause):
_, operator, value = clause
if operator.startswith('!') or operator.startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
return [bool_op,
('number', operator, value),
('reference', operator, value),
]
def chat_language(self, audience='internal'):
language = super().chat_language(audience=audience)
if audience == 'public':
language = self.customer.lang.code if self.customer.lang else None
return language
@classmethod
def copy(cls, agreements, default=None):
default = default.copy() if default is not None else {}
default.setdefault('number', None)
default.setdefault('reference')
default.setdefault('from_date', None)
default.setdefault('to_date', None)
return super().copy(agreements, default=default)
@classmethod
def set_number(cls, agreements):
'''
Fill the number field with the blanket agreement sequence
'''
pool = Pool()
Config = pool.get('sale.configuration')
config = Config(1)
for company, c_agreements in groupby(
agreements, key=lambda a: a.company):
c_agreements = [a for a in c_agreements if not a.number]
if c_agreements:
sequence = config.get_multivalue(
'blanket_agreement_sequence', company=company.id)
for agreement, number in zip(
c_agreements, sequence.get_many(len(c_agreements))):
agreement.number = number
cls.save(agreements)
@classmethod
def set_date(cls, agreements, field):
pool = Pool()
Date = pool.get('ir.date')
for company, agreements in groupby(
agreements, key=lambda p: p.company):
with Transaction().set_context(company=company.id):
today = Date.today()
cls.write([a for a in agreements if not getattr(a, field)], {
field: today,
})
@classmethod
def view_attributes(cls):
return super().view_attributes() + [
('/tree', 'visual',
If(Eval('state').in_(['cancelled', 'closed']),
'muted',
If(Eval('to_date', Date()) < Date(),
'warning',
''))),
]
def get_sale(self, lines=None):
pool = Pool()
Sale = pool.get('sale.sale')
sale = Sale(
company=self.company,
party=self.customer,
)
sale.on_change_party()
self.currency = self.currency
if lines:
sale_lines = []
for line in lines:
assert line.blanket_agreement == self
sale_line = line.get_sale_line(sale)
sale_lines.append(sale_line)
sale.lines = sale_lines
return sale
@classmethod
@ModelView.button
@Workflow.transition('cancelled')
def cancel(cls, agreements):
pass
@classmethod
@ModelView.button
@Workflow.transition('draft')
def draft(cls, agreements):
pass
@classmethod
@ModelView.button
@Workflow.transition('running')
def run(cls, agreements):
cls.set_number(agreements)
cls.set_date(agreements, 'from_date')
@classmethod
@ModelView.button_action(
'sale_blanket_agreement'
'.sale_blanket_agreement_create_sale_wizard')
def create_sale(cls, agreements):
pass
@classmethod
@ModelView.button
@Workflow.transition('closed')
def close(cls, agreements):
pool = Pool()
Warning = pool.get('res.user.warning')
Date = pool.get('ir.date')
today = Date.today()
cls.set_date(agreements, 'to_date')
for agreement in agreements:
if agreement.to_date > today:
if any(l.remaining_quantity > 0 for l in agreement.lines):
warning_key = Warning.format(
'closed_remaining_quantity', [agreement])
if Warning.check(warning_key):
raise BlanketAgreementClosingWarning(
warning_key,
gettext('sale_blanket_agreement'
'.msg_agreement_closed_remaining_quantity',
agreement=agreement.rec_name))
class BlanketAgreementLine(ModelSQL, ModelView):
__name__ = 'sale.blanket_agreement.line'
_states = {
'readonly': Eval('agreement_state') != 'draft'
}
blanket_agreement = fields.Many2One(
'sale.blanket_agreement', "Blanket Agreement",
ondelete='CASCADE', required=True,
states={
'readonly': (
_states['readonly'] & Bool(Eval('blanket_agreement'))),
})
product = fields.Many2One(
'product.product', "Product", ondelete='RESTRICT', required=True,
domain=[
If(Eval('agreement_state') == 'draft',
('salable', '=', True),
()),
],
states=_states,
context={
'company': Eval('company', None),
},
search_context={
'currency': Eval('_parent_agreement', {}).get('currency'),
'customer': Eval('_parent_agreement', {}).get('customer'),
'quantity': Eval('quantity'),
'uom': Eval('unit'),
},
depends=['company', 'unit', 'quantity'])
product_uom_category = fields.Function(
fields.Many2One(
'product.uom.category', "Product UoM Category",
help="The category of Unit of Measure for the product."),
'on_change_with_product_uom_category')
quantity = fields.Float("Quantity", digits='unit', states=_states)
unit = fields.Many2One(
'product.uom', "Unit", ondelete='RESTRICT', required=True,
states=_states)
unit_price = Monetary(
"Unit Price", digits=price_digits, currency='currency', required=True,
states=_states)
amount = fields.Function(
Monetary("Amount", digits='currency', currency='currency'),
'on_change_with_amount')
processed_quantity = fields.Function(
fields.Float("Processed quantity", digits='unit'),
'get_processed_quantity')
remaining_quantity = fields.Function(
fields.Float(
"Remaining quantity", digits='unit',
states={
'invisible': ~Eval('quantity'),
}),
'on_change_with_remaining_quantity')
sale_lines = fields.One2Many(
'sale.line', 'blanket_agreement_line', "Sale Lines", readonly=True)
agreement_state = fields.Function(
fields.Selection(
'get_sale_blanket_agreement_states', "Agreement State"),
'on_change_with_agreement_state')
company = fields.Function(
fields.Many2One('company.company', "Company"),
'on_change_with_company')
currency = fields.Function(
fields.Many2One('currency.currency', "Currency"),
'on_change_with_currency')
del _states
@classmethod
def __setup__(cls):
super().__setup__()
cls.__access__.add('blanket_agreement')
unit_categories = cls._unit_categories()
cls.unit.domain = [
If(Bool(Eval('product_uom_category')),
('category', 'in', [Eval(c) for c in unit_categories]),
('category', '!=', -1)),
]
@fields.depends(
'product', 'unit', 'blanket_agreement',
'_parent_blanket_agreement.customer',
methods=['on_change_with_amount'])
def on_change_product(self):
if not self.product:
return
category = self.product.sale_uom.category
if not self.unit or self.unit.category != category:
self.unit = self.product.sale_uom
@classmethod
def _unit_categories(cls):
return ['product_uom_category']
@fields.depends('product')
def on_change_with_product_uom_category(self, name=None):
return self.product.default_uom_category if self.product else None
@fields.depends(
'quantity', 'unit_price', 'blanket_agreement',
'_parent_blanket_agreement.currency')
def on_change_with_amount(self, name=None):
amount = (
Decimal(str(self.quantity or 0))
* (self.unit_price or Decimal(0)))
if self.blanket_agreement and self.blanket_agreement.currency:
return self.blanket_agreement.currency.round(amount)
return amount
@fields.depends('blanket_agreement', '_parent_blanket_agreement.currency')
def on_change_with_currency(self, name=None):
if self.blanket_agreement:
return self.blanket_agreement.currency
def get_processed_quantity(self, name=None):
processed_quantity = 0.
for line in self.sale_lines:
if line.sale.state in {'confirmed', 'processing', 'done'}:
processed_quantity += line.quantity_for_blanket_agreement(
self, round=False)
return self.unit.round(processed_quantity)
@fields.depends('quantity', 'processed_quantity')
def on_change_with_remaining_quantity(self, name=None):
if self.quantity is not None:
return max(self.quantity - (self.processed_quantity or 0.), 0.)
@classmethod
def get_sale_blanket_agreement_states(cls):
pool = Pool()
Agreement = pool.get('sale.blanket_agreement')
return Agreement.fields_get(['state'])['state']['selection']
@fields.depends('blanket_agreement', '_parent_blanket_agreement.state')
def on_change_with_agreement_state(self, name=None):
if self.blanket_agreement:
return self.blanket_agreement.state
@fields.depends('blanket_agreement', '_parent_blanket_agreement.company')
def on_change_with_company(self, name=None):
if self.blanket_agreement:
return self.blanket_agreement.company
def get_sale_line(self, sale):
pool = Pool()
SaleLine = pool.get('sale.line')
sale_line = SaleLine(
sale=sale,
product=self.product,
blanket_agreement_line=self,
)
sale_line.on_change_product()
self._set_sale_line_quantity(sale_line)
return sale_line
def _set_sale_line_quantity(self, sale_line):
if self.unit.category == self.product.sale_uom.category:
sale_line.quantity = self.remaining_quantity or 0
sale_line.unit = self.unit
sale_line.unit_price = self.unit_price
sale_line.on_change_quantity()
def get_rec_name(self, name):
pool = Pool()
Lang = pool.get('ir.lang')
lang = Lang.get()
name = f'{self.product.rec_name}s @ {self.blanket_agreement.rec_name}'
if self.quantity is not None:
name = '%s %s' % (lang.format_number_symbol(
self.quantity, self.unit, digits=self.unit.digits), name)
return name
@classmethod
def validate_fields(cls, records, field_names):
super().validate_fields(records, field_names)
cls.check_unit(records, field_names)
@classmethod
def check_unit(cls, lines, field_names=None):
if field_names and not (field_names & {'unit'}):
return
for line in lines:
for sale_line in line.sale_lines:
if not line.is_same_uom_category(sale_line):
raise UOMValidationError(
gettext('sale_blanket_agreement'
'.msg_agreement_line_incompatible_unit',
line=line.rec_name))
def is_same_uom_category(self, sale_line):
return self.unit.category == sale_line.product_uom_category
def remainig_quantity_for_sale(self, line, round=True):
pool = Pool()
Uom = pool.get('product.uom')
if (self.remaining_quantity is not None
and self.unit.category == line.unit.category):
return Uom.compute_qty(
self.unit, self.remaining_quantity, line.unit,
round=round)
@classmethod
def copy(cls, lines, default=None):
default = default.copy() if default is not None else {}
default.setdefault('sale_lines', None)
return super().copy(lines, default=default)
class Sale(metaclass=PoolMeta):
__name__ = 'sale.sale'
blanket_agreements = fields.Function(fields.Many2Many(
'sale.blanket_agreement', None, None, "Blanket Agreements"),
'get_blanket_agreements', searcher='search_blanket_agreements')
def get_blanket_agreements(self, name):
return list({
l.blanket_agreement_line.blanket_agreement.id
for l in self.lines
if l.blanket_agreement_line})
@classmethod
def search_blanket_agreements(cls, name, clause):
return [
('lines.blanket_agreement_line.blanket_agreement'
+ clause[0][len(name):], *clause[1:])]
@classmethod
@ModelView.button
@Workflow.transition('quotation')
@blanket_agreement_quantity_warning()
def quote(cls, sales):
super().quote(sales)
@classmethod
@ModelView.button
@Workflow.transition('confirmed')
@blanket_agreement_quantity_warning()
def confirm(cls, sales):
super().confirm(sales)
class Line(metaclass=PoolMeta):
__name__ = 'sale.line'
blanket_agreement_line = fields.Many2One(
'sale.blanket_agreement.line', "Blanket Agreement Line",
ondelete='RESTRICT',
states={
'invisible': ~Eval('product'),
'readonly': Eval('sale_state') != 'draft',
})
@classmethod
def __setup__(cls):
super().__setup__()
sale_date = Eval('_parent_sale', {}).get(
'sale_date', Date())
sale_date = If(sale_date, sale_date, Date())
cls.blanket_agreement_line.domain = [
If(Eval('sale_state').in_(['draft', 'quotation']),
[
('blanket_agreement.state', '=', 'running'),
['OR',
('blanket_agreement.from_date', '<=', sale_date),
('blanket_agreement.from_date', '=', None),
],
['OR',
('blanket_agreement.to_date', '>=', sale_date),
('blanket_agreement.to_date', '=', None),
],
cls._domain_blanket_agreemnt_line_product(),
],
[]),
('blanket_agreement.customer', '=',
Eval('_parent_sale', {}).get('party', -1)),
]
@classmethod
def _domain_blanket_agreemnt_line_product(cls):
return ('product', '=', Eval('product', -1))
@fields.depends(
'blanket_agreement_line', '_parent_blanket_agreement_line.unit',
'_parent_blanket_agreement_line.unit_price', 'unit')
def compute_unit_price(self):
pool = Pool()
Uom = pool.get('product.uom')
unit_price = super().compute_unit_price()
line = self.blanket_agreement_line
if (line
and self.unit
and line.unit
and self.unit.category == line.unit.category):
unit_price = Uom.compute_price(
line.unit, line.unit_price, self.unit)
unit_price = round_price(unit_price)
return unit_price
@fields.depends(
'quantity', 'unit',
'blanket_agreement_line',
'_parent_blanket_agreement_line.unit',
'_parent_blanket_agreement_line.remaining_quantity',
methods=['compute_unit_price', 'on_change_quantity'])
def on_change_blanket_agreement_line(self):
pool = Pool()
Uom = pool.get('product.uom')
if self.blanket_agreement_line:
line = self.blanket_agreement_line
self.unit_price = self.compute_unit_price()
if (self.unit and line.unit
and self.unit.category == line.unit.category):
if line.remaining_quantity is not None:
remaining_quantity = Uom.compute_qty(
line.unit, line.remaining_quantity, self.unit)
if (self.quantity is None
or remaining_quantity < self.quantity):
self.quantity = remaining_quantity
self.on_change_quantity()
@fields.depends(methods=['is_valid_product_for_blanket_agreement'])
def on_change_product(self):
super().on_change_product()
if not self.is_valid_product_for_blanket_agreement():
self.blanket_agreement_line = None
@fields.depends(
'blanket_agreement_line', 'product',
'_parent_blanket_agreement_line.product')
def is_valid_product_for_blanket_agreement(self):
if self.blanket_agreement_line:
return self.product == self.blanket_agreement_line.product
def quantity_for_blanket_agreement(self, line, round=True):
pool = Pool()
Uom = pool.get('product.uom')
if self.unit.category == line.unit.category:
quantity = (
self.actual_quantity if self.actual_quantity is not None
else self.quantity)
return Uom.compute_qty(self.unit, quantity, line.unit, round=round)
return 0
class BlanketAgreementCreateSale(Wizard):
__name__ = 'sale.blanket_agreement.create_sale'
start = StateView(
'sale.blanket_agreement.create_sale.start',
'sale_blanket_agreement'
'.sale_blanket_agreement_create_sale_start_form_view', [
Button("Cancel", 'end', 'tryton-cancel'),
Button("Create", 'create_sale', 'tryton-ok', default=True),
])
create_sale = StateAction('sale.act_sale_form')
def default_start(self, fields):
line_ids = [
line.id for line in self.record.lines
if line.remaining_quantity > 0]
return {
'blanket_agreement': self.record.id,
'lines': line_ids,
}
def do_create_sale(self, action):
if self.start.lines:
sale = self.record.get_sale(self.start.lines)
sale.save()
action['domains'] = []
action['views'].reverse()
return action, {'res_id': [sale.id]}
class BlanketAgreementCreateSaleStart(ModelView):
__name__ = 'sale.blanket_agreement.create_sale.start'
blanket_agreement = fields.Many2One('sale.blanket_agreement', "Agreement")
lines = fields.Many2Many(
'sale.blanket_agreement.line', None, None, "Lines",
domain=[('blanket_agreement', '=', Eval('blanket_agreement', -1))])