# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging from datetime import timedelta from functools import partial import psycopg2 import pytz from odoo import api, fields, models, tools, _ from odoo.tools import float_is_zero from odoo.exceptions import UserError from odoo.http import request from odoo.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: %s") % (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: %s") % (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