1844 lines
66 KiB
Python
1844 lines
66 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.
|
|
from decimal import Decimal
|
|
import datetime
|
|
from itertools import groupby
|
|
from functools import partial
|
|
from trytond.model import Workflow, ModelView, ModelSQL, fields
|
|
from trytond.modules.company import CompanyReport
|
|
from trytond.wizard import Wizard, StateAction, StateView, StateTransition, \
|
|
Button
|
|
from trytond.backend import TableHandler
|
|
from trytond.pyson import If, Eval, Bool, PYSONEncoder, Id
|
|
from trytond.transaction import Transaction
|
|
from trytond.pool import Pool, PoolMeta
|
|
from trytond.config import CONFIG
|
|
|
|
__all__ = ['Sale', 'SaleInvoice', 'SaleIgnoredInvoice', 'SaleRecreatedInvoice',
|
|
'SaleLine', 'SaleLineTax', 'SaleLineInvoiceLine', 'SaleLineIgnoredMove',
|
|
'SaleLineRecreatedMove', 'SaleReport', 'Template', 'Product',
|
|
'ShipmentOut', 'ShipmentOutReturn', 'Move', 'OpenCustomer',
|
|
'HandleShipmentExceptionAsk', 'HandleShipmentException',
|
|
'HandleInvoiceExceptionAsk', 'HandleInvoiceException',
|
|
'ReturnSale']
|
|
__metaclass__ = PoolMeta
|
|
|
|
|
|
class Sale(Workflow, ModelSQL, ModelView):
|
|
'Sale'
|
|
__name__ = 'sale.sale'
|
|
_rec_name = 'reference'
|
|
company = fields.Many2One('company.company', 'Company', required=True,
|
|
states={
|
|
'readonly': (Eval('state') != 'draft') | Eval('lines', [0]),
|
|
},
|
|
domain=[
|
|
('id', If(Eval('context', {}).contains('company'), '=', '!='),
|
|
Eval('context', {}).get('company', 0)),
|
|
],
|
|
depends=['state'], select=True)
|
|
reference = fields.Char('Reference', readonly=True, select=True)
|
|
description = fields.Char('Description',
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
},
|
|
depends=['state'])
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('quotation', 'Quotation'),
|
|
('confirmed', 'Confirmed'),
|
|
('processing', 'Processing'),
|
|
('done', 'Done'),
|
|
('cancel', 'Canceled'),
|
|
], 'State', readonly=True, required=True)
|
|
sale_date = fields.Date('Sale Date',
|
|
states={
|
|
'readonly': ~Eval('state').in_(['draft', 'quotation']),
|
|
'required': ~Eval('state').in_(['draft', 'quotation', 'cancel']),
|
|
},
|
|
depends=['state'])
|
|
payment_term = fields.Many2One('account.invoice.payment_term',
|
|
'Payment Term', required=True, states={
|
|
'readonly': Eval('state') != 'draft',
|
|
},
|
|
depends=['state'])
|
|
party = fields.Many2One('party.party', 'Party', required=True, select=True,
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
}, on_change=['party', 'payment_term'],
|
|
depends=['state'])
|
|
party_lang = fields.Function(fields.Char('Party Language',
|
|
on_change_with=['party']), 'on_change_with_party_lang')
|
|
invoice_address = fields.Many2One('party.address', 'Invoice Address',
|
|
domain=[('party', '=', Eval('party'))], states={
|
|
'readonly': Eval('state') != 'draft',
|
|
}, depends=['state', 'party'])
|
|
shipment_address = fields.Many2One('party.address', 'Shipment Address',
|
|
domain=[('party', '=', Eval('party'))], states={
|
|
'readonly': Eval('state') != 'draft',
|
|
},
|
|
depends=['party', 'state'])
|
|
warehouse = fields.Many2One('stock.location', 'Warehouse',
|
|
domain=[('type', '=', 'warehouse')], states={
|
|
'readonly': Eval('state') != 'draft',
|
|
},
|
|
depends=['state'])
|
|
currency = fields.Many2One('currency.currency', 'Currency', required=True,
|
|
states={
|
|
'readonly': (Eval('state') != 'draft') |
|
|
(Eval('lines', [0]) & Eval('currency', 0)),
|
|
},
|
|
depends=['state'])
|
|
currency_digits = fields.Function(fields.Integer('Currency Digits',
|
|
on_change_with=['currency']), 'on_change_with_currency_digits')
|
|
lines = fields.One2Many('sale.line', 'sale', 'Lines', states={
|
|
'readonly': Eval('state') != 'draft',
|
|
}, on_change=['lines', 'currency', 'party'],
|
|
depends=['party', 'state'])
|
|
comment = fields.Text('Comment')
|
|
untaxed_amount = fields.Function(fields.Numeric('Untaxed',
|
|
digits=(16, Eval('currency_digits', 2)),
|
|
depends=['currency_digits']), 'get_untaxed_amount')
|
|
untaxed_amount_cache = fields.Numeric('Untaxed Cache',
|
|
digits=(16, Eval('currency_digits', 2)),
|
|
readonly=True,
|
|
depends=['currency_digits'])
|
|
tax_amount = fields.Function(fields.Numeric('Tax',
|
|
digits=(16, Eval('currency_digits', 2)),
|
|
depends=['currency_digits']), 'get_tax_amount')
|
|
tax_amount_cache = fields.Numeric('Tax Cache',
|
|
digits=(16, Eval('currency_digits', 2)),
|
|
readonly=True,
|
|
depends=['currency_digits'])
|
|
total_amount = fields.Function(fields.Numeric('Total',
|
|
digits=(16, Eval('currency_digits', 2)),
|
|
depends=['currency_digits']), 'get_total_amount')
|
|
total_amount_cache = fields.Numeric('Total Tax',
|
|
digits=(16, Eval('currency_digits', 2)),
|
|
readonly=True,
|
|
depends=['currency_digits'])
|
|
invoice_method = fields.Selection([
|
|
('manual', 'Manual'),
|
|
('order', 'On Order Processed'),
|
|
('shipment', 'On Shipment Sent'),
|
|
],
|
|
'Invoice Method', required=True, states={
|
|
'readonly': Eval('state') != 'draft',
|
|
},
|
|
depends=['state'])
|
|
invoice_state = fields.Selection([
|
|
('none', 'None'),
|
|
('waiting', 'Waiting'),
|
|
('paid', 'Paid'),
|
|
('exception', 'Exception'),
|
|
], 'Invoice State', readonly=True, required=True)
|
|
invoices = fields.Many2Many('sale.sale-account.invoice',
|
|
'sale', 'invoice', 'Invoices', readonly=True)
|
|
invoices_ignored = fields.Many2Many('sale.sale-ignored-account.invoice',
|
|
'sale', 'invoice', 'Ignored Invoices', readonly=True)
|
|
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',
|
|
},
|
|
depends=['state'])
|
|
shipment_state = fields.Selection([
|
|
('none', 'None'),
|
|
('waiting', 'Waiting'),
|
|
('sent', 'Sent'),
|
|
('exception', 'Exception'),
|
|
], 'Shipment State', readonly=True, required=True)
|
|
shipments = fields.Function(fields.One2Many('stock.shipment.out', None,
|
|
'Shipments'), 'get_shipments')
|
|
shipment_returns = fields.Function(
|
|
fields.One2Many('stock.shipment.out.return', None, 'Shipment Returns'),
|
|
'get_shipment_returns')
|
|
moves = fields.Function(fields.One2Many('stock.move', None, 'Moves'),
|
|
'get_moves')
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(Sale, cls).__setup__()
|
|
cls._order.insert(0, ('sale_date', 'DESC'))
|
|
cls._order.insert(1, ('id', 'DESC'))
|
|
cls._constraints += [
|
|
('check_method', 'wrong_method')
|
|
]
|
|
cls._error_messages.update({
|
|
'wrong_method': 'Wrong combination of method!',
|
|
'addresses_required': 'Invoice and Shipment addresses must be '
|
|
'defined for the quotation.',
|
|
'warehouse_required': 'Warehouse must be defined for the ' \
|
|
'quotation.',
|
|
'missing_account_receivable': 'It misses '
|
|
'an "Account Receivable" on the party "%s"!',
|
|
'delete_cancel': 'Sale "%s" must be cancelled ' \
|
|
'before deletion!',
|
|
})
|
|
cls._transitions |= set((
|
|
('draft', 'quotation'),
|
|
('quotation', 'confirmed'),
|
|
('confirmed', 'processing'),
|
|
('processing', 'processing'),
|
|
('draft', 'cancel'),
|
|
('quotation', 'cancel'),
|
|
('quotation', 'draft'),
|
|
('cancel', 'draft'),
|
|
))
|
|
cls._buttons.update({
|
|
'cancel': {
|
|
'invisible': ~Eval('state').in_(['draft', 'quotation']),
|
|
},
|
|
'draft': {
|
|
'invisible': ~Eval('state').in_(['cancel', 'quotation']),
|
|
'icon': If(Eval('state') == 'cancel', 'tryton-clear',
|
|
'tryton-go-previous'),
|
|
},
|
|
'quote': {
|
|
'invisible': Eval('state') != 'draft',
|
|
'readonly': ~Eval('lines', []),
|
|
},
|
|
'confirm': {
|
|
'invisible': Eval('state') != 'quotation',
|
|
},
|
|
'process': {
|
|
'invisible': Eval('state') != 'confirmed',
|
|
},
|
|
'handle_invoice_exception': {
|
|
'invisible': ((Eval('invoice_state') != 'exception')
|
|
| (Eval('state') == 'cancel')),
|
|
'readonly': ~Eval('groups', []).contains(
|
|
Id('sale', 'group_sale')),
|
|
},
|
|
'handle_shipment_exception': {
|
|
'invisible': ((Eval('shipment_state') != 'exception')
|
|
| (Eval('state') == 'cancel')),
|
|
'readonly': ~Eval('groups', []).contains(
|
|
Id('sale', 'group_sale')),
|
|
},
|
|
})
|
|
# The states where amounts are cached
|
|
cls._states_cached = ['confirmed', 'processing', 'done', 'cancel']
|
|
|
|
@classmethod
|
|
def __register__(cls, module_name):
|
|
pool = Pool()
|
|
SaleLine = pool.get('sale.line')
|
|
SaleLine_InvoiceLine = pool.get('sale.line-account.invoice.line')
|
|
Move = pool.get('stock.move')
|
|
cursor = Transaction().cursor
|
|
# Migration from 1.2: packing renamed into shipment
|
|
cursor.execute("UPDATE ir_model_data "
|
|
"SET fs_id = REPLACE(fs_id, 'packing', 'shipment') "
|
|
"WHERE fs_id like '%%packing%%' AND module = %s",
|
|
(module_name,))
|
|
cursor.execute("UPDATE ir_model_field "
|
|
"SET relation = REPLACE(relation, 'packing', 'shipment'), "
|
|
"name = REPLACE(name, 'packing', 'shipment') "
|
|
"WHERE (relation like '%%packing%%' "
|
|
"OR name like '%%packing%%') AND module = %s",
|
|
(module_name,))
|
|
table = TableHandler(cursor, cls, module_name)
|
|
table.column_rename('packing_state', 'shipment_state')
|
|
table.column_rename('packing_method', 'shipment_method')
|
|
table.column_rename('packing_address', 'shipment_address')
|
|
|
|
super(Sale, cls).__register__(module_name)
|
|
|
|
# Migration from 1.2
|
|
cursor.execute("UPDATE " + cls._table + " "
|
|
"SET invoice_method = 'shipment' "
|
|
"WHERE invoice_method = 'packing'")
|
|
|
|
table = TableHandler(cursor, cls, module_name)
|
|
# Migration from 2.2
|
|
table.not_null_action('sale_date', 'remove')
|
|
|
|
# state confirmed splitted into confirmed and processing
|
|
if (TableHandler.table_exist(cursor, SaleLine._table)
|
|
and TableHandler.table_exist(cursor,
|
|
SaleLine_InvoiceLine._table)
|
|
and TableHandler.table_exist(cursor, Move._table)):
|
|
# Wrap subquery inside an other inner subquery because MySQL syntax
|
|
# doesn't allow update a table and select from the same table in a
|
|
# subquery.
|
|
cursor.execute('UPDATE "%s" '
|
|
"SET state = 'processing' "
|
|
'WHERE id IN ('
|
|
'SELECT id '
|
|
'FROM ('
|
|
'SELECT s.id '
|
|
'FROM "%s" AS s '
|
|
'INNER JOIN "%s" AS l ON l.sale = s.id '
|
|
'LEFT JOIN "%s" AS li ON li.sale_line = l.id '
|
|
'LEFT JOIN "%s" AS m ON m.sale_line = l.id '
|
|
"WHERE s.state = 'confirmed' "
|
|
'AND (li.id IS NOT NULL '
|
|
'OR m.id IS NOT NULL)) AS foo)'
|
|
% (cls._table, cls._table, SaleLine._table,
|
|
SaleLine_InvoiceLine._table, Move._table))
|
|
|
|
# Add index on create_date
|
|
table = TableHandler(cursor, cls, module_name)
|
|
table.index_action('create_date', action='add')
|
|
|
|
@classmethod
|
|
def default_payment_term(cls):
|
|
PaymentTerm = Pool().get('account.invoice.payment_term')
|
|
payment_terms = PaymentTerm.search(cls.payment_term.domain)
|
|
if len(payment_terms) == 1:
|
|
return payment_terms[0].id
|
|
|
|
@classmethod
|
|
def default_warehouse(cls):
|
|
Location = Pool().get('stock.location')
|
|
locations = Location.search(cls.warehouse.domain)
|
|
if len(locations) == 1:
|
|
return locations[0].id
|
|
|
|
@staticmethod
|
|
def default_company():
|
|
return Transaction().context.get('company')
|
|
|
|
@staticmethod
|
|
def default_state():
|
|
return 'draft'
|
|
|
|
@staticmethod
|
|
def default_currency():
|
|
Company = Pool().get('company.company')
|
|
company = Transaction().context.get('company')
|
|
if company:
|
|
return Company(company).currency.id
|
|
|
|
@staticmethod
|
|
def default_currency_digits():
|
|
Company = Pool().get('company.company')
|
|
company = Transaction().context.get('company')
|
|
if company:
|
|
return Company(company).currency.digits
|
|
return 2
|
|
|
|
@staticmethod
|
|
def default_invoice_method():
|
|
Config = Pool().get('sale.configuration')
|
|
config = Config(1)
|
|
return config.sale_invoice_method
|
|
|
|
@staticmethod
|
|
def default_invoice_state():
|
|
return 'none'
|
|
|
|
@staticmethod
|
|
def default_shipment_method():
|
|
Config = Pool().get('sale.configuration')
|
|
config = Config(1)
|
|
return config.sale_shipment_method
|
|
|
|
@staticmethod
|
|
def default_shipment_state():
|
|
return 'none'
|
|
|
|
def on_change_party(self):
|
|
invoice_address = None
|
|
shipment_address = None
|
|
payment_term = None
|
|
if self.party:
|
|
invoice_address = self.party.address_get(type='invoice')
|
|
shipment_address = self.party.address_get(type='delivery')
|
|
if self.party.customer_payment_term:
|
|
payment_term = self.party.customer_payment_term
|
|
|
|
changes = {}
|
|
if invoice_address:
|
|
changes['invoice_address'] = invoice_address.id
|
|
changes['invoice_address.rec_name'] = invoice_address.rec_name
|
|
else:
|
|
changes['invoice_address'] = None
|
|
if shipment_address:
|
|
changes['shipment_address'] = shipment_address.id
|
|
changes['shipment_address.rec_name'] = shipment_address.rec_name
|
|
else:
|
|
changes['shipment_address'] = None
|
|
if payment_term:
|
|
changes['payment_term'] = payment_term.id
|
|
changes['payment_term.rec_name'] = payment_term.rec_name
|
|
else:
|
|
changes['payment_term'] = self.default_payment_term()
|
|
return changes
|
|
|
|
def on_change_with_currency_digits(self, name=None):
|
|
if self.currency:
|
|
return self.currency.digits
|
|
return 2
|
|
|
|
def get_tax_context(self):
|
|
res = {}
|
|
if self.party and self.party.lang:
|
|
res['language'] = self.party.lang.code
|
|
return res
|
|
|
|
def on_change_with_party_lang(self, name=None):
|
|
if self.party and self.party.lang:
|
|
return self.party.lang.code
|
|
return CONFIG['language']
|
|
|
|
def on_change_lines(self):
|
|
pool = Pool()
|
|
Tax = pool.get('account.tax')
|
|
Invoice = pool.get('account.invoice')
|
|
|
|
res = {
|
|
'untaxed_amount': Decimal('0.0'),
|
|
'tax_amount': Decimal('0.0'),
|
|
'total_amount': Decimal('0.0'),
|
|
}
|
|
|
|
if self.lines:
|
|
taxes = {}
|
|
for line in self.lines:
|
|
if getattr(line, 'type', 'line') != 'line':
|
|
continue
|
|
res['untaxed_amount'] += line.amount or Decimal(0)
|
|
tax_list = ()
|
|
with Transaction().set_context(self.get_tax_context()):
|
|
tax_list = Tax.compute(getattr(line, 'taxes', []),
|
|
line.unit_price or Decimal('0.0'),
|
|
line.quantity or 0.0)
|
|
for tax in tax_list:
|
|
key, val = Invoice._compute_tax(tax, 'out_invoice')
|
|
if not key in taxes:
|
|
taxes[key] = val['amount']
|
|
else:
|
|
taxes[key] += val['amount']
|
|
if self.currency:
|
|
for key in taxes:
|
|
res['tax_amount'] += self.currency.round(taxes[key])
|
|
if self.currency:
|
|
res['untaxed_amount'] = self.currency.round(res['untaxed_amount'])
|
|
res['tax_amount'] = self.currency.round(res['tax_amount'])
|
|
res['total_amount'] = res['untaxed_amount'] + res['tax_amount']
|
|
if self.currency:
|
|
res['total_amount'] = self.currency.round(res['total_amount'])
|
|
return res
|
|
|
|
def get_untaxed_amount(self, name):
|
|
if (self.state in self._states_cached
|
|
and self.untaxed_amount_cache is not None):
|
|
return self.untaxed_amount_cache
|
|
amount = sum((l.amount for l in self.lines if l.type == 'line'),
|
|
Decimal(0))
|
|
return self.currency.round(amount)
|
|
|
|
def get_tax_amount(self, name):
|
|
pool = Pool()
|
|
Tax = pool.get('account.tax')
|
|
Invoice = pool.get('account.invoice')
|
|
|
|
if (self.state in self._states_cached
|
|
and self.tax_amount_cache is not None):
|
|
return self.tax_amount_cache
|
|
context = self.get_tax_context()
|
|
taxes = {}
|
|
for line in self.lines:
|
|
if line.type != 'line':
|
|
continue
|
|
with Transaction().set_context(context):
|
|
tax_list = Tax.compute(line.taxes, line.unit_price,
|
|
line.quantity)
|
|
# Don't round on each line to handle rounding error
|
|
for tax in tax_list:
|
|
key, val = Invoice._compute_tax(tax, 'out_invoice')
|
|
if not key in taxes:
|
|
taxes[key] = val['amount']
|
|
else:
|
|
taxes[key] += val['amount']
|
|
amount = sum((self.currency.round(taxes[key]) for key in taxes),
|
|
Decimal(0))
|
|
return self.currency.round(amount)
|
|
|
|
def get_total_amount(self, name):
|
|
if (self.state in self._states_cached
|
|
and self.total_amount_cache is not None):
|
|
return self.total_amount_cache
|
|
return self.currency.round(self.untaxed_amount + self.tax_amount)
|
|
|
|
def get_invoice_state(self):
|
|
'''
|
|
Return the invoice state for the sale.
|
|
'''
|
|
skip_ids = set(x.id for x in self.invoices_ignored)
|
|
skip_ids.update(x.id for x in self.invoices_recreated)
|
|
invoices = [i for i in self.invoices if i.id not in skip_ids]
|
|
if invoices:
|
|
if any(i.state == 'cancel' for i in invoices):
|
|
return 'exception'
|
|
elif all(i.state == 'paid' for i in invoices):
|
|
return 'paid'
|
|
else:
|
|
return 'waiting'
|
|
return 'none'
|
|
|
|
def set_invoice_state(self):
|
|
'''
|
|
Set the invoice state.
|
|
'''
|
|
state = self.get_invoice_state()
|
|
if self.invoice_state != state:
|
|
self.write([self], {
|
|
'invoice_state': state,
|
|
})
|
|
|
|
def get_shipments_returns(attribute):
|
|
"Computes the returns or shipments"
|
|
def method(self, name):
|
|
shipments = []
|
|
for line in self.lines:
|
|
for move in line.moves:
|
|
ship_or_return = getattr(move, attribute)
|
|
if bool(ship_or_return):
|
|
if ship_or_return.id not in shipments:
|
|
shipments.append(ship_or_return.id)
|
|
return shipments
|
|
return method
|
|
|
|
get_shipments = get_shipments_returns('shipment_out')
|
|
get_shipment_returns = get_shipments_returns('shipment_out_return')
|
|
|
|
def get_moves(self, name):
|
|
return [m.id for l in self.lines for m in l.moves]
|
|
|
|
def get_shipment_state(self):
|
|
'''
|
|
Return the shipment state for the sale.
|
|
'''
|
|
if self.moves:
|
|
if any(l.move_exception for l in self.lines):
|
|
return 'exception'
|
|
elif all(l.move_done for l in self.lines):
|
|
return 'sent'
|
|
else:
|
|
return 'waiting'
|
|
return 'none'
|
|
|
|
def set_shipment_state(self):
|
|
'''
|
|
Set the shipment state.
|
|
'''
|
|
state = self.get_shipment_state()
|
|
if self.shipment_state != state:
|
|
self.write([self], {
|
|
'shipment_state': state,
|
|
})
|
|
|
|
def check_method(self):
|
|
'''
|
|
Check the methods.
|
|
'''
|
|
if (self.invoice_method == 'shipment'
|
|
and self.shipment_method in ('invoice', 'manual')):
|
|
return False
|
|
if (self.shipment_method == 'invoice'
|
|
and self.invoice_method in ('shipment', 'manual')):
|
|
return False
|
|
return True
|
|
|
|
def get_rec_name(self, name):
|
|
return (self.reference or str(self.id)
|
|
+ ' - ' + self.party.rec_name)
|
|
|
|
@classmethod
|
|
def search_rec_name(cls, name, clause):
|
|
names = clause[2].split(' - ', 1)
|
|
res = [('reference', clause[1], names[0])]
|
|
if len(names) != 1 and names[1]:
|
|
res.append(('party', clause[1], names[1]))
|
|
return res
|
|
|
|
@classmethod
|
|
def copy(cls, sales, default=None):
|
|
if default is None:
|
|
default = {}
|
|
default = default.copy()
|
|
default['state'] = 'draft'
|
|
default['reference'] = None
|
|
default['invoice_state'] = 'none'
|
|
default['invoices'] = None
|
|
default['invoices_ignored'] = None
|
|
default['shipment_state'] = 'none'
|
|
default.setdefault('sale_date', None)
|
|
return super(Sale, cls).copy(sales, default=default)
|
|
|
|
def check_for_quotation(self):
|
|
if not self.invoice_address or not self.shipment_address:
|
|
self.raise_user_error('addresses_required')
|
|
for line in self.lines:
|
|
if line.quantity >= 0:
|
|
location = line.from_location
|
|
else:
|
|
location = line.to_location
|
|
if ((not location or not line.warehouse)
|
|
and line.product
|
|
and line.product.type in ('goods', 'assets')):
|
|
self.raise_user_error('warehouse_required')
|
|
|
|
@classmethod
|
|
def set_reference(cls, sales):
|
|
'''
|
|
Fill the reference field with the sale sequence
|
|
'''
|
|
pool = Pool()
|
|
Sequence = pool.get('ir.sequence')
|
|
Config = pool.get('sale.configuration')
|
|
|
|
config = Config(1)
|
|
for sale in sales:
|
|
if sale.reference:
|
|
continue
|
|
reference = Sequence.get_id(config.sale_sequence.id)
|
|
cls.write([sale], {
|
|
'reference': reference,
|
|
})
|
|
|
|
@classmethod
|
|
def set_sale_date(cls, sales):
|
|
Date = Pool().get('ir.date')
|
|
for sale in sales:
|
|
if not sale.sale_date:
|
|
cls.write([sale], {
|
|
'sale_date': Date.today(),
|
|
})
|
|
|
|
@classmethod
|
|
def store_cache(cls, sales):
|
|
for sale in sales:
|
|
cls.write([sale], {
|
|
'untaxed_amount_cache': sale.untaxed_amount,
|
|
'tax_amount_cache': sale.tax_amount,
|
|
'total_amount_cache': sale.total_amount,
|
|
})
|
|
|
|
def _get_invoice_line_sale_line(self, invoice_type):
|
|
'''
|
|
Return invoice line for each sale lines according to invoice_type
|
|
'''
|
|
res = {}
|
|
for line in self.lines:
|
|
val = line.get_invoice_line(invoice_type)
|
|
if val:
|
|
res[line.id] = val
|
|
return res
|
|
|
|
def _get_invoice_sale(self, invoice_type):
|
|
'''
|
|
Return invoice of type invoice_type
|
|
'''
|
|
pool = Pool()
|
|
Invoice = pool.get('account.invoice')
|
|
Journal = pool.get('account.journal')
|
|
|
|
journals = Journal.search([
|
|
('type', '=', 'revenue'),
|
|
], limit=1)
|
|
if journals:
|
|
journal, = journals
|
|
else:
|
|
journal = None
|
|
|
|
with Transaction().set_user(0, set_context=True):
|
|
return Invoice(
|
|
company=self.company,
|
|
type=invoice_type,
|
|
reference=self.reference,
|
|
journal=journal,
|
|
party=self.party,
|
|
invoice_address=self.invoice_address,
|
|
currency=self.currency,
|
|
account=self.party.account_receivable,
|
|
payment_term=self.payment_term,
|
|
)
|
|
|
|
def create_invoice(self, invoice_type):
|
|
'''
|
|
Create and return an invoice of type invoice_type
|
|
'''
|
|
pool = Pool()
|
|
Invoice = pool.get('account.invoice')
|
|
SaleLine = pool.get('sale.line')
|
|
if self.invoice_method == 'manual':
|
|
return
|
|
|
|
if not self.party.account_receivable:
|
|
self.raise_user_error('missing_account_receivable',
|
|
error_args=(self.party.rec_name,))
|
|
|
|
invoice_lines = self._get_invoice_line_sale_line(invoice_type)
|
|
if not invoice_lines:
|
|
return
|
|
|
|
invoice = self._get_invoice_sale(invoice_type)
|
|
invoice.save()
|
|
|
|
for line in self.lines:
|
|
if line.id not in invoice_lines:
|
|
continue
|
|
for invoice_line in invoice_lines[line.id]:
|
|
invoice_line.invoice = invoice.id
|
|
invoice_line.save()
|
|
SaleLine.write([line], {
|
|
'invoice_lines': [('add', [invoice_line.id])],
|
|
})
|
|
|
|
with Transaction().set_user(0, set_context=True):
|
|
Invoice.update_taxes([invoice])
|
|
|
|
self.write([self], {
|
|
'invoices': [('add', [invoice.id])],
|
|
})
|
|
return invoice
|
|
|
|
def _get_move_sale_line(self, shipment_type):
|
|
'''
|
|
Return move for each sale lines of the right shipment_type
|
|
'''
|
|
res = {}
|
|
for line in self.lines:
|
|
val = line.get_move(shipment_type)
|
|
if val:
|
|
res[line.id] = val
|
|
return res
|
|
|
|
def _group_shipment_key(self, moves, move):
|
|
'''
|
|
The key to group moves by shipments
|
|
|
|
move is a tuple of line id and a move
|
|
'''
|
|
SaleLine = Pool().get('sale.line')
|
|
line_id, move = move
|
|
line = SaleLine(line_id)
|
|
|
|
planned_date = max(m.planned_date for m in moves)
|
|
return (
|
|
('planned_date', planned_date),
|
|
('warehouse', line.warehouse.id),
|
|
)
|
|
|
|
_group_return_key = _group_shipment_key
|
|
|
|
def create_shipment(self, shipment_type):
|
|
'''
|
|
Create and return shipments of type shipment_type
|
|
'''
|
|
pool = Pool()
|
|
SaleLine = pool.get('sale.line')
|
|
|
|
if self.shipment_method == 'manual':
|
|
return
|
|
|
|
moves = self._get_move_sale_line(shipment_type)
|
|
if not moves:
|
|
return
|
|
if shipment_type == 'out':
|
|
keyfunc = partial(self._group_shipment_key, moves.values())
|
|
move_shipment_key = 'shipment_out'
|
|
Shipment = pool.get('stock.shipment.out')
|
|
elif shipment_type == 'return':
|
|
keyfunc = partial(self._group_return_key, moves.values())
|
|
move_shipment_key = 'shipment_out_return'
|
|
Shipment = pool.get('stock.shipment.out.return')
|
|
moves = moves.items()
|
|
moves = sorted(moves, key=keyfunc)
|
|
|
|
shipments = []
|
|
for key, grouped_moves in groupby(moves, key=keyfunc):
|
|
values = {
|
|
'customer': self.party.id,
|
|
'delivery_address': self.shipment_address.id,
|
|
'reference': self.reference,
|
|
'company': self.company.id,
|
|
}
|
|
values.update(dict(key))
|
|
with Transaction().set_user(0, set_context=True):
|
|
shipment = Shipment.create(values)
|
|
shipments.append(shipment)
|
|
for line_id, move in grouped_moves:
|
|
setattr(move, move_shipment_key, shipment.id)
|
|
move.save()
|
|
SaleLine.write([SaleLine(line_id)], {
|
|
'moves': [('add', [move.id])],
|
|
})
|
|
if shipment_type == 'out':
|
|
with Transaction().set_user(0, set_context=True):
|
|
Shipment.wait(shipments)
|
|
return shipments
|
|
|
|
def is_done(self):
|
|
return ((self.invoice_state == 'paid'
|
|
or self.invoice_method == 'manual')
|
|
and (self.shipment_state == 'sent'
|
|
or self.shipment_method == 'manual'))
|
|
|
|
@classmethod
|
|
def delete(cls, sales):
|
|
# Cancel before delete
|
|
cls.cancel(sales)
|
|
for sale in sales:
|
|
if sale.state != 'cancel':
|
|
cls.raise_user_error('delete_cancel', sale.rec_name)
|
|
super(Sale, cls).delete(sales)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('cancel')
|
|
def cancel(cls, sales):
|
|
cls.store_cache(sales)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('draft')
|
|
def draft(cls, sales):
|
|
pass
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('quotation')
|
|
def quote(cls, sales):
|
|
for sale in sales:
|
|
sale.check_for_quotation()
|
|
cls.set_reference(sales)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('confirmed')
|
|
def confirm(cls, sales):
|
|
cls.set_sale_date(sales)
|
|
cls.store_cache(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
|
|
@ModelView.button
|
|
@Workflow.transition('processing')
|
|
def process(cls, sales):
|
|
done = []
|
|
for sale in sales:
|
|
if sale.state in ('done', 'cancel'):
|
|
continue
|
|
sale.create_invoice('out_invoice')
|
|
sale.create_invoice('out_credit_note')
|
|
sale.set_invoice_state()
|
|
sale.create_shipment('out')
|
|
sale.create_shipment('return')
|
|
sale.set_shipment_state()
|
|
if sale.is_done():
|
|
done.append(sale)
|
|
if done:
|
|
cls.write(done, {
|
|
'state': 'done',
|
|
})
|
|
|
|
|
|
class SaleInvoice(ModelSQL):
|
|
'Sale - Invoice'
|
|
__name__ = 'sale.sale-account.invoice'
|
|
_table = 'sale_invoices_rel'
|
|
sale = fields.Many2One('sale.sale', 'Sale', ondelete='CASCADE',
|
|
select=True, required=True)
|
|
invoice = fields.Many2One('account.invoice', 'Invoice',
|
|
ondelete='RESTRICT', select=True, required=True)
|
|
|
|
|
|
class SaleIgnoredInvoice(ModelSQL):
|
|
'Sale - Ignored Invoice'
|
|
__name__ = 'sale.sale-ignored-account.invoice'
|
|
_table = 'sale_invoice_ignored_rel'
|
|
sale = fields.Many2One('sale.sale', 'Sale', ondelete='CASCADE',
|
|
select=True, required=True)
|
|
invoice = fields.Many2One('account.invoice', 'Invoice',
|
|
ondelete='RESTRICT', select=True, required=True)
|
|
|
|
|
|
class SaleRecreatedInvoice(ModelSQL):
|
|
'Sale - Recreated Invoice'
|
|
__name__ = 'sale.sale-recreated-account.invoice'
|
|
_table = 'sale_invoice_recreated_rel'
|
|
sale = fields.Many2One('sale.sale', 'Sale', ondelete='CASCADE',
|
|
select=True, required=True)
|
|
invoice = fields.Many2One('account.invoice', 'Invoice',
|
|
ondelete='RESTRICT', select=True, required=True)
|
|
|
|
|
|
class SaleLine(ModelSQL, ModelView):
|
|
'Sale Line'
|
|
__name__ = 'sale.line'
|
|
_rec_name = 'description'
|
|
sale = fields.Many2One('sale.sale', 'Sale', ondelete='CASCADE',
|
|
select=True)
|
|
sequence = fields.Integer('Sequence',
|
|
order_field='(%(table)s.sequence IS NULL) %(order)s, '
|
|
'%(table)s.sequence %(order)s')
|
|
type = fields.Selection([
|
|
('line', 'Line'),
|
|
('subtotal', 'Subtotal'),
|
|
('title', 'Title'),
|
|
('comment', 'Comment'),
|
|
], 'Type', select=True, required=True)
|
|
quantity = fields.Float('Quantity',
|
|
digits=(16, Eval('unit_digits', 2)),
|
|
states={
|
|
'invisible': Eval('type') != 'line',
|
|
'required': Eval('type') == 'line',
|
|
'readonly': ~Eval('_parent_sale', {}),
|
|
}, on_change=['product', 'quantity', 'unit',
|
|
'_parent_sale.currency', '_parent_sale.party',
|
|
'_parent_sale.sale_date'],
|
|
depends=['type', 'unit_digits'])
|
|
unit = fields.Many2One('product.uom', 'Unit',
|
|
states={
|
|
'required': Bool(Eval('product')),
|
|
'invisible': Eval('type') != 'line',
|
|
'readonly': ~Eval('_parent_sale', {}),
|
|
},
|
|
domain=[
|
|
If(Bool(Eval('product_uom_category')),
|
|
('category', '=', Eval('product_uom_category')),
|
|
('category', '!=', -1)),
|
|
],
|
|
on_change=['product', 'quantity', 'unit', '_parent_sale.currency',
|
|
'_parent_sale.party'],
|
|
depends=['product', 'type', 'product_uom_category'])
|
|
unit_digits = fields.Function(fields.Integer('Unit Digits',
|
|
on_change_with=['unit']), 'on_change_with_unit_digits')
|
|
product = fields.Many2One('product.product', 'Product',
|
|
domain=[('salable', '=', True)],
|
|
states={
|
|
'invisible': Eval('type') != 'line',
|
|
'readonly': ~Eval('_parent_sale', {}),
|
|
},
|
|
on_change=['product', 'unit', 'quantity', 'description',
|
|
'_parent_sale.party', '_parent_sale.currency',
|
|
'_parent_sale.sale_date'],
|
|
context={
|
|
'locations': If(Bool(Eval('_parent_sale', {}).get('warehouse')),
|
|
[Eval('_parent_sale', {}).get('warehouse', 0)], []),
|
|
'stock_date_end': Eval('_parent_sale', {}).get('sale_date'),
|
|
'salable': True,
|
|
'stock_skip_warehouse': True,
|
|
}, depends=['type'])
|
|
product_uom_category = fields.Function(
|
|
fields.Many2One('product.uom.category', 'Product Uom Category',
|
|
on_change_with=['product']),
|
|
'on_change_with_product_uom_category')
|
|
unit_price = fields.Numeric('Unit Price', digits=(16, 4),
|
|
states={
|
|
'invisible': Eval('type') != 'line',
|
|
'required': Eval('type') == 'line',
|
|
}, depends=['type'])
|
|
amount = fields.Function(fields.Numeric('Amount',
|
|
digits=(16, Eval('_parent_sale', {}).get('currency_digits', 2)),
|
|
states={
|
|
'invisible': ~Eval('type').in_(['line', 'subtotal']),
|
|
'readonly': ~Eval('_parent_sale'),
|
|
}, on_change_with=['type', 'quantity', 'unit_price', 'unit',
|
|
'_parent_sale.currency'],
|
|
depends=['type']), 'get_amount')
|
|
description = fields.Text('Description', size=None, required=True)
|
|
note = fields.Text('Note')
|
|
taxes = fields.Many2Many('sale.line-account.tax', 'line', 'tax', 'Taxes',
|
|
domain=[('parent', '=', None)], states={
|
|
'invisible': Eval('type') != 'line',
|
|
}, depends=['type'])
|
|
invoice_lines = fields.Many2Many('sale.line-account.invoice.line',
|
|
'sale_line', 'invoice_line', 'Invoice Lines', readonly=True)
|
|
moves = fields.One2Many('stock.move', 'sale_line', 'Moves',
|
|
readonly=True)
|
|
moves_ignored = fields.Many2Many('sale.line-ignored-stock.move',
|
|
'sale_line', 'move', 'Ignored Moves', readonly=True)
|
|
moves_recreated = fields.Many2Many('sale.line-recreated-stock.move',
|
|
'sale_line', 'move', 'Recreated Moves', readonly=True)
|
|
move_done = fields.Function(fields.Boolean('Moves Done'), 'get_move_done')
|
|
move_exception = fields.Function(fields.Boolean('Moves Exception'),
|
|
'get_move_exception')
|
|
warehouse = fields.Function(fields.Many2One('stock.location',
|
|
'Warehouse'), 'get_warehouse')
|
|
from_location = fields.Function(fields.Many2One('stock.location',
|
|
'From Location'), 'get_from_location')
|
|
to_location = fields.Function(fields.Many2One('stock.location',
|
|
'To Location'), 'get_to_location')
|
|
delivery_date = fields.Function(fields.Date('Delivery Date',
|
|
on_change_with=['product', '_parent_sale.sale_date'],
|
|
states={
|
|
'invisible': Eval('type') != 'line',
|
|
},
|
|
depends=['type']),
|
|
'on_change_with_delivery_date')
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(SaleLine, cls).__setup__()
|
|
cls._order.insert(0, ('sequence', 'ASC'))
|
|
cls._error_messages.update({
|
|
'customer_location_required': 'The customer location is required!',
|
|
'missing_account_revenue': 'It misses '
|
|
'an "Account Revenue" on product "%s"!',
|
|
'missing_account_revenue_property': 'It misses '
|
|
'an "account Revenue" default property!',
|
|
})
|
|
|
|
@classmethod
|
|
def __register__(cls, module_name):
|
|
super(SaleLine, cls).__register__(module_name)
|
|
cursor = Transaction().cursor
|
|
table = TableHandler(cursor, cls, module_name)
|
|
|
|
# Migration from 1.0 comment change into note
|
|
if table.column_exist('comment'):
|
|
cursor.execute('UPDATE "' + cls._table + '" SET note = comment')
|
|
table.drop_column('comment', exception=True)
|
|
|
|
# Migration from 2.4: drop required on sequence
|
|
table.not_null_action('sequence', action='remove')
|
|
|
|
@staticmethod
|
|
def default_type():
|
|
return 'line'
|
|
|
|
@staticmethod
|
|
def default_unit_digits():
|
|
return 2
|
|
|
|
def on_change_with_unit_digits(self, name=None):
|
|
if self.unit:
|
|
return self.unit.digits
|
|
return 2
|
|
|
|
def get_move_done(self, name):
|
|
Uom = Pool().get('product.uom')
|
|
done = True
|
|
if not self.product:
|
|
return True
|
|
if self.product.type == 'service':
|
|
return True
|
|
skip_ids = set(x.id for x in self.moves_ignored)
|
|
skip_ids.update(x.id for x in self.moves_recreated)
|
|
quantity = self.quantity
|
|
for move in self.moves:
|
|
if move.state != 'done' \
|
|
and move.id not in skip_ids:
|
|
done = False
|
|
break
|
|
quantity -= Uom.compute_qty(move.uom, move.quantity, self.unit)
|
|
if done:
|
|
if quantity > 0.0:
|
|
done = False
|
|
return done
|
|
|
|
def get_move_exception(self, name):
|
|
skip_ids = set(x.id for x in self.moves_ignored)
|
|
skip_ids.update(x.id for x in self.moves_recreated)
|
|
for move in self.moves:
|
|
if move.state == 'cancel' \
|
|
and move.id not in skip_ids:
|
|
return True
|
|
return False
|
|
|
|
def _get_tax_rule_pattern(self):
|
|
'''
|
|
Get tax rule pattern
|
|
'''
|
|
return {}
|
|
|
|
def _get_context_sale_price(self):
|
|
context = {}
|
|
if getattr(self, 'sale', None):
|
|
if getattr(self.sale, 'currency', None):
|
|
context['currency'] = self.sale.currency.id
|
|
if getattr(self.sale, 'party', None):
|
|
context['customer'] = self.sale.party.id
|
|
if getattr(self.sale, 'sale_date', None):
|
|
context['sale_date'] = self.sale.sale_date
|
|
if self.unit:
|
|
context['uom'] = self.unit.id
|
|
else:
|
|
context['uom'] = self.product.sale_uom.id
|
|
return context
|
|
|
|
def on_change_product(self):
|
|
Product = Pool().get('product.product')
|
|
|
|
if not self.product:
|
|
return {}
|
|
res = {}
|
|
|
|
party = None
|
|
party_context = {}
|
|
if self.sale and self.sale.party:
|
|
party = self.sale.party
|
|
if party.lang:
|
|
party_context['language'] = party.lang.code
|
|
|
|
category = self.product.sale_uom.category
|
|
if not self.unit or self.unit not in category.uoms:
|
|
res['unit'] = self.product.sale_uom.id
|
|
self.unit = self.product.sale_uom
|
|
res['unit.rec_name'] = self.product.sale_uom.rec_name
|
|
res['unit_digits'] = self.product.sale_uom.digits
|
|
|
|
with Transaction().set_context(self._get_context_sale_price()):
|
|
res['unit_price'] = Product.get_sale_price([self.product],
|
|
self.quantity or 0)[self.product.id]
|
|
if res['unit_price']:
|
|
res['unit_price'] = res['unit_price'].quantize(
|
|
Decimal(1) / 10 ** self.__class__.unit_price.digits[1])
|
|
res['taxes'] = []
|
|
pattern = self._get_tax_rule_pattern()
|
|
for tax in self.product.customer_taxes_used:
|
|
if party and party.customer_tax_rule:
|
|
tax_ids = party.customer_tax_rule.apply(tax, pattern)
|
|
if tax_ids:
|
|
res['taxes'].extend(tax_ids)
|
|
continue
|
|
res['taxes'].append(tax.id)
|
|
if party and party.customer_tax_rule:
|
|
tax_ids = party.customer_tax_rule.apply(None, pattern)
|
|
if tax_ids:
|
|
res['taxes'].extend(tax_ids)
|
|
|
|
if not self.description:
|
|
with Transaction().set_context(party_context):
|
|
res['description'] = Product(self.product.id).rec_name
|
|
|
|
self.unit_price = res['unit_price']
|
|
self.type = 'line'
|
|
res['amount'] = self.on_change_with_amount()
|
|
return res
|
|
|
|
def on_change_with_product_uom_category(self, name=None):
|
|
if self.product:
|
|
return self.product.default_uom_category.id
|
|
|
|
def on_change_quantity(self):
|
|
Product = Pool().get('product.product')
|
|
|
|
if not self.product:
|
|
return {}
|
|
res = {}
|
|
|
|
with Transaction().set_context(
|
|
self._get_context_sale_price()):
|
|
res['unit_price'] = Product.get_sale_price([self.product],
|
|
self.quantity or 0)[self.product.id]
|
|
if res['unit_price']:
|
|
res['unit_price'] = res['unit_price'].quantize(
|
|
Decimal(1) / 10 ** self.__class__.unit_price.digits[1])
|
|
return res
|
|
|
|
def on_change_unit(self):
|
|
return self.on_change_quantity()
|
|
|
|
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.0')) * \
|
|
(self.unit_price or Decimal('0.0'))
|
|
if currency:
|
|
return currency.round(amount)
|
|
return amount
|
|
return Decimal('0.0')
|
|
|
|
def get_amount(self, name):
|
|
if self.type == 'line':
|
|
return self.sale.currency.round(
|
|
Decimal(str(self.quantity)) * self.unit_price)
|
|
elif self.type == 'subtotal':
|
|
amount = Decimal('0.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.0')
|
|
return amount
|
|
return Decimal('0.0')
|
|
|
|
def get_warehouse(self, name):
|
|
return self.sale.warehouse.id if self.sale.warehouse else None
|
|
|
|
def get_from_location(self, name):
|
|
if self.quantity >= 0:
|
|
if self.warehouse:
|
|
return self.warehouse.output_location.id
|
|
else:
|
|
return self.sale.party.customer_location.id
|
|
|
|
def get_to_location(self, name):
|
|
if self.quantity >= 0:
|
|
return self.sale.party.customer_location.id
|
|
else:
|
|
if self.warehouse:
|
|
return self.warehouse.input_location.id
|
|
|
|
def on_change_with_delivery_date(self, name=None):
|
|
if self.product:
|
|
date = self.sale.sale_date if self.sale else None
|
|
return self.product.compute_delivery_date(date=date)
|
|
|
|
def get_invoice_line(self, invoice_type):
|
|
'''
|
|
Return a list of invoice lines for sale line according to invoice_type
|
|
'''
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
Property = pool.get('ir.property')
|
|
InvoiceLine = pool.get('account.invoice.line')
|
|
|
|
with Transaction().set_user(0, set_context=True):
|
|
invoice_line = InvoiceLine()
|
|
invoice_line.type = self.type
|
|
invoice_line.description = self.description
|
|
invoice_line.note = self.note
|
|
if self.type != 'line':
|
|
if (self.sale.invoice_method == 'order'
|
|
and ((all(l.quantity >= 0 for l in self.sale.lines
|
|
if l.type == 'line')
|
|
and invoice_type == 'out_invoice')
|
|
or (all(l.quantity <= 0 for l in self.sale.lines
|
|
if l.type == 'line')
|
|
and invoice_type == 'out_credit_note'))):
|
|
return [invoice_line]
|
|
else:
|
|
return []
|
|
|
|
if (invoice_type == 'out_invoice') != (self.quantity >= 0):
|
|
return []
|
|
|
|
if (self.sale.invoice_method == 'order'
|
|
or not self.product
|
|
or self.product.type == 'service'):
|
|
quantity = abs(self.quantity)
|
|
else:
|
|
quantity = 0.0
|
|
for move in self.moves:
|
|
if move.state == 'done':
|
|
quantity += Uom.compute_qty(move.uom, move.quantity,
|
|
self.unit)
|
|
|
|
skip_ids = set(l.id for i in self.sale.invoices_recreated
|
|
for l in i.lines)
|
|
for old_invoice_line in self.invoice_lines:
|
|
if old_invoice_line.type != 'line':
|
|
continue
|
|
if old_invoice_line.id not in skip_ids:
|
|
quantity -= Uom.compute_qty(old_invoice_line.unit,
|
|
old_invoice_line.quantity, self.unit)
|
|
invoice_line.quantity = quantity
|
|
|
|
if invoice_line.quantity <= 0.0:
|
|
return []
|
|
invoice_line.unit = self.unit
|
|
invoice_line.product = self.product
|
|
invoice_line.unit_price = self.unit_price
|
|
invoice_line.taxes = self.taxes
|
|
if self.product:
|
|
invoice_line.account = self.product.account_revenue_used
|
|
if not invoice_line.account:
|
|
self.raise_user_error('missing_account_revenue',
|
|
error_args=(self.product.rec_name,))
|
|
else:
|
|
for model in ('product.template', 'product.category'):
|
|
invoice_line.account = Property.get('account_revenue', model)
|
|
if invoice_line.account:
|
|
break
|
|
if not invoice_line.account:
|
|
self.raise_user_error('missing_account_revenue_property')
|
|
return [invoice_line]
|
|
|
|
@classmethod
|
|
def copy(cls, lines, default=None):
|
|
if default is None:
|
|
default = {}
|
|
default = default.copy()
|
|
default['moves'] = None
|
|
default['moves_ignored'] = None
|
|
default['moves_recreated'] = None
|
|
default['invoice_lines'] = None
|
|
return super(SaleLine, cls).copy(lines, default=default)
|
|
|
|
def get_move(self, shipment_type):
|
|
'''
|
|
Return moves for the sale line according ot shipment_type
|
|
'''
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
Move = pool.get('stock.move')
|
|
|
|
if self.type != 'line':
|
|
return
|
|
if not self.product:
|
|
return
|
|
if self.product.type == 'service':
|
|
return
|
|
if (shipment_type == 'out') != (self.quantity >= 0):
|
|
return
|
|
|
|
if self.sale.shipment_method == 'order':
|
|
quantity = abs(self.quantity)
|
|
else:
|
|
quantity = 0.0
|
|
for invoice_line in self.invoice_lines:
|
|
if invoice_line.invoice.state in ('open', 'paid'):
|
|
quantity += Uom.compute_qty(invoice_line.unit,
|
|
invoice_line.quantity, self.unit)
|
|
|
|
skip_ids = set(x.id for x in self.moves_recreated)
|
|
for move in self.moves:
|
|
if move.id not in skip_ids:
|
|
quantity -= Uom.compute_qty(move.uom, move.quantity,
|
|
self.unit)
|
|
if quantity <= 0.0:
|
|
return
|
|
if not self.sale.party.customer_location:
|
|
self.raise_user_error('customer_location_required')
|
|
with Transaction().set_user(0, set_context=True):
|
|
move = Move()
|
|
move.quantity = quantity
|
|
move.uom = 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
|
|
move.unit_price = self.unit_price
|
|
move.currency = self.sale.currency
|
|
move.planned_date = self.delivery_date
|
|
return move
|
|
|
|
|
|
class SaleLineTax(ModelSQL):
|
|
'Sale Line - Tax'
|
|
__name__ = 'sale.line-account.tax'
|
|
_table = 'sale_line_account_tax'
|
|
line = fields.Many2One('sale.line', 'Sale Line', ondelete='CASCADE',
|
|
select=True, required=True)
|
|
tax = fields.Many2One('account.tax', 'Tax', ondelete='RESTRICT',
|
|
select=True, required=True)
|
|
|
|
|
|
class SaleLineInvoiceLine(ModelSQL):
|
|
'Sale Line - Invoice Line'
|
|
__name__ = 'sale.line-account.invoice.line'
|
|
_table = 'sale_line_invoice_lines_rel'
|
|
sale_line = fields.Many2One('sale.line', 'Sale Line', ondelete='CASCADE',
|
|
select=True, required=True)
|
|
invoice_line = fields.Many2One('account.invoice.line', 'Invoice Line',
|
|
ondelete='RESTRICT', select=True, required=True)
|
|
|
|
|
|
class SaleLineIgnoredMove(ModelSQL):
|
|
'Sale Line - Ignored Move'
|
|
__name__ = 'sale.line-ignored-stock.move'
|
|
_table = 'sale_line_moves_ignored_rel'
|
|
sale_line = fields.Many2One('sale.line', 'Sale Line', ondelete='CASCADE',
|
|
select=True, required=True)
|
|
move = fields.Many2One('stock.move', 'Move', ondelete='RESTRICT',
|
|
select=True, required=True)
|
|
|
|
|
|
class SaleLineRecreatedMove(ModelSQL):
|
|
'Sale Line - Recreated Move'
|
|
__name__ = 'sale.line-recreated-stock.move'
|
|
_table = 'sale_line_moves_recreated_rel'
|
|
sale_line = fields.Many2One('sale.line', 'Sale Line', ondelete='CASCADE',
|
|
select=True, required=True)
|
|
move = fields.Many2One('stock.move', 'Move', ondelete='RESTRICT',
|
|
select=True, required=True)
|
|
|
|
|
|
class SaleReport(CompanyReport):
|
|
__name__ = 'sale.sale'
|
|
|
|
|
|
class Template:
|
|
__name__ = 'product.template'
|
|
salable = fields.Boolean('Salable', states={
|
|
'readonly': ~Eval('active', True),
|
|
}, depends=['active'])
|
|
sale_uom = fields.Many2One('product.uom', 'Sale UOM', states={
|
|
'readonly': ~Eval('active', True),
|
|
'invisible': ~Eval('salable', False),
|
|
'required': Eval('salable', False),
|
|
},
|
|
domain=[
|
|
('category', '=', Eval('default_uom_category')),
|
|
],
|
|
on_change_with=['default_uom', 'sale_uom', 'salable'],
|
|
depends=['active', 'salable', 'default_uom_category'])
|
|
delivery_time = fields.Integer('Delivery Time', states={
|
|
'readonly': ~Eval('active', True),
|
|
'invisible': ~Eval('salable', False),
|
|
'required': Eval('salable', False),
|
|
},
|
|
depends=['active', 'salable'],
|
|
help='In number of days')
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(Template, cls).__setup__()
|
|
required = ~Eval('account_category', False) & Eval('salable', False)
|
|
if not cls.account_revenue.states.get('required'):
|
|
cls.account_revenue.states['required'] = required
|
|
else:
|
|
cls.account_revenue.states['required'] = (
|
|
cls.account_revenue.states['required'] | required)
|
|
if 'account_category' not in cls.account_revenue.depends:
|
|
cls.account_revenue.depends.append('account_category')
|
|
if 'salable' not in cls.account_revenue.depends:
|
|
cls.account_revenue.depends.append('salable')
|
|
|
|
@staticmethod
|
|
def default_salable():
|
|
return True if Transaction().context.get('salable') else False
|
|
|
|
@staticmethod
|
|
def default_delivery_time():
|
|
return 0
|
|
|
|
def on_change_with_sale_uom(self):
|
|
if self.default_uom:
|
|
if self.sale_uom:
|
|
if self.default_uom.category == self.sale_uom.category:
|
|
return self.sale_uom.id
|
|
else:
|
|
return self.default_uom.id
|
|
else:
|
|
return self.default_uom.id
|
|
|
|
|
|
class Product:
|
|
__name__ = 'product.product'
|
|
|
|
@staticmethod
|
|
def get_sale_price(products, quantity=0):
|
|
'''
|
|
Return the sale price for products and quantity.
|
|
It uses if exists from the context:
|
|
uom: the unit of measure
|
|
currency: the currency id for the returned price
|
|
'''
|
|
pool = Pool()
|
|
Uom = pool.get('product.uom')
|
|
User = pool.get('res.user')
|
|
Currency = pool.get('currency.currency')
|
|
Date = pool.get('ir.date')
|
|
|
|
today = Date.today()
|
|
prices = {}
|
|
|
|
uom = None
|
|
if Transaction().context.get('uom'):
|
|
uom = Uom(Transaction().context.get('uom'))
|
|
|
|
currency = None
|
|
if Transaction().context.get('currency'):
|
|
currency = Currency(Transaction().context.get('currency'))
|
|
|
|
user = User(Transaction().user)
|
|
|
|
for product in products:
|
|
prices[product.id] = product.list_price
|
|
if uom:
|
|
prices[product.id] = Uom.compute_price(
|
|
product.default_uom, prices[product.id], uom)
|
|
if currency and user.company:
|
|
if user.company.currency != currency:
|
|
date = Transaction().context.get('sale_date') or today
|
|
with Transaction().set_context(date=date):
|
|
prices[product.id] = Currency.compute(
|
|
user.company.currency, prices[product.id],
|
|
currency, round=False)
|
|
return prices
|
|
|
|
def compute_delivery_date(self, date=None):
|
|
'''
|
|
Compute the delivery date a the given date
|
|
'''
|
|
Date = Pool().get('ir.date')
|
|
|
|
if not date:
|
|
date = Date.today()
|
|
return date + datetime.timedelta(self.delivery_time)
|
|
|
|
|
|
class ShipmentOut:
|
|
__name__ = 'stock.shipment.out'
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(ShipmentOut, cls).__setup__()
|
|
cls._error_messages.update({
|
|
'reset_move': 'You cannot reset to draft a move generated '
|
|
'by a sale.',
|
|
})
|
|
|
|
@classmethod
|
|
def write(cls, shipments, vals):
|
|
pool = Pool()
|
|
Sale = pool.get('sale.sale')
|
|
SaleLine = pool.get('sale.line')
|
|
|
|
super(ShipmentOut, cls).write(shipments, vals)
|
|
|
|
if 'state' in vals and vals['state'] in ('done', 'cancel'):
|
|
sales = []
|
|
move_ids = []
|
|
for shipment in shipments:
|
|
move_ids.extend([x.id for x in shipment.outgoing_moves])
|
|
|
|
sale_lines = SaleLine.search([
|
|
('moves', 'in', move_ids),
|
|
])
|
|
if sale_lines:
|
|
for sale_line in sale_lines:
|
|
if sale_line.sale not in sales:
|
|
sales.append(sale_line.sale)
|
|
|
|
with Transaction().set_user(0, set_context=True):
|
|
sales = Sale.browse([s.id for s in sales])
|
|
Sale.process(sales)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('draft')
|
|
def draft(cls, shipments):
|
|
for shipment in shipments:
|
|
for move in shipment.outgoing_moves:
|
|
if move.state == 'cancel' and move.sale_line:
|
|
cls.raise_user_error('reset_move')
|
|
|
|
return super(ShipmentOut, cls).draft(shipments)
|
|
|
|
|
|
class ShipmentOutReturn:
|
|
__name__ = 'stock.shipment.out.return'
|
|
|
|
@classmethod
|
|
def __setup__(cls):
|
|
super(ShipmentOutReturn, cls).__setup__()
|
|
cls._error_messages.update({
|
|
'reset_move': 'You cannot reset to draft a move generated '
|
|
'by a sale.',
|
|
})
|
|
|
|
@classmethod
|
|
def write(cls, shipments, vals):
|
|
pool = Pool()
|
|
Sale = pool.get('sale.sale')
|
|
SaleLine = pool.get('sale.line')
|
|
|
|
super(ShipmentOutReturn, cls).write(shipments, vals)
|
|
|
|
if 'state' in vals and vals['state'] == 'received':
|
|
sales = []
|
|
move_ids = []
|
|
for shipment in shipments:
|
|
move_ids.extend([x.id for x in shipment.incoming_moves])
|
|
|
|
sale_lines = SaleLine.search([
|
|
('moves', 'in', move_ids),
|
|
])
|
|
if sale_lines:
|
|
for sale_line in sale_lines:
|
|
if sale_line.sale not in sales:
|
|
sales.append(sale_line.sale)
|
|
|
|
with Transaction().set_user(0, set_context=True):
|
|
sales = Sale.browse([s.id for s in sales])
|
|
Sale.process(sales)
|
|
|
|
@classmethod
|
|
@ModelView.button
|
|
@Workflow.transition('draft')
|
|
def draft(cls, shipments):
|
|
for shipment in shipments:
|
|
for move in shipment.incoming_moves:
|
|
if move.state == 'cancel' and move.sale_line:
|
|
cls.raise_user_error('reset_move')
|
|
|
|
return super(ShipmentOutReturn, cls).draft(shipments)
|
|
|
|
|
|
class Move:
|
|
__name__ = 'stock.move'
|
|
sale_line = fields.Many2One('sale.line', 'Sale Line', select=True,
|
|
states={
|
|
'readonly': Eval('state') != 'draft',
|
|
},
|
|
depends=['state'])
|
|
sale = fields.Function(fields.Many2One('sale.sale', 'Sale', select=True),
|
|
'get_sale', searcher='search_sale')
|
|
sale_exception_state = fields.Function(fields.Selection([
|
|
('', ''),
|
|
('ignored', 'Ignored'),
|
|
('recreated', 'Recreated'),
|
|
], 'Exception State'), 'get_sale_exception_state')
|
|
|
|
def get_sale(self, name):
|
|
if self.sale_line:
|
|
return self.sale_line.sale.id
|
|
|
|
@classmethod
|
|
def search_sale(cls, name, clause):
|
|
return [('sale_line.' + name,) + clause[1:]]
|
|
|
|
def get_sale_exception_state(self, name):
|
|
if not self.sale_line:
|
|
return ''
|
|
if self in self.sale_line.moves_recreated:
|
|
return 'recreated'
|
|
if self in self.sale_line.moves_ignored:
|
|
return 'ignored'
|
|
return ''
|
|
|
|
@classmethod
|
|
def write(cls, moves, vals):
|
|
pool = Pool()
|
|
Sale = pool.get('sale.sale')
|
|
SaleLine = pool.get('sale.line')
|
|
|
|
super(Move, cls).write(moves, vals)
|
|
if 'state' in vals and vals['state'] in ('cancel',):
|
|
sales = set()
|
|
sale_lines = SaleLine.search([
|
|
('moves', 'in', [m.id for m in moves]),
|
|
])
|
|
if sale_lines:
|
|
for sale_line in sale_lines:
|
|
sales.add(sale_line.sale)
|
|
if sales:
|
|
with Transaction().set_user(0, set_context=True):
|
|
sales = Sale.browse([s.id for s in sales])
|
|
Sale.process(sales)
|
|
|
|
@classmethod
|
|
def delete(cls, moves):
|
|
pool = Pool()
|
|
Sale = pool.get('sale.sale')
|
|
SaleLine = pool.get('sale.line')
|
|
|
|
sales = set()
|
|
sale_lines = SaleLine.search([
|
|
('moves', 'in', [m.id for m in moves]),
|
|
])
|
|
|
|
super(Move, cls).delete(moves)
|
|
|
|
if sale_lines:
|
|
for sale_line in sale_lines:
|
|
sales.add(sale_line.sale)
|
|
if sales:
|
|
with Transaction().set_user(0, set_context=True):
|
|
sales = Sale.browse([s.id for s in sales])
|
|
Sale.process(list(sales))
|
|
|
|
|
|
class OpenCustomer(Wizard):
|
|
'Open Customers'
|
|
__name__ = 'sale.open_customer'
|
|
start_state = 'open_'
|
|
open_ = StateAction('party.act_party_form')
|
|
|
|
def do_open_(self, action):
|
|
pool = Pool()
|
|
ModelData = pool.get('ir.model.data')
|
|
Wizard = pool.get('ir.action.wizard')
|
|
cursor = Transaction().cursor
|
|
cursor.execute("SELECT DISTINCT(party) FROM sale_sale")
|
|
customer_ids = [line[0] for line in Transaction().cursor.fetchall()]
|
|
action['pyson_domain'] = PYSONEncoder().encode(
|
|
[('id', 'in', customer_ids)])
|
|
|
|
model_data, = ModelData.search([
|
|
('fs_id', '=', 'act_open_customer'),
|
|
('module', '=', 'sale'),
|
|
('inherit', '=', None),
|
|
], limit=1)
|
|
wizard = Wizard(model_data.db_id)
|
|
|
|
action['name'] = wizard.name
|
|
return action, {}
|
|
|
|
def transition_open_(self):
|
|
return 'end'
|
|
|
|
|
|
class HandleShipmentExceptionAsk(ModelView):
|
|
'Handle Shipment Exception'
|
|
__name__ = 'sale.handle.shipment.exception.ask'
|
|
recreate_moves = fields.Many2Many(
|
|
'stock.move', None, None, 'Recreate Moves',
|
|
domain=[('id', 'in', Eval('domain_moves'))], depends=['domain_moves'])
|
|
domain_moves = fields.Many2Many(
|
|
'stock.move', None, None, 'Domain Moves')
|
|
|
|
@classmethod
|
|
def __register__(cls, module_name):
|
|
cursor = Transaction().cursor
|
|
# Migration from 1.2: packing renamed into shipment
|
|
cursor.execute("UPDATE ir_model "
|
|
"SET model = REPLACE(model, 'packing', 'shipment') "
|
|
"WHERE model like '%%packing%%' AND module = %s",
|
|
(module_name,))
|
|
super(HandleShipmentExceptionAsk, cls).__register__(module_name)
|
|
|
|
|
|
class HandleShipmentException(Wizard):
|
|
'Handle Shipment Exception'
|
|
__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):
|
|
Sale = Pool().get('sale.sale')
|
|
sale = Sale(Transaction().context.get('active_id'))
|
|
|
|
moves = []
|
|
for line in sale.lines:
|
|
skips = set(line.moves_ignored)
|
|
skips.update(line.moves_recreated)
|
|
for move in line.moves:
|
|
if move.state == 'cancel' and move not in skips:
|
|
moves.append(move.id)
|
|
return {
|
|
'recreate_moves': moves,
|
|
'domain_moves': moves,
|
|
}
|
|
|
|
def transition_handle(self):
|
|
pool = Pool()
|
|
Sale = pool.get('sale.sale')
|
|
SaleLine = pool.get('sale.line')
|
|
|
|
sale = Sale(Transaction().context['active_id'])
|
|
|
|
for line in sale.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)
|
|
else:
|
|
moves_ignored.append(move.id)
|
|
|
|
SaleLine.write([line], {
|
|
'moves_ignored': [('add', moves_ignored)],
|
|
'moves_recreated': [('add', moves_recreated)],
|
|
})
|
|
Sale.process([sale])
|
|
return 'end'
|
|
|
|
|
|
class HandleInvoiceExceptionAsk(ModelView):
|
|
'Handle Invoice Exception'
|
|
__name__ = 'sale.handle.invoice.exception.ask'
|
|
recreate_invoices = fields.Many2Many(
|
|
'account.invoice', None, None, 'Recreate Invoices',
|
|
domain=[('id', 'in', Eval('domain_invoices'))],
|
|
depends=['domain_invoices'],
|
|
help='The selected invoices will be recreated. '
|
|
'The other ones will be ignored.')
|
|
domain_invoices = fields.Many2Many(
|
|
'account.invoice', None, None, 'Domain Invoices')
|
|
|
|
|
|
class HandleInvoiceException(Wizard):
|
|
'Handle Invoice Exception'
|
|
__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):
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
sale = Sale(Transaction().context['active_id'])
|
|
skips = set(sale.invoices_ignored)
|
|
skips.update(sale.invoices_recreated)
|
|
invoices = []
|
|
for invoice in sale.invoices:
|
|
if invoice.state == 'cancel' and invoice not in skips:
|
|
invoices.append(invoice.id)
|
|
return {
|
|
'recreate_invoices': invoices,
|
|
'domain_invoices': invoices,
|
|
}
|
|
|
|
def transition_handle(self):
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
sale = Sale(Transaction().context['active_id'])
|
|
|
|
skips = set(sale.invoices_ignored)
|
|
skips.update(sale.invoices_recreated)
|
|
invoices_ignored = []
|
|
invoices_recreated = []
|
|
for invoice in sale.invoices:
|
|
if invoice not in self.ask.domain_invoices or invoice in skips:
|
|
continue
|
|
if invoice in self.ask.recreate_invoices:
|
|
invoices_recreated.append(invoice.id)
|
|
else:
|
|
invoices_ignored.append(invoice.id)
|
|
|
|
Sale.write([sale], {
|
|
'invoices_ignored': [('add', invoices_ignored)],
|
|
'invoices_recreated': [('add', invoices_recreated)],
|
|
})
|
|
Sale.process([sale])
|
|
return 'end'
|
|
|
|
|
|
class ReturnSale(Wizard):
|
|
__name__ = 'sale.return_sale'
|
|
start_state = 'make_return'
|
|
make_return = StateTransition()
|
|
|
|
def transition_make_return(self):
|
|
Sale = Pool().get('sale.sale')
|
|
|
|
sale = Sale(Transaction().context['active_id'])
|
|
new_sale, = Sale.copy([sale])
|
|
for new_line in new_sale.lines:
|
|
new_line.quantity *= -1
|
|
new_line.save()
|
|
return 'end'
|