flectra/addons/point_of_sale/models/pos_order.py
2018-01-16 02:34:37 -08:00

1055 lines
51 KiB
Python

# -*- coding: utf-8 -*-
# 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
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.
"""
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': 'out_invoice',
'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,
'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):
# 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
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
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, line.tax_ids_after_fiscal_position.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})
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)
try:
aml.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.
_logger.error('Reconciliation did not work for order %s', order.name)
continue
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:
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'] += line.price_subtotal
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