flectra/addons/point_of_sale/models/pos_order.py

1117 lines
55 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
2018-01-16 11:34:37 +01:00
# Part of Odoo, Flectra. See LICENSE file for full copyright and licensing details.
import logging
from datetime import timedelta
from functools import partial
import psycopg2
import pytz
2018-01-16 11:34:37 +01:00
from flectra import api, fields, models, tools, _
from flectra.tools import float_is_zero
from flectra.exceptions import UserError
from flectra.http import request
from flectra.addons import decimal_precision as dp
_logger = logging.getLogger(__name__)
class PosOrder(models.Model):
_name = "pos.order"
_description = "Point of Sale Orders"
_order = "id desc"
@api.model
def _amount_line_tax(self, line, fiscal_position_id):
taxes = line.tax_ids.filtered(lambda t: t.company_id.id == line.order_id.company_id.id)
if fiscal_position_id:
taxes = fiscal_position_id.map_tax(taxes, line.product_id, line.order_id.partner_id)
price = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
taxes = taxes.compute_all(price, line.order_id.pricelist_id.currency_id, line.qty, product=line.product_id, partner=line.order_id.partner_id or False)['taxes']
return sum(tax.get('amount', 0.0) for tax in taxes)
@api.model
def _order_fields(self, ui_order):
process_line = partial(self.env['pos.order.line']._order_line_fields, session_id=ui_order['pos_session_id'])
return {
'name': ui_order['name'],
'user_id': ui_order['user_id'] or False,
'session_id': ui_order['pos_session_id'],
'lines': [process_line(l) for l in ui_order['lines']] if ui_order['lines'] else False,
'pos_reference': ui_order['name'],
'partner_id': ui_order['partner_id'] or False,
'date_order': ui_order['creation_date'],
'fiscal_position_id': ui_order['fiscal_position_id'],
'pricelist_id': ui_order['pricelist_id'],
}
def _payment_fields(self, ui_paymentline):
return {
'amount': ui_paymentline['amount'] or 0.0,
'payment_date': ui_paymentline['name'],
'statement_id': ui_paymentline['statement_id'],
'payment_name': ui_paymentline.get('note', False),
'journal': ui_paymentline['journal_id'],
}
# This deals with orders that belong to a closed session. In order
# to recover from this situation we create a new rescue session,
# making it obvious that something went wrong.
# A new, separate, rescue session is preferred for every such recovery,
# to avoid adding unrelated orders to live sessions.
def _get_valid_session(self, order):
PosSession = self.env['pos.session']
closed_session = PosSession.browse(order['pos_session_id'])
_logger.warning('session %s (ID: %s) was closed but received order %s (total: %s) belonging to it',
closed_session.name,
closed_session.id,
order['name'],
order['amount_total'])
rescue_session = PosSession.search([
('state', 'not in', ('closed', 'closing_control')),
('rescue', '=', True),
('config_id', '=', closed_session.config_id.id),
], limit=1)
if rescue_session:
_logger.warning('reusing recovery session %s for saving order %s', rescue_session.name, order['name'])
return rescue_session
_logger.warning('attempting to create recovery session for saving order %s', order['name'])
new_session = PosSession.create({
'config_id': closed_session.config_id.id,
'name': _('(RESCUE FOR %(session)s)') % {'session': closed_session.name},
'rescue': True, # avoid conflict with live sessions
})
# bypass opening_control (necessary when using cash control)
new_session.action_pos_session_open()
return new_session
def _match_payment_to_invoice(self, order):
account_precision = self.env['decimal.precision'].precision_get('Account')
# ignore orders with an amount_paid of 0 because those are returns through the POS
if not float_is_zero(order['amount_return'], account_precision) and not float_is_zero(order['amount_paid'], account_precision):
cur_amount_paid = 0
payments_to_keep = []
for payment in order.get('statement_ids'):
if cur_amount_paid + payment[2]['amount'] > order['amount_total']:
payment[2]['amount'] = order['amount_total'] - cur_amount_paid
payments_to_keep.append(payment)
break
cur_amount_paid += payment[2]['amount']
payments_to_keep.append(payment)
order['statement_ids'] = payments_to_keep
order['amount_return'] = 0
@api.model
def _process_order(self, pos_order):
prec_acc = self.env['decimal.precision'].precision_get('Account')
pos_session = self.env['pos.session'].browse(pos_order['pos_session_id'])
if pos_session.state == 'closing_control' or pos_session.state == 'closed':
pos_order['pos_session_id'] = self._get_valid_session(pos_order).id
order = self.create(self._order_fields(pos_order))
journal_ids = set()
for payments in pos_order['statement_ids']:
if not float_is_zero(payments[2]['amount'], precision_digits=prec_acc):
order.add_payment(self._payment_fields(payments[2]))
journal_ids.add(payments[2]['journal_id'])
if pos_session.sequence_number <= pos_order['sequence_number']:
pos_session.write({'sequence_number': pos_order['sequence_number'] + 1})
pos_session.refresh()
if not float_is_zero(pos_order['amount_return'], prec_acc):
cash_journal_id = pos_session.cash_journal_id.id
if not cash_journal_id:
# Select for change one of the cash journals used in this
# payment
cash_journal = self.env['account.journal'].search([
('type', '=', 'cash'),
('id', 'in', list(journal_ids)),
], limit=1)
if not cash_journal:
# If none, select for change one of the cash journals of the POS
# This is used for example when a customer pays by credit card
# an amount higher than total amount of the order and gets cash back
cash_journal = [statement.journal_id for statement in pos_session.statement_ids if statement.journal_id.type == 'cash']
if not cash_journal:
raise UserError(_("No cash statement found for this session. Unable to record returned cash."))
cash_journal_id = cash_journal[0].id
order.add_payment({
'amount': -pos_order['amount_return'],
'payment_date': fields.Datetime.now(),
'payment_name': _('return'),
'journal': cash_journal_id,
})
return order
def _prepare_analytic_account(self, line):
'''This method is designed to be inherited in a custom module'''
return False
def _create_account_move(self, dt, ref, journal_id, company_id):
date_tz_user = fields.Datetime.context_timestamp(self, fields.Datetime.from_string(dt))
date_tz_user = fields.Date.to_string(date_tz_user)
return self.env['account.move'].sudo().create({'ref': ref, 'journal_id': journal_id, 'date': date_tz_user})
def _prepare_invoice(self):
"""
Prepare the dict of values to create the new invoice for a pos order.
"""
invoice_type = 'out_invoice' if self.amount_total >= 0 else 'out_refund'
return {
'name': self.name,
'origin': self.name,
'account_id': self.partner_id.property_account_receivable_id.id,
'journal_id': self.session_id.config_id.invoice_journal_id.id,
'company_id': self.company_id.id,
'type': invoice_type,
'reference': self.name,
'partner_id': self.partner_id.id,
'comment': self.note or '',
# considering partner's sale pricelist's currency
'currency_id': self.pricelist_id.currency_id.id,
'user_id': self.env.uid,
}
@api.model
def _get_account_move_line_group_data_type_key(self, data_type, values):
"""
Return a tuple which will be used as a key for grouping account
move lines in _create_account_move_line method.
:param data_type: 'product', 'tax', ....
:param values: account move line values
:return: tuple() representing the data_type key
"""
if data_type == 'product':
return ('product',
values['partner_id'],
(values['product_id'], tuple(values['tax_ids'][0][2]), values['name']),
values['analytic_account_id'],
values['debit'] > 0)
elif data_type == 'tax':
return ('tax',
values['partner_id'],
values['tax_line_id'],
values['debit'] > 0)
elif data_type == 'counter_part':
return ('counter_part',
values['partner_id'],
values['account_id'],
values['debit'] > 0)
return False
def _action_create_invoice_line(self, line=False, invoice_id=False):
InvoiceLine = self.env['account.invoice.line']
inv_name = line.product_id.name_get()[0][1]
inv_line = {
'invoice_id': invoice_id,
'product_id': line.product_id.id,
'quantity': line.qty if self.amount_total >= 0 else -line.qty,
'account_analytic_id': self._prepare_analytic_account(line),
'name': inv_name,
}
# Oldlin trick
invoice_line = InvoiceLine.sudo().new(inv_line)
invoice_line._onchange_product_id()
invoice_line.invoice_line_tax_ids = invoice_line.invoice_line_tax_ids.filtered(lambda t: t.company_id.id == line.order_id.company_id.id).ids
fiscal_position_id = line.order_id.fiscal_position_id
if fiscal_position_id:
invoice_line.invoice_line_tax_ids = fiscal_position_id.map_tax(invoice_line.invoice_line_tax_ids, line.product_id, line.order_id.partner_id)
invoice_line.invoice_line_tax_ids = invoice_line.invoice_line_tax_ids.ids
# We convert a new id object back to a dictionary to write to
# bridge between old and new api
inv_line = invoice_line._convert_to_write({name: invoice_line[name] for name in invoice_line._cache})
inv_line.update(price_unit=line.price_unit, discount=line.discount, name=inv_name)
return InvoiceLine.sudo().create(inv_line)
def _create_account_move_line(self, session=None, move=None):
def _flatten_tax_and_children(taxes, group_done=None):
children = self.env['account.tax']
if group_done is None:
group_done = set()
for tax in taxes.filtered(lambda t: t.amount_type == 'group'):
if tax.id not in group_done:
group_done.add(tax.id)
children |= _flatten_tax_and_children(tax.children_tax_ids, group_done)
return taxes + children
# Tricky, via the workflow, we only have one id in the ids variable
"""Create a account move line of order grouped by products or not."""
IrProperty = self.env['ir.property']
ResPartner = self.env['res.partner']
if session and not all(session.id == order.session_id.id for order in self):
raise UserError(_('Selected orders do not have the same session!'))
grouped_data = {}
have_to_group_by = session and session.config_id.group_by or False
rounding_method = session and session.config_id.company_id.tax_calculation_rounding_method
def add_anglosaxon_lines(grouped_data):
Product = self.env['product.product']
Analytic = self.env['account.analytic.account']
for product_key in list(grouped_data.keys()):
if product_key[0] == "product":
line = grouped_data[product_key][0]
product = Product.browse(line['product_id'])
# In the SO part, the entries will be inverted by function compute_invoice_totals
price_unit = - product._get_anglo_saxon_price_unit()
account_analytic = Analytic.browse(line.get('analytic_account_id'))
res = Product._anglo_saxon_sale_move_lines(
line['name'], product, product.uom_id, line['quantity'], price_unit,
fiscal_position=order.fiscal_position_id,
account_analytic=account_analytic)
if res:
line1, line2 = res
line1 = Product._convert_prepared_anglosaxon_line(line1, order.partner_id)
insert_data('counter_part', {
'name': line1['name'],
'account_id': line1['account_id'],
'credit': line1['credit'] or 0.0,
'debit': line1['debit'] or 0.0,
'partner_id': line1['partner_id']
})
line2 = Product._convert_prepared_anglosaxon_line(line2, order.partner_id)
insert_data('counter_part', {
'name': line2['name'],
'account_id': line2['account_id'],
'credit': line2['credit'] or 0.0,
'debit': line2['debit'] or 0.0,
'partner_id': line2['partner_id']
})
for order in self.filtered(lambda o: not o.account_move or o.state == 'paid'):
current_company = order.sale_journal.company_id
account_def = IrProperty.get(
'property_account_receivable_id', 'res.partner')
order_account = order.partner_id.property_account_receivable_id.id or account_def and account_def.id
partner_id = ResPartner._find_accounting_partner(order.partner_id).id or False
if move is None:
# Create an entry for the sale
journal_id = self.env['ir.config_parameter'].sudo().get_param(
'pos.closing.journal_id_%s' % current_company.id, default=order.sale_journal.id)
move = self._create_account_move(
order.session_id.start_at, order.name, int(journal_id), order.company_id.id)
def insert_data(data_type, values):
# if have_to_group_by:
values.update({
'partner_id': partner_id,
'move_id': move.id,
})
key = self._get_account_move_line_group_data_type_key(data_type, values)
if not key:
return
grouped_data.setdefault(key, [])
if have_to_group_by:
if not grouped_data[key]:
grouped_data[key].append(values)
else:
current_value = grouped_data[key][0]
current_value['quantity'] = current_value.get('quantity', 0.0) + values.get('quantity', 0.0)
current_value['credit'] = current_value.get('credit', 0.0) + values.get('credit', 0.0)
current_value['debit'] = current_value.get('debit', 0.0) + values.get('debit', 0.0)
else:
grouped_data[key].append(values)
# because of the weird way the pos order is written, we need to make sure there is at least one line,
# because just after the 'for' loop there are references to 'line' and 'income_account' variables (that
# are set inside the for loop)
# TOFIX: a deep refactoring of this method (and class!) is needed
# in order to get rid of this stupid hack
assert order.lines, _('The POS order must have lines when calling this method')
# Create an move for each order line
cur = order.pricelist_id.currency_id
for line in order.lines:
amount = line.price_subtotal
# Search for the income account
if line.product_id.property_account_income_id.id:
income_account = line.product_id.property_account_income_id.id
elif line.product_id.categ_id.property_account_income_categ_id.id:
income_account = line.product_id.categ_id.property_account_income_categ_id.id
else:
raise UserError(_('Please define income '
'account for this product: "%s" (id:%d).')
% (line.product_id.name, line.product_id.id))
name = line.product_id.name
if line.notice:
# add discount reason in move
name = name + ' (' + line.notice + ')'
# Create a move for the line for the order line
# Just like for invoices, a group of taxes must be present on this base line
# As well as its children
base_line_tax_ids = _flatten_tax_and_children(line.tax_ids_after_fiscal_position).filtered(lambda tax: tax.type_tax_use in ['sale', 'none'])
insert_data('product', {
'name': name,
'quantity': line.qty,
'product_id': line.product_id.id,
'account_id': income_account,
'analytic_account_id': self._prepare_analytic_account(line),
'credit': ((amount > 0) and amount) or 0.0,
'debit': ((amount < 0) and -amount) or 0.0,
'tax_ids': [(6, 0, base_line_tax_ids.ids)],
'partner_id': partner_id
})
# Create the tax lines
taxes = line.tax_ids_after_fiscal_position.filtered(lambda t: t.company_id.id == current_company.id)
if not taxes:
continue
price = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
for tax in taxes.compute_all(price, cur, line.qty)['taxes']:
insert_data('tax', {
'name': _('Tax') + ' ' + tax['name'],
'product_id': line.product_id.id,
'quantity': line.qty,
'account_id': tax['account_id'] or income_account,
'credit': ((tax['amount'] > 0) and tax['amount']) or 0.0,
'debit': ((tax['amount'] < 0) and -tax['amount']) or 0.0,
'tax_line_id': tax['id'],
'partner_id': partner_id
})
# round tax lines per order
if rounding_method == 'round_globally':
for group_key, group_value in grouped_data.items():
if group_key[0] == 'tax':
for line in group_value:
line['credit'] = cur.round(line['credit'])
line['debit'] = cur.round(line['debit'])
# counterpart
insert_data('counter_part', {
'name': _("Trade Receivables"), # order.name,
'account_id': order_account,
'credit': ((order.amount_total < 0) and -order.amount_total) or 0.0,
'debit': ((order.amount_total > 0) and order.amount_total) or 0.0,
'partner_id': partner_id
})
order.write({'state': 'done', 'account_move': move.id})
if self and order.company_id.anglo_saxon_accounting:
add_anglosaxon_lines(grouped_data)
all_lines = []
for group_key, group_data in grouped_data.items():
for value in group_data:
all_lines.append((0, 0, value),)
if move: # In case no order was changed
move.sudo().write({'line_ids': all_lines})
move.sudo().post()
return True
def _reconcile_payments(self):
for order in self:
aml = order.statement_ids.mapped('journal_entry_ids') | order.account_move.line_ids | order.invoice_id.move_id.line_ids
aml = aml.filtered(lambda r: not r.reconciled and r.account_id.internal_type == 'receivable' and r.partner_id == order.partner_id.commercial_partner_id)
# Reconcile returns first
# to avoid mixing up the credit of a payment and the credit of a return
# in the receivable account
aml_returns = aml.filtered(lambda l: (l.journal_id.type == 'sale' and l.credit) or (l.journal_id.type != 'sale' and l.debit))
try:
aml_returns.reconcile()
(aml - aml_returns).reconcile()
except Exception:
# There might be unexpected situations where the automatic reconciliation won't
# work. We don't want the user to be blocked because of this, since the automatic
# reconciliation is introduced for convenience, not for mandatory accounting
# reasons.
# It may be interesting to have the Traceback logged anyway
# for debugging and support purposes
_logger.exception('Reconciliation did not work for order %s', order.name)
def _default_session(self):
return self.env['pos.session'].search([('state', '=', 'opened'), ('user_id', '=', self.env.uid)], limit=1)
def _default_pricelist(self):
return self._default_session().config_id.pricelist_id
name = fields.Char(string='Order Ref', required=True, readonly=True, copy=False, default='/')
company_id = fields.Many2one('res.company', string='Company', required=True, readonly=True, default=lambda self: self.env.user.company_id)
date_order = fields.Datetime(string='Order Date', readonly=True, index=True, default=fields.Datetime.now)
user_id = fields.Many2one(
comodel_name='res.users', string='Salesman',
help="Person who uses the cash register. It can be a reliever, a student or an interim employee.",
default=lambda self: self.env.uid,
states={'done': [('readonly', True)], 'invoiced': [('readonly', True)]},
)
amount_tax = fields.Float(compute='_compute_amount_all', string='Taxes', digits=0)
amount_total = fields.Float(compute='_compute_amount_all', string='Total', digits=0)
amount_paid = fields.Float(compute='_compute_amount_all', string='Paid', states={'draft': [('readonly', False)]}, readonly=True, digits=0)
amount_return = fields.Float(compute='_compute_amount_all', string='Returned', digits=0)
lines = fields.One2many('pos.order.line', 'order_id', string='Order Lines', states={'draft': [('readonly', False)]}, readonly=True, copy=True)
statement_ids = fields.One2many('account.bank.statement.line', 'pos_statement_id', string='Payments', states={'draft': [('readonly', False)]}, readonly=True)
pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', required=True, states={
'draft': [('readonly', False)]}, readonly=True, default=_default_pricelist)
partner_id = fields.Many2one('res.partner', string='Customer', change_default=True, index=True, states={'draft': [('readonly', False)], 'paid': [('readonly', False)]})
sequence_number = fields.Integer(string='Sequence Number', help='A session-unique sequence number for the order', default=1)
session_id = fields.Many2one(
'pos.session', string='Session', required=True, index=True,
domain="[('state', '=', 'opened')]", states={'draft': [('readonly', False)]},
readonly=True, default=_default_session)
config_id = fields.Many2one('pos.config', related='session_id.config_id', string="Point of Sale")
state = fields.Selection(
[('draft', 'New'), ('cancel', 'Cancelled'), ('paid', 'Paid'), ('done', 'Posted'), ('invoiced', 'Invoiced')],
'Status', readonly=True, copy=False, default='draft')
invoice_id = fields.Many2one('account.invoice', string='Invoice', copy=False)
account_move = fields.Many2one('account.move', string='Journal Entry', readonly=True, copy=False)
picking_id = fields.Many2one('stock.picking', string='Picking', readonly=True, copy=False)
picking_type_id = fields.Many2one('stock.picking.type', related='session_id.config_id.picking_type_id', string="Operation Type")
location_id = fields.Many2one(
comodel_name='stock.location',
related='session_id.config_id.stock_location_id',
string="Location", store=True,
readonly=True,
)
note = fields.Text(string='Internal Notes')
nb_print = fields.Integer(string='Number of Print', readonly=True, copy=False, default=0)
pos_reference = fields.Char(string='Receipt Ref', readonly=True, copy=False)
sale_journal = fields.Many2one('account.journal', related='session_id.config_id.journal_id', string='Sales Journal', store=True, readonly=True)
fiscal_position_id = fields.Many2one(
comodel_name='account.fiscal.position', string='Fiscal Position',
default=lambda self: self._default_session().config_id.default_fiscal_position_id,
readonly=True,
states={'draft': [('readonly', False)]},
)
@api.depends('statement_ids', 'lines.price_subtotal_incl', 'lines.discount')
def _compute_amount_all(self):
for order in self:
order.amount_paid = order.amount_return = order.amount_tax = 0.0
currency = order.pricelist_id.currency_id
order.amount_paid = sum(payment.amount for payment in order.statement_ids)
order.amount_return = sum(payment.amount < 0 and payment.amount or 0 for payment in order.statement_ids)
order.amount_tax = currency.round(sum(self._amount_line_tax(line, order.fiscal_position_id) for line in order.lines))
amount_untaxed = currency.round(sum(line.price_subtotal for line in order.lines))
order.amount_total = order.amount_tax + amount_untaxed
@api.onchange('partner_id')
def _onchange_partner_id(self):
if self.partner_id:
self.pricelist = self.partner_id.property_product_pricelist.id
@api.multi
def write(self, vals):
res = super(PosOrder, self).write(vals)
Partner = self.env['res.partner']
# If you change the partner of the PoS order, change also the partner of the associated bank statement lines
if 'partner_id' in vals:
for order in self:
partner_id = False
if order.invoice_id:
raise UserError(_("You cannot change the partner of a POS order for which an invoice has already been issued."))
if vals['partner_id']:
partner = Partner.browse(vals['partner_id'])
partner_id = Partner._find_accounting_partner(partner).id
order.statement_ids.write({'partner_id': partner_id})
return res
@api.multi
def unlink(self):
for pos_order in self.filtered(lambda pos_order: pos_order.state not in ['draft', 'cancel']):
raise UserError(_('In order to delete a sale, it must be new or cancelled.'))
return super(PosOrder, self).unlink()
@api.model
def create(self, values):
if values.get('session_id'):
# set name based on the sequence specified on the config
session = self.env['pos.session'].browse(values['session_id'])
values['name'] = session.config_id.sequence_id._next()
values.setdefault('pricelist_id', session.config_id.pricelist_id.id)
else:
# fallback on any pos.order sequence
values['name'] = self.env['ir.sequence'].next_by_code('pos.order')
return super(PosOrder, self).create(values)
@api.multi
def action_view_invoice(self):
return {
'name': _('Customer Invoice'),
'view_mode': 'form',
'view_id': self.env.ref('account.invoice_form').id,
'res_model': 'account.invoice',
'context': "{'type':'out_invoice'}",
'type': 'ir.actions.act_window',
'res_id': self.invoice_id.id,
}
@api.multi
def action_pos_order_paid(self):
if not self.test_paid():
raise UserError(_("Order is not paid."))
self.write({'state': 'paid'})
return self.create_picking()
@api.multi
def action_pos_order_invoice(self):
Invoice = self.env['account.invoice']
for order in self:
# Force company for all SUPERUSER_ID action
local_context = dict(self.env.context, force_company=order.company_id.id, company_id=order.company_id.id)
if order.invoice_id:
Invoice += order.invoice_id
continue
if not order.partner_id:
raise UserError(_('Please provide a partner for the sale.'))
invoice = Invoice.new(order._prepare_invoice())
invoice._onchange_partner_id()
invoice.fiscal_position_id = order.fiscal_position_id
inv = invoice._convert_to_write({name: invoice[name] for name in invoice._cache})
new_invoice = Invoice.with_context(local_context).sudo().create(inv)
message = _("This invoice has been created from the point of sale session: <a href=# data-oe-model=pos.order data-oe-id=%d>%s</a>") % (order.id, order.name)
new_invoice.message_post(body=message)
order.write({'invoice_id': new_invoice.id, 'state': 'invoiced'})
Invoice += new_invoice
for line in order.lines:
self.with_context(local_context)._action_create_invoice_line(line, new_invoice.id)
new_invoice.with_context(local_context).sudo().compute_taxes()
order.sudo().write({'state': 'invoiced'})
if not Invoice:
return {}
return {
'name': _('Customer Invoice'),
'view_type': 'form',
'view_mode': 'form',
'view_id': self.env.ref('account.invoice_form').id,
'res_model': 'account.invoice',
'context': "{'type':'out_invoice'}",
'type': 'ir.actions.act_window',
'nodestroy': True,
'target': 'current',
'res_id': Invoice and Invoice.ids[0] or False,
}
# this method is unused, and so is the state 'cancel'
@api.multi
def action_pos_order_cancel(self):
return self.write({'state': 'cancel'})
@api.multi
def action_pos_order_done(self):
return self._create_account_move_line()
@api.model
def create_from_ui(self, orders):
# Keep only new orders
submitted_references = [o['data']['name'] for o in orders]
pos_order = self.search([('pos_reference', 'in', submitted_references)])
existing_orders = pos_order.read(['pos_reference'])
existing_references = set([o['pos_reference'] for o in existing_orders])
orders_to_save = [o for o in orders if o['data']['name'] not in existing_references]
order_ids = []
for tmp_order in orders_to_save:
to_invoice = tmp_order['to_invoice']
order = tmp_order['data']
if to_invoice:
self._match_payment_to_invoice(order)
pos_order = self._process_order(order)
order_ids.append(pos_order.id)
try:
pos_order.action_pos_order_paid()
except psycopg2.OperationalError:
# do not hide transactional errors, the order(s) won't be saved!
raise
except Exception as e:
_logger.error('Could not fully process the POS Order: %s', tools.ustr(e))
if to_invoice:
pos_order.action_pos_order_invoice()
pos_order.invoice_id.sudo().action_invoice_open()
pos_order.account_move = pos_order.invoice_id.move_id
return order_ids
def test_paid(self):
"""A Point of Sale is paid when the sum
@return: True
"""
for order in self:
if order.lines and not order.amount_total:
continue
if (not order.lines) or (not order.statement_ids) or (abs(order.amount_total - order.amount_paid) > 0.00001):
return False
return True
def create_picking(self):
"""Create a picking for each order and validate it."""
Picking = self.env['stock.picking']
Move = self.env['stock.move']
StockWarehouse = self.env['stock.warehouse']
for order in self:
if not order.lines.filtered(lambda l: l.product_id.type in ['product', 'consu']):
continue
address = order.partner_id.address_get(['delivery']) or {}
picking_type = order.picking_type_id
return_pick_type = order.picking_type_id.return_picking_type_id or order.picking_type_id
order_picking = Picking
return_picking = Picking
moves = Move
location_id = order.location_id.id
if order.partner_id:
destination_id = order.partner_id.property_stock_customer.id
else:
if (not picking_type) or (not picking_type.default_location_dest_id):
customerloc, supplierloc = StockWarehouse._get_partner_locations()
destination_id = customerloc.id
else:
destination_id = picking_type.default_location_dest_id.id
if picking_type:
message = _("This transfer has been created from the point of sale session: <a href=# data-oe-model=pos.order data-oe-id=%d>%s</a>") % (order.id, order.name)
picking_vals = {
'origin': order.name,
'partner_id': address.get('delivery', False),
'date_done': order.date_order,
'picking_type_id': picking_type.id,
'company_id': order.company_id.id,
'move_type': 'direct',
'note': order.note or "",
'location_id': location_id,
'location_dest_id': destination_id,
}
pos_qty = any([x.qty > 0 for x in order.lines if x.product_id.type in ['product', 'consu']])
if pos_qty:
order_picking = Picking.create(picking_vals.copy())
order_picking.message_post(body=message)
neg_qty = any([x.qty < 0 for x in order.lines if x.product_id.type in ['product', 'consu']])
if neg_qty:
return_vals = picking_vals.copy()
return_vals.update({
'location_id': destination_id,
'location_dest_id': return_pick_type != picking_type and return_pick_type.default_location_dest_id.id or location_id,
'picking_type_id': return_pick_type.id
})
return_picking = Picking.create(return_vals)
return_picking.message_post(body=message)
for line in order.lines.filtered(lambda l: l.product_id.type in ['product', 'consu'] and not float_is_zero(l.qty, precision_rounding=l.product_id.uom_id.rounding)):
moves |= Move.create({
'name': line.name,
'product_uom': line.product_id.uom_id.id,
'picking_id': order_picking.id if line.qty >= 0 else return_picking.id,
'picking_type_id': picking_type.id if line.qty >= 0 else return_pick_type.id,
'product_id': line.product_id.id,
'product_uom_qty': abs(line.qty),
'state': 'draft',
'location_id': location_id if line.qty >= 0 else destination_id,
'location_dest_id': destination_id if line.qty >= 0 else return_pick_type != picking_type and return_pick_type.default_location_dest_id.id or location_id,
})
# prefer associating the regular order picking, not the return
order.write({'picking_id': order_picking.id or return_picking.id})
if return_picking:
order._force_picking_done(return_picking)
if order_picking:
order._force_picking_done(order_picking)
# when the pos.config has no picking_type_id set only the moves will be created
if moves and not return_picking and not order_picking:
moves._action_assign()
moves.filtered(lambda m: m.state in ['confirmed', 'waiting'])._force_assign()
moves.filtered(lambda m: m.product_id.tracking == 'none')._action_done()
return True
def _force_picking_done(self, picking):
"""Force picking in order to be set as done."""
self.ensure_one()
picking.action_assign()
picking.force_assign()
wrong_lots = self.set_pack_operation_lot(picking)
if not wrong_lots:
picking.action_done()
def set_pack_operation_lot(self, picking=None):
"""Set Serial/Lot number in pack operations to mark the pack operation done."""
StockProductionLot = self.env['stock.production.lot']
PosPackOperationLot = self.env['pos.pack.operation.lot']
has_wrong_lots = False
for order in self:
for move in (picking or self.picking_id).move_lines:
picking_type = (picking or self.picking_id).picking_type_id
lots_necessary = True
if picking_type:
lots_necessary = picking_type and picking_type.use_existing_lots
qty = 0
qty_done = 0
pack_lots = []
pos_pack_lots = PosPackOperationLot.search([('order_id', '=', order.id), ('product_id', '=', move.product_id.id)])
pack_lot_names = [pos_pack.lot_name for pos_pack in pos_pack_lots]
if pack_lot_names and lots_necessary:
for lot_name in list(set(pack_lot_names)):
stock_production_lot = StockProductionLot.search([('name', '=', lot_name), ('product_id', '=', move.product_id.id)])
if stock_production_lot:
if stock_production_lot.product_id.tracking == 'lot':
# if a lot nr is set through the frontend it will refer to the full quantity
qty = move.product_uom_qty
else: # serial numbers
qty = 1.0
qty_done += qty
pack_lots.append({'lot_id': stock_production_lot.id, 'qty': qty})
else:
has_wrong_lots = True
elif move.product_id.tracking == 'none' or not lots_necessary:
qty_done = move.product_uom_qty
else:
has_wrong_lots = True
for pack_lot in pack_lots:
lot_id, qty = pack_lot['lot_id'], pack_lot['qty']
self.env['stock.move.line'].create({
'move_id': move.id,
'product_id': move.product_id.id,
'product_uom_id': move.product_uom.id,
'qty_done': qty,
'location_id': move.location_id.id,
'location_dest_id': move.location_dest_id.id,
'lot_id': lot_id,
})
if not pack_lots and not float_is_zero(qty_done, precision_rounding=move.product_uom.rounding):
move.quantity_done = qty_done
return has_wrong_lots
def _prepare_bank_statement_line_payment_values(self, data):
"""Create a new payment for the order"""
args = {
'amount': data['amount'],
'date': data.get('payment_date', fields.Date.today()),
'name': self.name + ': ' + (data.get('payment_name', '') or ''),
'partner_id': self.env["res.partner"]._find_accounting_partner(self.partner_id).id or False,
}
journal_id = data.get('journal', False)
statement_id = data.get('statement_id', False)
assert journal_id or statement_id, "No statement_id or journal_id passed to the method!"
journal = self.env['account.journal'].browse(journal_id)
# use the company of the journal and not of the current user
company_cxt = dict(self.env.context, force_company=journal.company_id.id)
account_def = self.env['ir.property'].with_context(company_cxt).get('property_account_receivable_id', 'res.partner')
args['account_id'] = (self.partner_id.property_account_receivable_id.id) or (account_def and account_def.id) or False
if not args['account_id']:
if not args['partner_id']:
msg = _('There is no receivable account defined to make payment.')
else:
msg = _('There is no receivable account defined to make payment for the partner: "%s" (id:%d).') % (
self.partner_id.name, self.partner_id.id,)
raise UserError(msg)
context = dict(self.env.context)
context.pop('pos_session_id', False)
for statement in self.session_id.statement_ids:
if statement.id == statement_id:
journal_id = statement.journal_id.id
break
elif statement.journal_id.id == journal_id:
statement_id = statement.id
break
if not statement_id:
raise UserError(_('You have to open at least one cashbox.'))
args.update({
'statement_id': statement_id,
'pos_statement_id': self.id,
'journal_id': journal_id,
'ref': self.session_id.name,
})
return args
def add_payment(self, data):
"""Create a new payment for the order"""
args = self._prepare_bank_statement_line_payment_values(data)
context = dict(self.env.context)
context.pop('pos_session_id', False)
self.env['account.bank.statement.line'].with_context(context).create(args)
return args.get('statement_id', False)
@api.multi
def refund(self):
"""Create a copy of order for refund order"""
PosOrder = self.env['pos.order']
current_session = self.env['pos.session'].search([('state', '!=', 'closed'), ('user_id', '=', self.env.uid)], limit=1)
if not current_session:
raise UserError(_('To return product(s), you need to open a session that will be used to register the refund.'))
for order in self:
clone = order.copy({
# ot used, name forced by create
'name': order.name + _(' REFUND'),
'session_id': current_session.id,
'date_order': fields.Datetime.now(),
'pos_reference': order.pos_reference,
'lines': False,
})
for line in order.lines:
clone_line = line.copy({
# required=True, copy=False
'name': line.name + _(' REFUND'),
'order_id': clone.id,
'qty': -line.qty,
})
PosOrder += clone
return {
'name': _('Return Products'),
'view_type': 'form',
'view_mode': 'form',
'res_model': 'pos.order',
'res_id': PosOrder.ids[0],
'view_id': False,
'context': self.env.context,
'type': 'ir.actions.act_window',
'target': 'current',
}
class PosOrderLine(models.Model):
_name = "pos.order.line"
_description = "Lines of Point of Sale Orders"
_rec_name = "product_id"
def _order_line_fields(self, line, session_id=None):
if line and 'name' not in line[2]:
session = self.env['pos.session'].browse(session_id).exists() if session_id else None
if session and session.config_id.sequence_line_id:
# set name based on the sequence specified on the config
line[2]['name'] = session.config_id.sequence_line_id._next()
else:
# fallback on any pos.order.line sequence
line[2]['name'] = self.env['ir.sequence'].next_by_code('pos.order.line')
if line and 'tax_ids' not in line[2]:
product = self.env['product.product'].browse(line[2]['product_id'])
line[2]['tax_ids'] = [(6, 0, [x.id for x in product.taxes_id])]
return line
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id)
name = fields.Char(string='Line No', required=True, copy=False)
notice = fields.Char(string='Discount Notice')
product_id = fields.Many2one('product.product', string='Product', domain=[('sale_ok', '=', True)], required=True, change_default=True)
price_unit = fields.Float(string='Unit Price', digits=0)
qty = fields.Float('Quantity', digits=dp.get_precision('Product Unit of Measure'), default=1)
price_subtotal = fields.Float(compute='_compute_amount_line_all', digits=0, string='Subtotal w/o Tax')
price_subtotal_incl = fields.Float(compute='_compute_amount_line_all', digits=0, string='Subtotal')
discount = fields.Float(string='Discount (%)', digits=0, default=0.0)
order_id = fields.Many2one('pos.order', string='Order Ref', ondelete='cascade')
create_date = fields.Datetime(string='Creation Date', readonly=True)
tax_ids = fields.Many2many('account.tax', string='Taxes', readonly=True)
tax_ids_after_fiscal_position = fields.Many2many('account.tax', compute='_get_tax_ids_after_fiscal_position', string='Taxes')
pack_lot_ids = fields.One2many('pos.pack.operation.lot', 'pos_order_line_id', string='Lot/serial Number')
@api.model
def create(self, values):
if values.get('order_id') and not values.get('name'):
# set name based on the sequence specified on the config
config_id = self.order_id.browse(values['order_id']).session_id.config_id.id
# HACK: sequence created in the same transaction as the config
# cf TODO master is pos.config create
# remove me saas-15
self.env.cr.execute("""
SELECT s.id
FROM ir_sequence s
JOIN pos_config c
ON s.create_date=c.create_date
WHERE c.id = %s
AND s.code = 'pos.order.line'
LIMIT 1
""", (config_id,))
sequence = self.env.cr.fetchone()
if sequence:
values['name'] = self.env['ir.sequence'].browse(sequence[0])._next()
if not values.get('name'):
# fallback on any pos.order sequence
values['name'] = self.env['ir.sequence'].next_by_code('pos.order.line')
return super(PosOrderLine, self).create(values)
@api.depends('price_unit', 'tax_ids', 'qty', 'discount', 'product_id')
def _compute_amount_line_all(self):
for line in self:
fpos = line.order_id.fiscal_position_id
tax_ids_after_fiscal_position = fpos.map_tax(line.tax_ids, line.product_id, line.order_id.partner_id) if fpos else line.tax_ids
price = line.price_unit * (1 - (line.discount or 0.0) / 100.0)
taxes = tax_ids_after_fiscal_position.compute_all(price, line.order_id.pricelist_id.currency_id, line.qty, product=line.product_id, partner=line.order_id.partner_id)
line.update({
'price_subtotal_incl': taxes['total_included'],
'price_subtotal': taxes['total_excluded'],
})
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
if not self.order_id.pricelist_id:
raise UserError(
_('You have to select a pricelist in the sale form !\n'
'Please set one before choosing a product.'))
price = self.order_id.pricelist_id.get_product_price(
self.product_id, self.qty or 1.0, self.order_id.partner_id)
self._onchange_qty()
self.tax_ids = self.product_id.taxes_id.filtered(lambda r: not self.company_id or r.company_id == self.company_id)
fpos = self.order_id.fiscal_position_id
tax_ids_after_fiscal_position = fpos.map_tax(self.tax_ids, self.product_id, self.order_id.partner_id) if fpos else self.tax_ids
self.price_unit = self.env['account.tax']._fix_tax_included_price_company(price, self.product_id.taxes_id, tax_ids_after_fiscal_position, self.company_id)
@api.onchange('qty', 'discount', 'price_unit', 'tax_ids')
def _onchange_qty(self):
if self.product_id:
if not self.order_id.pricelist_id:
raise UserError(_('You have to select a pricelist in the sale form !'))
price = self.price_unit * (1 - (self.discount or 0.0) / 100.0)
self.price_subtotal = self.price_subtotal_incl = price * self.qty
if (self.product_id.taxes_id):
taxes = self.product_id.taxes_id.compute_all(price, self.order_id.pricelist_id.currency_id, self.qty, product=self.product_id, partner=False)
self.price_subtotal = taxes['total_excluded']
self.price_subtotal_incl = taxes['total_included']
@api.multi
def _get_tax_ids_after_fiscal_position(self):
for line in self:
line.tax_ids_after_fiscal_position = line.order_id.fiscal_position_id.map_tax(line.tax_ids, line.product_id, line.order_id.partner_id)
class PosOrderLineLot(models.Model):
_name = "pos.pack.operation.lot"
_description = "Specify product lot/serial number in pos order line"
pos_order_line_id = fields.Many2one('pos.order.line')
order_id = fields.Many2one('pos.order', related="pos_order_line_id.order_id")
lot_name = fields.Char('Lot Name')
product_id = fields.Many2one('product.product', related='pos_order_line_id.product_id')
class ReportSaleDetails(models.AbstractModel):
_name = 'report.point_of_sale.report_saledetails'
@api.model
def get_sale_details(self, date_start=False, date_stop=False, configs=False):
""" Serialise the orders of the day information
params: date_start, date_stop string representing the datetime of order
"""
if not configs:
configs = self.env['pos.config'].search([])
user_tz = pytz.timezone(self.env.context.get('tz') or self.env.user.tz or 'UTC')
today = user_tz.localize(fields.Datetime.from_string(fields.Date.context_today(self)))
today = today.astimezone(pytz.timezone('UTC'))
if date_start:
date_start = fields.Datetime.from_string(date_start)
else:
# start by default today 00:00:00
date_start = today
if date_stop:
# set time to 23:59:59
date_stop = fields.Datetime.from_string(date_stop)
else:
# stop by default today 23:59:59
date_stop = today + timedelta(days=1, seconds=-1)
# avoid a date_stop smaller than date_start
date_stop = max(date_stop, date_start)
date_start = fields.Datetime.to_string(date_start)
date_stop = fields.Datetime.to_string(date_stop)
orders = self.env['pos.order'].search([
('date_order', '>=', date_start),
('date_order', '<=', date_stop),
('state', 'in', ['paid','invoiced','done']),
('config_id', 'in', configs.ids)])
user_currency = self.env.user.company_id.currency_id
total = 0.0
products_sold = {}
taxes = {}
for order in orders:
if user_currency != order.pricelist_id.currency_id:
total += order.pricelist_id.currency_id.compute(order.amount_total, user_currency)
else:
total += order.amount_total
currency = order.session_id.currency_id
for line in order.lines:
key = (line.product_id, line.price_unit, line.discount)
products_sold.setdefault(key, 0.0)
products_sold[key] += line.qty
if line.tax_ids_after_fiscal_position:
line_taxes = line.tax_ids_after_fiscal_position.compute_all(line.price_unit * (1-(line.discount or 0.0)/100.0), currency, line.qty, product=line.product_id, partner=line.order_id.partner_id or False)
for tax in line_taxes['taxes']:
taxes.setdefault(tax['id'], {'name': tax['name'], 'tax_amount':0.0, 'base_amount':0.0})
taxes[tax['id']]['tax_amount'] += tax['amount']
taxes[tax['id']]['base_amount'] += tax['base']
else:
taxes.setdefault(0, {'name': _('No Taxes'), 'tax_amount':0.0, 'base_amount':0.0})
taxes[0]['base_amount'] += line.price_subtotal_incl
st_line_ids = self.env["account.bank.statement.line"].search([('pos_statement_id', 'in', orders.ids)]).ids
if st_line_ids:
self.env.cr.execute("""
SELECT aj.name, sum(amount) total
FROM account_bank_statement_line AS absl,
account_bank_statement AS abs,
account_journal AS aj
WHERE absl.statement_id = abs.id
AND abs.journal_id = aj.id
AND absl.id IN %s
GROUP BY aj.name
""", (tuple(st_line_ids),))
payments = self.env.cr.dictfetchall()
else:
payments = []
return {
'currency_precision': user_currency.decimal_places,
'total_paid': user_currency.round(total),
'payments': payments,
'company_name': self.env.user.company_id.name,
'taxes': list(taxes.values()),
'products': sorted([{
'product_id': product.id,
'product_name': product.name,
'code': product.default_code,
'quantity': qty,
'price_unit': price_unit,
'discount': discount,
'uom': product.uom_id.name
} for (product, price_unit, discount), qty in products_sold.items()], key=lambda l: l['product_name'])
}
@api.multi
def get_report_values(self, docids, data=None):
data = dict(data or {})
configs = self.env['pos.config'].browse(data['config_ids'])
data.update(self.get_sale_details(data['date_start'], data['date_stop'], configs))
return data