Initial commit: Sale packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:49 +02:00
commit 14e3d26998
6469 changed files with 2479670 additions and 0 deletions

View file

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_bank_statement
from . import account_cash_rounding
from . import account_payment
from . import account_journal
from . import account_tax
from . import account_move
from . import barcode_rule
from . import chart_template
from . import digest
from . import pos_category
from . import pos_config
from . import pos_order
from . import pos_session
from . import product
from . import res_partner
from . import res_company
from . import res_config_settings
from . import stock_picking
from . import stock_rule
from . import stock_warehouse
from . import pos_payment
from . import pos_payment_method
from . import pos_bill

View file

@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# Copyright (C) 2004-2008 PC Solutions (<http://pcsol.be>). All Rights Reserved
from odoo import fields, models, api, _
from odoo.exceptions import UserError
class AccountBankStatementLine(models.Model):
_inherit = 'account.bank.statement.line'
pos_session_id = fields.Many2one('pos.session', string="Session", copy=False)

View file

@ -0,0 +1,11 @@
from odoo import api, models, _
from odoo.exceptions import UserError
class AccountCashRounding(models.Model):
_inherit = 'account.cash.rounding'
@api.ondelete(at_uninstall=False)
def _unlink_except_pos_config(self):
if self.env['pos.config'].search_count([('rounding_method', 'in', self.ids)], limit=1):
raise UserError(_('You cannot delete a rounding method that is used in a Point of Sale configuration.'))

View file

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# Copyright (C) 2004-2008 PC Solutions (<http://pcsol.be>). All Rights Reserved
from odoo import fields, models, api, _
from odoo.exceptions import ValidationError
class AccountJournal(models.Model):
_inherit = 'account.journal'
pos_payment_method_ids = fields.One2many('pos.payment.method', 'journal_id', string='Point of Sale Payment Methods')
@api.constrains('type')
def _check_type(self):
methods = self.env['pos.payment.method'].sudo().search([("journal_id", "in", self.ids)])
if methods:
raise ValidationError(_("This journal is associated with a payment method. You cannot modify its type"))
def _check_no_active_payments(self):
hanging_journal_entries = self.env['pos.payment'].search(
[
('payment_method_id', 'in', self.pos_payment_method_ids.ids),
('session_id.state', '=', 'opened')
], limit=1)
if(hanging_journal_entries):
payment_method = hanging_journal_entries.payment_method_id.name
pos_order = hanging_journal_entries.pos_order_id.name
pos_session = hanging_journal_entries.session_id.name
raise ValidationError(_("This journal is associated with payment method %s that is being used by order %s in the active pos session %s", payment_method, pos_order, pos_session))
@api.ondelete(at_uninstall=False)
def _unlink_journal_except_with_active_payments(self):
for journal in self:
journal._check_no_active_payments()
def action_archive(self):
self._check_no_active_payments()
return super().action_archive()
def _get_journal_inbound_outstanding_payment_accounts(self):
res = super()._get_journal_inbound_outstanding_payment_accounts()
account_ids = set(res.ids)
for payment_method in self.sudo().pos_payment_method_ids:
account_ids.add(payment_method.outstanding_account_id.id or self.company_id.account_journal_payment_debit_account_id.id)
return self.env['account.account'].browse(account_ids)

View file

@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
class AccountMove(models.Model):
_inherit = 'account.move'
pos_order_ids = fields.One2many('pos.order', 'account_move')
pos_payment_ids = fields.One2many('pos.payment', 'account_move_id')
@api.depends('tax_cash_basis_created_move_ids')
def _compute_always_tax_exigible(self):
super()._compute_always_tax_exigible()
# The pos closing move does not create caba entries (anymore); we set the tax values directly on the closing move.
# (But there may still be old closing moves that used caba entries from previous versions.)
relevant_moves = self.filtered(lambda move: not (move.always_tax_exigible or move.tax_cash_basis_created_move_ids))
if not relevant_moves:
return
sessions = self.env['pos.session'].with_context(active_test=False).search([
('move_id', 'in', relevant_moves.ids),
])
sessions.move_id.always_tax_exigible = True
def _stock_account_get_last_step_stock_moves(self):
stock_moves = super(AccountMove, self)._stock_account_get_last_step_stock_moves()
for invoice in self.filtered(lambda x: x.move_type == 'out_invoice'):
stock_moves += invoice.sudo().mapped('pos_order_ids.picking_ids.move_ids').filtered(lambda x: x.state == 'done' and x.location_dest_id.usage == 'customer')
for invoice in self.filtered(lambda x: x.move_type == 'out_refund'):
stock_moves += invoice.sudo().mapped('pos_order_ids.picking_ids.move_ids').filtered(lambda x: x.state == 'done' and x.location_id.usage == 'customer')
return stock_moves
def _get_invoiced_lot_values(self):
self.ensure_one()
lot_values = super(AccountMove, self)._get_invoiced_lot_values()
if self.state == 'draft':
return lot_values
# user may not have access to POS orders, but it's ok if they have
# access to the invoice
for order in self.sudo().pos_order_ids:
for line in order.lines:
lots = line.pack_lot_ids or False
if lots:
for lot in lots:
lot_values.append({
'product_name': lot.product_id.name,
'quantity': line.qty if lot.product_id.tracking == 'lot' else 1.0,
'uom_name': line.product_uom_id.name,
'lot_name': lot.lot_name,
'pos_lot_id': lot.id,
})
return lot_values
def _compute_payments_widget_reconciled_info(self):
"""Add pos_payment_name field in the reconciled vals to be able to show the payment method in the invoice."""
super()._compute_payments_widget_reconciled_info()
for move in self:
if move.invoice_payments_widget:
if move.state == 'posted' and move.is_invoice(include_receipts=True):
reconciled_partials = move._get_all_reconciled_invoice_partials()
for i, reconciled_partial in enumerate(reconciled_partials):
counterpart_line = reconciled_partial['aml']
pos_payment = counterpart_line.move_id.sudo().pos_payment_ids
move.invoice_payments_widget['content'][i].update({
'pos_payment_name': pos_payment.payment_method_id.name,
})
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def _stock_account_get_anglo_saxon_price_unit(self):
self.ensure_one()
if not self.product_id:
return self.price_unit
price_unit = super(AccountMoveLine, self)._stock_account_get_anglo_saxon_price_unit()
sudo_order = self.move_id.sudo().pos_order_ids
if sudo_order:
price_unit = sudo_order._get_pos_anglo_saxon_price_unit(self.product_id, self.move_id.partner_id.id, self.quantity)
return price_unit

View file

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, api
class AccountPayment(models.Model):
_inherit = 'account.payment'
pos_payment_method_id = fields.Many2one('pos.payment.method', "POS Payment Method")
force_outstanding_account_id = fields.Many2one("account.account", "Forced Outstanding Account", check_company=True)
pos_session_id = fields.Many2one('pos.session', "POS Session")
def _get_valid_liquidity_accounts(self):
result = super()._get_valid_liquidity_accounts()
return result | self.pos_payment_method_id.outstanding_account_id
@api.depends("force_outstanding_account_id")
def _compute_outstanding_account_id(self):
"""When force_outstanding_account_id is set, we use it as the outstanding_account_id."""
super()._compute_outstanding_account_id()
for payment in self:
if payment.force_outstanding_account_id:
payment.outstanding_account_id = payment.force_outstanding_account_id

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from odoo import _, api, models
from odoo.exceptions import UserError
from odoo.tools import split_every
class AccountTax(models.Model):
_inherit = 'account.tax'
def write(self, vals):
forbidden_fields = {
'amount_type', 'amount', 'type_tax_use', 'tax_group_id', 'price_include',
'include_base_amount', 'is_base_affected',
}
if forbidden_fields & set(vals.keys()):
lines = self.env['pos.order.line'].sudo().search([
('order_id.session_id.state', '!=', 'closed')
])
self_ids = set(self.ids)
for lines_chunk in map(self.env['pos.order.line'].sudo().browse, split_every(100000, lines.ids)):
if any(tid in self_ids for ts in lines_chunk.read(['tax_ids']) for tid in ts['tax_ids']):
raise UserError(_(
'It is forbidden to modify a tax used in a POS order not posted. '
'You must close the POS sessions before modifying the tax.'
))
lines_chunk.invalidate_cache(['tax_ids'], lines_chunk.ids)
return super(AccountTax, self).write(vals)

View file

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
from odoo.tools.translate import _
class BarcodeRule(models.Model):
_inherit = 'barcode.rule'
type = fields.Selection(selection_add=[
('weight', 'Weighted Product'),
('price', 'Priced Product'),
('discount', 'Discounted Product'),
('client', 'Client'),
('cashier', 'Cashier')
], ondelete={
'weight': 'set default',
'price': 'set default',
'discount': 'set default',
'client': 'set default',
'cashier': 'set default',
})

View file

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from odoo import api, models
class AccountChartTemplate(models.Model):
_inherit = 'account.chart.template'
def _load(self, company):
"""Remove the payment methods that are created for the company before installing the chart of accounts.
Keeping these existing pos.payment.method records interferes with the installation of chart of accounts
because pos.payment.method model has fields linked to account.journal and account.account records that are
deleted during the loading of chart of accounts.
"""
self.env['pos.payment.method'].search([('company_id', '=', company.id)]).unlink()
result = super(AccountChartTemplate, self)._load(company)
self.env['pos.config'].post_install_pos_localisation(companies=company)
return result

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo.exceptions import AccessError
class Digest(models.Model):
_inherit = 'digest.digest'
kpi_pos_total = fields.Boolean('POS Sales')
kpi_pos_total_value = fields.Monetary(compute='_compute_kpi_pos_total_value')
def _compute_kpi_pos_total_value(self):
if not self.env.user.has_group('point_of_sale.group_pos_user'):
raise AccessError(_("Do not have access, skip this data for user's digest email"))
for record in self:
start, end, company = record._get_kpi_compute_parameters()
record.kpi_pos_total_value = sum(self.env['pos.order'].search([
('date_order', '>=', start),
('date_order', '<', end),
('state', 'not in', ['draft', 'cancel', 'invoiced']),
('company_id', '=', company.id)
]).mapped('amount_total'))
def _compute_kpis_actions(self, company, user):
res = super(Digest, self)._compute_kpis_actions(company, user)
res['kpi_pos_total'] = 'point_of_sale.action_pos_sale_graph&menu_id=%s' % self.env.ref('point_of_sale.menu_point_root').id
return res

View file

@ -0,0 +1,21 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class Bill(models.Model):
_name = "pos.bill"
_order = "value"
_description = "Coins/Bills"
name = fields.Char("Name")
value = fields.Float("Coin/Bill Value", required=True, digits=(16, 4))
pos_config_ids = fields.Many2many("pos.config", string="Point of Sales")
@api.model
def name_create(self, name):
try:
value = float(name)
except:
raise UserError(_("The name of the Coins/Bills must be a number."))
result = super().create({"name": name, "value": value})
return result.name_get()[0]

View file

@ -0,0 +1,45 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
class PosCategory(models.Model):
_name = "pos.category"
_description = "Point of Sale Category"
_order = "sequence, name"
@api.constrains('parent_id')
def _check_category_recursion(self):
if not self._check_recursion():
raise ValidationError(_('Error ! You cannot create recursive categories.'))
name = fields.Char(string='Category Name', required=True, translate=True)
parent_id = fields.Many2one('pos.category', string='Parent Category', index=True)
child_id = fields.One2many('pos.category', 'parent_id', string='Children Categories')
sequence = fields.Integer(help="Gives the sequence order when displaying a list of product categories.")
image_128 = fields.Image("Image", max_width=128, max_height=128)
# During loading of data, the image is not loaded so we expose a lighter
# field to determine whether a pos.category has an image or not.
has_image = fields.Boolean(compute='_compute_has_image')
def name_get(self):
def get_names(cat):
res = []
while cat:
res.append(cat.name)
cat = cat.parent_id
return res
return [(cat.id, " / ".join(reversed(get_names(cat)))) for cat in self if cat.name]
@api.ondelete(at_uninstall=False)
def _unlink_except_session_open(self):
if self.search_count([('id', 'in', self.ids)]):
if self.env['pos.session'].sudo().search_count([('state', '!=', 'closed')]):
raise UserError(_('You cannot delete a point of sale category while a session is still opened.'))
@api.depends('has_image')
def _compute_has_image(self):
for category in self:
category.has_image = bool(category.image_128)

View file

@ -0,0 +1,709 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime
from uuid import uuid4
import pytz
from odoo import api, fields, models, tools, _
from odoo.exceptions import AccessError, ValidationError, UserError
class PosConfig(models.Model):
_name = 'pos.config'
_description = 'Point of Sale Configuration'
def _default_warehouse_id(self):
return self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1).id
def _default_picking_type_id(self):
return self.env['stock.warehouse'].search([('company_id', '=', self.env.company.id)], limit=1).pos_type_id.id
def _default_sale_journal(self):
return self.env['account.journal'].search([('type', 'in', ('sale', 'general')), ('company_id', '=', self.env.company.id), ('code', '=', 'POSS')], limit=1)
def _default_invoice_journal(self):
return self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', self.env.company.id)], limit=1)
def _default_payment_methods(self):
""" Should only default to payment methods that are compatible to this config's company and currency.
"""
domain = [
('split_transactions', '=', False),
('company_id', '=', self.env.company.id),
'|',
('journal_id', '=', False),
('journal_id.currency_id', 'in', (False, self.env.company.currency_id.id)),
]
non_cash_pm = self.env['pos.payment.method'].search(domain + [('is_cash_count', '=', False)])
available_cash_pm = self.env['pos.payment.method'].search(domain + [('is_cash_count', '=', True),
('config_ids', '=', False)], limit=1)
return non_cash_pm | available_cash_pm
def _default_pricelist(self):
return self.env['product.pricelist'].search([('company_id', 'in', (False, self.env.company.id)), ('currency_id', '=', self.env.company.currency_id.id)], limit=1)
def _get_group_pos_manager(self):
return self.env.ref('point_of_sale.group_pos_manager')
def _get_group_pos_user(self):
return self.env.ref('point_of_sale.group_pos_user')
name = fields.Char(string='Point of Sale', required=True, help="An internal identification of the point of sale.")
is_installed_account_accountant = fields.Boolean(string="Is the Full Accounting Installed",
compute="_compute_is_installed_account_accountant")
picking_type_id = fields.Many2one(
'stock.picking.type',
string='Operation Type',
default=_default_picking_type_id,
required=True,
domain="[('code', '=', 'outgoing'), ('warehouse_id.company_id', '=', company_id)]",
ondelete='restrict')
journal_id = fields.Many2one(
'account.journal', string='Point of Sale Journal',
domain=[('type', 'in', ('general', 'sale'))],
help="Accounting journal used to post POS session journal entries and POS invoice payments.",
default=_default_sale_journal,
ondelete='restrict')
invoice_journal_id = fields.Many2one(
'account.journal', string='Invoice Journal',
domain=[('type', '=', 'sale')],
help="Accounting journal used to create invoices.",
default=_default_invoice_journal)
currency_id = fields.Many2one('res.currency', compute='_compute_currency', string="Currency")
iface_cashdrawer = fields.Boolean(string='Cashdrawer', help="Automatically open the cashdrawer.")
iface_electronic_scale = fields.Boolean(string='Electronic Scale', help="Enables Electronic Scale integration.")
iface_customer_facing_display = fields.Boolean(compute='_compute_customer_facing_display')
iface_customer_facing_display_via_proxy = fields.Boolean(string='Customer Facing Display', help="Show checkout to customers with a remotely-connected screen.")
iface_customer_facing_display_local = fields.Boolean(string='Local Customer Facing Display', help="Show checkout to customers.")
iface_print_via_proxy = fields.Boolean(string='Print via Proxy', help="Bypass browser printing and prints via the hardware proxy.")
iface_scan_via_proxy = fields.Boolean(string='Scan via Proxy', help="Enable barcode scanning with a remotely connected barcode scanner and card swiping with a Vantiv card reader.")
iface_big_scrollbars = fields.Boolean('Large Scrollbars', help='For imprecise industrial touchscreens.')
iface_print_auto = fields.Boolean(string='Automatic Receipt Printing', default=False,
help='The receipt will automatically be printed at the end of each order.')
iface_print_skip_screen = fields.Boolean(string='Skip Preview Screen', default=True,
help='The receipt screen will be skipped if the receipt can be printed automatically.')
iface_tax_included = fields.Selection([('subtotal', 'Tax-Excluded Price'), ('total', 'Tax-Included Price')], string="Tax Display", default='total', required=True)
iface_start_categ_id = fields.Many2one('pos.category', string='Initial Category',
help='The point of sale will display this product category by default. If no category is specified, all available products will be shown.')
iface_available_categ_ids = fields.Many2many('pos.category', string='Available PoS Product Categories',
help='The point of sale will only display products which are within one of the selected category trees. If no category is specified, all available products will be shown')
restrict_price_control = fields.Boolean(string='Restrict Price Modifications to Managers',
help="Only users with Manager access rights for PoS app can modify the product prices on orders.")
is_margins_costs_accessible_to_every_user = fields.Boolean(string='Margins & Costs', default=False,
help='When disabled, only PoS manager can view the margin and cost of product among the Product info.')
cash_control = fields.Boolean(string='Advanced Cash Control', compute='_compute_cash_control', help="Check the amount of the cashbox at opening and closing.")
set_maximum_difference = fields.Boolean('Set Maximum Difference', help="Set a maximum difference allowed between the expected and counted money during the closing of the session.")
receipt_header = fields.Text(string='Receipt Header', help="A short text that will be inserted as a header in the printed receipt.")
receipt_footer = fields.Text(string='Receipt Footer', help="A short text that will be inserted as a footer in the printed receipt.")
proxy_ip = fields.Char(string='IP Address', size=45,
help='The hostname or ip address of the hardware proxy, Will be autodetected if left empty.')
active = fields.Boolean(default=True)
uuid = fields.Char(readonly=True, default=lambda self: str(uuid4()), copy=False,
help='A globally unique identifier for this pos configuration, used to prevent conflicts in client-generated data.')
sequence_id = fields.Many2one('ir.sequence', string='Order IDs Sequence', readonly=True,
help="This sequence is automatically created by Odoo but you can change it "
"to customize the reference numbers of your orders.", copy=False, ondelete='restrict')
sequence_line_id = fields.Many2one('ir.sequence', string='Order Line IDs Sequence', readonly=True,
help="This sequence is automatically created by Odoo but you can change it "
"to customize the reference numbers of your orders lines.", copy=False)
session_ids = fields.One2many('pos.session', 'config_id', string='Sessions')
current_session_id = fields.Many2one('pos.session', compute='_compute_current_session', string="Current Session")
current_session_state = fields.Char(compute='_compute_current_session')
number_of_opened_session = fields.Integer(string="Number of Opened Session", compute='_compute_current_session')
last_session_closing_cash = fields.Float(compute='_compute_last_session')
last_session_closing_date = fields.Date(compute='_compute_last_session')
pos_session_username = fields.Char(compute='_compute_current_session_user')
pos_session_state = fields.Char(compute='_compute_current_session_user')
pos_session_duration = fields.Char(compute='_compute_current_session_user')
pricelist_id = fields.Many2one('product.pricelist', string='Default Pricelist', required=True, default=_default_pricelist,
help="The pricelist used if no customer is selected or if the customer has no Sale Pricelist configured.")
available_pricelist_ids = fields.Many2many('product.pricelist', string='Available Pricelists', default=_default_pricelist,
help="Make several pricelists available in the Point of Sale. You can also apply a pricelist to specific customers from their contact form (in Sales tab). To be valid, this pricelist must be listed here as an available pricelist. Otherwise the default pricelist will apply.")
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
group_pos_manager_id = fields.Many2one('res.groups', string='Point of Sale Manager Group', default=_get_group_pos_manager,
help='This field is there to pass the id of the pos manager group to the point of sale client.')
group_pos_user_id = fields.Many2one('res.groups', string='Point of Sale User Group', default=_get_group_pos_user,
help='This field is there to pass the id of the pos user group to the point of sale client.')
iface_tipproduct = fields.Boolean(string="Product tips")
tip_product_id = fields.Many2one('product.product', string='Tip Product',
help="This product is used as reference on customer receipts.")
fiscal_position_ids = fields.Many2many('account.fiscal.position', string='Fiscal Positions', help='This is useful for restaurants with onsite and take-away services that imply specific tax rates.')
default_fiscal_position_id = fields.Many2one('account.fiscal.position', string='Default Fiscal Position')
default_bill_ids = fields.Many2many('pos.bill', string="Coins/Bills")
use_pricelist = fields.Boolean("Use a pricelist.")
tax_regime_selection = fields.Boolean("Tax Regime Selection value")
start_category = fields.Boolean("Start Category", default=False)
limit_categories = fields.Boolean("Restrict Categories")
module_pos_restaurant = fields.Boolean("Is a Bar/Restaurant")
module_pos_discount = fields.Boolean("Global Discounts")
module_pos_mercury = fields.Boolean(string="Integrated Card Payments")
is_posbox = fields.Boolean("PosBox")
is_header_or_footer = fields.Boolean("Custom Header & Footer")
module_pos_hr = fields.Boolean(help="Show employee login screen")
amount_authorized_diff = fields.Float('Amount Authorized Difference',
help="This field depicts the maximum difference allowed between the ending balance and the theoretical cash when "
"closing a session, for non-POS managers. If this maximum is reached, the user will have an error message at "
"the closing of his session saying that he needs to contact his manager.")
payment_method_ids = fields.Many2many('pos.payment.method', string='Payment Methods', default=lambda self: self._default_payment_methods())
company_has_template = fields.Boolean(string="Company has chart of accounts", compute="_compute_company_has_template")
current_user_id = fields.Many2one('res.users', string='Current Session Responsible', compute='_compute_current_session_user')
other_devices = fields.Boolean(string="Other Devices", help="Connect devices to your PoS without an IoT Box.")
rounding_method = fields.Many2one('account.cash.rounding', string="Cash rounding")
cash_rounding = fields.Boolean(string="Cash Rounding")
only_round_cash_method = fields.Boolean(string="Only apply rounding on cash")
has_active_session = fields.Boolean(compute='_compute_current_session')
manual_discount = fields.Boolean(string="Line Discounts", default=True)
ship_later = fields.Boolean(string="Ship Later")
warehouse_id = fields.Many2one('stock.warehouse', default=_default_warehouse_id, ondelete='restrict')
route_id = fields.Many2one('stock.route', string="Spefic route for products delivered later.")
picking_policy = fields.Selection([
('direct', 'As soon as possible'),
('one', 'When all products are ready')],
string='Shipping Policy', required=True, default='direct',
help="If you deliver all products at once, the delivery order will be scheduled based on the greatest "
"product lead time. Otherwise, it will be based on the shortest.")
limited_products_loading = fields.Boolean('Limited Product Loading',
default=True,
help="we load all starred products (favorite), all services, recent inventory movements of products, and the most recently updated products.\n"
"When the session is open, we keep on loading all remaining products in the background.\n"
"In the meantime, you can click on the 'database icon' in the searchbar to load products from database.")
limited_products_amount = fields.Integer(default=20000)
product_load_background = fields.Boolean(default=False)
limited_partners_loading = fields.Boolean('Limited Partners Loading',
default=True,
help="By default, 10000 partners are loaded.\n"
"When the session is open, we keep on loading all remaining partners in the background.\n"
"In the meantime, you can use the 'Load Customers' button to load partners from database.")
limited_partners_amount = fields.Integer(default=10000)
partner_load_background = fields.Boolean(default=False)
@api.depends('payment_method_ids')
def _compute_cash_control(self):
for config in self:
config.cash_control = bool(config.payment_method_ids.filtered('is_cash_count'))
@api.depends('company_id')
def _compute_company_has_template(self):
for config in self:
config.company_has_template = self.env['account.chart.template'].existing_accounting(config.company_id) or config.company_id.chart_template_id
def _compute_is_installed_account_accountant(self):
account_accountant = self.env['ir.module.module'].sudo().search([('name', '=', 'account_accountant'), ('state', '=', 'installed')])
for pos_config in self:
pos_config.is_installed_account_accountant = account_accountant and account_accountant.id
@api.depends('journal_id.currency_id', 'journal_id.company_id.currency_id', 'company_id', 'company_id.currency_id')
def _compute_currency(self):
for pos_config in self:
if pos_config.journal_id:
pos_config.currency_id = pos_config.journal_id.currency_id.id or pos_config.journal_id.company_id.currency_id.id
else:
pos_config.currency_id = pos_config.company_id.currency_id.id
@api.depends('session_ids', 'session_ids.state')
def _compute_current_session(self):
"""If there is an open session, store it to current_session_id / current_session_State.
"""
for pos_config in self:
opened_sessions = pos_config.session_ids.filtered(lambda s: not s.state == 'closed')
session = pos_config.session_ids.filtered(lambda s: not s.state == 'closed' and not s.rescue)
# sessions ordered by id desc
pos_config.number_of_opened_session = len(opened_sessions)
pos_config.has_active_session = opened_sessions and True or False
pos_config.current_session_id = session and session[0].id or False
pos_config.current_session_state = session and session[0].state or False
@api.depends('session_ids')
def _compute_last_session(self):
PosSession = self.env['pos.session']
for pos_config in self:
session = PosSession.search_read(
[('config_id', '=', pos_config.id), ('state', '=', 'closed')],
['cash_register_balance_end_real', 'stop_at'],
order="stop_at desc", limit=1)
if session:
timezone = pytz.timezone(self._context.get('tz') or self.env.user.tz or 'UTC')
pos_config.last_session_closing_date = session[0]['stop_at'].astimezone(timezone).date()
pos_config.last_session_closing_cash = session[0]['cash_register_balance_end_real']
else:
pos_config.last_session_closing_cash = 0
pos_config.last_session_closing_date = False
@api.depends('session_ids')
def _compute_current_session_user(self):
for pos_config in self:
session = pos_config.session_ids.filtered(lambda s: s.state in ['opening_control', 'opened', 'closing_control'] and not s.rescue)
if session:
pos_config.pos_session_username = session[0].user_id.sudo().name
pos_config.pos_session_state = session[0].state
pos_config.pos_session_duration = (
datetime.now() - session[0].start_at
).days if session[0].start_at else 0
pos_config.current_user_id = session[0].user_id
else:
pos_config.pos_session_username = False
pos_config.pos_session_state = False
pos_config.pos_session_duration = 0
pos_config.current_user_id = False
@api.depends('iface_customer_facing_display_via_proxy', 'iface_customer_facing_display_local')
def _compute_customer_facing_display(self):
for config in self:
config.iface_customer_facing_display = config.iface_customer_facing_display_via_proxy or config.iface_customer_facing_display_local
@api.constrains('rounding_method')
def _check_rounding_method_strategy(self):
for config in self:
if config.cash_rounding and config.rounding_method.strategy != 'add_invoice_line':
selection_value = "Add a rounding line"
for key, val in self.env["account.cash.rounding"]._fields["strategy"]._description_selection(config.env):
if key == "add_invoice_line":
selection_value = val
break
raise ValidationError(_(
"The cash rounding strategy of the point of sale %(pos)s must be: '%(value)s'",
pos=config.name,
value=selection_value,
))
@api.constrains('company_id', 'journal_id')
def _check_company_journal(self):
for config in self:
if config.journal_id and config.journal_id.company_id.id != config.company_id.id:
raise ValidationError(_("The sales journal of the point of sale %s must belong to its company.", config.name))
def _check_profit_loss_cash_journal(self):
if self.cash_control and self.payment_method_ids:
for method in self.payment_method_ids:
if method.is_cash_count and (not method.journal_id.loss_account_id or not method.journal_id.profit_account_id):
raise ValidationError(_("You need a loss and profit account on your cash journal."))
@api.constrains('company_id', 'invoice_journal_id')
def _check_company_invoice_journal(self):
for config in self:
if config.invoice_journal_id and config.invoice_journal_id.company_id.id != config.company_id.id:
raise ValidationError(_("The invoice journal of the point of sale %s must belong to the same company.", config.name))
@api.constrains('company_id', 'payment_method_ids')
def _check_company_payment(self):
for config in self:
if self.env['pos.payment.method'].search_count([('id', 'in', config.payment_method_ids.ids), ('company_id', '!=', config.company_id.id)]):
raise ValidationError(_("The payment methods for the point of sale %s must belong to its company.", self.name))
@api.constrains('pricelist_id', 'use_pricelist', 'available_pricelist_ids', 'journal_id', 'invoice_journal_id', 'payment_method_ids')
def _check_currencies(self):
for config in self:
if config.use_pricelist and config.pricelist_id not in config.available_pricelist_ids:
raise ValidationError(_("The default pricelist must be included in the available pricelists."))
# Check if the config's payment methods are compatible with its currency
for pm in config.payment_method_ids:
if pm.journal_id and pm.journal_id.currency_id and pm.journal_id.currency_id != config.currency_id:
raise ValidationError(_("All payment methods must be in the same currency as the Sales Journal or the company currency if that is not set."))
if any(self.available_pricelist_ids.mapped(lambda pricelist: pricelist.currency_id != self.currency_id)):
raise ValidationError(_("All available pricelists must be in the same currency as the company or"
" as the Sales Journal set on this point of sale if you use"
" the Accounting application."))
if self.invoice_journal_id.currency_id and self.invoice_journal_id.currency_id != self.currency_id:
raise ValidationError(_("The invoice journal must be in the same currency as the Sales Journal or the company currency if that is not set."))
@api.constrains('iface_start_categ_id', 'iface_available_categ_ids')
def _check_start_categ(self):
for config in self:
allowed_categ_ids = config.iface_available_categ_ids or self.env['pos.category'].search([])
if config.iface_start_categ_id and config.iface_start_categ_id not in allowed_categ_ids:
raise ValidationError(_("Start category should belong in the available categories."))
def _check_payment_method_ids(self):
self.ensure_one()
if not self.payment_method_ids:
raise ValidationError(
_("You must have at least one payment method configured to launch a session.")
)
@api.constrains('limited_partners_amount', 'limited_partners_loading')
def _check_limited_partners(self):
for rec in self:
if rec.limited_partners_loading and not rec.limited_partners_amount:
raise ValidationError(
_("Number of partners loaded can not be 0"))
@api.constrains('limited_products_amount', 'limited_products_loading')
def _check_limited_products(self):
for rec in self:
if rec.limited_products_loading and not rec.limited_products_amount:
raise ValidationError(
_("Number of product loaded can not be 0"))
@api.constrains('pricelist_id', 'available_pricelist_ids')
def _check_pricelists(self):
self._check_companies()
self = self.sudo()
if self.pricelist_id.company_id and self.pricelist_id.company_id != self.company_id:
raise ValidationError(
_("The default pricelist must belong to no company or the company of the point of sale."))
@api.constrains('company_id', 'available_pricelist_ids')
def _check_companies(self):
for config in self:
if any(pricelist.company_id.id not in [False, config.company_id.id] for pricelist in config.available_pricelist_ids):
raise ValidationError(_("The selected pricelists must belong to no company or the company of the point of sale."))
def name_get(self):
result = []
for config in self:
last_session = self.env['pos.session'].search([('config_id', '=', config.id)], limit=1)
if (not last_session) or (last_session.state == 'closed'):
result.append((config.id, _("%(pos_name)s (not used)", pos_name=config.name)))
else:
result.append((config.id, "%s (%s)" % (config.name, last_session.user_id.name)))
return result
def _check_header_footer(self, values):
if not self.env.is_admin() and {'is_header_or_footer', 'receipt_header', 'receipt_footer'} & values.keys():
raise AccessError(_('Only administrators can edit receipt headers and footers'))
def _config_sequence_implementation(self):
return 'standard'
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
self._check_header_footer(vals)
IrSequence = self.env['ir.sequence'].sudo()
val = {
'name': _('POS Order %s', vals['name']),
'padding': 4,
'prefix': "%s/" % vals['name'],
'code': "pos.order",
'company_id': vals.get('company_id', False),
'implementation': self._config_sequence_implementation(),
}
# force sequence_id field to new pos.order sequence
vals['sequence_id'] = IrSequence.create(val).id
val.update(name=_('POS order line %s', vals['name']), code='pos.order.line')
vals['sequence_line_id'] = IrSequence.create(val).id
pos_configs = super().create(vals_list)
pos_configs.sudo()._check_modules_to_install()
pos_configs.sudo()._check_groups_implied()
# If you plan to add something after this, use a new environment. The one above is no longer valid after the modules install.
return pos_configs
def _reset_default_on_vals(self, vals):
if 'tip_product_id' in vals and any(self.mapped('iface_tipproduct')) and not vals['tip_product_id']:
default_product = self.env.ref('point_of_sale.product_product_tip', False)
if default_product:
vals['tip_product_id'] = default_product.id
else:
raise UserError(_('The default tip product is missing. Please manually specify the tip product. (See Tips field.)'))
def write(self, vals):
self._check_header_footer(vals)
self._reset_default_on_vals(vals)
opened_session = self.mapped('session_ids').filtered(lambda s: s.state != 'closed')
if opened_session:
forbidden_fields = []
for key in self._get_forbidden_change_fields():
if key in vals.keys():
field_name = self._fields[key].get_description(self.env)["string"]
forbidden_fields.append(field_name)
if len(forbidden_fields) > 0:
raise UserError(_(
"Unable to modify this PoS Configuration because you can't modify %s while a session is open.",
", ".join(forbidden_fields)
))
result = super(PosConfig, self).write(vals)
self.sudo()._set_fiscal_position()
self.sudo()._check_modules_to_install()
self.sudo()._check_groups_implied()
return result
def _get_forbidden_change_fields(self):
forbidden_keys = ['module_pos_hr', 'module_pos_restaurant', 'available_pricelist_ids',
'limit_categories', 'iface_available_categ_ids', 'use_pricelist', 'module_pos_discount',
'payment_method_ids', 'iface_tipproduc']
return forbidden_keys
def unlink(self):
# Delete the pos.config records first then delete the sequences linked to them
sequences_to_delete = self.sequence_id | self.sequence_line_id
res = super(PosConfig, self).unlink()
sequences_to_delete.unlink()
return res
# TODO-JCB: Maybe we can move this logic in `_reset_default_on_vals`
def _set_fiscal_position(self):
for config in self:
if config.tax_regime_selection and config.default_fiscal_position_id and (config.default_fiscal_position_id.id not in config.fiscal_position_ids.ids):
config.fiscal_position_ids = [(4, config.default_fiscal_position_id.id)]
elif not config.tax_regime_selection and config.fiscal_position_ids.ids:
config.fiscal_position_ids = [(5, 0, 0)]
def _check_modules_to_install(self):
# determine modules to install
expected = [
fname[7:] # 'module_account' -> 'account'
for fname in self._fields
if fname.startswith('module_')
if any(pos_config[fname] for pos_config in self)
]
if expected:
STATES = ('installed', 'to install', 'to upgrade')
modules = self.env['ir.module.module'].sudo().search([('name', 'in', expected)])
modules = modules.filtered(lambda module: module.state not in STATES)
if modules:
modules.button_immediate_install()
# just in case we want to do something if we install a module. (like a refresh ...)
return True
return False
def _check_groups_implied(self):
for pos_config in self:
for field_name in [f for f in pos_config._fields if f.startswith('group_')]:
field = pos_config._fields[field_name]
if field.type in ('boolean', 'selection') and hasattr(field, 'implied_group'):
field_group_xmlids = getattr(field, 'group', 'base.group_user').split(',')
field_groups = self.env['res.groups'].concat(*(self.env.ref(it) for it in field_group_xmlids))
field_groups.write({'implied_ids': [(4, self.env.ref(field.implied_group).id)]})
def execute(self):
return {
'type': 'ir.actions.client',
'tag': 'reload',
'params': {'wait': True}
}
def _force_http(self):
enforce_https = self.env['ir.config_parameter'].sudo().get_param('point_of_sale.enforce_https')
if not enforce_https and self.other_devices:
return True
return False
# Methods to open the POS
def _action_to_open_ui(self):
if not self.current_session_id:
self.env['pos.session'].create({'user_id': self.env.uid, 'config_id': self.id})
path = '/pos/web' if self._force_http() else '/pos/ui'
return {
'type': 'ir.actions.act_url',
'url': path + '?config_id=%d' % self.id,
'target': 'self',
}
def _check_before_creating_new_session(self):
self._check_pricelists()
self._check_company_journal()
self._check_company_invoice_journal()
self._check_company_payment()
self._check_currencies()
self._check_profit_loss_cash_journal()
self._check_payment_method_ids()
def open_ui(self):
"""Open the pos interface with config_id as an extra argument.
In vanilla PoS each user can only have one active session, therefore it was not needed to pass the config_id
on opening a session. It is also possible to login to sessions created by other users.
:returns: dict
"""
self.ensure_one()
if not self.current_session_id:
self._check_before_creating_new_session()
self._validate_fields(self._fields)
# check if there's any product for this PoS
domain = [('available_in_pos', '=', True)]
if self.limit_categories and self.iface_available_categ_ids:
domain.append(('pos_categ_id', 'in', self.iface_available_categ_ids.ids))
if not self.env['product.product'].search(domain):
return {
'name': _("There is no product linked to your PoS"),
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
'res_model': 'pos.session.check_product_wizard',
'target': 'new',
'context': {'config_id': self.id}
}
return self._action_to_open_ui()
def open_existing_session_cb(self):
""" close session button
access session form to validate entries
"""
self.ensure_one()
return self._open_session(self.current_session_id.id)
def _open_session(self, session_id):
self._check_pricelists() # The pricelist company might have changed after the first opening of the session
return {
'name': _('Session'),
'view_mode': 'form,tree',
'res_model': 'pos.session',
'res_id': session_id,
'view_id': False,
'type': 'ir.actions.act_window',
}
def open_opened_session_list(self):
return {
'name': _('Opened Sessions'),
'res_model': 'pos.session',
'view_mode': 'tree,kanban,form',
'type': 'ir.actions.act_window',
'domain': [('state', '!=', 'closed'), ('config_id', '=', self.id)]
}
# All following methods are made to create data needed in POS, when a localisation
# is installed, or if POS is installed on database having companies that already have
# a localisation installed
@api.model
def post_install_pos_localisation(self, companies=False):
self = self.sudo()
if not companies:
companies = self.env['res.company'].search([])
for company in companies.filtered('chart_template_id'):
pos_configs = self.search([('company_id', '=', company.id)])
pos_configs.setup_defaults(company)
def setup_defaults(self, company):
"""Extend this method to customize the existing pos.config of the company during the installation
of a localisation.
:param self pos.config: pos.config records present in the company during the installation of localisation.
:param company res.company: the single company where the pos.config defaults will be setup.
"""
self.assign_payment_journals(company)
self.generate_pos_journal(company)
self.setup_invoice_journal(company)
def assign_payment_journals(self, company):
for pos_config in self:
if pos_config.payment_method_ids or pos_config.has_active_session:
continue
cash_journal = self.env['account.journal'].search([
('company_id', '=', company.id),
('type', '=', 'cash'),
('currency_id', 'in', [pos_config.currency_id.id, False]),
], limit=1)
bank_journal = self.env['account.journal'].search([
('company_id', '=', company.id),
('type', '=', 'bank'),
('currency_id', 'in', [pos_config.currency_id.id, False]),
], limit=1)
payment_methods = self.env['pos.payment.method']
if cash_journal:
payment_methods |= payment_methods.create({
'name': _('Cash'),
'journal_id': cash_journal.id,
'company_id': company.id,
})
if bank_journal:
payment_methods |= payment_methods.create({
'name': _('Bank'),
'journal_id': bank_journal.id,
'company_id': company.id,
})
payment_methods |= payment_methods.create({
'name': _('Customer Account'),
'company_id': company.id,
'split_transactions': True,
})
pos_config.write({'payment_method_ids': [(6, 0, payment_methods.ids)]})
def generate_pos_journal(self, company):
for pos_config in self:
if pos_config.journal_id:
continue
pos_journal = self.env['account.journal'].search([('company_id', '=', company.id), ('code', '=', 'POSS')])
if not pos_journal:
pos_journal = self.env['account.journal'].create({
'type': 'general',
'name': _('Point of Sale'),
'code': 'POSS',
'company_id': company.id,
'sequence': 20
})
pos_config.write({'journal_id': pos_journal.id})
def setup_invoice_journal(self, company):
for pos_config in self:
invoice_journal_id = pos_config.invoice_journal_id or self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', company.id)], limit=1)
if invoice_journal_id:
pos_config.write({'invoice_journal_id': invoice_journal_id.id})
def get_limited_products_loading(self, fields):
query = """
WITH pm AS (
SELECT product_id,
Max(write_date) date
FROM stock_move_line
GROUP BY product_id
ORDER BY date DESC
)
SELECT p.id
FROM product_product p
LEFT JOIN product_template t ON product_tmpl_id=t.id
LEFT JOIN pm ON p.id=pm.product_id
WHERE (
t.available_in_pos
AND t.sale_ok
AND (t.company_id=%(company_id)s OR t.company_id IS NULL)
AND %(available_categ_ids)s IS NULL OR t.pos_categ_id=ANY(%(available_categ_ids)s)
) OR p.id=%(tip_product_id)s
ORDER BY t.priority DESC,
case when t.detailed_type = 'service' then 1 else 0 end DESC,
pm.date DESC NULLS LAST,
p.write_date
LIMIT %(limit)s
"""
params = {
'company_id': self.company_id.id,
'available_categ_ids': self.iface_available_categ_ids.mapped('id') if self.iface_available_categ_ids else None,
'tip_product_id': self.tip_product_id.id if self.tip_product_id else None,
'limit': self.limited_products_amount
}
self.env.cr.execute(query, params)
product_ids = self.env.cr.fetchall()
products = self.env['product.product'].search_read([('id', 'in', product_ids)], fields=fields)
return products
def get_limited_partners_loading(self):
self.env.cr.execute("""
WITH pm AS
(
SELECT partner_id,
Count(partner_id) order_count
FROM pos_order
GROUP BY partner_id)
SELECT id
FROM res_partner AS partner
LEFT JOIN pm
ON (
partner.id = pm.partner_id)
WHERE (
partner.company_id=%s OR partner.company_id IS NULL
)
ORDER BY COALESCE(pm.order_count, 0) DESC,
NAME limit %s;
""", [self.company_id.id, str(self.limited_partners_amount)])
result = self.env.cr.fetchall()
return result
def action_pos_config_modal_edit(self):
return {
'view_mode': 'form',
'res_model': 'pos.config',
'type': 'ir.actions.act_window',
'target': 'new',
'res_id': self.id,
'context': {'pos_config_open_modal': True},
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,147 @@
from odoo import api, fields, models, _
from odoo.tools import formatLang, float_is_zero
from odoo.exceptions import ValidationError
class PosPayment(models.Model):
""" Used to register payments made in a pos.order.
See `payment_ids` field of pos.order model.
The main characteristics of pos.payment can be read from
`payment_method_id`.
"""
_name = "pos.payment"
_description = "Point of Sale Payments"
_order = "id desc"
name = fields.Char(string='Label', readonly=True)
pos_order_id = fields.Many2one('pos.order', string='Order', required=True, index=True)
amount = fields.Monetary(string='Amount', required=True, currency_field='currency_id', readonly=True, help="Total amount of the payment.")
payment_method_id = fields.Many2one('pos.payment.method', string='Payment Method', required=True)
payment_date = fields.Datetime(string='Date', required=True, readonly=True, default=lambda self: fields.Datetime.now())
currency_id = fields.Many2one('res.currency', string='Currency', related='pos_order_id.currency_id')
currency_rate = fields.Float(string='Conversion Rate', related='pos_order_id.currency_rate', help='Conversion rate from company currency to order currency.')
partner_id = fields.Many2one('res.partner', string='Customer', related='pos_order_id.partner_id')
session_id = fields.Many2one('pos.session', string='Session', related='pos_order_id.session_id', store=True, index=True)
company_id = fields.Many2one('res.company', string='Company', related='pos_order_id.company_id', store=True)
card_type = fields.Char('Type of card used')
cardholder_name = fields.Char('Cardholder Name')
transaction_id = fields.Char('Payment Transaction ID')
payment_status = fields.Char('Payment Status')
ticket = fields.Char('Payment Receipt Info')
is_change = fields.Boolean(string='Is this payment change?', default=False)
account_move_id = fields.Many2one('account.move', index='btree_not_null')
def name_get(self):
res = []
for payment in self:
if payment.name:
res.append((payment.id, '%s %s' % (payment.name, formatLang(self.env, payment.amount, currency_obj=payment.currency_id))))
else:
res.append((payment.id, formatLang(self.env, payment.amount, currency_obj=payment.currency_id)))
return res
@api.constrains('payment_method_id')
def _check_payment_method_id(self):
for payment in self:
if payment.payment_method_id not in payment.session_id.config_id.payment_method_ids:
raise ValidationError(_('The payment method selected is not allowed in the config of the POS session.'))
def _export_for_ui(self, payment):
return {
'payment_method_id': payment.payment_method_id.id,
'amount': payment.amount,
'payment_status': payment.payment_status,
'card_type': payment.card_type,
'cardholder_name': payment.cardholder_name,
'transaction_id': payment.transaction_id,
'ticket': payment.ticket,
'is_change': payment.is_change,
}
def export_for_ui(self):
return self.mapped(self._export_for_ui) if self else []
def _create_payment_moves(self, is_reverse=False):
result = self.env['account.move']
change_payment = self.filtered(lambda p: p.is_change and p.payment_method_id.type == 'cash')
payment_to_change = self.filtered(lambda p: not p.is_change and p.payment_method_id.type == 'cash')[:1]
normal_payments = (self - payment_to_change) - change_payment if change_payment else self
# Handle normal payments
for payment in normal_payments:
payment_method = payment.payment_method_id
if payment_method.type == 'pay_later' or float_is_zero(payment.amount, precision_rounding=payment.pos_order_id.currency_id.rounding):
continue
payment_move = payment._create_payment_move_entry(is_reverse)
payment.write({'account_move_id': payment_move.id})
result |= payment_move
payment_move._post()
# Handle change payments
if change_payment and payment_to_change:
result |= payment_to_change._create_payment_move_with_change(is_reverse, change_payment)
return result
def _create_payment_move_with_change(self, is_reverse, change_payment):
if self.payment_method_id.type != 'pay_later' and not float_is_zero(self.amount, precision_rounding=self.pos_order_id.currency_id.rounding):
payment_move = self._generate_payment_move(is_reverse, change_payment)
self.write({'account_move_id': payment_move.id})
payment_move._post()
return payment_move
def _create_payment_move_entry(self, is_reverse=False):
self.ensure_one()
return self._generate_payment_move(is_reverse)
def _generate_payment_move(self, is_reverse, change_payment=None):
order = self.pos_order_id
pos_session = order.session_id
journal = pos_session.config_id.journal_id
pos_payment_ids = self.ids
payment_amount = self.amount
if change_payment:
pos_payment_ids += change_payment.ids
payment_amount += change_payment.amount
payment_move = self.env['account.move'].with_context(default_journal_id=journal.id).create({
'journal_id': journal.id,
'date': fields.Date.context_today(order, order.date_order),
'ref': _('Invoice payment for %s (%s) using %s') % (order.name, order.account_move.name, self.payment_method_id.name),
'pos_payment_ids': pos_payment_ids,
})
amounts = pos_session._update_amounts({'amount': 0, 'amount_converted': 0}, {'amount': payment_amount}, self.payment_date)
credit_line_values = self._prepare_credit_line_payment(payment_move)
credit_line_vals = pos_session._credit_amounts(credit_line_values, amounts['amount'], amounts['amount_converted'])
debit_line_values = self._prepare_debit_line_payment(payment_move, is_reverse)
debit_line_vals = pos_session._debit_amounts(debit_line_values, amounts['amount'], amounts['amount_converted'])
self.env['account.move.line'].with_context(check_move_validity=False).create([credit_line_vals, debit_line_vals])
return payment_move
def _prepare_credit_line_payment(self, payment_move):
accounting_partner = self.env["res.partner"]._find_accounting_partner(self.partner_id)
order = self.pos_order_id
return {
'account_id': accounting_partner.with_company(order.company_id).property_account_receivable_id.id, # The field being company dependant, we need to make sure the right value is received.
'move_id': payment_move.id,
'partner_id': accounting_partner.id,
}
def _prepare_debit_line_payment(self, payment_move, is_reverse):
accounting_partner = self.env["res.partner"]._find_accounting_partner(self.partner_id)
order = self.pos_order_id
is_split_transaction = self.payment_method_id.split_transactions
if is_split_transaction and is_reverse:
reversed_move_receivable_account_id = accounting_partner.with_company(order.company_id).property_account_receivable_id.id
elif is_reverse:
reversed_move_receivable_account_id = self.payment_method_id.receivable_account_id.id or self.company_id.account_default_pos_receivable_account_id.id
else:
reversed_move_receivable_account_id = self.company_id.account_default_pos_receivable_account_id.id
return {
'account_id': reversed_move_receivable_account_id,
'move_id': payment_move.id,
'partner_id': accounting_partner.id if is_split_transaction and is_reverse else False,
}

View file

@ -0,0 +1,96 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class PosPaymentMethod(models.Model):
_name = "pos.payment.method"
_description = "Point of Sale Payment Methods"
_order = "id asc"
def _get_payment_terminal_selection(self):
return []
name = fields.Char(string="Method", required=True, translate=True, help='Defines the name of the payment method that will be displayed in the Point of Sale when the payments are selected.')
outstanding_account_id = fields.Many2one('account.account',
string='Outstanding Account',
ondelete='restrict',
help='Leave empty to use the default account from the company setting.\n'
'Account used as outstanding account when creating accounting payment records for bank payments.')
receivable_account_id = fields.Many2one('account.account',
string='Intermediary Account',
ondelete='restrict',
domain=[('reconcile', '=', True), ('account_type', '=', 'asset_receivable')],
help="Leave empty to use the default account from the company setting.\n"
"Overrides the company's receivable account (for Point of Sale) used in the journal entries.")
is_cash_count = fields.Boolean(string='Cash', compute="_compute_is_cash_count", store=True)
journal_id = fields.Many2one('account.journal',
string='Journal',
domain=[('type', 'in', ('cash', 'bank'))],
ondelete='restrict',
help='Leave empty to use the receivable account of customer.\n'
'Defines the journal where to book the accumulated payments (or individual payment if Identify Customer is true) after closing the session.\n'
'For cash journal, we directly write to the default account in the journal via statement lines.\n'
'For bank journal, we write to the outstanding account specified in this payment method.\n'
'Only cash and bank journals are allowed.')
split_transactions = fields.Boolean(
string='Identify Customer',
default=False,
help='Forces to set a customer when using this payment method and splits the journal entries for each customer. It could slow down the closing process.')
open_session_ids = fields.Many2many('pos.session', string='Pos Sessions', compute='_compute_open_session_ids', help='Open PoS sessions that are using this payment method.')
config_ids = fields.Many2many('pos.config', string='Point of Sale Configurations')
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
use_payment_terminal = fields.Selection(selection=lambda self: self._get_payment_terminal_selection(), string='Use a Payment Terminal', help='Record payments with a terminal on this journal.')
# used to hide use_payment_terminal when no payment interfaces are installed
hide_use_payment_terminal = fields.Boolean(compute='_compute_hide_use_payment_terminal')
active = fields.Boolean(default=True)
type = fields.Selection(selection=[('cash', 'Cash'), ('bank', 'Bank'), ('pay_later', 'Customer Account')], compute="_compute_type")
@api.depends('type')
def _compute_hide_use_payment_terminal(self):
no_terminals = not bool(self._fields['use_payment_terminal'].selection(self))
for payment_method in self:
payment_method.hide_use_payment_terminal = no_terminals or payment_method.type in ('cash', 'pay_later')
@api.onchange('use_payment_terminal')
def _onchange_use_payment_terminal(self):
"""Used by inheriting model to unset the value of the field related to the unselected payment terminal."""
pass
@api.depends('config_ids')
def _compute_open_session_ids(self):
for payment_method in self:
payment_method.open_session_ids = self.env['pos.session'].search([('config_id', 'in', payment_method.config_ids.ids), ('state', '!=', 'closed')])
@api.depends('journal_id', 'split_transactions')
def _compute_type(self):
for pm in self:
if pm.journal_id.type in {'cash', 'bank'}:
pm.type = pm.journal_id.type
else:
pm.type = 'pay_later'
@api.onchange('journal_id')
def _onchange_journal_id(self):
for pm in self:
if pm.journal_id and pm.journal_id.type not in ['cash', 'bank']:
raise UserError(_("Only journals of type 'Cash' or 'Bank' could be used with payment methods."))
if self.is_cash_count:
self.use_payment_terminal = False
@api.depends('type')
def _compute_is_cash_count(self):
for pm in self:
pm.is_cash_count = pm.type == 'cash'
def _is_write_forbidden(self, fields):
return bool(fields and self.open_session_ids)
def write(self, vals):
if self._is_write_forbidden(set(vals.keys())):
raise UserError(_('Please close and validate the following open PoS Sessions before modifying this payment method.\n'
'Open sessions: %s', (' '.join(self.open_session_ids.mapped('name')),)))
return super(PosPaymentMethod, self).write(vals)
def copy(self, default=None):
default = dict(default or {}, config_ids=[(5, 0, 0)])
return super().copy(default)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from itertools import groupby
from operator import itemgetter
from datetime import date
class ProductTemplate(models.Model):
_inherit = 'product.template'
available_in_pos = fields.Boolean(string='Available in POS', help='Check if you want this product to appear in the Point of Sale.', default=False)
to_weight = fields.Boolean(string='To Weigh With Scale', help="Check if the product should be weighted using the hardware scale integration.")
pos_categ_id = fields.Many2one(
'pos.category', string='Point of Sale Category',
help="Category used in the Point of Sale.")
@api.ondelete(at_uninstall=False)
def _unlink_except_open_session(self):
product_ctx = dict(self.env.context or {}, active_test=False)
if self.with_context(product_ctx).search_count([('id', 'in', self.ids), ('available_in_pos', '=', True)]):
if self.env['pos.session'].sudo().search_count([('state', '!=', 'closed')]):
raise UserError(_('You cannot delete a product saleable in point of sale while a session is still opened.'))
@api.onchange('sale_ok')
def _onchange_sale_ok(self):
if not self.sale_ok:
self.available_in_pos = False
class ProductProduct(models.Model):
_inherit = 'product.product'
@api.ondelete(at_uninstall=False)
def _unlink_except_active_pos_session(self):
product_ctx = dict(self.env.context or {}, active_test=False)
if self.env['pos.session'].sudo().search_count([('state', '!=', 'closed')]):
if self.with_context(product_ctx).search_count([('id', 'in', self.ids), ('product_tmpl_id.available_in_pos', '=', True)]):
raise UserError(_('You cannot delete a product saleable in point of sale while a session is still opened.'))
def get_product_info_pos(self, price, quantity, pos_config_id):
self.ensure_one()
config = self.env['pos.config'].browse(pos_config_id)
# Tax related
taxes = self.taxes_id.compute_all(price, config.currency_id, quantity, self)
grouped_taxes = {}
for tax in taxes['taxes']:
if tax['id'] in grouped_taxes:
grouped_taxes[tax['id']]['amount'] += tax['amount']/quantity if quantity else 0
else:
grouped_taxes[tax['id']] = {
'name': tax['name'],
'amount': tax['amount']/quantity if quantity else 0
}
all_prices = {
'price_without_tax': taxes['total_excluded']/quantity if quantity else 0,
'price_with_tax': taxes['total_included']/quantity if quantity else 0,
'tax_details': list(grouped_taxes.values()),
}
# Pricelists
if config.use_pricelist:
pricelists = config.available_pricelist_ids
else:
pricelists = config.pricelist_id
price_per_pricelist_id = pricelists._price_get(self, quantity)
pricelist_list = [{'name': pl.name, 'price': price_per_pricelist_id[pl.id]} for pl in pricelists]
# Warehouses
warehouse_list = [
{'name': w.name,
'available_quantity': self.with_context({'warehouse': w.id}).qty_available,
'forecasted_quantity': self.with_context({'warehouse': w.id}).virtual_available,
'uom': self.uom_name}
for w in self.env['stock.warehouse'].search([])]
# Suppliers
key = itemgetter('partner_id')
supplier_list = []
for key, group in groupby(sorted(self.seller_ids, key=key), key=key):
for s in list(group):
if not((s.date_start and s.date_start > date.today()) or (s.date_end and s.date_end < date.today()) or (s.min_qty > quantity)):
supplier_list.append({
'name': s.partner_id.name,
'delay': s.delay,
'price': s.price
})
break
# Variants
variant_list = [{'name': attribute_line.attribute_id.name,
'values': list(map(lambda attr_name: {'name': attr_name, 'search': '%s %s' % (self.name, attr_name)}, attribute_line.value_ids.mapped('name')))}
for attribute_line in self.attribute_line_ids]
return {
'all_prices': all_prices,
'pricelists': pricelist_list,
'warehouses': warehouse_list,
'suppliers': supplier_list,
'variants': variant_list
}
class UomCateg(models.Model):
_inherit = 'uom.category'
is_pos_groupable = fields.Boolean(string='Group Products in POS',
help="Check if you want to group products of this category in point of sale orders")
class Uom(models.Model):
_inherit = 'uom.uom'
is_pos_groupable = fields.Boolean(related='category_id.is_pos_groupable', readonly=False)

View file

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from odoo import api, models, fields, _
from odoo.exceptions import ValidationError
class ResCompany(models.Model):
_inherit = 'res.company'
point_of_sale_update_stock_quantities = fields.Selection([
('closing', 'At the session closing (faster)'),
('real', 'In real time (accurate but slower)'),
], default='closing', string="Update quantities in stock",
help="At the session closing: A picking is created for the entire session when it's closed\n In real time: Each order sent to the server create its own picking")
point_of_sale_use_ticket_qr_code = fields.Boolean(
string='Use QR code on ticket',
help="Add a QR code on the ticket, which the user can scan to request the invoice linked to its order.")
@api.constrains('period_lock_date', 'fiscalyear_lock_date')
def validate_period_lock_date(self):
""" This constrains makes it impossible to change the period lock date if
some open POS session exists into it. Without that, these POS sessions
would trigger an error message saying that the period has been locked when
trying to close them.
"""
pos_session_model = self.env['pos.session'].sudo()
for record in self:
sessions_in_period = pos_session_model.search(
[
"&",
"&",
("company_id", "=", record.id),
("state", "!=", "closed"),
"|",
("start_at", "<=", record.period_lock_date),
("start_at", "<=", record.fiscalyear_lock_date),
]
)
if sessions_in_period:
sessions_str = ', '.join(sessions_in_period.mapped('name'))
raise ValidationError(_("Please close all the point of sale sessions in this period before closing it. Open sessions are: %s ") % (sessions_str))

View file

@ -0,0 +1,327 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
import logging
_logger = logging.getLogger(__name__)
class ResConfigSettings(models.TransientModel):
"""
NOTES
1. Fields with name starting with 'pos_' are removed from the vals before super call to `create`.
Values of these fields are written to `pos_config_id` record after the super call.
This is done so that these fields are written at the same time to the active pos.config record.
2. During `creation` of this record, each related field is written to the source record
*one after the other*, so constraints on the source record that are based on multiple
fields might not work properly. However, only the *modified* related fields are written
to the source field. But the identification of modified fields happen during the super
call, not before `create` is called. Because of this, vals contains a lot of field before
super call, then the number of fields is reduced after.
"""
_inherit = 'res.config.settings'
def _default_pos_config(self):
# Default to the last modified pos.config.
active_model = self.env.context.get('active_model', '')
if active_model == 'pos.config':
return self.env.context.get('active_id')
return self.env['pos.config'].search([('company_id', '=', self.env.company.id)], order='write_date desc', limit=1)
pos_config_id = fields.Many2one('pos.config', string="Point of Sale", default=lambda self: self._default_pos_config())
sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax", related='company_id.account_sale_tax_id', readonly=False)
module_pos_mercury = fields.Boolean(string="Vantiv Payment Terminal", help="The transactions are processed by Vantiv. Set your Vantiv credentials on the related payment method.")
module_pos_adyen = fields.Boolean(string="Adyen Payment Terminal", help="The transactions are processed by Adyen. Set your Adyen credentials on the related payment method.")
module_pos_stripe = fields.Boolean(string="Stripe Payment Terminal", help="The transactions are processed by Stripe. Set your Stripe credentials on the related payment method.")
module_pos_six = fields.Boolean(string="Six Payment Terminal", help="The transactions are processed by Six. Set the IP address of the terminal on the related payment method.")
update_stock_quantities = fields.Selection(related="company_id.point_of_sale_update_stock_quantities", readonly=False)
account_default_pos_receivable_account_id = fields.Many2one(string='Default Account Receivable (PoS)', related='company_id.account_default_pos_receivable_account_id', readonly=False)
is_default_pricelist_displayed = fields.Boolean(compute="_compute_pos_pricelist_id", compute_sudo=True)
barcode_nomenclature_id = fields.Many2one('barcode.nomenclature', related='company_id.nomenclature_id', readonly=False)
# pos.config fields
pos_module_pos_discount = fields.Boolean(related='pos_config_id.module_pos_discount', readonly=False)
pos_module_pos_hr = fields.Boolean(related='pos_config_id.module_pos_hr', readonly=False)
pos_module_pos_restaurant = fields.Boolean(related='pos_config_id.module_pos_restaurant', readonly=False)
pos_allowed_pricelist_ids = fields.Many2many('product.pricelist', compute='_compute_pos_allowed_pricelist_ids')
pos_amount_authorized_diff = fields.Float(related='pos_config_id.amount_authorized_diff', readonly=False)
pos_available_pricelist_ids = fields.Many2many('product.pricelist', string='Available Pricelists', compute='_compute_pos_pricelist_id', readonly=False, store=True)
pos_cash_control = fields.Boolean(related='pos_config_id.cash_control')
pos_cash_rounding = fields.Boolean(related='pos_config_id.cash_rounding', readonly=False, string="Cash Rounding (PoS)")
pos_company_has_template = fields.Boolean(related='pos_config_id.company_has_template')
pos_default_bill_ids = fields.Many2many(related='pos_config_id.default_bill_ids', readonly=False)
pos_default_fiscal_position_id = fields.Many2one('account.fiscal.position', string='Default Fiscal Position', compute='_compute_pos_fiscal_positions', readonly=False, store=True)
pos_fiscal_position_ids = fields.Many2many('account.fiscal.position', string='Fiscal Positions', compute='_compute_pos_fiscal_positions', readonly=False, store=True)
pos_has_active_session = fields.Boolean(related='pos_config_id.has_active_session')
pos_iface_available_categ_ids = fields.Many2many('pos.category', string='Available PoS Product Categories', compute='_compute_pos_iface_available_categ_ids', readonly=False, store=True)
pos_iface_big_scrollbars = fields.Boolean(related='pos_config_id.iface_big_scrollbars', readonly=False)
pos_iface_cashdrawer = fields.Boolean(string='Cashdrawer', compute='_compute_pos_iface_cashdrawer', readonly=False, store=True)
pos_iface_customer_facing_display_local = fields.Boolean(related='pos_config_id.iface_customer_facing_display_local', readonly=False)
pos_iface_customer_facing_display_via_proxy = fields.Boolean(string='Customer Facing Display', compute='_compute_pos_iface_customer_facing_display_via_proxy', readonly=False, store=True)
pos_iface_electronic_scale = fields.Boolean(string='Electronic Scale', compute='_compute_pos_iface_electronic_scale', readonly=False, store=True)
pos_iface_print_auto = fields.Boolean(related='pos_config_id.iface_print_auto', readonly=False)
pos_iface_print_skip_screen = fields.Boolean(related='pos_config_id.iface_print_skip_screen', readonly=False)
pos_iface_print_via_proxy = fields.Boolean(string='Print via Proxy', compute='_compute_pos_iface_print_via_proxy', readonly=False, store=True)
pos_iface_scan_via_proxy = fields.Boolean(string='Scan via Proxy', compute='_compute_pos_iface_scan_via_proxy', readonly=False, store=True)
pos_iface_start_categ_id = fields.Many2one('pos.category', string='Initial Category', compute='_compute_pos_iface_start_categ_id', readonly=False, store=True)
pos_iface_tax_included = fields.Selection(related='pos_config_id.iface_tax_included', readonly=False)
pos_iface_tipproduct = fields.Boolean(related='pos_config_id.iface_tipproduct', readonly=False)
pos_invoice_journal_id = fields.Many2one(related='pos_config_id.invoice_journal_id', readonly=False)
pos_is_header_or_footer = fields.Boolean(related='pos_config_id.is_header_or_footer', readonly=False)
pos_is_margins_costs_accessible_to_every_user = fields.Boolean(related='pos_config_id.is_margins_costs_accessible_to_every_user', readonly=False)
pos_is_posbox = fields.Boolean(related='pos_config_id.is_posbox', readonly=False)
pos_journal_id = fields.Many2one(related='pos_config_id.journal_id', readonly=False)
pos_limit_categories = fields.Boolean(related='pos_config_id.limit_categories', readonly=False)
pos_limited_partners_amount = fields.Integer(related='pos_config_id.limited_partners_amount', readonly=False)
pos_limited_partners_loading = fields.Boolean(related='pos_config_id.limited_partners_loading', readonly=False)
pos_limited_products_amount = fields.Integer(related='pos_config_id.limited_products_amount', readonly=False)
pos_limited_products_loading = fields.Boolean(related='pos_config_id.limited_products_loading', readonly=False)
pos_manual_discount = fields.Boolean(related='pos_config_id.manual_discount', readonly=False)
pos_only_round_cash_method = fields.Boolean(related='pos_config_id.only_round_cash_method', readonly=False)
pos_other_devices = fields.Boolean(related='pos_config_id.other_devices', readonly=False)
pos_partner_load_background = fields.Boolean(related='pos_config_id.partner_load_background', readonly=False)
pos_payment_method_ids = fields.Many2many(related='pos_config_id.payment_method_ids', readonly=False)
pos_picking_policy = fields.Selection(related='pos_config_id.picking_policy', readonly=False)
pos_picking_type_id = fields.Many2one(related='pos_config_id.picking_type_id', readonly=False)
pos_pricelist_id = fields.Many2one('product.pricelist', string='Default Pricelist', compute='_compute_pos_pricelist_id', readonly=False, store=True)
pos_product_load_background = fields.Boolean(related='pos_config_id.product_load_background', readonly=False)
pos_proxy_ip = fields.Char(string='IP Address', compute='_compute_pos_proxy_ip', readonly=False, store=True)
pos_receipt_footer = fields.Text(string='Receipt Footer', compute='_compute_pos_receipt_header_footer', readonly=False, store=True)
pos_receipt_header = fields.Text(string='Receipt Header', compute='_compute_pos_receipt_header_footer', readonly=False, store=True)
pos_restrict_price_control = fields.Boolean(related='pos_config_id.restrict_price_control', readonly=False)
pos_rounding_method = fields.Many2one(related='pos_config_id.rounding_method', readonly=False)
pos_route_id = fields.Many2one(related='pos_config_id.route_id', readonly=False)
pos_selectable_categ_ids = fields.Many2many('pos.category', compute='_compute_pos_selectable_categ_ids')
pos_sequence_id = fields.Many2one(related='pos_config_id.sequence_id')
pos_set_maximum_difference = fields.Boolean(related='pos_config_id.set_maximum_difference', readonly=False)
pos_ship_later = fields.Boolean(related='pos_config_id.ship_later', readonly=False)
pos_start_category = fields.Boolean(related='pos_config_id.start_category', readonly=False)
pos_tax_regime_selection = fields.Boolean(related='pos_config_id.tax_regime_selection', readonly=False)
pos_tip_product_id = fields.Many2one('product.product', string='Tip Product', compute='_compute_pos_tip_product_id', readonly=False, store=True)
pos_use_pricelist = fields.Boolean(related='pos_config_id.use_pricelist', readonly=False)
pos_warehouse_id = fields.Many2one(related='pos_config_id.warehouse_id', readonly=False, string="Warehouse (PoS)")
point_of_sale_use_ticket_qr_code = fields.Boolean(related='company_id.point_of_sale_use_ticket_qr_code', readonly=False)
@api.model
def _keep_new_vals(self, pos_config, pos_fields_vals):
""" Keep vals in pos_fields_vals that are different than
pos_config's values.
"""
new_vals = {}
for field, val in pos_fields_vals.items():
if pos_config._fields.get(field):
cache_value = pos_config._fields.get(field).convert_to_cache(val, pos_config)
record_value = pos_config._fields.get(field).convert_to_record(cache_value, pos_config)
if record_value != pos_config[field]:
new_vals[field] = val
return new_vals
@api.model_create_multi
def create(self, vals_list):
# STEP: Remove the 'pos' fields from each vals.
# They will be written atomically to `pos_config_id` after the super call.
pos_config_id_to_fields_vals_map = {}
for vals in vals_list:
pos_config_id = vals.get('pos_config_id')
if pos_config_id:
pos_fields_vals = {}
if vals.get('pos_cash_rounding'):
vals['group_cash_rounding'] = True
if vals.get('pos_use_pricelist'):
vals['group_product_pricelist'] = True
for field in self._fields.values():
if field.name == 'pos_config_id':
continue
val = vals.get(field.name)
# Add only to pos_fields_vals if
# 1. _field is in vals -- meaning, the _field is in view.
# 2. _field starts with 'pos_' -- meaning, the _field is a pos field.
if field.name.startswith('pos_') and val is not None:
pos_config_field_name = field.name[4:]
if not pos_config_field_name in self.env['pos.config']._fields:
_logger.warning("The value of '%s' is not properly saved to the pos_config_id field because the destination"
" field '%s' is not a valid field in the pos.config model.", field.name, pos_config_field_name)
else:
pos_fields_vals[pos_config_field_name] = val
del vals[field.name]
pos_config_id_to_fields_vals_map[pos_config_id] = pos_fields_vals
# STEP: Call super on the modified vals_list.
# NOTE: When creating `res.config.settings` records, it doesn't write on *unmodified* related fields.
result = super().create(vals_list)
# STEP: Finally, we write the value of 'pos' fields to 'pos_config_id'.
for pos_config_id, pos_fields_vals in pos_config_id_to_fields_vals_map.items():
pos_config = self.env['pos.config'].browse(pos_config_id)
pos_fields_vals = self._keep_new_vals(pos_config, pos_fields_vals)
pos_config.write(pos_fields_vals)
return result
def set_values(self):
super(ResConfigSettings, self).set_values()
if not self.group_product_pricelist:
self.env['pos.config'].search([
('use_pricelist', '=', True)
]).use_pricelist = False
if not self.group_cash_rounding:
self.env['pos.config'].search([
('cash_rounding', '=', True)
]).cash_rounding = False
def action_pos_config_create_new(self):
return {
'view_mode': 'form',
'res_model': 'pos.config',
'type': 'ir.actions.act_window',
'target': 'new',
'res_id': False,
'context': {'pos_config_open_modal': True, 'pos_config_create_mode': True},
}
def pos_open_ui(self):
if self._context.get('pos_config_id'):
pos_config_id = self._context['pos_config_id']
pos_config = self.env['pos.config'].browse(pos_config_id)
return pos_config.open_ui()
@api.model
def _is_cashdrawer_displayed(self, res_config):
return res_config.pos_iface_print_via_proxy
@api.depends('pos_limit_categories', 'pos_config_id')
def _compute_pos_iface_available_categ_ids(self):
for res_config in self:
if not res_config.pos_limit_categories:
res_config.pos_iface_available_categ_ids = False
else:
res_config.pos_iface_available_categ_ids = res_config.pos_config_id.iface_available_categ_ids
@api.depends('pos_start_category', 'pos_config_id')
def _compute_pos_iface_start_categ_id(self):
for res_config in self:
if not res_config.pos_start_category:
res_config.pos_iface_start_categ_id = False
else:
res_config.pos_iface_start_categ_id = res_config.pos_config_id.iface_start_categ_id
@api.depends('pos_iface_available_categ_ids')
def _compute_pos_selectable_categ_ids(self):
for res_config in self:
if res_config.pos_iface_available_categ_ids:
res_config.pos_selectable_categ_ids = res_config.pos_iface_available_categ_ids
else:
res_config.pos_selectable_categ_ids = self.env['pos.category'].search([])
@api.depends('pos_iface_print_via_proxy', 'pos_config_id')
def _compute_pos_iface_cashdrawer(self):
for res_config in self:
if self._is_cashdrawer_displayed(res_config):
res_config.pos_iface_cashdrawer = res_config.pos_config_id.iface_cashdrawer
else:
res_config.pos_iface_cashdrawer = False
@api.depends('pos_is_header_or_footer', 'pos_config_id')
def _compute_pos_receipt_header_footer(self):
for res_config in self:
if res_config.pos_is_header_or_footer:
res_config.pos_receipt_header = res_config.pos_config_id.receipt_header
res_config.pos_receipt_footer = res_config.pos_config_id.receipt_footer
else:
res_config.pos_receipt_header = False
res_config.pos_receipt_footer = False
@api.depends('pos_tax_regime_selection', 'pos_config_id')
def _compute_pos_fiscal_positions(self):
for res_config in self:
if res_config.pos_tax_regime_selection:
res_config.pos_default_fiscal_position_id = res_config.pos_config_id.default_fiscal_position_id
res_config.pos_fiscal_position_ids = res_config.pos_config_id.fiscal_position_ids
else:
res_config.pos_default_fiscal_position_id = False
res_config.pos_fiscal_position_ids = [(5, 0, 0)]
@api.depends('pos_iface_tipproduct', 'pos_config_id')
def _compute_pos_tip_product_id(self):
for res_config in self:
if res_config.pos_iface_tipproduct:
res_config.pos_tip_product_id = res_config.pos_config_id.tip_product_id
else:
res_config.pos_tip_product_id = False
@api.depends('pos_use_pricelist', 'pos_config_id', 'pos_journal_id')
def _compute_pos_pricelist_id(self):
for res_config in self:
currency_id = res_config.pos_journal_id.currency_id.id if res_config.pos_journal_id.currency_id else res_config.pos_config_id.company_id.currency_id.id
pricelists_in_current_currency = self.env['product.pricelist'].search([('company_id', 'in', (False, res_config.pos_config_id.company_id.id)), ('currency_id', '=', currency_id)])
if not res_config.pos_use_pricelist:
res_config.pos_available_pricelist_ids = pricelists_in_current_currency[:1]
res_config.pos_pricelist_id = pricelists_in_current_currency[:1]
else:
if any([p.currency_id.id != currency_id for p in res_config.pos_available_pricelist_ids]):
res_config.pos_available_pricelist_ids = pricelists_in_current_currency
res_config.pos_pricelist_id = pricelists_in_current_currency[:1]
else:
res_config.pos_available_pricelist_ids = res_config.pos_config_id.available_pricelist_ids
res_config.pos_pricelist_id = res_config.pos_config_id.pricelist_id
# TODO: Remove this field in master because it's always True.
res_config.is_default_pricelist_displayed = True
@api.depends('pos_available_pricelist_ids', 'pos_use_pricelist')
def _compute_pos_allowed_pricelist_ids(self):
for res_config in self:
if res_config.pos_use_pricelist:
res_config.pos_allowed_pricelist_ids = res_config.pos_available_pricelist_ids.ids
else:
res_config.pos_allowed_pricelist_ids = self.env['product.pricelist'].search([]).ids
@api.depends('pos_is_posbox', 'pos_config_id')
def _compute_pos_proxy_ip(self):
for res_config in self:
if not res_config.pos_is_posbox:
res_config.pos_proxy_ip = False
else:
res_config.pos_proxy_ip = res_config.pos_config_id.proxy_ip
@api.depends('pos_is_posbox', 'pos_config_id')
def _compute_pos_iface_print_via_proxy(self):
for res_config in self:
if not res_config.pos_is_posbox:
res_config.pos_iface_print_via_proxy = False
else:
res_config.pos_iface_print_via_proxy = res_config.pos_config_id.iface_print_via_proxy
@api.depends('pos_is_posbox', 'pos_config_id')
def _compute_pos_iface_scan_via_proxy(self):
for res_config in self:
if not res_config.pos_is_posbox:
res_config.pos_iface_scan_via_proxy = False
else:
res_config.pos_iface_scan_via_proxy = res_config.pos_config_id.iface_scan_via_proxy
@api.depends('pos_is_posbox', 'pos_config_id')
def _compute_pos_iface_electronic_scale(self):
for res_config in self:
if not res_config.pos_is_posbox:
res_config.pos_iface_electronic_scale = False
else:
res_config.pos_iface_electronic_scale = res_config.pos_config_id.iface_electronic_scale
@api.depends('pos_is_posbox', 'pos_config_id')
def _compute_pos_iface_customer_facing_display_via_proxy(self):
for res_config in self:
if not res_config.pos_is_posbox:
res_config.pos_iface_customer_facing_display_via_proxy = False
else:
res_config.pos_iface_customer_facing_display_via_proxy = res_config.pos_config_id.iface_customer_facing_display_via_proxy

View file

@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class ResPartner(models.Model):
_inherit = 'res.partner'
pos_order_count = fields.Integer(
compute='_compute_pos_order',
help="The number of point of sales orders related to this customer",
groups="point_of_sale.group_pos_user",
)
pos_order_ids = fields.One2many('pos.order', 'partner_id', readonly=True)
def _compute_pos_order(self):
# retrieve all children partners and prefetch 'parent_id' on them
all_partners = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
all_partners.read(['parent_id'])
pos_order_data = self.env['pos.order']._read_group(
domain=[('partner_id', 'in', all_partners.ids)],
fields=['partner_id'], groupby=['partner_id']
)
self.pos_order_count = 0
for group in pos_order_data:
partner = self.browse(group['partner_id'][0])
while partner:
if partner in self:
partner.pos_order_count += group['partner_id_count']
partner = partner.parent_id
def action_view_pos_order(self):
'''
This function returns an action that displays the pos orders from partner.
'''
action = self.env['ir.actions.act_window']._for_xml_id('point_of_sale.action_pos_pos_form')
if self.is_company:
action['domain'] = [('partner_id.commercial_partner_id', '=', self.id)]
else:
action['domain'] = [('partner_id', '=', self.id)]
return action
@api.model
def create_from_ui(self, partner):
""" create or modify a partner from the point of sale ui.
partner contains the partner's fields. """
# image is a dataurl, get the data after the comma
if partner.get('image_1920'):
partner['image_1920'] = partner['image_1920'].split(',')[1]
partner_id = partner.pop('id', False)
if partner_id: # Modifying existing partner
self.browse(partner_id).write(partner)
else:
partner_id = self.create(partner).id
return partner_id
@api.ondelete(at_uninstall=False)
def _unlink_except_active_pos_session(self):
running_sessions = self.env['pos.session'].sudo().search([('state', '!=', 'closed')])
if running_sessions:
raise UserError(
_("You cannot delete contacts while there are active PoS sessions. Close the session(s) %s first.")
% ", ".join(session.name for session in running_sessions)
)

View file

@ -0,0 +1,319 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_is_zero, float_compare
from itertools import groupby
from collections import defaultdict
class StockPicking(models.Model):
_inherit='stock.picking'
pos_session_id = fields.Many2one('pos.session', index=True)
pos_order_id = fields.Many2one('pos.order', index=True)
def _prepare_picking_vals(self, partner, picking_type, location_id, location_dest_id):
return {
'partner_id': partner.id if partner else False,
'user_id': False,
'picking_type_id': picking_type.id,
'move_type': 'direct',
'location_id': location_id,
'location_dest_id': location_dest_id,
}
@api.model
def _create_picking_from_pos_order_lines(self, location_dest_id, lines, picking_type, partner=False):
"""We'll create some picking based on order_lines"""
pickings = self.env['stock.picking']
stockable_lines = 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))
if not stockable_lines:
return pickings
positive_lines = stockable_lines.filtered(lambda l: l.qty > 0)
negative_lines = stockable_lines - positive_lines
if positive_lines:
location_id = picking_type.default_location_src_id.id
positive_picking = self.env['stock.picking'].create(
self._prepare_picking_vals(partner, picking_type, location_id, location_dest_id)
)
positive_picking._create_move_from_pos_order_lines(positive_lines)
self.env.flush_all()
try:
with self.env.cr.savepoint():
positive_picking._action_done()
except (UserError, ValidationError):
pass
pickings |= positive_picking
if negative_lines:
if picking_type.return_picking_type_id:
return_picking_type = picking_type.return_picking_type_id
return_location_id = return_picking_type.default_location_dest_id.id
else:
return_picking_type = picking_type
return_location_id = picking_type.default_location_src_id.id
negative_picking = self.env['stock.picking'].create(
self._prepare_picking_vals(partner, return_picking_type, location_dest_id, return_location_id)
)
negative_picking._create_move_from_pos_order_lines(negative_lines)
self.env.flush_all()
try:
with self.env.cr.savepoint():
negative_picking._action_done()
except (UserError, ValidationError):
pass
pickings |= negative_picking
return pickings
def _prepare_stock_move_vals(self, first_line, order_lines):
return {
'name': first_line.name,
'product_uom': first_line.product_id.uom_id.id,
'picking_id': self.id,
'picking_type_id': self.picking_type_id.id,
'product_id': first_line.product_id.id,
'product_uom_qty': abs(sum(order_lines.mapped('qty'))),
'state': 'draft',
'location_id': self.location_id.id,
'location_dest_id': self.location_dest_id.id,
'company_id': self.company_id.id,
}
def _create_move_from_pos_order_lines(self, lines):
self.ensure_one()
lines_by_product = groupby(sorted(lines, key=lambda l: l.product_id.id), key=lambda l: l.product_id.id)
move_vals = []
for dummy, olines in lines_by_product:
order_lines = self.env['pos.order.line'].concat(*olines)
move_vals.append(self._prepare_stock_move_vals(order_lines[0], order_lines))
moves = self.env['stock.move'].create(move_vals)
confirmed_moves = moves._action_confirm()
confirmed_moves._add_mls_related_to_order(lines, are_qties_done=True)
self._link_owner_on_return_picking(lines)
def _link_owner_on_return_picking(self, lines):
"""This method tries to retrieve the owner of the returned product"""
if lines[0].order_id.refunded_order_ids.picking_ids:
returned_lines_picking = lines[0].order_id.refunded_order_ids.picking_ids
returnable_qty_by_product = {}
for move_line in returned_lines_picking.move_line_ids:
returnable_qty_by_product[(move_line.product_id.id, move_line.owner_id.id or 0)] = move_line.qty_done
for move in self.move_line_ids:
for keys in returnable_qty_by_product:
if move.product_id.id == keys[0] and keys[1] and returnable_qty_by_product[keys] > 0:
move.write({'owner_id': keys[1]})
returnable_qty_by_product[keys] -= move.qty_done
def _send_confirmation_email(self):
# Avoid sending Mail/SMS for POS deliveries
pickings = self.filtered(lambda p: p.picking_type_id != p.picking_type_id.warehouse_id.pos_type_id)
return super(StockPicking, pickings)._send_confirmation_email()
def _action_done(self):
res = super()._action_done()
for rec in self:
if rec.picking_type_id.code != 'outgoing':
continue
if rec.pos_order_id.to_ship and not rec.pos_order_id.to_invoice:
cost_per_account = defaultdict(lambda: 0.0)
for line in rec.pos_order_id.lines:
if line.product_id.type != 'product' or line.product_id.valuation != 'real_time':
continue
out = line.product_id.categ_id.property_stock_account_output_categ_id
exp = line.product_id._get_product_accounts()['expense']
cost_per_account[(out, exp)] += line.total_cost
move_vals = []
for (out_acc, exp_acc), cost in cost_per_account.items():
move_vals.append({
'journal_id': rec.pos_order_id.sale_journal.id,
'date': rec.pos_order_id.date_order,
'ref': 'pos_order_'+str(rec.pos_order_id.id),
'line_ids': [
(0, 0, {
'name': rec.pos_order_id.name,
'account_id': exp_acc.id,
'debit': cost,
'credit': 0.0,
}),
(0, 0, {
'name': rec.pos_order_id.name,
'account_id': out_acc.id,
'debit': 0.0,
'credit': cost,
}),
],
})
move = self.env['account.move'].sudo().create(move_vals)
move.action_post()
return res
class StockPickingType(models.Model):
_inherit = 'stock.picking.type'
@api.depends('warehouse_id')
def _compute_hide_reservation_method(self):
super()._compute_hide_reservation_method()
for picking_type in self:
if picking_type == picking_type.warehouse_id.pos_type_id:
picking_type.hide_reservation_method = True
@api.constrains('active')
def _check_active(self):
for picking_type in self:
if picking_type.active:
continue
pos_config = self.env['pos.config'].sudo().search([('picking_type_id', '=', picking_type.id)], limit=1)
if pos_config:
raise ValidationError(_("You cannot archive '%s' as it is used by a POS configuration '%s'.", picking_type.name, pos_config.name))
class ProcurementGroup(models.Model):
_inherit = 'procurement.group'
pos_order_id = fields.Many2one('pos.order', 'POS Order')
class StockMove(models.Model):
_inherit = 'stock.move'
def _get_new_picking_values(self):
vals = super(StockMove, self)._get_new_picking_values()
vals['pos_session_id'] = self.mapped('group_id.pos_order_id.session_id').id
vals['pos_order_id'] = self.mapped('group_id.pos_order_id').id
return vals
def _key_assign_picking(self):
keys = super(StockMove, self)._key_assign_picking()
return keys + (self.group_id.pos_order_id,)
@api.model
def _prepare_lines_data_dict(self, order_lines):
lines_data = defaultdict(dict)
for product_id, olines in groupby(sorted(order_lines, key=lambda l: l.product_id.id), key=lambda l: l.product_id.id):
lines_data[product_id].update({'order_lines': self.env['pos.order.line'].concat(*olines)})
return lines_data
def _complete_done_qties(self, set_quantity_done_on_move=False):
self._action_assign()
for move_line in self.move_line_ids:
move_line.qty_done = move_line.reserved_uom_qty
mls_vals = []
moves_to_set = set()
for move in self:
if float_compare(move.product_uom_qty, move.quantity_done, precision_rounding=move.product_uom.rounding) > 0:
remaining_qty = move.product_uom_qty - move.quantity_done
mls_vals.append(dict(move._prepare_move_line_vals(), qty_done=remaining_qty))
moves_to_set.add(move.id)
self.env['stock.move.line'].create(mls_vals)
if set_quantity_done_on_move:
for move in self.env['stock.move'].browse(moves_to_set):
move.quantity_done = move.product_uom_qty
def _create_production_lots_for_pos_order(self, lines):
''' Search for existing lots and create missing ones.
:param lines: pos order lines with pack lot ids.
:type lines: pos.order.line recordset.
:return stock.lot recordset.
'''
valid_lots = self.env['stock.lot']
moves = self.filtered(lambda m: m.picking_type_id.use_existing_lots)
# Already called in self._action_confirm() but just to be safe when coming from _launch_stock_rule_from_pos_order_lines.
self._check_company()
if moves:
moves_product_ids = set(moves.mapped('product_id').ids)
lots = lines.pack_lot_ids.filtered(lambda l: l.lot_name and l.product_id.id in moves_product_ids)
lots_data = set(lots.mapped(lambda l: (l.product_id.id, l.lot_name)))
existing_lots = self.env['stock.lot'].search([
('company_id', '=', moves[0].picking_type_id.company_id.id),
('product_id', 'in', lines.product_id.ids),
('name', 'in', lots.mapped('lot_name')),
])
#The previous search may return (product_id.id, lot_name) combinations that have no matching in lines.pack_lot_ids.
for lot in existing_lots:
if (lot.product_id.id, lot.name) in lots_data:
valid_lots |= lot
lots_data.remove((lot.product_id.id, lot.name))
moves = moves.filtered(lambda m: m.picking_type_id.use_create_lots)
if moves:
moves_product_ids = set(moves.mapped('product_id').ids)
missing_lot_values = []
for lot_product_id, lot_name in filter(lambda l: l[0] in moves_product_ids, lots_data):
missing_lot_values.append({'company_id': self.company_id.id, 'product_id': lot_product_id, 'name': lot_name})
valid_lots |= self.env['stock.lot'].create(missing_lot_values)
return valid_lots
def _add_mls_related_to_order(self, related_order_lines, are_qties_done=True):
lines_data = self._prepare_lines_data_dict(related_order_lines)
qty_fname = 'qty_done' if are_qties_done else 'reserved_uom_qty'
# Moves with product_id not in related_order_lines. This can happend e.g. when product_id has a phantom-type bom.
moves_to_assign = self.filtered(lambda m: m.product_id.id not in lines_data or m.product_id.tracking == 'none'
or (not m.picking_type_id.use_existing_lots and not m.picking_type_id.use_create_lots))
moves_to_assign._complete_done_qties(set_quantity_done_on_move=True)
moves_remaining = self - moves_to_assign
existing_lots = moves_remaining._create_production_lots_for_pos_order(related_order_lines)
move_lines_to_create = []
mls_qties = []
if are_qties_done:
for move in moves_remaining:
for line in lines_data[move.product_id.id]['order_lines']:
sum_of_lots = 0
for lot in line.pack_lot_ids.filtered(lambda l: l.lot_name):
if line.product_id.tracking == 'serial':
qty = 1
else:
qty = abs(line.qty)
ml_vals = dict(move._prepare_move_line_vals())
if existing_lots:
existing_lot = existing_lots.filtered_domain([('product_id', '=', line.product_id.id), ('name', '=', lot.lot_name)])
quant = self.env['stock.quant']
if existing_lot:
quant = self.env['stock.quant'].search(
[('lot_id', '=', existing_lot.id), ('quantity', '>', '0.0'), ('location_id', 'child_of', move.location_id.id)],
order='id desc',
limit=1
)
ml_vals.update({
'lot_id': existing_lot.id,
'location_id': quant.location_id.id or move.location_id.id,
'owner_id': quant.owner_id.id or False,
})
else:
ml_vals.update({'lot_name': lot.lot_name})
move_lines_to_create.append(ml_vals)
mls_qties.append(qty)
sum_of_lots += qty
if abs(line.qty) != sum_of_lots:
difference_qty = abs(line.qty) - sum_of_lots
ml_vals = move._prepare_move_line_vals()
if line.product_id.tracking == 'serial':
move_lines_to_create.extend([ml_vals for i in range(int(difference_qty))])
mls_qties.extend([1]*int(difference_qty))
else:
move_lines_to_create.append(ml_vals)
mls_qties.append(difference_qty)
move_lines = self.env['stock.move.line'].create(move_lines_to_create)
for move_line, qty in zip(move_lines, mls_qties):
move_line.write({qty_fname: qty})
else:
for move in moves_remaining:
for line in lines_data[move.product_id.id]['order_lines']:
for lot in line.pack_lot_ids.filtered(lambda l: l.lot_name):
if line.product_id.tracking == 'serial':
qty = 1
else:
qty = abs(line.qty)
if existing_lots:
existing_lot = existing_lots.filtered_domain([('product_id', '=', line.product_id.id), ('name', '=', lot.lot_name)])
if existing_lot:
available_quantity = move._get_available_quantity(move.location_id, lot_id=existing_lot, strict=True)
if not float_is_zero(available_quantity, precision_rounding=line.product_id.uom_id.rounding):
move._update_reserved_quantity(qty, min(qty, available_quantity), move.location_id, existing_lot)
continue

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class StockRule(models.Model):
_inherit = 'stock.rule'
def _get_stock_move_values(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values):
move_values = super()._get_stock_move_values(product_id, product_qty, product_uom, location_id, name, origin, company_id, values)
if values.get('product_description_variants') and values.get('group_id') and values['group_id'].pos_order_id:
move_values['description_picking'] = values['product_description_variants']
return move_values

View file

@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class Warehouse(models.Model):
_inherit = "stock.warehouse"
pos_type_id = fields.Many2one('stock.picking.type', string="Point of Sale Operation Type")
def _get_sequence_values(self, name=False, code=False):
sequence_values = super(Warehouse, self)._get_sequence_values(name=name, code=code)
sequence_values.update({
'pos_type_id': {
'name': self.name + ' ' + _('Picking POS'),
'prefix': self.code + '/' + (self.pos_type_id.sequence_code or 'POS') + '/',
'padding': 5,
'company_id': self.company_id.id,
}
})
return sequence_values
def _get_picking_type_update_values(self):
picking_type_update_values = super(Warehouse, self)._get_picking_type_update_values()
picking_type_update_values.update({
'pos_type_id': {'default_location_src_id': self.lot_stock_id.id}
})
return picking_type_update_values
def _get_picking_type_create_values(self, max_sequence):
picking_type_create_values, max_sequence = super(Warehouse, self)._get_picking_type_create_values(max_sequence)
picking_type_create_values.update({
'pos_type_id': {
'name': _('PoS Orders'),
'code': 'outgoing',
'default_location_src_id': self.lot_stock_id.id,
'default_location_dest_id': self.env.ref('stock.stock_location_customers').id,
'sequence': max_sequence + 1,
'sequence_code': 'POS',
'company_id': self.company_id.id,
'show_operations': False,
}
})
return picking_type_create_values, max_sequence + 2
@api.model
def _create_missing_pos_picking_types(self):
warehouses = self.env['stock.warehouse'].search([('pos_type_id', '=', False)])
for warehouse in warehouses:
new_vals = warehouse._create_or_update_sequences_and_picking_types()
warehouse.write(new_vals)