2620 lines
94 KiB
Python
2620 lines
94 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 datetime
|
|
import math
|
|
from collections import defaultdict
|
|
from decimal import Decimal
|
|
from functools import partial
|
|
from itertools import chain, groupby
|
|
|
|
from sql import Literal, Null, operators
|
|
from sql.conditionals import Coalesce
|
|
from sql.functions import CharLength
|
|
|
|
from trytond import backend
|
|
from trytond.i18n import gettext
|
|
from trytond.ir.attachment import AttachmentCopyMixin
|
|
from trytond.ir.note import NoteCopyMixin
|
|
from trytond.model import (
|
|
ChatMixin, Index, ModelSQL, ModelView, Unique, Workflow, fields,
|
|
sequence_ordered)
|
|
from trytond.model.exceptions import AccessError
|
|
from trytond.modules.account.tax import TaxableMixin
|
|
from trytond.modules.account_product.exceptions import AccountError
|
|
from trytond.modules.company import CompanyReport
|
|
from trytond.modules.company.model import (
|
|
employee_field, reset_employee, set_employee)
|
|
from trytond.modules.currency.fields import Monetary
|
|
from trytond.modules.product import price_digits
|
|
from trytond.pool import Pool
|
|
from trytond.pyson import Bool, Eval, If, PYSONEncoder
|
|
from trytond.tools import cached_property, firstline, sortable_values
|
|
from trytond.transaction import Transaction
|
|
from trytond.wizard import (
|
|
Button, StateAction, StateTransition, StateView, Wizard)
|
|
|
|
from .exceptions import (
|
|
PartyLocationError, SaleMoveQuantity, SaleQuotationError,
|
|
SaleValidationError)
|
|
|
|
|
|
def samesign(a, b):
|
|
return math.copysign(a, b) == a
|
|
|
|
|
|
def get_shipments_returns(model_name):
|
|
"Computes the returns or shipments"
|
|
def method(self, name):
|
|
Model = Pool().get(model_name)
|
|
shipments = set()
|
|
for line in self.line_lines:
|
|
for move in line.moves:
|
|
if isinstance(move.shipment, Model):
|
|
shipments.add(move.shipment.id)
|
|
return list(shipments)
|
|
return method
|
|
|
|
|
|
def search_shipments_returns(model_name):
|
|
"Search on shipments or returns"
|
|
def method(self, name, clause):
|
|
_, operator, operand, *extra = clause
|
|
nested = clause[0][len(name):]
|
|
if not nested:
|
|
if isinstance(clause[2], str):
|
|
nested = '.rec_name'
|
|
else:
|
|
nested = '.id'
|
|
return [('lines.moves.shipment' + nested,
|
|
operator, operand, model_name, *extra)]
|
|
return classmethod(method)
|
|
|
|
|
|
class Sale(
|
|
Workflow, ModelSQL, ModelView, TaxableMixin,
|
|
AttachmentCopyMixin, NoteCopyMixin, ChatMixin):
|
|
__name__ = 'sale.sale'
|
|
_rec_name = 'number'
|
|
company = fields.Many2One(
|
|
'company.company', "Company", required=True,
|
|
states={
|
|
'readonly': (
|
|
(Eval('state') != 'draft')
|
|
| Eval('lines', [0])
|
|
| Eval('party', True)
|
|
| Eval('invoice_party', True)
|
|
| Eval('shipment_party', True)),
|
|
})
|
|
number = fields.Char("Number", readonly=True)
|
|
reference = fields.Char("Reference")
|
|
description = fields.Char('Description',
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
})
|
|
quotation_date = fields.Date(
|
|
"Quotation Date",
|
|
states={
|
|
'readonly': (
|
|
(Eval('state') != 'draft')
|
|
| (Eval('number') & Eval('lines'))),
|
|
'invisible': ~Eval('state').in_(['draft', 'quotation']),
|
|
},
|
|
help="When the quotation was edited.")
|
|
quotation_validity = fields.TimeDelta(
|
|
"Quotation Validity",
|
|
domain=['OR',
|
|
('quotation_validity', '>=', datetime.timedelta()),
|
|
('quotation_validity', '=', None),
|
|
],
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
'invisible': ~Eval('state').in_(['draft', 'quotation']),
|
|
},
|
|
help="How much time the quotation is valid.")
|
|
quotation_expire = fields.Function(fields.Date(
|
|
"Quotation Expire",
|
|
states={
|
|
'invisible': ~Eval('state').in_(['draft', 'quotation']),
|
|
},
|
|
help="Until when the quotation is still valid."),
|
|
'on_change_with_quotation_expire')
|
|
sale_date = fields.Date('Sale Date',
|
|
domain=[
|
|
If(Eval('quotation_expire') & (Eval('state') == 'confirmed'),
|
|
('sale_date', '<=', Eval('quotation_expire')),
|
|
()),
|
|
],
|
|
states={
|
|
'readonly': ~Eval('state').in_(['draft', 'quotation']),
|
|
'required': ~Eval('state').in_(
|
|
['draft', 'quotation', 'cancelled']),
|
|
})
|
|
payment_term = fields.Many2One(
|
|
'account.invoice.payment_term', "Payment Term", ondelete='RESTRICT',
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
})
|
|
party = fields.Many2One(
|
|
'party.party', "Party", required=True,
|
|
states={
|
|
'readonly': ((Eval('state') != 'draft')
|
|
| (Eval('lines', [0]) & Eval('party'))),
|
|
},
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
depends={'company'})
|
|
party_lang = fields.Function(fields.Char('Party Language'),
|
|
'on_change_with_party_lang')
|
|
contact = fields.Many2One(
|
|
'party.contact_mechanism', "Contact",
|
|
context={
|
|
'company': Eval('company', -1),
|
|
},
|
|
search_context={
|
|
'related_party': Eval('party'),
|
|
},
|
|
depends={'company'})
|
|
invoice_party = fields.Many2One('party.party', "Invoice Party",
|
|
states={
|
|
'readonly': ((Eval('state') != 'draft')
|
|
| Eval('lines', [0])),
|
|
},
|
|
context={
|
|
'company': Eval('company', -1),
|
|
'party_contact_mechanism_usage': 'invoice',
|
|
},
|
|
search_context={
|
|
'related_party': Eval('party'),
|
|
},
|
|
depends={'company'})
|
|
invoice_address = fields.Many2One('party.address', 'Invoice Address',
|
|
domain=[
|
|
('party', '=', If(Bool(Eval('invoice_party')),
|
|
Eval('invoice_party', -1), Eval('party', -1))),
|
|
],
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
'required': ~Eval('state').in_(
|
|
['draft', 'quotation', 'cancelled']),
|
|
})
|
|
shipment_party = fields.Many2One('party.party', 'Shipment Party',
|
|
states={
|
|
'readonly': (Eval('state') != 'draft'),
|
|
},
|
|
context={
|
|
'company': Eval('company', -1),
|
|
'party_contact_mechanism_usage': 'delivery',
|
|
},
|
|
search_context={
|
|
'related_party': Eval('party'),
|
|
},
|
|
depends={'company'})
|
|
shipment_address = fields.Many2One('party.address', 'Shipment Address',
|
|
domain=['OR',
|
|
('party', '=', If(Bool(Eval('shipment_party')),
|
|
Eval('shipment_party', -1), Eval('party', -1))),
|
|
('warehouses', 'where', [
|
|
('id', '=', Eval('warehouse', -1)),
|
|
If(Eval('state').in_(['draft', 'quotation']),
|
|
('allow_pickup', '=', True),
|
|
()),
|
|
]),
|
|
],
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
})
|
|
warehouse = fields.Many2One('stock.location', 'Warehouse',
|
|
domain=[('type', '=', 'warehouse')], states={
|
|
'readonly': Eval('state') != 'draft',
|
|
})
|
|
currency = fields.Many2One('currency.currency', 'Currency', required=True,
|
|
states={
|
|
'readonly': ((Eval('state') != 'draft')
|
|
| (Eval('lines', [0]) & Eval('currency', 0))),
|
|
})
|
|
lines = fields.One2Many(
|
|
'sale.line', 'sale', "Lines",
|
|
states={
|
|
'readonly': (
|
|
(Eval('state') != 'draft')
|
|
| ~Eval('company')
|
|
| ~Eval('currency')),
|
|
})
|
|
line_lines = fields.One2Many(
|
|
'sale.line', 'sale', "Line - Lines", readonly=True,
|
|
filter=[
|
|
('type', '=', 'line'),
|
|
])
|
|
comment = fields.Text('Comment')
|
|
untaxed_amount = fields.Function(Monetary(
|
|
"Untaxed", digits='currency', currency='currency'), 'get_amount')
|
|
untaxed_amount_cache = fields.Numeric(
|
|
"Untaxed Cache", digits='currency', readonly=True)
|
|
tax_amount = fields.Function(Monetary(
|
|
"Tax", digits='currency', currency='currency'), 'get_amount')
|
|
tax_amount_cache = fields.Numeric(
|
|
"Tax Cache", digits='currency', readonly=True)
|
|
total_amount = fields.Function(Monetary(
|
|
"Total", digits='currency', currency='currency'), 'get_amount')
|
|
total_amount_cache = fields.Numeric(
|
|
"Total Cache", digits='currency', readonly=True)
|
|
invoice_method = fields.Selection([
|
|
('manual', 'Manual'),
|
|
('order', 'On Order Processed'),
|
|
('shipment', 'On Shipment Sent'),
|
|
],
|
|
'Invoice Method', required=True, states={
|
|
'readonly': Eval('state') != 'draft',
|
|
})
|
|
invoice_method_string = invoice_method.translated('invoice_method')
|
|
invoice_state = fields.Selection([
|
|
('none', 'None'),
|
|
('pending', "Pending"),
|
|
('awaiting payment', "Awaiting Payment"),
|
|
('partially paid', "Partially Paid"),
|
|
('paid', 'Paid'),
|
|
('exception', 'Exception'),
|
|
], 'Invoice State', readonly=True, required=True, sort=False)
|
|
invoices = fields.Function(fields.Many2Many(
|
|
'account.invoice', None, None, "Invoices"),
|
|
'get_invoices', searcher='search_invoices')
|
|
invoices_ignored = fields.Many2Many(
|
|
'sale.sale-ignored-account.invoice', 'sale', 'invoice',
|
|
"Ignored Invoices",
|
|
domain=[
|
|
('id', 'in', Eval('invoices', [])),
|
|
('state', '=', 'cancelled'),
|
|
],
|
|
states={
|
|
'invisible': ~Eval('invoices_ignored', []),
|
|
})
|
|
invoices_recreated = fields.Many2Many(
|
|
'sale.sale-recreated-account.invoice', 'sale', 'invoice',
|
|
'Recreated Invoices', readonly=True)
|
|
shipment_method = fields.Selection([
|
|
('manual', 'Manual'),
|
|
('order', 'On Order Processed'),
|
|
('invoice', 'On Invoice Paid'),
|
|
], 'Shipment Method', required=True,
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
})
|
|
shipment_method_string = shipment_method.translated('shipment_method')
|
|
shipment_state = fields.Selection([
|
|
('none', 'None'),
|
|
('waiting', 'Waiting'),
|
|
('partially shipped', 'Partially Shipped'),
|
|
('sent', 'Sent'),
|
|
('exception', 'Exception'),
|
|
], "Shipment State", readonly=True, required=True, sort=False)
|
|
shipments = fields.Function(fields.Many2Many(
|
|
'stock.shipment.out', None, None, "Shipments"),
|
|
'get_shipments', searcher='search_shipments')
|
|
shipment_returns = fields.Function(fields.Many2Many(
|
|
'stock.shipment.out.return', None, None, "Shipment Returns"),
|
|
'get_shipment_returns', searcher='search_shipment_returns')
|
|
moves = fields.Function(
|
|
fields.Many2Many('stock.move', None, None, "Stock Moves"),
|
|
'get_moves', searcher='search_moves')
|
|
origin = fields.Reference(
|
|
"Origin", selection='get_origin',
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
})
|
|
shipping_date = fields.Date(
|
|
"Shipping Date",
|
|
domain=[If(Bool(Eval('sale_date')) & Bool(Eval('shipping_date')),
|
|
('shipping_date', '>=', Eval('sale_date')),
|
|
()),
|
|
],
|
|
states={
|
|
'readonly': Eval('state').in_(['processing', 'done', 'cancelled']),
|
|
},
|
|
help="When the shipping of goods should start.")
|
|
|
|
quoted_by = employee_field(
|
|
"Quoted By",
|
|
states=['quotation', 'confirmed', 'processing', 'done', 'cancelled'])
|
|
confirmed_by = employee_field(
|
|
"Confirmed By",
|
|
states=['confirmed', 'processing', 'done', 'cancelled'])
|
|
state = fields.Selection([
|
|
('draft', "Draft"),
|
|
('quotation', "Quotation"),
|
|
('confirmed', "Confirmed"),
|
|
('processing', "Processing"),
|
|
('done', "Done"),
|
|
('cancelled', "Cancelled"),
|
|
], "State", readonly=True, required=True, sort=False)
|
|
state_string = state.translated('state')
|
|
|
|
@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.party, Index.Range())),
|
|
Index(
|
|
t,
|
|
(t.state, Index.Equality(cardinality='low')),
|
|
where=t.state.in_([
|
|
'draft', 'quotation', 'confirmed', 'processing'])),
|
|
Index(
|
|
t,
|
|
(t.invoice_state, Index.Equality(cardinality='low')),
|
|
where=t.invoice_state.in_([
|
|
'none', 'waiting', 'exception'])),
|
|
Index(
|
|
t,
|
|
(t.shipment_state, Index.Equality(cardinality='low')),
|
|
where=t.shipment_state.in_([
|
|
'none', 'waiting', 'exception'])),
|
|
})
|
|
cls._order = [
|
|
('sale_date', 'DESC NULLS FIRST'),
|
|
('id', 'DESC'),
|
|
]
|
|
cls._transitions |= set((
|
|
('draft', 'quotation'),
|
|
('quotation', 'confirmed'),
|
|
('confirmed', 'processing'),
|
|
('confirmed', 'draft'),
|
|
('processing', 'processing'),
|
|
('processing', 'done'),
|
|
('done', 'processing'),
|
|
('draft', 'cancelled'),
|
|
('quotation', 'cancelled'),
|
|
('quotation', 'draft'),
|
|
('cancelled', 'draft'),
|
|
))
|
|
cls._buttons.update({
|
|
'cancel': {
|
|
'invisible': ~Eval('state').in_(['draft', 'quotation']),
|
|
'depends': ['state'],
|
|
},
|
|
'draft': {
|
|
'invisible': ~Eval('state').in_(
|
|
['cancelled', 'quotation', 'confirmed']),
|
|
'icon': If(Eval('state') == 'cancelled',
|
|
'tryton-undo',
|
|
'tryton-back'),
|
|
'depends': ['state'],
|
|
},
|
|
'quote': {
|
|
'invisible': Eval('state') != 'draft',
|
|
'readonly': ~Eval('lines', Eval('untaxed_amount', 0)),
|
|
'depends': ['state'],
|
|
},
|
|
'confirm': {
|
|
'invisible': Eval('state') != 'quotation',
|
|
'depends': ['state'],
|
|
},
|
|
'process': {
|
|
'invisible': ~Eval('state').in_(
|
|
['confirmed', 'processing', 'done']),
|
|
'icon': If(Eval('state') == 'confirmed',
|
|
'tryton-forward', 'tryton-refresh'),
|
|
'depends': ['state'],
|
|
},
|
|
'manual_invoice': {
|
|
'invisible': (
|
|
(Eval('invoice_method') != 'manual')
|
|
| ~Eval('state').in_(['processing', 'done'])),
|
|
'depends': ['invoice_method', 'state'],
|
|
},
|
|
'manual_shipment': {
|
|
'invisible': (
|
|
(Eval('shipment_method') != 'manual')
|
|
| ~Eval('state').in_(['processing', 'done'])),
|
|
'depends': ['shipment_method', 'state'],
|
|
},
|
|
'handle_invoice_exception': {
|
|
'invisible': ((Eval('invoice_state') != 'exception')
|
|
| (Eval('state') == 'cancelled')),
|
|
'depends': ['state', 'invoice_state'],
|
|
},
|
|
'handle_shipment_exception': {
|
|
'invisible': ((Eval('shipment_state') != 'exception')
|
|
| (Eval('state') == 'cancelled')),
|
|
'depends': ['state', 'shipment_state'],
|
|
},
|
|
'modify_header': {
|
|
'invisible': ((Eval('state') != 'draft')
|
|
| ~Eval('lines', [-1])),
|
|
'depends': ['state'],
|
|
},
|
|
})
|
|
# The states where amounts are cached
|
|
cls._states_cached = ['confirmed', 'processing', 'done', 'cancelled']
|
|
|
|
@classmethod
|
|
def __register__(cls, module_name):
|
|
cursor = Transaction().connection.cursor()
|
|
sql_table = cls.__table__()
|
|
|
|
super().__register__(module_name)
|
|
|
|
# Migration from 6.6: rename invoice state waiting to pending
|
|
cursor.execute(*sql_table.update(
|
|
[sql_table.invoice_state], ['pending'],
|
|
where=sql_table.invoice_state == 'waiting'))
|
|
|
|
@classmethod
|
|
def order_number(cls, tables):
|
|
table, _ = tables[None]
|
|
return [
|
|
~((table.state == 'cancelled') & (table.number == Null)),
|
|
CharLength(table.number), table.number]
|
|
|
|
@classmethod
|
|
def default_payment_term(cls, **pattern):
|
|
pool = Pool()
|
|
Configuration = pool.get('account.configuration')
|
|
config = Configuration(1)
|
|
payment_term = config.get_multivalue(
|
|
'default_customer_payment_term', **pattern)
|
|
return payment_term.id if payment_term else None
|
|
|
|
@classmethod
|
|
def default_warehouse(cls):
|
|
Location = Pool().get('stock.location')
|
|
return Location.get_default_warehouse()
|
|
|
|
@staticmethod
|
|
def default_company():
|
|
return Transaction().context.get('company')
|
|
|
|
@fields.depends('company')
|
|
def on_change_company(self):
|
|
self.quotation_validity = self.default_quotation_validity(
|
|
company=self.company.id if self.company else None)
|
|
self.payment_term = self.default_payment_term(
|
|
company=self.company.id if self.company else None)
|
|
self.invoice_method = self.default_invoice_method(
|
|
company=self.company.id if self.company else None)
|
|
self.shipment_method = self.default_shipment_method(
|
|
company=self.company.id if self.company else None)
|
|
|
|
@classmethod
|
|
def default_quotation_validity(cls, **pattern):
|
|
Config = Pool().get('sale.configuration')
|
|
config = Config(1)
|
|
return config.get_multivalue('sale_quotation_validity', **pattern)
|
|
|
|
@fields.depends('company', 'quotation_date', 'quotation_validity')
|
|
def on_change_with_quotation_expire(self, name=None):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
if self.quotation_validity is not None:
|
|
with Transaction().set_context(
|
|
company=self.company.id if self.company else None):
|
|
today = Date.today()
|
|
return (self.quotation_date or today) + self.quotation_validity
|
|
|
|
@classmethod
|
|
def domain_quotation_expire(cls, domain, tables):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
today = Date.today()
|
|
field = cls.quotation_expire._field
|
|
table, _ = tables[None]
|
|
name, operator, value = domain
|
|
Operator = fields.SQL_OPERATORS[operator]
|
|
column = (
|
|
Coalesce(table.quotation_date, today) + table.quotation_validity)
|
|
expression = Operator(column, field._domain_value(operator, value))
|
|
if isinstance(expression, operators.In) and not expression.right:
|
|
expression = Literal(False)
|
|
elif isinstance(expression, operators.NotIn) and not expression.right:
|
|
expression = Literal(True)
|
|
expression = field._domain_add_null(
|
|
column, operator, value, expression)
|
|
return expression
|
|
|
|
@staticmethod
|
|
def default_state():
|
|
return 'draft'
|
|
|
|
@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
|
|
|
|
@classmethod
|
|
def default_invoice_method(cls, **pattern):
|
|
Config = Pool().get('sale.configuration')
|
|
config = Config(1)
|
|
return config.get_multivalue('sale_invoice_method', **pattern)
|
|
|
|
@staticmethod
|
|
def default_invoice_state():
|
|
return 'none'
|
|
|
|
@classmethod
|
|
def default_shipment_method(cls, **pattern):
|
|
Config = Pool().get('sale.configuration')
|
|
config = Config(1)
|
|
return config.get_multivalue('sale_shipment_method', **pattern)
|
|
|
|
@staticmethod
|
|
def default_shipment_state():
|
|
return 'none'
|
|
|
|
@fields.depends(
|
|
'company', 'party', 'invoice_party', 'shipment_party', 'warehouse',
|
|
'payment_term', 'lines')
|
|
def on_change_party(self):
|
|
if not self.invoice_party:
|
|
self.invoice_address = None
|
|
if not self.shipment_party:
|
|
self.shipment_address = None
|
|
self.payment_term = self.default_payment_term(
|
|
company=self.company.id if self.company else None)
|
|
if not self.lines:
|
|
self.currency = self.default_currency(
|
|
company=self.company.id if self.company else None)
|
|
if self.party:
|
|
if not self.invoice_party:
|
|
self.invoice_address = self.party.address_get(type='invoice')
|
|
if not self.shipment_party:
|
|
with Transaction().set_context(
|
|
warehouse=(
|
|
self.warehouse.id if self.warehouse else None)):
|
|
self.shipment_address = self.party.address_get(
|
|
type='delivery')
|
|
if self.party.sale_shipment_method:
|
|
self.shipment_method = self.party.sale_shipment_method
|
|
else:
|
|
self.shipment_method = self.default_shipment_method()
|
|
if self.party.customer_payment_term:
|
|
self.payment_term = self.party.customer_payment_term
|
|
if self.party.sale_invoice_method:
|
|
self.invoice_method = self.party.sale_invoice_method
|
|
else:
|
|
self.invoice_method = self.default_invoice_method()
|
|
if not self.lines:
|
|
if self.party.customer_currency:
|
|
self.currency = self.party.customer_currency
|
|
|
|
@fields.depends('party', 'invoice_party')
|
|
def on_change_invoice_party(self):
|
|
if self.invoice_party:
|
|
self.invoice_address = self.invoice_party.address_get(
|
|
type='invoice')
|
|
elif self.party:
|
|
self.invoice_address = self.party.address_get(type='invoice')
|
|
|
|
@fields.depends('party', 'shipment_party', 'warehouse')
|
|
def on_change_shipment_party(self):
|
|
with Transaction().set_context(
|
|
warehouse=self.warehouse.id if self.warehouse else None):
|
|
if self.shipment_party:
|
|
self.shipment_address = self.shipment_party.address_get(
|
|
type='delivery')
|
|
elif self.party:
|
|
self.shipment_address = self.party.address_get(type='delivery')
|
|
if self.party.sale_shipment_method:
|
|
self.shipment_method = self.party.sale_shipment_method
|
|
|
|
@fields.depends('party', 'company')
|
|
def _get_tax_context(self):
|
|
context = {}
|
|
if self.party and self.party.lang:
|
|
context['language'] = self.party.lang.code
|
|
if self.company:
|
|
context['company'] = self.company.id
|
|
return context
|
|
|
|
@fields.depends('party')
|
|
def on_change_with_party_lang(self, name=None):
|
|
Config = Pool().get('ir.configuration')
|
|
if self.party and self.party.lang:
|
|
return self.party.lang.code
|
|
return Config.get_language()
|
|
|
|
@fields.depends('lines', 'currency', methods=['get_tax_amount'])
|
|
def on_change_lines(self):
|
|
self.untaxed_amount = Decimal(0)
|
|
self.tax_amount = Decimal(0)
|
|
self.total_amount = Decimal(0)
|
|
|
|
if self.lines:
|
|
for line in self.lines:
|
|
self.untaxed_amount += getattr(line, 'amount', None) or 0
|
|
self.tax_amount = self.get_tax_amount()
|
|
if self.currency:
|
|
self.untaxed_amount = self.currency.round(self.untaxed_amount)
|
|
self.tax_amount = self.currency.round(self.tax_amount)
|
|
self.total_amount = self.untaxed_amount + self.tax_amount
|
|
if self.currency:
|
|
self.total_amount = self.currency.round(self.total_amount)
|
|
|
|
@property
|
|
def taxable_lines(self):
|
|
taxable_lines = []
|
|
for line in self.lines:
|
|
taxable_lines.extend(line.taxable_lines)
|
|
return taxable_lines
|
|
|
|
@fields.depends(methods=['_get_taxes'])
|
|
def get_tax_amount(self):
|
|
return sum((t.amount for t in self._get_taxes().values()), Decimal(0))
|
|
|
|
@classmethod
|
|
def get_amount(cls, sales, names):
|
|
untaxed_amount = {}
|
|
tax_amount = {}
|
|
total_amount = {}
|
|
|
|
if {'tax_amount', 'total_amount'} & set(names):
|
|
compute_taxes = True
|
|
else:
|
|
compute_taxes = False
|
|
# Browse separately not cached to limit number of lines read
|
|
cached, not_cached = [], []
|
|
for sale in sales:
|
|
if sale.state in cls._states_cached:
|
|
cached.append(sale)
|
|
else:
|
|
not_cached.append(sale)
|
|
for sale in chain(cached, cls.browse(not_cached)):
|
|
if (sale.state in cls._states_cached
|
|
and sale.untaxed_amount_cache is not None
|
|
and sale.tax_amount_cache is not None
|
|
and sale.total_amount_cache is not None):
|
|
untaxed_amount[sale.id] = sale.untaxed_amount_cache
|
|
if compute_taxes:
|
|
tax_amount[sale.id] = sale.tax_amount_cache
|
|
total_amount[sale.id] = sale.total_amount_cache
|
|
else:
|
|
untaxed_amount[sale.id] = sum(
|
|
(line.amount for line in sale.line_lines), Decimal(0))
|
|
if compute_taxes:
|
|
tax_amount[sale.id] = sale.get_tax_amount()
|
|
total_amount[sale.id] = (
|
|
untaxed_amount[sale.id] + tax_amount[sale.id])
|
|
|
|
result = {
|
|
'untaxed_amount': untaxed_amount,
|
|
'tax_amount': tax_amount,
|
|
'total_amount': total_amount,
|
|
}
|
|
for key in list(result.keys()):
|
|
if key not in names:
|
|
del result[key]
|
|
return result
|
|
|
|
def get_invoices(self, name):
|
|
invoices = set()
|
|
for line in self.line_lines:
|
|
for invoice_line in line.invoice_lines:
|
|
if invoice_line.invoice:
|
|
invoices.add(invoice_line.invoice.id)
|
|
return list(invoices)
|
|
|
|
@classmethod
|
|
def search_invoices(cls, name, clause):
|
|
return [
|
|
('lines', 'where', [
|
|
('invoice_lines.invoice' + clause[0][len(name):],
|
|
*clause[1:]),
|
|
('type', '=', 'line'),
|
|
]),
|
|
]
|
|
|
|
@property
|
|
def _invoices_for_state(self):
|
|
return self.invoices
|
|
|
|
def get_invoice_state(self):
|
|
'''
|
|
Return the invoice state for the sale.
|
|
'''
|
|
skips = set(self.invoices_ignored)
|
|
skips.update(self.invoices_recreated)
|
|
invoices = [i for i in self._invoices_for_state if i not in skips]
|
|
|
|
def is_cancelled(invoice):
|
|
return invoice.state == 'cancelled' and not invoice.cancel_move
|
|
|
|
def is_paid(invoice):
|
|
return (
|
|
invoice.state == 'paid'
|
|
or (invoice.state == 'cancelled' and invoice.cancel_move))
|
|
if invoices:
|
|
if any(is_cancelled(i) for i in invoices):
|
|
return 'exception'
|
|
elif all(is_paid(i) for i in invoices):
|
|
return 'paid'
|
|
elif any(is_paid(i) for i in invoices):
|
|
return 'partially paid'
|
|
elif any(i.state == 'posted' for i in invoices):
|
|
return 'awaiting payment'
|
|
else:
|
|
return 'pending'
|
|
return 'none'
|
|
|
|
get_shipments = get_shipments_returns('stock.shipment.out')
|
|
get_shipment_returns = get_shipments_returns('stock.shipment.out.return')
|
|
|
|
search_shipments = search_shipments_returns('stock.shipment.out')
|
|
search_shipment_returns = search_shipments_returns(
|
|
'stock.shipment.out.return')
|
|
|
|
def get_shipment_state(self):
|
|
'''
|
|
Return the shipment state for the sale.
|
|
'''
|
|
if any(l.moves_exception for l in self.line_lines):
|
|
return 'exception'
|
|
elif any(m.state != 'cancelled' for m in self.moves):
|
|
if all(l.moves_progress >= 1.0 for l in self.line_lines
|
|
if l.moves_progress is not None):
|
|
return 'sent'
|
|
elif any(l.moves_progress for l in self.line_lines):
|
|
return 'partially shipped'
|
|
else:
|
|
return 'waiting'
|
|
return 'none'
|
|
|
|
def get_moves(self, name):
|
|
return [m.id for l in self.line_lines for m in l.moves]
|
|
|
|
@classmethod
|
|
def search_moves(cls, name, clause):
|
|
return [
|
|
('lines', 'where', [
|
|
clause,
|
|
('type', '=', 'line'),
|
|
]),
|
|
]
|
|
|
|
@classmethod
|
|
def _get_origin(cls):
|
|
'Return list of Model names for origin Reference'
|
|
return ['sale.sale']
|
|
|
|
@classmethod
|
|
def get_origin(cls):
|
|
Model = Pool().get('ir.model')
|
|
get_name = Model.get_name
|
|
models = cls._get_origin()
|
|
return [(None, '')] + [(m, get_name(m)) for m in models]
|
|
|
|
@property
|
|
def report_address(self):
|
|
if self.invoice_address:
|
|
return self.invoice_address.full_address
|
|
else:
|
|
return ''
|
|
|
|
@property
|
|
def delivery_full_address(self):
|
|
if self.shipment_address:
|
|
return self.shipment_address.full_address
|
|
return ''
|
|
|
|
@classmethod
|
|
def validate_fields(cls, sales, field_names):
|
|
super().validate_fields(sales, field_names)
|
|
cls.check_method(sales, field_names)
|
|
|
|
@classmethod
|
|
def check_method(cls, sales, field_names=None):
|
|
'''
|
|
Check the methods.
|
|
'''
|
|
if field_names and not (field_names & {
|
|
'invoice_method', 'shipment_method'}):
|
|
return
|
|
for sale in sales:
|
|
if (sale.invoice_method == 'shipment'
|
|
and sale.shipment_method in {'invoice', 'manual'}):
|
|
raise SaleValidationError(
|
|
gettext('sale.msg_sale_invalid_method',
|
|
invoice_method=sale.invoice_method_string,
|
|
shipment_method=sale.shipment_method_string,
|
|
sale=sale.rec_name))
|
|
if (sale.shipment_method == 'invoice'
|
|
and sale.invoice_method in {'shipment', 'manual'}):
|
|
raise SaleValidationError(
|
|
gettext('sale.msg_sale_invalid_method',
|
|
invoice_method=sale.invoice_method_string,
|
|
shipment_method=sale.shipment_method_string,
|
|
sale=sale.rec_name))
|
|
|
|
@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'
|
|
domain = [bool_op,
|
|
('number', operator, value),
|
|
('reference', operator, value),
|
|
]
|
|
return domain
|
|
|
|
@classmethod
|
|
def view_attributes(cls):
|
|
attributes = super().view_attributes() + [
|
|
('/form//field[@name="comment"]', 'spell', Eval('party_lang')),
|
|
('/tree', 'visual', If(Eval('state') == 'cancelled', 'muted', '')),
|
|
('/tree/field[@name="invoice_state"]', 'visual',
|
|
If(Eval('invoice_state') == 'exception', 'danger', '')),
|
|
('/tree/field[@name="shipment_state"]', 'visual',
|
|
If(Eval('shipment_state') == 'exception', 'danger', '')),
|
|
]
|
|
if Transaction().context.get('modify_header'):
|
|
attributes.extend([
|
|
('//group[@id="states"]', 'states', {'invisible': True}),
|
|
('//group[@id="amount"]', 'states', {'invisible': True}),
|
|
('//group[@id="links"]', 'states', {'invisible': True}),
|
|
('//group[@id="buttons"]', 'states', {'invisible': True}),
|
|
])
|
|
return attributes
|
|
|
|
def chat_language(self, audience='internal'):
|
|
language = super().chat_language(audience=audience)
|
|
if audience == 'public':
|
|
language = self.party.lang.code if self.party.lang else None
|
|
return language
|
|
|
|
@classmethod
|
|
def get_resources_to_copy(cls, name):
|
|
return {
|
|
'stock.shipment.out',
|
|
'stock.shipment.out.return',
|
|
'account.invoice',
|
|
}
|
|
|
|
@classmethod
|
|
def copy(cls, sales, default=None):
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default.setdefault('number', None)
|
|
default.setdefault('reference')
|
|
default.setdefault('invoice_state', 'none')
|
|
default.setdefault('invoices_ignored', None)
|
|
default.setdefault('shipment_state', 'none')
|
|
default.setdefault('quotation_date')
|
|
default.setdefault('sale_date', None)
|
|
default.setdefault('quoted_by')
|
|
default.setdefault('confirmed_by')
|
|
default.setdefault('untaxed_amount_cache')
|
|
default.setdefault('tax_amount_cache')
|
|
default.setdefault('total_amount_cache')
|
|
return super().copy(sales, default=default)
|
|
|
|
def check_for_quotation(self):
|
|
if not self.invoice_address:
|
|
raise SaleQuotationError(
|
|
gettext('sale.msg_sale_invoice_address_required_for_quotation',
|
|
sale=self.rec_name))
|
|
for line in self.line_lines:
|
|
if (line.movable
|
|
and line.quantity >= 0
|
|
and not self.shipment_address):
|
|
raise SaleQuotationError(
|
|
gettext('sale'
|
|
'.msg_sale_shipment_address_required_for_quotation',
|
|
sale=self.rec_name))
|
|
if (line.quantity or 0) >= 0:
|
|
location = line.from_location
|
|
else:
|
|
location = line.to_location
|
|
if (not location or not line.warehouse) and line.movable:
|
|
raise SaleQuotationError(
|
|
gettext('sale.msg_sale_warehouse_required_for_quotation',
|
|
sale=self.rec_name,
|
|
line=line.rec_name))
|
|
|
|
@classmethod
|
|
def set_number(cls, sales):
|
|
'''
|
|
Fill the number field with the sale sequence
|
|
'''
|
|
pool = Pool()
|
|
Config = pool.get('sale.configuration')
|
|
|
|
config = Config(1)
|
|
for company, c_sales in groupby(sales, key=lambda s: s.company):
|
|
c_sales = [s for s in c_sales if not s.number]
|
|
if c_sales:
|
|
sequence = config.get_multivalue(
|
|
'sale_sequence', company=company.id)
|
|
for sale, number in zip(
|
|
c_sales, sequence.get_many(len(c_sales))):
|
|
sale.number = number
|
|
cls.save(sales)
|
|
|
|
@classmethod
|
|
def set_sale_date(cls, sales):
|
|
Date = Pool().get('ir.date')
|
|
for company, c_sales in groupby(sales, key=lambda s: s.company):
|
|
with Transaction().set_context(company=company.id):
|
|
today = Date.today()
|
|
cls.write([s for s in c_sales if not s.sale_date], {
|
|
'sale_date': today,
|
|
})
|
|
|
|
@classmethod
|
|
def store_cache(cls, sales):
|
|
sales = list(sales)
|
|
cls.write(sales, {
|
|
'untaxed_amount_cache': None,
|
|
'tax_amount_cache': None,
|
|
'total_amount_cache': None,
|
|
})
|
|
for sale in sales:
|
|
sale.untaxed_amount_cache = sale.untaxed_amount
|
|
sale.tax_amount_cache = sale.tax_amount
|
|
sale.total_amount_cache = sale.total_amount
|
|
cls.save(sales)
|
|
|
|
def _get_invoice(self):
|
|
'Return invoice'
|
|
pool = Pool()
|
|
Invoice = pool.get('account.invoice')
|
|
party = self.invoice_party or self.party
|
|
invoice = Invoice(
|
|
company=self.company,
|
|
type='out',
|
|
party=party,
|
|
invoice_address=self.invoice_address,
|
|
currency=self.currency,
|
|
account=party.account_receivable_used,
|
|
)
|
|
invoice.set_journal()
|
|
invoice.payment_term = self.payment_term
|
|
return invoice
|
|
|
|
def create_invoice(self):
|
|
'Create and return an invoice'
|
|
context = Transaction().context
|
|
if (self.invoice_method == 'manual'
|
|
and not context.get('_sale_manual_invoice', False)):
|
|
return
|
|
|
|
invoice_lines = []
|
|
for line in self.lines:
|
|
invoice_lines.append(line.get_invoice_line())
|
|
invoice_lines = list(chain(*invoice_lines))
|
|
if not invoice_lines:
|
|
return
|
|
|
|
invoice = self._get_invoice()
|
|
if getattr(invoice, 'lines', None):
|
|
invoice_lines = list(invoice.lines) + invoice_lines
|
|
invoice.lines = invoice_lines
|
|
return invoice
|
|
|
|
def _group_shipment_key(self, moves, move):
|
|
'''
|
|
The key to group moves by shipments
|
|
|
|
move is a tuple of line and a move
|
|
'''
|
|
line, move = move
|
|
|
|
if any(m.planned_date is None for m in moves):
|
|
planned_date = None
|
|
else:
|
|
planned_date = max(m.planned_date for m in moves)
|
|
return (
|
|
('planned_date', planned_date),
|
|
('origin_planned_date', planned_date),
|
|
('warehouse', line.warehouse.id),
|
|
)
|
|
|
|
_group_return_key = _group_shipment_key
|
|
|
|
def _get_shipment_sale(self, Shipment, key):
|
|
values = {
|
|
'customer': self.shipment_party or self.party,
|
|
'company': self.company,
|
|
}
|
|
values.update(dict(key))
|
|
shipment = Shipment(**values)
|
|
shipment.on_change_warehouse()
|
|
if Shipment.__name__ == 'stock.shipment.out':
|
|
if self.shipment_address == self.warehouse.address:
|
|
shipment.delivery_address = shipment.warehouse.address
|
|
else:
|
|
shipment.delivery_address = self.shipment_address
|
|
elif Shipment.__name__ == 'stock.shipment.out.return':
|
|
shipment.contact_address = values['customer'].address_get()
|
|
return shipment
|
|
|
|
def _get_shipment_moves(self, shipment_type):
|
|
moves = {}
|
|
for line in self.line_lines:
|
|
move = line.get_move(shipment_type)
|
|
if move:
|
|
moves[line] = move
|
|
return moves
|
|
|
|
def create_shipment(self, shipment_type):
|
|
'''
|
|
Create and return shipments of type shipment_type
|
|
'''
|
|
pool = Pool()
|
|
context = Transaction().context
|
|
if (self.shipment_method == 'manual'
|
|
and not context.get('_sale_manual_shipment', False)):
|
|
return
|
|
|
|
moves = self._get_shipment_moves(shipment_type)
|
|
if not moves:
|
|
return
|
|
if shipment_type == 'out':
|
|
keyfunc = partial(self._group_shipment_key, list(moves.values()))
|
|
Shipment = pool.get('stock.shipment.out')
|
|
elif shipment_type == 'return':
|
|
keyfunc = partial(self._group_return_key, list(moves.values()))
|
|
Shipment = pool.get('stock.shipment.out.return')
|
|
moves = moves.items()
|
|
moves = sorted(moves, key=sortable_values(keyfunc))
|
|
|
|
shipments = []
|
|
for key, grouped_moves in groupby(moves, key=keyfunc):
|
|
shipment = self._get_shipment_sale(Shipment, key)
|
|
shipment.moves = (list(getattr(shipment, 'moves', []))
|
|
+ [x[1] for x in grouped_moves])
|
|
shipments.append(shipment)
|
|
return shipments
|
|
|
|
def is_done(self):
|
|
return ((self.invoice_state == 'paid'
|
|
or (self.invoice_state == 'none'
|
|
and all(
|
|
l.invoice_progress >= 1
|
|
for l in self.line_lines
|
|
if l.invoice_progress is not None)))
|
|
and (self.shipment_state == 'sent'
|
|
or (self.shipment_state == 'none'
|
|
and all(
|
|
l.moves_progress >= 1
|
|
for l in self.line_lines
|
|
if l.moves_progress is not None))))
|
|
|
|
@classmethod
|
|
def check_modification(cls, mode, sales, values=None, external=False):
|
|
super().check_modification(
|
|
mode, sales, values=values, external=external)
|
|
if mode == 'delete':
|
|
for sale in sales:
|
|
if sale.state not in {'cancelled', 'draft'}:
|
|
raise AccessError(gettext(
|
|
'sale.msg_sale_delete_cancel',
|
|
sale=sale.rec_name))
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('cancelled')
|
|
def cancel(cls, sales):
|
|
cls.store_cache(sales)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('draft')
|
|
@reset_employee('quoted_by', 'confirmed_by')
|
|
def draft(cls, sales):
|
|
cls.write(sales, {
|
|
'tax_amount_cache': None,
|
|
'untaxed_amount_cache': None,
|
|
'total_amount_cache': None,
|
|
})
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('quotation')
|
|
@set_employee('quoted_by')
|
|
def quote(cls, sales):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
for company, c_sales in groupby(sales, key=lambda s: s.company):
|
|
with Transaction().set_context(company=company.id):
|
|
today = Date.today()
|
|
for sale in c_sales:
|
|
sale.check_for_quotation()
|
|
if not sale.quotation_date:
|
|
sale.quotation_date = today
|
|
cls.save(sales)
|
|
cls.set_number(sales)
|
|
|
|
@property
|
|
def process_after(self):
|
|
pool = Pool()
|
|
Configuration = pool.get('sale.configuration')
|
|
config = Configuration(1)
|
|
return config.sale_process_after
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('confirmed')
|
|
@set_employee('confirmed_by')
|
|
def confirm(cls, sales):
|
|
transaction = Transaction()
|
|
context = transaction.context
|
|
cls.set_sale_date(sales)
|
|
cls.store_cache(sales)
|
|
for process_after, sub_sales in groupby(
|
|
sales, lambda s: s.process_after):
|
|
with transaction.set_context(
|
|
queue_scheduled_at=process_after,
|
|
queue_batch=context.get('queue_batch', True)):
|
|
cls.__queue__.process(sub_sales)
|
|
|
|
@classmethod
|
|
@ModelView.button_action('sale.wizard_invoice_handle_exception')
|
|
def handle_invoice_exception(cls, sales):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button_action('sale.wizard_shipment_handle_exception')
|
|
def handle_shipment_exception(cls, sales):
|
|
pass
|
|
|
|
@classmethod
|
|
@Workflow.transition('processing')
|
|
def proceed(cls, sales):
|
|
pass
|
|
|
|
@classmethod
|
|
@Workflow.transition('done')
|
|
def do(cls, sales):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
def process(cls, sales):
|
|
states = {'confirmed', 'processing', 'done'}
|
|
sales = [s for s in sales if s.state in states]
|
|
cls.lock(sales)
|
|
cls._process_invoice(sales)
|
|
cls._process_shipment(sales)
|
|
cls._process_invoice_shipment_states(sales)
|
|
cls._process_state(sales)
|
|
|
|
@classmethod
|
|
def _process_invoice(cls, sales):
|
|
pool = Pool()
|
|
Invoice = pool.get('account.invoice')
|
|
|
|
invoices = {}
|
|
for sale in sales:
|
|
invoice = sale.create_invoice()
|
|
if invoice:
|
|
invoices[sale] = invoice
|
|
|
|
Invoice.save(invoices.values())
|
|
for sale, invoice in invoices.items():
|
|
sale.copy_resources_to(invoice)
|
|
|
|
@classmethod
|
|
def _process_shipment(cls, sales):
|
|
pool = Pool()
|
|
ShipmentOut = pool.get('stock.shipment.out')
|
|
ShipmentOutReturn = pool.get('stock.shipment.out.return')
|
|
|
|
shipments_out, shipments_return = {}, {}
|
|
for sale in sales:
|
|
shipments = sale.create_shipment('out')
|
|
if shipments:
|
|
shipments_out[sale] = shipments
|
|
shipments = sale.create_shipment('return')
|
|
if shipments:
|
|
shipments_return[sale] = shipments
|
|
|
|
shipments = sum((v for v in shipments_out.values()), [])
|
|
ShipmentOut.save(shipments)
|
|
ShipmentOut.wait(shipments)
|
|
for sale, shipments in shipments_out.items():
|
|
for shipment in shipments:
|
|
sale.copy_resources_to(shipment)
|
|
|
|
shipments = sum((v for v in shipments_return.values()), [])
|
|
ShipmentOutReturn.save(shipments)
|
|
for sale, shipments in shipments_return.items():
|
|
for shipment in shipments:
|
|
sale.copy_resources_to(shipment)
|
|
|
|
@classmethod
|
|
def _process_invoice_shipment_states(cls, sales):
|
|
pool = Pool()
|
|
Line = pool.get('sale.line')
|
|
lines = []
|
|
invoice_states, shipment_states = defaultdict(list), defaultdict(list)
|
|
for sale in sales:
|
|
invoice_state = sale.get_invoice_state()
|
|
if sale.invoice_state != invoice_state:
|
|
invoice_states[invoice_state].append(sale)
|
|
shipment_state = sale.get_shipment_state()
|
|
if sale.shipment_state != shipment_state:
|
|
shipment_states[shipment_state].append(sale)
|
|
|
|
for line in sale.line_lines:
|
|
line.set_actual_quantity()
|
|
lines.append(line)
|
|
|
|
for invoice_state, sales in invoice_states.items():
|
|
cls.write(sales, {'invoice_state': invoice_state})
|
|
cls.log(sales, 'transition', f'invoice_state:{invoice_state}')
|
|
for shipment_state, sales in shipment_states.items():
|
|
cls.write(sales, {'shipment_state': shipment_state})
|
|
cls.log(sales, 'transition', f'shipment_state:{shipment_state}')
|
|
Line.save(lines)
|
|
|
|
@classmethod
|
|
def _process_state(cls, sales):
|
|
done, process = [], []
|
|
for sale in sales:
|
|
if sale.is_done():
|
|
if sale.state != 'done':
|
|
if sale.state == 'confirmed':
|
|
process.append(sale)
|
|
done.append(sale)
|
|
elif sale.state != 'processing':
|
|
process.append(sale)
|
|
if process:
|
|
cls.proceed(process)
|
|
if done:
|
|
cls.do(done)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
def manual_invoice(cls, sales):
|
|
sales = [s for s in sales if s.invoice_method == 'manual']
|
|
with Transaction().set_context(_sale_manual_invoice=True):
|
|
cls.process(sales)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
def manual_shipment(cls, sales):
|
|
sales = [s for s in sales if s.shipment_method == 'manual']
|
|
with Transaction().set_context(_sale_manual_shipment=True):
|
|
cls.process(sales)
|
|
|
|
@classmethod
|
|
@ModelView.button_action('sale.wizard_modify_header')
|
|
def modify_header(cls, sales):
|
|
pass
|
|
|
|
@classmethod
|
|
def cancel_expired_quotation(cls):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
today = Date.today()
|
|
sales = cls.search([
|
|
('state', '=', 'quotation'),
|
|
('quotation_expire', '<', today),
|
|
('company', '=', Transaction().context.get('company', -1)),
|
|
])
|
|
cls.cancel(sales)
|
|
|
|
|
|
class SaleIgnoredInvoice(ModelSQL):
|
|
__name__ = 'sale.sale-ignored-account.invoice'
|
|
sale = fields.Many2One(
|
|
'sale.sale', "Sale", ondelete='CASCADE', required=True)
|
|
invoice = fields.Many2One(
|
|
'account.invoice', "Invoice", ondelete='RESTRICT', required=True,
|
|
domain=[
|
|
('sales', '=', Eval('sale', -1)),
|
|
('state', '=', 'cancelled'),
|
|
])
|
|
|
|
@classmethod
|
|
def __register__(cls, module):
|
|
# Migration from 7.0: rename to standard name
|
|
backend.TableHandler.table_rename(
|
|
'sale_invoice_ignored_rel', cls._table)
|
|
super().__register__(module)
|
|
|
|
|
|
class SaleRecreatedInvoice(ModelSQL):
|
|
__name__ = 'sale.sale-recreated-account.invoice'
|
|
sale = fields.Many2One(
|
|
'sale.sale', "Sale", ondelete='CASCADE', required=True)
|
|
invoice = fields.Many2One(
|
|
'account.invoice', "Invoice", ondelete='RESTRICT', required=True,
|
|
domain=[
|
|
('sales', '=', Eval('sale', -1)),
|
|
('state', '=', 'cancelled'),
|
|
])
|
|
|
|
@classmethod
|
|
def __register__(cls, module):
|
|
# Migration from 7.0: rename to standard name
|
|
backend.TableHandler.table_rename(
|
|
'sale_invoice_recreated_rel', cls._table)
|
|
super().__register__(module)
|
|
|
|
|
|
class SaleLine(TaxableMixin, sequence_ordered(), ModelSQL, ModelView):
|
|
__name__ = 'sale.line'
|
|
sale = fields.Many2One(
|
|
'sale.sale', "Sale", ondelete='CASCADE', required=True,
|
|
states={
|
|
'readonly': ((Eval('sale_state') != 'draft')
|
|
& Bool(Eval('sale'))),
|
|
})
|
|
type = fields.Selection([
|
|
('line', 'Line'),
|
|
('subtotal', 'Subtotal'),
|
|
('title', 'Title'),
|
|
('comment', 'Comment'),
|
|
], "Type", required=True,
|
|
states={
|
|
'readonly': Eval('sale_state') != 'draft',
|
|
})
|
|
quantity = fields.Float(
|
|
"Quantity", digits='unit',
|
|
domain=[
|
|
If(Eval('type') != 'line',
|
|
('quantity', '=', None),
|
|
()),
|
|
],
|
|
states={
|
|
'invisible': Eval('type') != 'line',
|
|
'required': Eval('type') == 'line',
|
|
'readonly': Eval('sale_state') != 'draft',
|
|
})
|
|
actual_quantity = fields.Float(
|
|
"Actual Quantity", digits='unit', readonly=True,
|
|
domain=[
|
|
If(Eval('type') != 'line',
|
|
('actual_quantity', '=', None),
|
|
()),
|
|
],
|
|
states={
|
|
'invisible': ((Eval('type') != 'line') | ~Eval('actual_quantity')),
|
|
})
|
|
unit = fields.Many2One('product.uom', 'Unit', ondelete='RESTRICT',
|
|
states={
|
|
'required': Bool(Eval('product')),
|
|
'invisible': Eval('type') != 'line',
|
|
'readonly': Eval('sale_state') != 'draft',
|
|
},
|
|
domain=[
|
|
If(Bool(Eval('product_uom_category')),
|
|
('category', '=', Eval('product_uom_category')),
|
|
('category', '!=', -1)),
|
|
If(Eval('type') != 'line',
|
|
('id', '=', None),
|
|
()),
|
|
])
|
|
product = fields.Many2One('product.product', 'Product',
|
|
ondelete='RESTRICT',
|
|
domain=[
|
|
If(Eval('sale_state').in_(['draft', 'quotation'])
|
|
& ~(Eval('quantity', 0) < 0),
|
|
('salable', '=', True),
|
|
()),
|
|
If(Eval('type') != 'line',
|
|
('id', '=', None),
|
|
()),
|
|
],
|
|
states={
|
|
'invisible': Eval('type') != 'line',
|
|
'readonly': Eval('sale_state') != 'draft',
|
|
},
|
|
context={
|
|
'company': Eval('company', None),
|
|
},
|
|
search_context={
|
|
'locations': If(Bool(Eval('warehouse')),
|
|
[Eval('warehouse', -1)], []),
|
|
'stock_date_end': Eval('sale_date', None),
|
|
'stock_skip_warehouse': True,
|
|
'currency': Eval('currency', -1),
|
|
'customer': Eval('customer', -1),
|
|
'sale_date': Eval('sale_date', None),
|
|
'uom': Eval('unit'),
|
|
'taxes': Eval('taxes', []),
|
|
'quantity': Eval('quantity'),
|
|
},
|
|
depends={
|
|
'company', 'warehouse', 'sale_date', 'currency', 'customer'})
|
|
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')
|
|
unit_price = Monetary(
|
|
"Unit Price", digits=price_digits, currency='currency',
|
|
domain=[
|
|
If(Eval('type') != 'line',
|
|
('unit_price', '=', None),
|
|
()),
|
|
],
|
|
states={
|
|
'invisible': Eval('type') != 'line',
|
|
'required': Eval('type') == 'line',
|
|
'readonly': Eval('sale_state') != 'draft'
|
|
})
|
|
amount = fields.Function(Monetary(
|
|
"Amount", digits='currency', currency='currency',
|
|
states={
|
|
'invisible': ~Eval('type').in_(['line', 'subtotal']),
|
|
},
|
|
depends={'sale_state'}), 'get_amount')
|
|
currency = fields.Function(
|
|
fields.Many2One('currency.currency', 'Currency'),
|
|
'on_change_with_currency')
|
|
description = fields.Text('Description', size=None,
|
|
states={
|
|
'readonly': Eval('sale_state') != 'draft',
|
|
},
|
|
depends=['sale_state'])
|
|
summary = fields.Function(
|
|
fields.Char('Summary'), 'on_change_with_summary',
|
|
searcher='search_summary')
|
|
note = fields.Text('Note')
|
|
taxes = fields.Many2Many('sale.line-account.tax', 'line', 'tax', 'Taxes',
|
|
order=[('tax.sequence', 'ASC'), ('tax.id', 'ASC')],
|
|
domain=[
|
|
('parent', '=', None),
|
|
['OR',
|
|
('group', '=', None),
|
|
('group.kind', 'in', ['sale', 'both']),
|
|
],
|
|
('company', '=', Eval('company', -1)),
|
|
If(Eval('type') != 'line',
|
|
('id', '=', None),
|
|
()),
|
|
],
|
|
states={
|
|
'invisible': Eval('type') != 'line',
|
|
'readonly': Eval('sale_state') != 'draft',
|
|
},
|
|
depends={'sale'})
|
|
invoice_lines = fields.One2Many(
|
|
'account.invoice.line', 'origin', "Invoice Lines", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('invoice_lines'),
|
|
})
|
|
invoice_progress = fields.Function(
|
|
fields.Float("Invoice Progress", digits=(1, 4)),
|
|
'get_invoice_progress')
|
|
moves = fields.One2Many(
|
|
'stock.move', 'origin', "Stock Moves", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('moves'),
|
|
})
|
|
moves_ignored = fields.Many2Many(
|
|
'sale.line-ignored-stock.move', 'sale_line', 'move',
|
|
"Ignored Stock Moves",
|
|
domain=[
|
|
('id', 'in', Eval('moves', [])),
|
|
('state', '=', 'cancelled'),
|
|
],
|
|
states={
|
|
'invisible': ~Eval('moves_ignored', []),
|
|
})
|
|
moves_recreated = fields.Many2Many(
|
|
'sale.line-recreated-stock.move', 'sale_line', 'move',
|
|
"Recreated Moves", readonly=True,
|
|
states={
|
|
'invisible': ~Eval('moves_recreated'),
|
|
})
|
|
moves_exception = fields.Function(fields.Boolean(
|
|
"Moves Exception",
|
|
states={
|
|
'invisible': ~Eval('movable'),
|
|
}),
|
|
'get_moves_exception')
|
|
moves_progress = fields.Function(fields.Float(
|
|
"Moves Progress", digits=(1, 4),
|
|
states={
|
|
'invisible': ~Eval('movable'),
|
|
}),
|
|
'get_moves_progress')
|
|
warehouse = fields.Function(fields.Many2One(
|
|
'stock.location', "Warehouse",
|
|
states={
|
|
'invisible': ~Eval('movable'),
|
|
}),
|
|
'on_change_with_warehouse')
|
|
from_location = fields.Function(fields.Many2One(
|
|
'stock.location', "From Location",
|
|
states={
|
|
'invisible': ~Eval('movable'),
|
|
}),
|
|
'get_from_location')
|
|
to_location = fields.Function(fields.Many2One(
|
|
'stock.location', "To Location",
|
|
states={
|
|
'invisible': ~Eval('movable'),
|
|
}),
|
|
'get_to_location')
|
|
movable = fields.Function(
|
|
fields.Boolean("Movable"), 'on_change_with_movable')
|
|
|
|
shipping_date = fields.Function(fields.Date('Shipping Date',
|
|
states={
|
|
'invisible': Eval('type') != 'line',
|
|
}),
|
|
'on_change_with_shipping_date')
|
|
sale_state = fields.Function(
|
|
fields.Selection('get_sale_states', "Sale State"),
|
|
'on_change_with_sale_state', searcher='search_sale_state')
|
|
company = fields.Function(
|
|
fields.Many2One('company.company', "Company"),
|
|
'on_change_with_company')
|
|
customer = fields.Function(
|
|
fields.Many2One(
|
|
'party.party', "Customer",
|
|
context={
|
|
'company': Eval('company', -1),
|
|
}),
|
|
'on_change_with_customer', searcher='search_customer')
|
|
sale_date = fields.Function(
|
|
fields.Date("Sale Date"),
|
|
'on_change_with_sale_date', searcher='search_sale_date')
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
t = cls.__table__()
|
|
cls.__access__.add('sale')
|
|
cls._sql_indexes.update({
|
|
Index(t,
|
|
(t.product, Index.Range()),
|
|
(t.sale, Index.Equality()),
|
|
where=t.type == 'line'),
|
|
})
|
|
cls._order.insert(0, ('sale.sale_date', 'DESC NULLS FIRST'))
|
|
cls._order.insert(1, ('sale.id', 'DESC'))
|
|
|
|
@staticmethod
|
|
def default_type():
|
|
return 'line'
|
|
|
|
@fields.depends('type', 'taxes')
|
|
def on_change_type(self):
|
|
if self.type != 'line':
|
|
self.product = None
|
|
self.unit = None
|
|
self.taxes = None
|
|
|
|
@property
|
|
def _invoice_remaining_quantity(self):
|
|
"Compute the remaining quantity to be paid"
|
|
pool = Pool()
|
|
UoM = pool.get('product.uom')
|
|
if self.type != 'line':
|
|
return
|
|
skips = set(self.sale.invoices_ignored)
|
|
quantity = self.quantity
|
|
if self.sale.invoice_method == 'shipment':
|
|
moves_ignored = set(self.moves_ignored)
|
|
for move in self.moves:
|
|
if move in moves_ignored:
|
|
quantity -= UoM.compute_qty(
|
|
move.unit, math.copysign(move.quantity, self.quantity),
|
|
self.unit)
|
|
for invoice_line in self.invoice_lines:
|
|
if invoice_line.type != 'line':
|
|
continue
|
|
if (invoice_line.invoice
|
|
and (invoice_line.invoice.state == 'paid'
|
|
or invoice_line.invoice in skips)):
|
|
quantity -= UoM.compute_qty(
|
|
invoice_line.unit or self.unit, invoice_line.quantity,
|
|
self.unit)
|
|
return quantity
|
|
|
|
def get_invoice_progress(self, name):
|
|
progress = None
|
|
quantity = self._invoice_remaining_quantity
|
|
if quantity is not None and self.quantity:
|
|
progress = round((self.quantity - quantity) / self.quantity, 4)
|
|
progress = max(0., min(1., progress))
|
|
return progress
|
|
|
|
@property
|
|
def _move_remaining_quantity(self):
|
|
"Compute the remaining quantity to ship"
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
if self.type != 'line' or not self.product:
|
|
return
|
|
if not self.movable:
|
|
return
|
|
skips = set(self.moves_ignored)
|
|
quantity = abs(self.quantity)
|
|
if self.sale.shipment_method == 'invoice':
|
|
invoices_ignored = set(self.sale.invoices_ignored)
|
|
for invoice_line in self.invoice_lines:
|
|
if invoice_line.type != 'line':
|
|
continue
|
|
if invoice_line.invoice in invoices_ignored:
|
|
quantity -= Uom.compute_qty(
|
|
invoice_line.unit or self.unit, invoice_line.quantity,
|
|
self.unit)
|
|
for move in self.moves:
|
|
if move.state == 'done' or move in skips:
|
|
quantity -= Uom.compute_qty(
|
|
move.unit, move.quantity, self.unit)
|
|
return quantity
|
|
|
|
def get_moves_exception(self, name):
|
|
skips = set(self.moves_ignored)
|
|
skips.update(self.moves_recreated)
|
|
return any(
|
|
m.state == 'cancelled' for m in self.moves if m not in skips)
|
|
|
|
def get_moves_progress(self, name):
|
|
progress = None
|
|
quantity = self._move_remaining_quantity
|
|
if quantity is not None and self.quantity:
|
|
progress = round(
|
|
(abs(self.quantity) - quantity) / abs(self.quantity), 4)
|
|
progress = max(0., min(1., progress))
|
|
return progress
|
|
|
|
@property
|
|
def taxable_lines(self):
|
|
# In case we're called from an on_change
|
|
# we have to use some sensible defaults
|
|
if getattr(self, 'type', None) == 'line':
|
|
return [(
|
|
getattr(self, 'taxes', None) or [],
|
|
getattr(self, 'unit_price', None) or Decimal(0),
|
|
getattr(self, 'quantity', None) or 0,
|
|
None,
|
|
)]
|
|
else:
|
|
return []
|
|
|
|
def _get_tax_context(self):
|
|
return self.sale._get_tax_context()
|
|
|
|
def _get_tax_rule_pattern(self):
|
|
'''
|
|
Get tax rule pattern
|
|
'''
|
|
return {}
|
|
|
|
@fields.depends(
|
|
'sale', '_parent_sale.currency', '_parent_sale.party',
|
|
'_parent_sale.sale_date', 'company',
|
|
'unit', 'product', 'taxes')
|
|
def _get_context_sale_price(self):
|
|
context = {}
|
|
if self.sale:
|
|
if self.sale.currency:
|
|
context['currency'] = self.sale.currency.id
|
|
if self.sale.party:
|
|
context['customer'] = self.sale.party.id
|
|
context['sale_date'] = self.sale.sale_date
|
|
if self.company:
|
|
context['company'] = self.company.id
|
|
if self.unit:
|
|
context['uom'] = self.unit.id
|
|
elif self.product:
|
|
context['uom'] = self.product.sale_uom.id
|
|
context['taxes'] = [t.id for t in self.taxes or []]
|
|
return context
|
|
|
|
@fields.depends(
|
|
'sale', 'taxes', '_parent_sale.party', '_parent_sale.invoice_party',
|
|
methods=['compute_taxes', 'on_change_with_amount'])
|
|
def on_change_sale(self):
|
|
party = None
|
|
if self.sale:
|
|
party = self.sale.invoice_party or self.sale.party
|
|
self.taxes = self.compute_taxes(party)
|
|
self.amount = self.on_change_with_amount()
|
|
|
|
@fields.depends(
|
|
'product', 'unit', 'sale', 'taxes',
|
|
'_parent_sale.party', '_parent_sale.invoice_party',
|
|
methods=['compute_taxes', 'compute_unit_price',
|
|
'on_change_with_amount'])
|
|
def on_change_product(self):
|
|
party = None
|
|
if self.sale:
|
|
party = self.sale.invoice_party or self.sale.party
|
|
|
|
# Set taxes before unit_price to have taxes in context of sale price
|
|
self.taxes = self.compute_taxes(party)
|
|
|
|
if self.product:
|
|
category = self.product.sale_uom.category
|
|
if not self.unit or self.unit.category != category:
|
|
self.unit = self.product.sale_uom
|
|
|
|
self.unit_price = self.compute_unit_price()
|
|
|
|
self.amount = self.on_change_with_amount()
|
|
|
|
@cached_property
|
|
def product_name(self):
|
|
return self.product.rec_name if self.product else ''
|
|
|
|
@fields.depends(
|
|
'type', 'product',
|
|
methods=['on_change_with_company', '_get_tax_rule_pattern'])
|
|
def compute_taxes(self, party):
|
|
pool = Pool()
|
|
AccountConfiguration = pool.get('account.configuration')
|
|
|
|
if self.type != 'line':
|
|
return []
|
|
|
|
company = self.on_change_with_company()
|
|
taxes = set()
|
|
pattern = self._get_tax_rule_pattern()
|
|
taxes_used = []
|
|
if self.product:
|
|
taxes_used = self.product.customer_taxes_used
|
|
elif company:
|
|
account_config = AccountConfiguration(1)
|
|
account = account_config.get_multivalue(
|
|
'default_category_account_revenue', company=company.id)
|
|
if account:
|
|
taxes_used = account.taxes
|
|
for tax in taxes_used:
|
|
if party and party.customer_tax_rule:
|
|
tax_ids = party.customer_tax_rule.apply(tax, pattern)
|
|
if tax_ids:
|
|
taxes.update(tax_ids)
|
|
continue
|
|
taxes.add(tax.id)
|
|
if party and party.customer_tax_rule:
|
|
tax_ids = party.customer_tax_rule.apply(None, pattern)
|
|
if tax_ids:
|
|
taxes.update(tax_ids)
|
|
return list(taxes)
|
|
|
|
@fields.depends(
|
|
'product', 'quantity', 'unit_price',
|
|
methods=['_get_context_sale_price'])
|
|
def compute_unit_price(self):
|
|
pool = Pool()
|
|
Product = pool.get('product.product')
|
|
|
|
if not self.product:
|
|
return self.unit_price
|
|
|
|
with Transaction().set_context(
|
|
self._get_context_sale_price()):
|
|
return Product.get_sale_price(
|
|
[self.product], abs(self.quantity or 0))[self.product.id]
|
|
|
|
@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(methods=['compute_unit_price'])
|
|
def on_change_quantity(self):
|
|
self.unit_price = self.compute_unit_price()
|
|
|
|
@fields.depends(methods=['on_change_quantity', 'on_change_with_amount'])
|
|
def on_change_unit(self):
|
|
self.on_change_quantity()
|
|
self.amount = self.on_change_with_amount()
|
|
|
|
@fields.depends(methods=['on_change_quantity', 'on_change_with_amount'])
|
|
def on_change_taxes(self):
|
|
self.on_change_quantity()
|
|
self.amount = self.on_change_with_amount()
|
|
|
|
@fields.depends('description')
|
|
def on_change_with_summary(self, name=None):
|
|
return firstline(self.description or '')
|
|
|
|
@classmethod
|
|
def search_summary(cls, name, clause):
|
|
return [('description', *clause[1:])]
|
|
|
|
@fields.depends(
|
|
'type', 'quantity', 'unit_price',
|
|
'sale', '_parent_sale.currency')
|
|
def on_change_with_amount(self):
|
|
if self.type == 'line':
|
|
currency = self.sale.currency if self.sale else None
|
|
amount = Decimal(str(self.quantity or 0)) * \
|
|
(self.unit_price or Decimal(0))
|
|
if currency:
|
|
return currency.round(amount)
|
|
return amount
|
|
return Decimal(0)
|
|
|
|
def get_amount(self, name):
|
|
if self.type == 'line':
|
|
return self.on_change_with_amount()
|
|
elif self.type == 'subtotal':
|
|
amount = Decimal(0)
|
|
for line2 in self.sale.lines:
|
|
if line2.type == 'line':
|
|
amount += line2.sale.currency.round(
|
|
Decimal(str(line2.quantity)) * line2.unit_price)
|
|
elif line2.type == 'subtotal':
|
|
if self == line2:
|
|
break
|
|
amount = Decimal(0)
|
|
return amount
|
|
return Decimal(0)
|
|
|
|
@fields.depends('sale', '_parent_sale.warehouse')
|
|
def on_change_with_warehouse(self, name=None):
|
|
return self.sale.warehouse if self.sale else None
|
|
|
|
def get_from_location(self, name):
|
|
if (self.quantity or 0) >= 0:
|
|
if self.warehouse:
|
|
return self.warehouse.output_location
|
|
else:
|
|
party = self.sale.shipment_party or self.sale.party
|
|
return party.customer_location
|
|
|
|
def get_to_location(self, name):
|
|
if (self.quantity or 0) >= 0:
|
|
party = self.sale.shipment_party or self.sale.party
|
|
return party.customer_location
|
|
else:
|
|
if self.warehouse:
|
|
return self.warehouse.input_location
|
|
|
|
@classmethod
|
|
def movable_types(cls):
|
|
pool = Pool()
|
|
Move = pool.get('stock.move')
|
|
return Move.get_product_types()
|
|
|
|
@fields.depends('product')
|
|
def on_change_with_movable(self, name=None):
|
|
if self.product:
|
|
return self.product.type in self.movable_types()
|
|
|
|
@fields.depends('moves', methods=['planned_shipping_date'])
|
|
def on_change_with_shipping_date(self, name=None):
|
|
moves = [m for m in self.moves if m.state != 'cancelled']
|
|
if moves:
|
|
dates = filter(
|
|
None, (m.effective_date or m.planned_date for m in moves))
|
|
return min(dates, default=None)
|
|
return self.planned_shipping_date
|
|
|
|
@property
|
|
@fields.depends(
|
|
'product', 'quantity', 'company', 'sale',
|
|
'_parent_sale.sale_date', '_parent_sale.shipping_date')
|
|
def planned_shipping_date(self):
|
|
if self.product and self.quantity is not None and self.quantity > 0:
|
|
date = self.sale.sale_date if self.sale else None
|
|
shipping_date = self.product.compute_shipping_date(date=date)
|
|
if shipping_date == datetime.date.max:
|
|
shipping_date = None
|
|
elif self.sale and self.sale.shipping_date:
|
|
shipping_date = max(shipping_date, self.sale.shipping_date)
|
|
return shipping_date
|
|
|
|
@fields.depends('sale', '_parent_sale.currency')
|
|
def on_change_with_currency(self, name=None):
|
|
return self.sale.currency if self.sale else None
|
|
|
|
@classmethod
|
|
def get_sale_states(cls):
|
|
pool = Pool()
|
|
Sale = pool.get('sale.sale')
|
|
return Sale.fields_get(['state'])['state']['selection']
|
|
|
|
@fields.depends('sale', '_parent_sale.state')
|
|
def on_change_with_sale_state(self, name=None):
|
|
if self.sale:
|
|
return self.sale.state
|
|
|
|
@classmethod
|
|
def search_sale_state(cls, name, clause):
|
|
return [('sale.state', *clause[1:])]
|
|
|
|
@fields.depends('sale', '_parent_sale.company')
|
|
def on_change_with_company(self, name=None):
|
|
return self.sale.company if self.sale else None
|
|
|
|
@fields.depends('sale', '_parent_sale.party')
|
|
def on_change_with_customer(self, name=None):
|
|
return self.sale.party if self.sale else None
|
|
|
|
@classmethod
|
|
def search_customer(cls, name, clause):
|
|
return [('sale.party' + clause[0][len(name):], *clause[1:])]
|
|
|
|
@fields.depends('sale', '_parent_sale.sale_date')
|
|
def on_change_with_sale_date(self, name=None):
|
|
if self.sale:
|
|
return self.sale.sale_date
|
|
|
|
@classmethod
|
|
def search_sale_date(cls, name, clause):
|
|
return [('sale.sale_date', *clause[1:])]
|
|
|
|
@classmethod
|
|
def order_sale_date(cls, tables):
|
|
return cls.sale.convert_order('sale.sale_date', tables, cls)
|
|
|
|
def get_invoice_line(self):
|
|
'Return a list of invoice lines for sale line'
|
|
pool = Pool()
|
|
InvoiceLine = pool.get('account.invoice.line')
|
|
AccountConfiguration = pool.get('account.configuration')
|
|
account_config = AccountConfiguration(1)
|
|
|
|
if self.type != 'line':
|
|
return []
|
|
|
|
invoice_line = InvoiceLine()
|
|
invoice_line.type = self.type
|
|
invoice_line.currency = self.currency
|
|
invoice_line.company = self.company
|
|
invoice_line.description = self.description
|
|
invoice_line.note = self.note
|
|
invoice_line.origin = self
|
|
|
|
quantity = (self._get_invoice_line_quantity()
|
|
- self._get_invoiced_quantity())
|
|
|
|
if self.unit:
|
|
quantity = self.unit.round(quantity)
|
|
invoice_line.quantity = quantity
|
|
|
|
if not invoice_line.quantity:
|
|
return []
|
|
|
|
invoice_line.unit = self.unit
|
|
invoice_line.product = self.product
|
|
invoice_line.unit_price = self.unit_price
|
|
invoice_line.taxes = self.taxes
|
|
invoice_line.invoice_type = 'out'
|
|
if self.product:
|
|
invoice_line.account = self.product.account_revenue_used
|
|
if not invoice_line.account:
|
|
raise AccountError(
|
|
gettext('sale.msg_sale_product_missing_account_revenue',
|
|
sale=self.sale.rec_name,
|
|
product=self.product.rec_name))
|
|
else:
|
|
invoice_line.account = account_config.get_multivalue(
|
|
'default_category_account_revenue', company=self.company.id)
|
|
if not invoice_line.account:
|
|
raise AccountError(
|
|
gettext('sale.msg_sale_missing_account_revenue',
|
|
sale=self.sale.rec_name))
|
|
invoice_line.stock_moves = self._get_invoice_line_moves(
|
|
invoice_line.quantity)
|
|
return [invoice_line]
|
|
|
|
def _get_invoice_line_quantity(self):
|
|
'Return the quantity that should be invoiced'
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
|
|
if (self.sale.invoice_method in {'order', 'manual'}
|
|
or not self.product
|
|
or self.product.type == 'service'):
|
|
return self.quantity
|
|
elif self.sale.invoice_method == 'shipment':
|
|
quantity = 0.0
|
|
for move in self.moves:
|
|
if move.state != 'done':
|
|
continue
|
|
qty = Uom.compute_qty(move.unit, move.quantity, self.unit)
|
|
# Test only against to_location
|
|
# as it is what matters for sale
|
|
dest_type = 'customer'
|
|
if (move.to_location.type == dest_type
|
|
and move.from_location.type != dest_type):
|
|
quantity += qty
|
|
elif (move.from_location.type == dest_type
|
|
and move.to_location.type != dest_type):
|
|
quantity -= qty
|
|
return quantity
|
|
|
|
def _get_invoiced_quantity(self):
|
|
'Return the quantity already invoiced'
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
|
|
quantity = 0
|
|
skips = {l for i in self.sale.invoices_recreated for l in i.lines}
|
|
for invoice_line in self.invoice_lines:
|
|
if invoice_line.type != 'line':
|
|
continue
|
|
if invoice_line not in skips:
|
|
if self.unit:
|
|
quantity += Uom.compute_qty(
|
|
invoice_line.unit or self.unit, invoice_line.quantity,
|
|
self.unit)
|
|
else:
|
|
quantity += invoice_line.quantity
|
|
return quantity
|
|
|
|
def _get_invoice_line_moves(self, quantity):
|
|
'Return the stock moves that should be invoiced'
|
|
moves = []
|
|
if self.sale.invoice_method in {'order', 'manual'}:
|
|
if self.sale.shipment_method not in {'order', 'manual'}:
|
|
moves.extend(self.moves)
|
|
elif (self.sale.invoice_method == 'shipment'
|
|
and samesign(self.quantity, quantity)):
|
|
for move in self.moves:
|
|
if move.state == 'done':
|
|
if move.invoiced_quantity < move.quantity:
|
|
moves.append(move)
|
|
return moves
|
|
|
|
def get_move(self, shipment_type):
|
|
'''
|
|
Return moves for the sale line according to shipment_type
|
|
'''
|
|
pool = Pool()
|
|
Move = pool.get('stock.move')
|
|
Date = pool.get('ir.date')
|
|
|
|
if self.type != 'line':
|
|
return
|
|
if not self.product:
|
|
return
|
|
if self.product.type not in Move.get_product_types():
|
|
return
|
|
|
|
if (shipment_type == 'out') != (self.quantity >= 0):
|
|
return
|
|
|
|
quantity = (self._get_move_quantity(shipment_type)
|
|
- self._get_shipped_quantity(shipment_type))
|
|
|
|
quantity = self.unit.round(quantity)
|
|
if quantity <= 0:
|
|
return
|
|
|
|
if not self.sale.party.customer_location:
|
|
raise PartyLocationError(
|
|
gettext('sale.msg_sale_customer_location_required',
|
|
sale=self.sale.rec_name,
|
|
party=self.sale.party.rec_name))
|
|
|
|
with Transaction().set_context(company=self.sale.company.id):
|
|
today = Date.today()
|
|
|
|
move = Move()
|
|
move.quantity = quantity
|
|
move.unit = self.unit
|
|
move.product = self.product
|
|
move.from_location = self.from_location
|
|
move.to_location = self.to_location
|
|
move.state = 'draft'
|
|
move.company = self.sale.company
|
|
if move.on_change_with_unit_price_required():
|
|
move.unit_price = self.unit_price
|
|
move.currency = self.sale.currency
|
|
else:
|
|
move.unit_price = None
|
|
move.currency = None
|
|
move.planned_date = max(self.planned_shipping_date or today, today)
|
|
move.invoice_lines = self._get_move_invoice_lines(shipment_type)
|
|
move.origin = self
|
|
move.origin_planned_date = move.planned_date
|
|
return move
|
|
|
|
def _get_move_quantity(self, shipment_type):
|
|
'Return the quantity that should be shipped'
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
|
|
if self.sale.shipment_method in {'order', 'manual'}:
|
|
return abs(self.quantity)
|
|
elif self.sale.shipment_method == 'invoice':
|
|
quantity = 0.0
|
|
for invoice_line in self.invoice_lines:
|
|
if (invoice_line.invoice
|
|
and invoice_line.invoice.state == 'paid'):
|
|
quantity += Uom.compute_qty(
|
|
invoice_line.unit or self.unit, invoice_line.quantity,
|
|
self.unit)
|
|
return quantity
|
|
|
|
def _get_shipped_quantity(self, shipment_type):
|
|
'Return the quantity already shipped'
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
|
|
quantity = 0
|
|
skips = set(m for m in self.moves_recreated)
|
|
for move in self.moves:
|
|
if move not in skips:
|
|
quantity += Uom.compute_qty(
|
|
move.unit, move.quantity, self.unit)
|
|
return quantity
|
|
|
|
def check_move_quantity(self):
|
|
pool = Pool()
|
|
Lang = pool.get('ir.lang')
|
|
Warning = pool.get('res.user.warning')
|
|
lang = Lang.get()
|
|
move_type = 'in' if self.quantity >= 0 else 'return'
|
|
quantity = (
|
|
self._get_move_quantity(move_type)
|
|
- self._get_shipped_quantity(move_type))
|
|
if self.unit.round(quantity) < 0:
|
|
warning_name = Warning.format(
|
|
'check_move_quantity', [self])
|
|
if Warning.check(warning_name):
|
|
raise SaleMoveQuantity(warning_name, gettext(
|
|
'sale.msg_sale_line_move_quantity',
|
|
line=self.rec_name,
|
|
extra=lang.format_number_symbol(
|
|
-quantity, self.unit),
|
|
quantity=lang.format_number_symbol(
|
|
self.quantity, self.unit)))
|
|
|
|
def _get_move_invoice_lines(self, shipment_type):
|
|
'Return the invoice lines that should be shipped'
|
|
invoice_lines = []
|
|
if self.sale.shipment_method in {'order', 'manual'}:
|
|
if self.sale.invoice_method in {'order', 'manual'}:
|
|
invoice_lines.extend(self.invoice_lines)
|
|
elif self.sale.shipment_method == 'invoice':
|
|
for invoice_line in self.invoice_lines:
|
|
if (invoice_line.invoice
|
|
and invoice_line.invoice.state == 'paid'
|
|
and samesign(self.quantity, invoice_line.quantity)):
|
|
if invoice_line.moved_quantity < invoice_line.quantity:
|
|
invoice_lines.append(invoice_line)
|
|
return invoice_lines
|
|
|
|
def set_actual_quantity(self):
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
if self.type != 'line':
|
|
return
|
|
moved_quantity = 0
|
|
for move in self.moves:
|
|
if move.state != 'cancelled' and self.unit:
|
|
moved_quantity += Uom.compute_qty(
|
|
move.unit, move.quantity, self.unit, round=False)
|
|
if self.quantity < 0:
|
|
moved_quantity *= -1
|
|
invoiced_quantity = 0
|
|
for invoice_line in self.invoice_lines:
|
|
if (not invoice_line.invoice
|
|
or invoice_line.invoice.state != 'cancelled'):
|
|
if self.unit:
|
|
invoiced_quantity += Uom.compute_qty(
|
|
invoice_line.unit or self.unit, invoice_line.quantity,
|
|
self.unit, round=False)
|
|
else:
|
|
invoiced_quantity += invoice_line.quantity
|
|
actual_quantity = max(moved_quantity, invoiced_quantity, key=abs)
|
|
if self.unit:
|
|
actual_quantity = self.unit.round(actual_quantity)
|
|
if self.actual_quantity != actual_quantity:
|
|
self.actual_quantity = actual_quantity
|
|
|
|
def get_rec_name(self, name):
|
|
pool = Pool()
|
|
Lang = pool.get('ir.lang')
|
|
if self.product:
|
|
lang = Lang.get()
|
|
return (lang.format_number_symbol(
|
|
self.quantity or 0, self.unit, digits=self.unit.digits)
|
|
+ ' %s @ %s' % (self.product.rec_name, self.sale.rec_name))
|
|
else:
|
|
return self.sale.rec_name
|
|
|
|
@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,
|
|
('sale.rec_name', *clause[1:]),
|
|
('product.rec_name', *clause[1:]),
|
|
]
|
|
|
|
@classmethod
|
|
def view_attributes(cls):
|
|
return super().view_attributes() + [
|
|
('/form//field[@name="note"]|/form//field[@name="description"]',
|
|
'spell', Eval('_parent_sale', {}).get('party_lang'))]
|
|
|
|
@classmethod
|
|
def check_modification(cls, mode, lines, values=None, external=False):
|
|
super().check_modification(
|
|
mode, lines, values=values, external=external)
|
|
if mode == 'create':
|
|
for line in lines:
|
|
if line.sale.state != 'draft':
|
|
raise AccessError(gettext(
|
|
'sale.msg_sale_line_create_draft',
|
|
sale=line.sale.rec_name))
|
|
elif mode == 'delete':
|
|
for line in lines:
|
|
if line.sale.state not in {'cancelled', 'draft'}:
|
|
raise AccessError(gettext(
|
|
'sale.msg_sale_line_delete_cancel_draft',
|
|
line=line.rec_name,
|
|
sale=line.sale.rec_name))
|
|
|
|
@classmethod
|
|
def copy(cls, lines, default=None):
|
|
if default is None:
|
|
default = {}
|
|
else:
|
|
default = default.copy()
|
|
default.setdefault('moves', None)
|
|
default.setdefault('moves_ignored', None)
|
|
default.setdefault('moves_recreated', None)
|
|
default.setdefault('invoice_lines', None)
|
|
default.setdefault('actual_quantity')
|
|
return super().copy(lines, default=default)
|
|
|
|
|
|
class SaleLineTax(ModelSQL):
|
|
__name__ = 'sale.line-account.tax'
|
|
line = fields.Many2One(
|
|
'sale.line', "Sale Line", ondelete='CASCADE', required=True)
|
|
tax = fields.Many2One(
|
|
'account.tax', "Tax", ondelete='RESTRICT', required=True)
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super().__setup__()
|
|
t = cls.__table__()
|
|
cls._sql_constraints += [
|
|
('line_tax_unique', Unique(t, t.line, t.tax),
|
|
'sale.msg_sale_line_tax_unique'),
|
|
]
|
|
|
|
@classmethod
|
|
def __register__(cls, module):
|
|
# Migration from 7.0: rename to standard name
|
|
backend.TableHandler.table_rename('sale_line_account_tax', cls._table)
|
|
super().__register__(module)
|
|
|
|
|
|
class SaleLineIgnoredMove(ModelSQL):
|
|
__name__ = 'sale.line-ignored-stock.move'
|
|
sale_line = fields.Many2One(
|
|
'sale.line', "Sale Line", ondelete='CASCADE', required=True)
|
|
move = fields.Many2One(
|
|
'stock.move', "Stock Move", ondelete='RESTRICT', required=True,
|
|
domain=[
|
|
('origin.id', '=', Eval('sale_line', -1), 'sale.line'),
|
|
('state', '=', 'cancelled'),
|
|
])
|
|
|
|
@classmethod
|
|
def __register__(cls, module):
|
|
# Migration from 7.0: rename to standard name
|
|
backend.TableHandler.table_rename(
|
|
'sale_line_moves_ignored_rel', cls._table)
|
|
super().__register__(module)
|
|
|
|
|
|
class SaleLineRecreatedMove(ModelSQL):
|
|
__name__ = 'sale.line-recreated-stock.move'
|
|
sale_line = fields.Many2One(
|
|
'sale.line', "Sale Line", ondelete='CASCADE', required=True)
|
|
move = fields.Many2One(
|
|
'stock.move', "Stock Move", ondelete='RESTRICT', required=True,
|
|
domain=[
|
|
('origin.id', '=', Eval('sale_line', -1), 'sale.line'),
|
|
('state', '=', 'cancelled'),
|
|
])
|
|
|
|
@classmethod
|
|
def __register__(cls, module):
|
|
# Migration from 7.0: rename to standard name
|
|
backend.TableHandler.table_rename(
|
|
'sale_line_moves_recreated_rel', cls._table)
|
|
super().__register__(module)
|
|
|
|
|
|
class SaleReport(CompanyReport):
|
|
__name__ = 'sale.sale'
|
|
|
|
@classmethod
|
|
def execute(cls, ids, data):
|
|
with Transaction().set_context(address_with_party=True):
|
|
return super().execute(ids, data)
|
|
|
|
@classmethod
|
|
def get_context(cls, records, header, data):
|
|
pool = Pool()
|
|
Date = pool.get('ir.date')
|
|
context = super().get_context(records, header, data)
|
|
company = header.get('company')
|
|
with Transaction().set_context(
|
|
company=company.id if company else None):
|
|
context['today'] = Date.today()
|
|
return context
|
|
|
|
|
|
class HandleShipmentExceptionAsk(ModelView):
|
|
__name__ = 'sale.handle.shipment.exception.ask'
|
|
recreate_moves = fields.Many2Many(
|
|
'stock.move', None, None, "Stock Moves to Recreate",
|
|
domain=[
|
|
('id', 'in', Eval('domain_moves', [])),
|
|
('id', 'not in', Eval('ignore_moves', [])),
|
|
],
|
|
help="The selected cancelled stock moves will be recreated.")
|
|
ignore_moves = fields.Many2Many(
|
|
'stock.move', None, None, "Stock Moves to Ignore",
|
|
domain=[
|
|
('id', 'in', Eval('domain_moves', [])),
|
|
('id', 'not in', Eval('recreate_moves', [])),
|
|
],
|
|
help="The selected cancelled stock moves will be ignored.")
|
|
domain_moves = fields.Many2Many(
|
|
'stock.move', None, None, 'Domain Stock Moves')
|
|
|
|
|
|
class HandleShipmentException(Wizard):
|
|
__name__ = 'sale.handle.shipment.exception'
|
|
start_state = 'ask'
|
|
ask = StateView('sale.handle.shipment.exception.ask',
|
|
'sale.handle_shipment_exception_ask_view_form', [
|
|
Button('Cancel', 'end', 'tryton-cancel'),
|
|
Button('OK', 'handle', 'tryton-ok', default=True),
|
|
])
|
|
handle = StateTransition()
|
|
|
|
def default_ask(self, fields):
|
|
moves = []
|
|
for line in self.record.lines:
|
|
skips = set(line.moves_ignored)
|
|
skips.update(line.moves_recreated)
|
|
for move in line.moves:
|
|
if move.state == 'cancelled' and move not in skips:
|
|
moves.append(move.id)
|
|
return {
|
|
'domain_moves': moves,
|
|
}
|
|
|
|
def transition_handle(self):
|
|
pool = Pool()
|
|
SaleLine = pool.get('sale.line')
|
|
|
|
for line in self.record.lines:
|
|
moves_ignored = []
|
|
moves_recreated = []
|
|
skips = set(line.moves_ignored)
|
|
skips.update(line.moves_recreated)
|
|
for move in line.moves:
|
|
if move not in self.ask.domain_moves or move in skips:
|
|
continue
|
|
if move in self.ask.recreate_moves:
|
|
moves_recreated.append(move.id)
|
|
elif move in self.ask.ignore_moves:
|
|
moves_ignored.append(move.id)
|
|
|
|
SaleLine.write([line], {
|
|
'moves_ignored': [('add', moves_ignored)],
|
|
'moves_recreated': [('add', moves_recreated)],
|
|
})
|
|
self.model.__queue__.process([self.record])
|
|
return 'end'
|
|
|
|
|
|
class HandleInvoiceExceptionAsk(ModelView):
|
|
__name__ = 'sale.handle.invoice.exception.ask'
|
|
recreate_invoices = fields.Many2Many(
|
|
'account.invoice', None, None, "Invoices to Recreate",
|
|
domain=[
|
|
('id', 'in', Eval('domain_invoices', [])),
|
|
('id', 'not in', Eval('ignore_invoices', []))
|
|
],
|
|
help="The selected cancelled invoices will be recreated.")
|
|
ignore_invoices = fields.Many2Many(
|
|
'account.invoice', None, None, "Invoices to Ignore",
|
|
domain=[
|
|
('id', 'in', Eval('domain_invoices', [])),
|
|
('id', 'not in', Eval('recreate_invoices', []))
|
|
],
|
|
help="The selected cancelled invoices will be ignored.")
|
|
domain_invoices = fields.Many2Many(
|
|
'account.invoice', None, None, 'Domain Invoices')
|
|
|
|
|
|
class HandleInvoiceException(Wizard):
|
|
__name__ = 'sale.handle.invoice.exception'
|
|
start_state = 'ask'
|
|
ask = StateView('sale.handle.invoice.exception.ask',
|
|
'sale.handle_invoice_exception_ask_view_form', [
|
|
Button('Cancel', 'end', 'tryton-cancel'),
|
|
Button('OK', 'handle', 'tryton-ok', default=True),
|
|
])
|
|
handle = StateTransition()
|
|
|
|
def default_ask(self, fields):
|
|
skips = set(self.record.invoices_ignored)
|
|
skips.update(self.record.invoices_recreated)
|
|
invoices = []
|
|
for invoice in self.record.invoices:
|
|
if invoice.state == 'cancelled' and invoice not in skips:
|
|
invoices.append(invoice.id)
|
|
return {
|
|
'domain_invoices': invoices,
|
|
}
|
|
|
|
def transition_handle(self):
|
|
invoices_ignored = []
|
|
invoices_recreated = []
|
|
for invoice in self.ask.domain_invoices:
|
|
if invoice in self.ask.recreate_invoices:
|
|
invoices_recreated.append(invoice.id)
|
|
elif invoice in self.ask.ignore_invoices:
|
|
invoices_ignored.append(invoice.id)
|
|
|
|
self.model.write([self.record], {
|
|
'invoices_ignored': [('add', invoices_ignored)],
|
|
'invoices_recreated': [('add', invoices_recreated)],
|
|
})
|
|
self.model.__queue__.process([self.record])
|
|
return 'end'
|
|
|
|
|
|
class ReturnSaleStart(ModelView):
|
|
__name__ = 'sale.return_sale.start'
|
|
|
|
|
|
class ReturnSale(Wizard):
|
|
__name__ = 'sale.return_sale'
|
|
start = StateView('sale.return_sale.start',
|
|
'sale.return_sale_start_view_form', [
|
|
Button('Cancel', 'end', 'tryton-cancel'),
|
|
Button('Return', 'return_', 'tryton-ok', default=True),
|
|
])
|
|
return_ = StateAction('sale.act_sale_form')
|
|
|
|
def do_return_(self, action):
|
|
sales = self.records
|
|
return_sales = self.model.copy(sales, default={
|
|
'origin': lambda data: (
|
|
'%s,%s' % (self.model.__name__, data['id'])),
|
|
'lines.quantity': lambda data: (
|
|
data['quantity'] * -1 if data['type'] == 'line'
|
|
else data['quantity']),
|
|
})
|
|
|
|
data = {'res_id': [s.id for s in return_sales]}
|
|
if len(return_sales) == 1:
|
|
action['views'].reverse()
|
|
return action, data
|
|
|
|
|
|
class ModifyHeaderStateView(StateView):
|
|
def get_view(self, wizard, state_name):
|
|
with Transaction().set_context(modify_header=True):
|
|
return super().get_view(
|
|
wizard, state_name)
|
|
|
|
def get_defaults(self, wizard, state_name, fields):
|
|
return {}
|
|
|
|
|
|
class ModifyHeader(Wizard):
|
|
__name__ = 'sale.modify_header'
|
|
start = ModifyHeaderStateView('sale.sale',
|
|
'sale.modify_header_form', [
|
|
Button("Cancel", 'end', 'tryton-cancel'),
|
|
Button("Modify", 'modify', 'tryton-ok', default=True),
|
|
])
|
|
modify = StateTransition()
|
|
|
|
def get_sale(self):
|
|
if self.record.state != 'draft':
|
|
raise AccessError(
|
|
gettext('sale.msg_sale_modify_header_draft',
|
|
sale=self.record.rec_name))
|
|
return self.record
|
|
|
|
def value_start(self, fields):
|
|
sale = self.get_sale()
|
|
values = {}
|
|
for fieldname in fields:
|
|
values[fieldname] = getattr(sale, fieldname)
|
|
# Mimic an empty sale in draft state to get the fields' states right
|
|
values['lines'] = []
|
|
return values
|
|
|
|
def transition_modify(self):
|
|
pool = Pool()
|
|
Line = pool.get('sale.line')
|
|
|
|
sale = self.get_sale()
|
|
values = self.start._save_values()
|
|
self.model.write([sale], values)
|
|
self.model.log([sale], 'write', ','.join(sorted(values.keys())))
|
|
|
|
# Call on_change after the save to ensure parent sale
|
|
# has the modified values
|
|
for line in sale.lines:
|
|
line.on_change_product()
|
|
Line.save(sale.lines)
|
|
|
|
return 'end'
|
|
|
|
|
|
class OpenProduct(Wizard):
|
|
__name__ = 'sale.open_product'
|
|
_readonly = True
|
|
|
|
start = StateAction('sale.act_product')
|
|
|
|
def _context(self):
|
|
context = {
|
|
'stock_skip_warehouse': True,
|
|
'locations': [],
|
|
}
|
|
companies = set()
|
|
currencies = set()
|
|
customers = set()
|
|
sale_dates = set()
|
|
for record in self.records:
|
|
companies.add(record.company.id)
|
|
currencies.add(record.currency.id)
|
|
customers.add(record.party.id)
|
|
sale_dates.add(record.sale_date)
|
|
if self.record.warehouse:
|
|
context['locations'].append(self.record.warehouse.id)
|
|
if len(companies) == 1:
|
|
context['company'], = companies
|
|
if len(currencies) == 1:
|
|
context['currency'], = currencies
|
|
if len(customers) == 1:
|
|
context['customer'], = customers
|
|
if len(sale_dates) == 1:
|
|
context['sale_date'], = sale_dates
|
|
return context
|
|
|
|
def do_start(self, action):
|
|
product_ids = {
|
|
l.product.id for r in self.records for l in r.lines if l.product}
|
|
encoder = PYSONEncoder()
|
|
domain = [
|
|
('id', 'in', list(product_ids)),
|
|
]
|
|
action['pyson_domain'] = encoder.encode(domain)
|
|
action['pyson_context'] = encoder.encode(self._context())
|
|
name_suffix = ', '.join(r.rec_name for r in self.records[:5])
|
|
if len(self.records) > 5:
|
|
name_suffix += ',...'
|
|
action['name'] += f' ({name_suffix})'
|
|
return action, {}
|