19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -1,26 +1,53 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_bank_statement
from . import pos_load_mixin
from . import account_account
from . import account_cash_rounding
from . import account_payment
from . import account_journal
from . import account_tax
from . import account_tax_group
from . import account_move
from . import pos_bus_mixin
from . import barcode_rule
from . import chart_template
from . import binary
from . import digest
from . import pos_category
from . import pos_config
from . import pos_order
from . import pos_session
from . import product
from . import product_pricelist
from . import product_attribute
from . import product_category
from . import product_product
from . import product_template
from . import uom
from . import product_combo
from . import product_combo_item
from . import res_partner
from . import res_company
from . import res_config_settings
from . import ir_http
from . import ir_module_module
from . import stock_picking
from . import stock_rule
from . import stock_reference
from . import stock_warehouse
from . import pos_payment
from . import pos_payment_method
from . import pos_bill
from . import report_sale_details
from . import pos_printer
from . import pos_note
from . import res_users
from . import decimal_precision
from . import res_country
from . import res_country_state
from . import res_lang
from . import account_fiscal_position
from . import res_currency
from . import pos_preset
from . import product_tag
from . import resource_calendar_attendance
from . import product_uom
from . import ir_sequence

View file

@ -0,0 +1,17 @@
from odoo import api, models
class AccountAccount(models.Model):
_name = 'account.account'
_inherit = ['account.account', 'pos.load.mixin']
@api.model
def _load_pos_data_fields(self, config):
return [
'id', 'non_trade',
]
@api.model
def _load_pos_data_domain(self, data, config):
property_account_receivable_ids = {partner['property_account_receivable_id'] for partner in data['res.partner']}
return [('id', 'in', property_account_receivable_ids)]

View file

@ -8,4 +8,4 @@ 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)
pos_session_id = fields.Many2one('pos.session', string="Session", copy=False, index='btree_not_null')

View file

@ -0,0 +1,21 @@
from odoo import models, api
class AccountFiscalPosition(models.Model):
_name = 'account.fiscal.position'
_inherit = ['account.fiscal.position', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data, config):
fp_ids = [preset['fiscal_position_id'] for preset in data['pos.preset']]
partner_fp_ids = list({partner['fiscal_position_id'] for partner in data['res.partner'] if partner['fiscal_position_id']}) if 'res.partner' in data.keys() else []
return [('id', 'in', config.fiscal_position_ids.ids + fp_ids + partner_fp_ids)]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'display_name', 'tax_map', 'tax_ids']
def action_archive(self):
configs = self.env['pos.config'].search([('default_fiscal_position_id', 'in', self.ids)])
configs.default_fiscal_position_id = False
return super().action_archive()

View file

@ -3,6 +3,8 @@
# Copyright (C) 2004-2008 PC Solutions (<http://pcsol.be>). All Rights Reserved
from odoo import fields, models, api, _
from odoo.exceptions import ValidationError
from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
class AccountJournal(models.Model):
_inherit = 'account.journal'
@ -16,22 +18,21 @@ class AccountJournal(models.Model):
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))
linked_payment_methods = self.env['pos.payment.method'].sudo().search([('journal_id', 'in', self.ids)], limit=1)
if linked_payment_methods:
raise ValidationError(_("You can not archive this journal because it is set on the following payment method : %s.", linked_payment_methods.name))
@api.ondelete(at_uninstall=False)
def _unlink_journal_except_with_active_payments(self):
for journal in self:
journal._check_no_active_payments()
@api.ondelete(at_uninstall=True)
def _unlink_journal_cascade_pos_payment_methods(self):
if self.env.context.get(MODULE_UNINSTALL_FLAG): # only cascade when switching CoA
self.pos_payment_method_ids.unlink()
self.env['pos.config'].search([('journal_id', 'in', self.ids)]).unlink()
def action_archive(self):
self._check_no_active_payments()
return super().action_archive()
@ -40,5 +41,20 @@ class AccountJournal(models.Model):
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)
account_ids.add(payment_method.outstanding_account_id.id)
return self.env['account.account'].browse(account_ids)
@api.model
def _ensure_company_account_journal(self):
journal = self.search([
('code', '=', 'POSS'),
('company_id', '=', self.env.company.id),
], limit=1)
if not journal:
journal = self.create({
'name': _('Point of Sale'),
'code': 'POSS',
'type': 'general',
'company_id': self.env.company.id,
})
return journal

View file

@ -1,27 +1,37 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
from odoo import fields, models, api, _
class AccountMove(models.Model):
_inherit = 'account.move'
_name = 'account.move'
_inherit = ['account.move', 'pos.load.mixin']
pos_order_ids = fields.One2many('pos.order', 'account_move')
pos_payment_ids = fields.One2many('pos.payment', 'account_move_id')
pos_refunded_invoice_ids = fields.Many2many('account.move', 'refunded_invoices', 'refund_account_move', 'original_account_move')
reversed_pos_order_id = fields.Many2one('pos.order', string="Reversed POS Order",
index='btree_not_null',
help="The pos order that was reverted after closing the session to create an invoice for it.")
pos_session_ids = fields.One2many("pos.session", "move_id", "POS Sessions")
pos_order_count = fields.Integer(compute="_compute_origin_pos_count", string='POS Order Count')
@api.depends('tax_cash_basis_created_move_ids')
@api.depends('pos_order_ids')
def _compute_origin_pos_count(self):
for move in self:
move.pos_order_count = len(move.sudo().pos_order_ids)
@api.depends('tax_cash_basis_created_move_ids', 'pos_session_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
for move in self:
if move.always_tax_exigible or move.tax_cash_basis_created_move_ids:
continue
if move.pos_session_ids:
move.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()
@ -66,20 +76,71 @@ class AccountMove(models.Model):
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
pos_payment = counterpart_line.move_id.sudo().pos_payment_ids[:1]
move.invoice_payments_widget['content'][i].update({
'pos_payment_name': pos_payment.payment_method_id.name,
})
def _compute_amount(self):
super()._compute_amount()
for move in self:
if move.move_type == 'entry' and move.reversed_pos_order_id:
move.amount_total_signed = move.amount_total_signed * -1
def _compute_tax_totals(self):
return super(AccountMove, self.with_context(linked_to_pos=bool(self.sudo().pos_order_ids)))._compute_tax_totals()
def _compute_is_storno(self):
# EXTENDS 'account'
super()._compute_is_storno()
for move in self:
move.is_storno = move.is_storno or (
move.company_id.account_storno and move.reversed_pos_order_id
)
def action_view_source_pos_orders(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id('point_of_sale.action_pos_pos_form')
if len(self.pos_order_ids) == 1:
action['views'] = [(self.env.ref('point_of_sale.view_pos_pos_form', False).id, 'form')]
action['res_id'] = self.pos_order_ids.id
else:
action['domain'] = [('id', 'in', self.pos_order_ids.ids)]
return action
def button_draft(self):
if self.sudo().pos_order_ids.filtered(lambda o: o.session_id.state != 'closed'):
self.env.user._bus_send("simple_notification", {
'type': 'danger',
'message': _("You can't reset this invoice to draft because the POS session is still open. Please close the ongoing session first, then try again."),
'sticky': True,
})
return False
return super().button_draft()
@api.model
def _load_pos_data_fields(self, config):
result = super()._load_pos_data_fields(config)
return result or ['id', 'name']
@api.model
def _load_pos_data_domain(self, data, config):
return False
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
def _stock_account_get_anglo_saxon_price_unit(self):
def _get_cogs_value(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()
price_unit = super()._get_cogs_value()
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
def _compute_name(self):
amls = self.filtered(lambda l: not l.move_id.pos_session_ids)
super(AccountMoveLine, amls)._compute_name()

View file

@ -9,11 +9,7 @@ class AccountPayment(models.Model):
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
pos_session_id = fields.Many2one('pos.session', "POS Session", index='btree_not_null')
@api.depends("force_outstanding_account_id")
def _compute_outstanding_account_id(self):
@ -22,3 +18,18 @@ class AccountPayment(models.Model):
for payment in self:
if payment.force_outstanding_account_id:
payment.outstanding_account_id = payment.force_outstanding_account_id
def _get_payment_method_codes_to_exclude(self):
res = super()._get_payment_method_codes_to_exclude()
# Sepa Credit Transfer is an outgoing payment method. It requires a partner and bank
# account. In the context of PoS orders, you can make refunds that are not linked to
# a specific customer. We ensure that account.payment are not created using the sepa_ct
# account.payment.method.line. If not, closing the session would not be possible unless
# having an account.payment.method.line with a smaller sequence than sepa_ct.
account_sepa = self.env['ir.module.module'].search([('name', '=', 'account_iso20022')])
if account_sepa.state == 'installed':
sepa_ct = self.env.ref('account_iso20022.account_payment_method_sepa_ct', raise_if_not_found=False)
if sepa_ct and 'pos_payment' in self.env.context and sepa_ct.code not in res:
res.append(sepa_ct.code)
return res

View file

@ -6,7 +6,8 @@ from odoo.tools import split_every
class AccountTax(models.Model):
_inherit = 'account.tax'
_name = 'account.tax'
_inherit = ['account.tax', 'pos.load.mixin']
def write(self, vals):
forbidden_fields = {
@ -24,5 +25,39 @@ class AccountTax(models.Model):
'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)
lines_chunk.invalidate_recordset(['tax_ids'])
return super(AccountTax, self).write(vals)
def _hook_compute_is_used(self, taxes_to_compute):
# OVERRIDE in order to fetch taxes used in pos
used_taxes = super()._hook_compute_is_used(taxes_to_compute)
taxes_to_compute -= used_taxes
if taxes_to_compute:
self.env['pos.order.line'].flush_model(['tax_ids'])
self.env.cr.execute("""
SELECT id
FROM account_tax
WHERE EXISTS(
SELECT 1
FROM account_tax_pos_order_line_rel AS pos
WHERE account_tax_id IN %s
AND account_tax.id = pos.account_tax_id
)
""", [tuple(taxes_to_compute)])
used_taxes.update([tax[0] for tax in self.env.cr.fetchall()])
return used_taxes
@api.model
def _load_pos_data_domain(self, data, config):
return self.env['account.tax']._check_company_domain(config.company_id.id)
@api.model
def _load_pos_data_fields(self, config):
return [
'id', 'name', 'price_include', 'include_base_amount', 'is_base_affected', 'has_negative_factor',
'amount_type', 'children_tax_ids', 'amount', 'company_id', 'id', 'sequence', 'tax_group_id',
]

View file

@ -0,0 +1,15 @@
from odoo import api, models
class AccountTaxGroup(models.Model):
_name = 'account.tax.group'
_inherit = ['account.tax.group', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data, config):
tax_group_ids = [tax_data['tax_group_id'] for tax_data in data['account.tax']]
return [('id', 'in', tax_group_ids)]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'pos_receipt_label']

View file

@ -0,0 +1,13 @@
from odoo import http
from odoo.http import request
from odoo.addons.web.controllers.binary import Binary
class PointOfSaleBinary(Binary):
@http.route([
'/web/image/pos.config/<id>/<string:field>',
'/web/image/pos.config/<id>/<string:field>/<int:width>x<int:height>'], type='http', auth="public")
def point_of_sale_content_image(self, field='raw', **kwargs):
if request.env.user._is_public() and field == 'customer_display_bg_img':
request.env = request.env(su=True)
return super().content_image(field=field, model='pos.config', **kwargs)

View file

@ -1,18 +0,0 @@
# -*- 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,10 @@
from odoo import models, api
class DecimalPrecision(models.Model):
_name = 'decimal.precision'
_inherit = ['decimal.precision', 'pos.load.mixin']
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'digits']

View file

@ -5,7 +5,7 @@ from odoo import fields, models, _
from odoo.exceptions import AccessError
class Digest(models.Model):
class DigestDigest(models.Model):
_inherit = 'digest.digest'
kpi_pos_total = fields.Boolean('POS Sales')
@ -14,16 +14,16 @@ class Digest(models.Model):
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'))
self._calculate_company_based_kpi(
'pos.order',
'kpi_pos_total_value',
date_field='date_order',
additional_domain=[('state', 'not in', ['draft', 'cancel']), ('account_move', '=', False)],
sum_field='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
res = super()._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,10 @@
from odoo import models
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
@classmethod
def _get_translation_frontend_modules_name(cls):
mods = super()._get_translation_frontend_modules_name()
return mods + ['point_of_sale']

View file

@ -0,0 +1,14 @@
from odoo import api, models
class IrModuleModule(models.Model):
_name = 'ir.module.module'
_inherit = ['pos.load.mixin', 'ir.module.module']
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'state']
@api.model
def _load_pos_data_domain(self, data, config):
return [('name', '=', 'pos_settle_due')]

View file

@ -0,0 +1,21 @@
from odoo import api, models, _
from odoo.exceptions import UserError
class IrSequence(models.Model):
_inherit = 'ir.sequence'
@api.ondelete(at_uninstall=False)
def _unlink_sequence(self):
configs = self.env['pos.config'].search(domain=[
'|', '|', '|',
('order_seq_id', 'in', self.ids),
('order_line_seq_id', 'in', self.ids),
('device_seq_id', 'in', self.ids),
('order_backend_seq_id', 'in', self.ids)
])
if len(configs):
raise UserError(_(
"You cannot delete a sequence used in an active POS config: %s",
configs.order_seq_id.mapped('name')
))

View file

@ -2,20 +2,29 @@ from odoo import api, fields, models, _
from odoo.exceptions import UserError
class Bill(models.Model):
_name = "pos.bill"
class PosBill(models.Model):
_name = 'pos.bill'
_order = "value"
_description = "Coins/Bills"
_inherit = ["pos.load.mixin"]
name = fields.Char("Name")
value = fields.Float("Coin/Bill Value", required=True, digits=(16, 4))
value = fields.Float("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:
except ValueError:
raise UserError(_("The name of the Coins/Bills must be a number."))
result = super().create({"name": name, "value": value})
return result.name_get()[0]
return result.id, result.display_name
@api.model
def _load_pos_data_domain(self, data, config):
return ['|', ('id', 'in', config.default_bill_ids.ids), ('pos_config_ids', '=', False)]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'value']

View file

@ -0,0 +1,40 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import uuid
from odoo import fields, models, api
class PosBusMixin(models.AbstractModel):
_name = 'pos.bus.mixin'
_description = "Bus Mixin"
access_token = fields.Char('Security Token', copy=False)
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for record in records:
record._ensure_access_token()
return records
def _ensure_access_token(self):
if self.access_token:
return self.access_token
token = self.access_token = str(uuid.uuid4())
return token
def _notify(self, *notifications, private=True) -> None:
""" Send a notification to the bus.
ex: one notification: ``self._notify('STATUS', {'status': 'closed'})``
multiple notifications: ``self._notify(('STATUS', {'status': 'closed'}), ('TABLE_ORDER_COUNT', {'count': 2}))``
"""
self.ensure_one()
self._ensure_access_token()
if isinstance(notifications[0], str):
if len(notifications) != 2:
raise ValueError("If you want to send a single notification, you must provide a name: str and a message: any")
notifications = [notifications]
for name, message in notifications:
self.env["bus.bus"]._sendone(
self.access_token, f"{self.access_token}-{name}" if private else name, message
)

View file

@ -1,37 +1,63 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from typing import List, Tuple
import random
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
class PosCategory(models.Model):
_name = "pos.category"
_name = 'pos.category'
_description = "Point of Sale Category"
_inherit = ['pos.load.mixin']
_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.'))
if self._has_cycle():
raise ValidationError(_('Error! You cannot create recursive categories.'))
def get_default_color(self):
return random.randint(0, 10)
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')
child_ids = 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)
image_512 = fields.Image("Image", max_width=512, max_height=512)
image_128 = fields.Image("Image 128", related="image_512", max_width=128, max_height=128, store=True)
color = fields.Integer('Color', required=False, default=get_default_color)
hour_until = fields.Float(string='Availability Until', default=24.0, help="The product will be available until this hour for online order and self order.")
hour_after = fields.Float(string='Availability After', default=0.0, help="The product will be available after this hour for online order and self order.")
# 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.model
def _load_pos_data_domain(self, data, config):
domain = []
if config.limit_categories:
preparation_categories = [printer['product_categories_ids'] for printer in data['pos.printer']]
flattened_preparation_categories = [item for sublist in preparation_categories for item in sublist]
domain += [('id', 'in', flattened_preparation_categories + config.iface_available_categ_ids.ids)]
return domain
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'parent_id', 'child_ids', 'write_date', 'has_image', 'color', 'sequence', 'hour_until', 'hour_after']
def _get_hierarchy(self) -> List[str]:
""" Returns a list representing the hierarchy of the categories. """
self.ensure_one()
return (self.parent_id._get_hierarchy() if self.parent_id else []) + [(self.name or '')]
@api.depends('parent_id')
def _compute_display_name(self):
for cat in self:
cat.display_name = " / ".join(cat._get_hierarchy())
@api.ondelete(at_uninstall=False)
def _unlink_except_session_open(self):
@ -43,3 +69,20 @@ class PosCategory(models.Model):
def _compute_has_image(self):
for category in self:
category.has_image = bool(category.image_128)
def _get_descendants(self):
available_categories = self
for child in self.child_ids:
available_categories |= child
available_categories |= child._get_descendants()
return available_categories
@api.constrains('hour_until', 'hour_after')
def _check_hour(self):
for category in self:
if category.hour_until and not (0.0 <= category.hour_until <= 24.0):
raise ValidationError(_('The Availability Until must be set between 00:00 and 24:00'))
if category.hour_after and not (0.0 <= category.hour_after <= 24.0):
raise ValidationError(_('The Availability After must be set between 00:00 and 24:00'))
if category.hour_until and category.hour_after and category.hour_until < category.hour_after:
raise ValidationError(_('The Availability Until must be greater than Availability After.'))

View file

@ -0,0 +1,68 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.fields import Domain
from odoo.exceptions import AccessError
class PosLoadMixin(models.AbstractModel):
_name = 'pos.load.mixin'
_description = "PoS data loading mixin"
@api.model
def _load_pos_data_search_read(self, data, config):
""" Search and return records to be loaded in the pos """
if not config:
raise ValueError("config must be provided to search for PoS data.")
domain = self._server_date_to_domain(self._load_pos_data_domain(data, config))
if domain is False:
return []
records = self.search(domain)
return self._load_pos_data_read(records, config)
@api.model
def _load_pos_data_domain(self, data, config):
""" Return the domain used to filter records """
return []
@api.model
def _server_date_to_domain(self, domain):
""" Optionally restrict the domain to records modified after the last server sync """
if domain is False:
return domain
last_server_date = self.env.context.get('pos_last_server_date', False)
limited_loading = self.env.context.get('pos_limited_loading', True)
model_included = self._name not in ['pos.session', 'pos.config']
if limited_loading and last_server_date and model_included:
domain = Domain.AND([domain, [('write_date', '>', last_server_date)]])
return domain
@api.model
def _load_pos_data_read(self, records, config):
""" Read specific fields from the given records """
if not config:
raise ValueError("config must be provided to read PoS data.")
fields = self._load_pos_data_fields(config)
records = records._filtered_access("read").read(fields, load=False)
return records or []
def _unrelevant_records(self, config):
unrelevant_record_ids = []
for record in self:
try:
if not record.active:
unrelevant_record_ids.append(record.id)
except AccessError:
# If the user has no read access, consider the record as unrelevant
unrelevant_record_ids.append(record.id)
return unrelevant_record_ids
@api.model
def _load_pos_data_fields(self, config):
""" Return the list of fields to be loaded """
return []

View file

@ -0,0 +1,27 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
class PosNote(models.Model):
_name = 'pos.note'
_description = 'PoS Note'
_inherit = ['pos.load.mixin']
_order = "sequence"
name = fields.Char(required=True)
sequence = fields.Integer('Sequence', default=1)
color = fields.Integer(string='Color')
_name_unique = models.Constraint(
'unique (name)',
'A note with this name already exists',
)
@api.model
def _load_pos_data_domain(self, data, config):
return [('id', 'in', config.note_ids.ids)] if config.note_ids else []
@api.model
def _load_pos_data_fields(self, config):
return ['name', 'color']

View file

@ -1,6 +1,7 @@
from odoo import api, fields, models, _
from odoo.tools import formatLang, float_is_zero
from odoo.exceptions import ValidationError
from uuid import uuid4
class PosPayment(models.Model):
@ -11,36 +12,56 @@ class PosPayment(models.Model):
`payment_method_id`.
"""
_name = "pos.payment"
_name = 'pos.payment'
_description = "Point of Sale Payments"
_order = "id desc"
_inherit = ['pos.load.mixin']
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.")
pos_order_id = fields.Many2one('pos.order', string='Order', required=True, index=True, ondelete='cascade')
amount = fields.Monetary(string='Amount', required=True, currency_field='currency_id', 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)
user_id = fields.Many2one('res.users', string='Employee', related='session_id.user_id')
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')
card_type = fields.Char(string='Type of card used', help='The type of the payment card (e.g. CREDIT CARD OR DEBIT CARD)')
card_brand = fields.Char(string='Brand of card', help='The brand of the payment card (e.g. Visa, AMEX, ...)')
card_no = fields.Char(string='Card Number(Last 4 Digit)')
cardholder_name = fields.Char(string='Card Owner name')
payment_ref_no = fields.Char(string='Payment reference number', help='Payment reference number from payment provider terminal')
payment_method_authcode = fields.Char(string='Payment APPR Code')
payment_method_issuer_bank = fields.Char(string='Payment Issuer Bank')
payment_method_payment_mode = fields.Char(string='Payment Mode')
transaction_id = fields.Char(string='Payment Transaction ID')
payment_status = fields.Char(string='Payment Status')
ticket = fields.Char(string='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')
uuid = fields.Char(string='Uuid', readonly=True, default=lambda self: str(uuid4()), copy=False)
def name_get(self):
res = []
_unique_uuid = models.Constraint('unique (uuid)', 'A payment with this uuid already exists')
@api.model
def _load_pos_data_domain(self, data, config):
return [('pos_order_id', 'in', [order['id'] for order in data['pos.order']])]
@api.depends('amount', 'currency_id')
def _compute_display_name(self):
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))))
payment.display_name = f'{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
payment.display_name = formatLang(self.env, payment.amount, currency_obj=payment.currency_id)
@api.constrains('amount')
def _check_amount(self):
for payment in self:
if payment.pos_order_id.state == 'done' or payment.pos_order_id.account_move:
raise ValidationError(_('You cannot edit a payment for a posted order.'))
@api.constrains('payment_method_id')
def _check_payment_method_id(self):
@ -48,100 +69,84 @@ class PosPayment(models.Model):
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:
for payment in self - change_payment:
order = payment.pos_order_id
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):
if payment_method.type == 'pay_later' or float_is_zero(payment.amount, precision_rounding=order.currency_id.rounding):
continue
payment_move = payment._create_payment_move_entry(is_reverse)
payment.write({'account_move_id': payment_move.id})
accounting_partner = self.env["res.partner"]._find_accounting_partner(payment.partner_id)
pos_session = order.session_id
journal = pos_session.config_id.journal_id
if change_payment and payment == payment_to_change:
pos_payment_ids = payment.ids + change_payment.ids
payment_amount = payment.amount + change_payment.amount
else:
pos_payment_ids = payment.ids
payment_amount = 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 %(order)s (%(account_move)s) using %(payment_method)s', order=order.name, account_move=order.account_move.name, payment_method=payment_method.name),
'pos_payment_ids': pos_payment_ids,
})
result |= payment_move
payment.write({'account_move_id': payment_move.id})
amounts = pos_session._update_amounts({'amount': 0, 'amount_converted': 0}, {'amount': payment_amount}, payment.payment_date)
credit_line_vals = pos_session._credit_amounts({
'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.
'partner_id': accounting_partner.id,
'move_id': payment_move.id,
'no_followup': False,
}, amounts['amount'], amounts['amount_converted'])
is_split_transaction = payment.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 = payment.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
debit_line_vals = pos_session._debit_amounts({
'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,
'no_followup': False,
}, amounts['amount'], amounts['amount_converted'])
self.env['account.move.line'].create([credit_line_vals, debit_line_vals])
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 _get_receivable_lines_for_invoice_reconciliation(self, receivable_account):
"""
If this payment is linked to an account.move, this returns the corresponding receivable lines
that should be reconciled with the invoice's receivable lines.
The introduced heuristics here is important for cases where the pos receivable account is the same
as the receivable account of the customer.
def _create_payment_move_entry(self, is_reverse=False):
self.ensure_one()
return self._generate_payment_move(is_reverse)
- positive payment -> negative balance lines
- negative payment -> positive balance lines
"""
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
result = self.env['account.move.line']
for payment in self:
if not payment.account_move_id:
continue
if change_payment:
pos_payment_ids += change_payment.ids
payment_amount += change_payment.amount
currency = payment.currency_id
is_positive_amount = currency.compare_amounts(payment.amount, 0) > 0
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
for line in payment.account_move_id.line_ids:
if currency.compare_amounts(line.balance, 0) == 0 or line.account_id != receivable_account or line.reconciled:
continue
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,
}
if is_positive_amount:
if currency.compare_amounts(line.balance, 0) < 0:
result |= line
else:
if currency.compare_amounts(line.balance, 0) > 0:
result |= line
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,
}
return result

View file

@ -1,21 +1,31 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.exceptions import UserError, ValidationError
class PosPaymentMethod(models.Model):
_name = "pos.payment.method"
_name = 'pos.payment.method'
_description = "Point of Sale Payment Methods"
_order = "id asc"
_order = "sequence, id"
_inherit = ['pos.load.mixin']
def _get_payment_terminal_selection(self):
return []
def _get_payment_method_type(self):
selection = [('none', self.env._("None required")), ('terminal', self.env._("Terminal"))]
if self.env['res.partner.bank'].get_available_qr_methods_in_sequence():
selection.append(('qr_code', self.env._("Bank App (QR Code)")))
return selection
def _is_online_payment(self):
return False
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.')
sequence = fields.Integer(copy=False)
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.')
help='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',
@ -25,8 +35,10 @@ class PosPaymentMethod(models.Model):
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'))],
domain=['|', '&', ('type', '=', 'cash'), ('pos_payment_method_ids', '=', False), ('type', '=', 'bank')],
ondelete='restrict',
index='btree_not_null',
check_company=True,
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'
@ -37,19 +49,58 @@ class PosPaymentMethod(models.Model):
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')
config_ids = fields.Many2many('pos.config', string='Point of Sale')
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company)
default_pos_receivable_account_name = fields.Char(related="company_id.account_default_pos_receivable_account_id.display_name", string="Default Receivable Account Name")
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")
image = fields.Image("Image", max_width=50, max_height=50)
payment_method_type = fields.Selection(selection=lambda self: self._get_payment_method_type(), string="Integration", default='none', required=True)
default_qr = fields.Char(compute='_compute_qr')
qr_code_method = fields.Selection(
string='QR Code Format', copy=False,
selection=lambda self: self.env['res.partner.bank'].get_available_qr_methods_in_sequence(),
help='Type of QR-code to be generated for this payment method.',
)
hide_qr_code_method = fields.Boolean(compute='_compute_hide_qr_code_method')
@api.depends('type')
@api.model
def get_provider_status(self, modules_list):
return {
'state': self.env['ir.module.module'].search_read([('name', 'in', modules_list)], ['name', 'state']),
}
@api.model
def _load_pos_data_domain(self, data, config):
return ['|', ('active', '=', False), ('active', '=', True)]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'is_cash_count', 'use_payment_terminal', 'split_transactions', 'type', 'image', 'sequence', 'payment_method_type', 'default_qr']
@api.depends('type', 'payment_method_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')
payment_method.hide_use_payment_terminal = no_terminals or payment_method.type in ('cash', 'pay_later') or payment_method.payment_method_type != 'terminal'
@api.depends('payment_method_type')
def _compute_hide_qr_code_method(self):
for payment_method in self:
payment_method.hide_qr_code_method = payment_method.payment_method_type != 'qr_code' or len(self.env['res.partner.bank'].get_available_qr_methods_in_sequence()) == 1
@api.onchange('payment_method_type')
def _onchange_payment_method_type(self):
# We don't display the field if there is only one option and cannot set a default on it
if self.payment_method_type == 'none':
self.use_payment_terminal = False
selection_options = self.env['res.partner.bank'].get_available_qr_methods_in_sequence()
if len(selection_options) == 1:
self.qr_code_method = selection_options[0][0]
@api.onchange('use_payment_terminal')
def _onchange_use_payment_terminal(self):
@ -74,6 +125,9 @@ class PosPaymentMethod(models.Model):
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 pm.journal_id and pm.journal_id.type == 'bank':
chart_template = self.with_context(allowed_company_ids=self.env.company.root_id.ids).env['account.chart.template']
pm.outstanding_account_id = chart_template.ref('account_journal_payment_debit_account_id', raise_if_not_found=False) or self.company_id.transfer_account_id
if self.is_cash_count:
self.use_payment_terminal = False
@ -83,14 +137,101 @@ class PosPaymentMethod(models.Model):
pm.is_cash_count = pm.type == 'cash'
def _is_write_forbidden(self, fields):
return bool(fields and self.open_session_ids)
whitelisted_fields = {'sequence'}
return bool(fields - whitelisted_fields and self.open_session_ids)
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('payment_method_type', False):
self._force_payment_method_type_values(vals, vals['payment_method_type'])
return super().create(vals_list)
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):
if 'payment_method_type' in vals:
self._force_payment_method_type_values(vals, vals['payment_method_type'])
return super().write(vals)
pmt_terminal = self.filtered(lambda pm: pm.payment_method_type == 'terminal')
pmt_qr = self.filtered(lambda pm: pm.payment_method_type == 'qr_code')
not_pmt = self - pmt_terminal - pmt_qr
res = True
forced_vals = vals.copy()
if pmt_terminal:
self._force_payment_method_type_values(forced_vals, 'terminal', True)
res = super(PosPaymentMethod, pmt_terminal).write(forced_vals) and res
if pmt_qr:
self._force_payment_method_type_values(forced_vals, 'qr_code', True)
res = super(PosPaymentMethod, pmt_qr).write(forced_vals) and res
if not_pmt:
res = super(PosPaymentMethod, not_pmt).write(vals) and res
return res
@staticmethod
def _force_payment_method_type_values(vals, payment_method_type, if_present=False):
if payment_method_type == 'terminal':
disabled_fields_name = ['qr_code_method']
elif payment_method_type == 'qr_code':
disabled_fields_name = ['use_payment_terminal']
else:
disabled_fields_name = ['use_payment_terminal', 'qr_code_method']
if if_present:
for name in disabled_fields_name:
if name in vals:
vals[name] = False
else:
for name in disabled_fields_name:
vals[name] = False
def copy_data(self, default=None):
default = dict(default or {}, config_ids=[(5, 0, 0)])
return super().copy(default)
vals_list = super().copy_data(default=default)
for pm, vals in zip(self, vals_list):
if pm.journal_id and pm.journal_id.type == 'cash':
if ('journal_id' in default and default['journal_id'] == pm.journal_id.id) or ('journal_id' not in default):
vals['journal_id'] = False
return vals_list
@api.constrains('payment_method_type', 'journal_id', 'qr_code_method')
def _check_payment_method(self):
for rec in self:
if rec.payment_method_type == "qr_code":
if (rec.journal_id.type != 'bank' or not rec.journal_id.bank_account_id):
raise ValidationError(_("At least one bank account must be defined on the journal to allow registering QR code payments with Bank apps."))
if not rec.qr_code_method:
raise ValidationError(_("You must select a QR-code method to generate QR-codes for this payment method."))
error_msg = self.journal_id.bank_account_id._get_error_messages_for_qr(self.qr_code_method, False, rec.company_id.currency_id)
if error_msg:
raise ValidationError(error_msg)
@api.depends('payment_method_type', 'journal_id')
def _compute_qr(self):
for pm in self:
if pm.payment_method_type != "qr_code":
pm.default_qr = False
continue
try:
# Generate QR without amount that can then be used when the POS is offline
pm.default_qr = pm.get_qr_code(False, '', '', pm.company_id.currency_id.id, False)
except UserError:
pm.default_qr = False
def get_qr_code(self, amount, free_communication, structured_communication, currency, debtor_partner):
""" Generates and returns a QR-code
"""
self.ensure_one()
if self.payment_method_type != "qr_code" or not self.qr_code_method:
raise UserError(_("This payment method is not configured to generate QR codes."))
payment_bank = self.journal_id.bank_account_id
debtor_partner = self.env['res.partner'].browse(debtor_partner)
currency = self.env['res.currency'].browse(currency)
return payment_bank.with_context(is_online_qr=True).build_qr_code_base64(
float(amount), free_communication, structured_communication, currency, debtor_partner, self.qr_code_method, silent_errors=False)

View file

@ -0,0 +1,117 @@
from odoo import fields, models, api, _
from odoo.exceptions import ValidationError, UserError
from datetime import datetime, timedelta
from collections import defaultdict
class PosPreset(models.Model):
_name = 'pos.preset'
_inherit = ['pos.load.mixin']
_description = 'Easily load a set of configuration options'
name = fields.Char(string='Label', required=True, translate=True)
pricelist_id = fields.Many2one('product.pricelist', string='Pricelist')
fiscal_position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position')
identification = fields.Selection([('none', 'Not required'), ('address', 'Address'), ('name', 'Name')], default="none", string='Identification', required=True)
is_return = fields.Boolean(string='Return mode', default=False, help="All quantity in the cart will be in negative. Ideal for return managment.")
color = fields.Integer(string='Color', default=0)
image_512 = fields.Image(string='Image', max_width=512, max_height=512)
image_128 = fields.Image(string='Image 128', related="image_512", max_width=128, max_height=128, store=True)
has_image = fields.Boolean(compute='_compute_has_image')
count_linked_orders = fields.Integer(compute='_compute_count_linked_orders')
count_linked_config = fields.Integer(compute='_compute_count_linked_config')
# Timing options
use_timing = fields.Boolean(string='Manage orders by time', default=False)
resource_calendar_id = fields.Many2one('resource.calendar', 'Resource')
attendance_ids = fields.One2many(related="resource_calendar_id.attendance_ids", string="Attendances", readonly=False)
slots_per_interval = fields.Integer(string='Capacity', default=5)
interval_time = fields.Integer(string='Interval time (in min)', default=20)
@api.constrains('attendance_ids')
def _check_slots(self):
for preset in self:
for attendance in preset.attendance_ids:
if attendance.hour_from % 24 >= attendance.hour_to % 24:
raise ValidationError(_('The start time must be before the end time.'))
@api.model
def _load_pos_data_domain(self, data, config):
preset_ids = config.available_preset_ids.ids + [config.default_preset_id.id]
return [('id', 'in', preset_ids)]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'pricelist_id', 'fiscal_position_id', 'is_return', 'color', 'has_image', 'write_date', 'identification',
'use_timing', 'slots_per_interval', 'interval_time', 'attendance_ids']
def _compute_count_linked_orders(self):
for record in self:
record.count_linked_orders = self.env['pos.order'].search_count([('preset_id', 'in', record.ids)])
def _compute_count_linked_config(self):
for record in self:
record.count_linked_config = self.env['pos.config'].search_count([
'|', ('default_preset_id', 'in', record.ids),
('available_preset_ids', 'in', record.ids)
])
@api.depends('has_image')
def _compute_has_image(self):
for record in self:
record.has_image = bool(record.image_512)
# Slots are created directly here in the form of dates, to avoid polluting
# the database with a “slots” model. All we need is the slot time, and with the preset
# information we can deduce the maximum occupancy per slot.
def get_available_slots(self):
self.ensure_one()
usage = self._compute_slots_usage()
return {
'usage_utc': usage,
}
def _compute_slots_usage(self):
usage = defaultdict(int)
orders = self.env['pos.order'].search([
('preset_id', '=', self.id),
('session_id.state', '=', 'opened'),
('preset_time', '!=', False),
('state', 'in', ['draft', 'paid']),
('create_date', '>=', fields.Datetime.now() - timedelta(days=1))
])
for order in orders:
sql_datetime_str = order.preset_time.strftime("%Y-%m-%d %H:%M:%S")
if not usage[sql_datetime_str]:
usage[sql_datetime_str] = []
usage[sql_datetime_str].append(order.id)
return usage
def action_open_linked_orders(self):
self.ensure_one()
return {
'name': _('Linked Orders'),
'view_mode': 'list',
'res_model': 'pos.order',
'type': 'ir.actions.act_window',
'domain': [('preset_id', '=', self.id)],
}
def action_open_linked_config(self):
self.ensure_one()
return {
'name': _('Linked POS Configurations'),
'view_mode': 'list',
'res_model': 'pos.config',
'type': 'ir.actions.act_window',
'domain': ['|', ('default_preset_id', '=', self.id), ('available_preset_ids', 'in', self.id)]
}
@api.ondelete(at_uninstall=False)
def _unlink_except_used_preset(self):
for preset in self:
if preset.count_linked_config:
raise UserError(_('You cannot delete a preset that is linked to a POS configuration.'))

View file

@ -0,0 +1,81 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from base64 import b32encode
from hashlib import sha256
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
def format_epson_certified_domain(serial_number):
"""Epson printers can be configured to use a wildcard certificate,
for a domain name derived from the printer serial number.
:param serial_number: The printer serial number or an IP address.
:return: The corresponding domain name, or the original IP address.
"""
if "." in serial_number:
# If the field is provided an epson serial number, convert it to a domain name
# Note: serial numbers should not contain dots, as IPs or URLs would.
return serial_number
epson_domain = "omnilinkcert.epson.biz"
sha256_hash = sha256(serial_number.encode()).digest()
base32_text = b32encode(sha256_hash).decode().rstrip("=")
return f"{base32_text.lower()}.{epson_domain}"
class PosPrinter(models.Model):
_name = 'pos.printer'
_description = 'Point of Sale Printer'
_inherit = ['pos.load.mixin']
name = fields.Char('Printer Name', required=True, default='Printer', help='An internal identification of the printer')
printer_type = fields.Selection(
string='Printer Type',
default='iot',
selection=[
('iot', 'Use a printer connected to the IoT Box'),
('epson_epos', 'Use an Epson printer'),
]
)
proxy_ip = fields.Char('Proxy IP Address', help="The IP Address or hostname of the Printer's hardware proxy")
product_categories_ids = fields.Many2many('pos.category', 'printer_category_rel', 'printer_id', 'category_id', string='Printed Product Categories')
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
pos_config_ids = fields.Many2many('pos.config', 'pos_config_printer_rel', 'printer_id', 'config_id')
epson_printer_ip = fields.Char(
string='Epson Printer IP Address',
help=(
"Local IP address of an Epson receipt printer, or its serial number if the "
"'Automatic Certificate Update' option is enabled in the printer settings."
),
default="0.0.0.0"
)
@api.model
def _load_pos_data_domain(self, data, config):
return [('id', 'in', config.printer_ids.ids)]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'proxy_ip', 'product_categories_ids', 'printer_type', 'epson_printer_ip']
@api.model
def use_local_network_access(self):
use_lna = bool(self.env['ir.config_parameter'].sudo().get_param('point_of_sale.use_lna'))
return {
'use_lna': use_lna
}
@api.constrains('epson_printer_ip')
def _constrains_epson_printer_ip(self):
for record in self:
if record.printer_type == 'epson_epos' and not record.epson_printer_ip:
raise ValidationError(_("Epson Printer IP Address cannot be empty."))
@api.onchange("epson_printer_ip")
def _onchange_epson_printer_ip(self):
for rec in self:
if rec.epson_printer_ip:
rec.epson_printer_ip = format_epson_certified_domain(rec.epson_printer_ip)

View file

@ -1,117 +0,0 @@
# -*- 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,73 @@
from odoo import api, fields, models
class ProductAttribute(models.Model):
_name = 'product.attribute'
_inherit = ['product.attribute', 'pos.load.mixin']
@api.model
def _load_pos_data_fields(self, config):
return ['name', 'display_type', 'create_variant']
class ProductAttributeCustomValue(models.Model):
_name = 'product.attribute.custom.value'
_inherit = ["product.attribute.custom.value", "pos.load.mixin"]
pos_order_line_id = fields.Many2one('pos.order.line', string="PoS Order Line", ondelete='cascade', index='btree_not_null')
@api.model
def _load_pos_data_domain(self, data, config):
return [('pos_order_line_id', 'in', [line['id'] for line in data['pos.order.line']])]
@api.model
def _load_pos_data_fields(self, config):
return ['custom_value', 'custom_product_template_attribute_value_id', 'pos_order_line_id', 'write_date']
class ProductTemplateAttributeLine(models.Model):
_name = 'product.template.attribute.line'
_inherit = ['product.template.attribute.line', 'pos.load.mixin']
@api.model
def _load_pos_data_fields(self, config):
return ['display_name', 'attribute_id', 'product_template_value_ids']
@api.model
def _load_pos_data_domain(self, data, config):
loaded_product_tmpl_ids = list({p['id'] for p in data['product.template']})
return [('product_tmpl_id', 'in', loaded_product_tmpl_ids)]
class ProductTemplateAttributeValue(models.Model):
_name = 'product.template.attribute.value'
_inherit = ['product.template.attribute.value', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data, config):
ptav_ids = {ptav_id for p in data['product.product'] for ptav_id in p['product_template_variant_value_ids']}
ptav_ids.update({ptav_id for ptal in data['product.template.attribute.line'] for ptav_id in ptal['product_template_value_ids']})
return [
('ptav_active', '=', True),
('attribute_id', 'in', [attr['id'] for attr in data['product.attribute']]),
('id', 'in', list(ptav_ids)),
]
@api.model
def _load_pos_data_fields(self, config):
return ['attribute_id', 'attribute_line_id', 'product_attribute_value_id', 'price_extra', 'name', 'is_custom', 'html_color', 'image', 'exclude_for']
class ProductTemplateAttributeExclusion(models.Model):
_name = 'product.template.attribute.exclusion'
_inherit = ['product.template.attribute.exclusion', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data, config):
loaded_product_tmpl_ids = list({p['id'] for p in data['product.template']})
loaded_ptav_ids = list({ptav['id'] for ptav in data['product.template.attribute.value']})
return [('product_tmpl_id', 'in', loaded_product_tmpl_ids), ('product_template_attribute_value_id', 'in', loaded_ptav_ids)]
@api.model
def _load_pos_data_fields(self, config):
return ['value_ids', 'product_template_attribute_value_id']

View file

@ -0,0 +1,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class ProductCategory(models.Model):
_name = 'product.category'
_inherit = ['product.category', 'pos.load.mixin']
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'parent_id']

View file

@ -0,0 +1,35 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, models, fields
from odoo.exceptions import ValidationError
class ProductCombo(models.Model):
_name = 'product.combo'
_inherit = ['product.combo', 'pos.load.mixin']
qty_max = fields.Integer(string="Maximum quantity", default=1, help="Maximum number of items to select in the combo.")
qty_free = fields.Integer(string="Free quantity", default=1, help="Number of free items included in the combo.")
@api.model
def _load_pos_data_domain(self, data, config):
return [('id', 'in', list(set().union(*[product.get('combo_ids') for product in data['product.template']])))]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'combo_item_ids', 'base_price', 'qty_free', 'qty_max']
@api.constrains('qty_max')
def _check_qty_max(self):
if any(combo.qty_max < 1 for combo in self):
raise ValidationError(_("The maximum quantity of a combo must be greater or equal to 1."))
@api.constrains('qty_free')
def _check_qty_free(self):
if any(combo.qty_free < 0 for combo in self):
raise ValidationError(_("The free quantity of a combo must be greater or equal to 0."))
@api.constrains('qty_max', 'qty_free')
def _check_qty_max_greater_than_qty_free(self):
if any(combo.qty_free > combo.qty_max for combo in self):
raise ValidationError(_("The free quantity must be smaller or equal to the maximum quantity."))

View file

@ -0,0 +1,16 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class ProductComboItem(models.Model):
_name = 'product.combo.item'
_inherit = ['product.combo.item', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data, config):
return [('id', 'in', list(set().union(*[combo.get('combo_item_ids') for combo in data['product.combo']])))]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'combo_id', 'product_id', 'extra_price']

View file

@ -0,0 +1,43 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ProductPricelist(models.Model):
_name = 'product.pricelist'
_inherit = ['product.pricelist', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data, config):
pricelist_ids = [preset['pricelist_id'] for preset in data['pos.preset']]
return [('id', 'in', config._get_available_pricelists().ids + pricelist_ids)]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'display_name', 'item_ids']
class ProductPricelistItem(models.Model):
_name = 'product.pricelist.item'
_inherit = ['product.pricelist.item', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data, config):
product_tmpl_ids = [p['product_tmpl_id'] for p in data['product.product']]
product_ids = [p['id'] for p in data['product.product']]
product_categ = [c['id'] for c in data['product.category']]
pricelist_ids = [p['id'] for p in data['product.pricelist']]
now = fields.Datetime.now()
return [
('pricelist_id', 'in', pricelist_ids),
'|', ('product_tmpl_id', '=', False), ('product_tmpl_id', 'in', product_tmpl_ids),
'|', ('product_id', '=', False), ('product_id', 'in', product_ids),
'|', ('date_start', '=', False), ('date_start', '<=', now),
'|', ('date_end', '=', False), ('date_end', '>=', now),
'|', ('categ_id', '=', False), ('categ_id', 'in', product_categ),
]
@api.model
def _load_pos_data_fields(self, config):
return ['product_tmpl_id', 'product_id', 'pricelist_id', 'price_surcharge', 'price_discount', 'price_round',
'price_min_margin', 'price_max_margin', 'company_id', 'currency_id', 'date_start', 'date_end', 'compute_price',
'fixed_price', 'percent_price', 'base_pricelist_id', 'base', 'categ_id', 'min_quantity']

View file

@ -0,0 +1,59 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, fields, _
from odoo.exceptions import UserError
class ProductProduct(models.Model):
_name = 'product.product'
_inherit = ['product.product', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data, config):
return [('product_tmpl_id', 'in', [p['id'] for p in data['product.template']])]
@api.model
def _load_pos_data_fields(self, config):
taxes = self.env['account.tax'].search(self.env['account.tax']._check_company_domain(config.company_id.id))
product_fields = taxes._eval_taxes_computation_prepare_product_fields()
return list(product_fields.union({
'id', 'lst_price', 'display_name', 'product_tmpl_id', 'product_template_variant_value_ids',
'product_template_attribute_value_ids', 'barcode', 'product_tag_ids', 'default_code', 'standard_price'
}))
@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(_(
"To delete a product, make sure all point of sale sessions are closed.\n\n"
"Deleting a product available in a session would be like attempting to snatch a hamburger from a customers hand mid-bite; chaos will ensue as ketchup and mayo go flying everywhere!",
))
@api.ondelete(at_uninstall=False)
def _unlink_except_special_product(self):
self.product_tmpl_id._check_is_special_product()
@api.model
def _load_pos_data_read(self, records, config):
read_records = super()._load_pos_data_read(records, config)
different_currency = config.currency_id != self.env.company.currency_id
if different_currency:
for product in read_records:
product['lst_price'] = self.env.company.currency_id._convert(
product['lst_price'], config.currency_id, self.env.company, fields.Date.today()
)
product['standard_price'] = self.env.company.currency_id._convert(
product['standard_price'], config.currency_id, self.env.company, fields.Date.today()
)
return read_records
def _can_return_content(self, field_name=None, access_token=None):
if field_name == "image_128" and self.sudo().available_in_pos:
return True
return super()._can_return_content(field_name, access_token)
def action_archive(self):
self.product_tmpl_id._ensure_unused_in_pos()
self.product_tmpl_id._check_is_special_product()
return super().action_archive()

View file

@ -0,0 +1,25 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.tools import is_html_empty
class ProductTag(models.Model):
_name = 'product.tag'
_inherit = ['product.tag', 'pos.load.mixin']
pos_description = fields.Html(string='Description', translate=True)
has_image = fields.Boolean(compute='_compute_has_image')
@api.model
def _load_pos_data_fields(self, config):
return ['name', 'pos_description', 'color', 'has_image', 'write_date']
@api.depends('has_image')
def _compute_has_image(self):
for record in self:
record.has_image = bool(record.image)
def write(self, vals):
if vals.get('pos_description') and is_html_empty(vals['pos_description']):
vals['pos_description'] = ''
return super().write(vals)

View file

@ -0,0 +1,428 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from collections import defaultdict
from odoo.tools import SQL, is_html_empty
from itertools import groupby
from operator import itemgetter
from datetime import date
from odoo.fields import Domain
class ProductTemplate(models.Model):
_name = 'product.template'
_inherit = ['product.template', 'pos.load.mixin']
@api.model
def _default_pos_sequence(self):
self.env.cr.execute('SELECT MAX(pos_sequence) FROM %s' % self._table)
max_sequence = self.env.cr.fetchone()[0]
if max_sequence is None:
return 1
return max_sequence + 1
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_ids = fields.Many2many(
'pos.category', string='Point of Sale Category',
help="Category used in the Point of Sale.")
public_description = fields.Html(
string="Product Description",
translate=True
)
pos_optional_product_ids = fields.Many2many(
comodel_name='product.template',
relation='pos_product_optional_rel',
column1='src_id',
column2='dest_id',
string="POS Optional Products",
help="Optional products are suggested when customers add items to their cart (e.g., adding a burger suggests cold drinks or fries).")
color = fields.Integer('Color Index', compute="_compute_color", store=True, readonly=False)
pos_sequence = fields.Integer(
string="POS Sequence",
help="Determine the display order in the POS Terminal",
default=_default_pos_sequence,
copy=False,
)
def write(self, vals):
# Clear empty public description content to avoid side-effects on product page
# when there is no content to display anyway.
if vals.get('public_description') and is_html_empty(vals['public_description']):
vals['public_description'] = ''
return super().write(vals)
@api.depends('pos_categ_ids')
def _compute_color(self):
"""Automatically set the color field based on the selected category."""
for product in self:
if product.pos_categ_ids:
product.color = product.pos_categ_ids[0].color
def create_product_variant_from_pos(self, attribute_value_ids, config_id):
""" Create a product variant from the POS interface. """
self.ensure_one()
pos_config = self.env['pos.config'].browse(config_id)
product_template_attribute_value_ids = self.env['product.template.attribute.value'].browse(attribute_value_ids)
product_variant = self._create_product_variant(product_template_attribute_value_ids)
return {
'product.product': product_variant.read(self.env['product.product']._load_pos_data_fields(pos_config), load=False),
}
@api.model
def _load_pos_data_domain(self, data, config):
domain = [
*self.env['product.template']._check_company_domain(config.company_id),
('available_in_pos', '=', True),
('sale_ok', '=', True),
]
if config.limit_categories:
domain += [('pos_categ_ids', 'in', config.iface_available_categ_ids.ids)]
return domain
@api.model
def load_product_from_pos(self, config_id, domain, offset=0, limit=0):
load_archived = self.env.context.get('load_archived', False)
domain = Domain(domain)
config = self.env['pos.config'].browse(config_id)
product_tmpls = self._load_product_with_domain(domain, load_archived, offset, limit)
# product.combo and product.combo.item loading
for product_tmpl in product_tmpls:
if product_tmpl.type == 'combo':
product_tmpls += product_tmpl.combo_ids.combo_item_ids.product_id.product_tmpl_id
combo_domain = Domain('id', 'in', product_tmpls.combo_ids.ids)
combo_records = self.env['product.combo'].search(combo_domain)
combo_read = self.env['product.combo']._load_pos_data_read(combo_records, config)
combo_item_domain = Domain('combo_id', 'in', product_tmpls.combo_ids.ids)
combo_item_records = self.env['product.combo.item'].search(combo_item_domain)
combo_item_read = self.env['product.combo.item']._load_pos_data_read(combo_item_records, config)
products = product_tmpls.product_variant_ids
# product.pricelist_item & product.pricelist loading
pricelists = config.current_session_id.get_pos_ui_product_pricelist_item_by_product(
product_tmpls.ids,
products.ids,
config.id
)
# product.template.attribute.value & product.template.attribute.line loading
product_tmpl_attr_line = product_tmpls.attribute_line_ids
product_tmpl_attr_line_read = product_tmpl_attr_line._load_pos_data_read(product_tmpl_attr_line, config)
product_tmpl_attr_value = product_tmpls.attribute_line_ids.product_template_value_ids
product_tmpl_attr_value_read = product_tmpl_attr_value._load_pos_data_read(product_tmpl_attr_value, config)
# product.template.attribute.exclusion loading
product_tmpl_excl = self.env['product.template.attribute.exclusion']
product_tmpl_exclusion = product_tmpl_attr_value.exclude_for + product_tmpl_excl.search([
('product_tmpl_id', 'in', product_tmpls.ids),
])
product_tmpl_exclusion_read = product_tmpl_excl._load_pos_data_read(product_tmpl_exclusion, config)
# product.product loading
product_read = products._load_pos_data_read(products.with_context(display_default_code=False), config)
# product.template loading
product_tmpl_read = self._load_pos_data_read(product_tmpls, config)
# product.uom loading
packaging_domain = Domain('product_id', 'in', products.ids)
barcode_in_domain = any('barcode' in condition.field_expr for condition in domain.iter_conditions())
if barcode_in_domain:
barcode = [condition.value for condition in domain.iter_conditions() if 'barcode' in condition.field_expr]
flat = [item for sublist in barcode for item in sublist]
packaging_domain |= Domain('barcode', 'in', flat)
product_uom = self.env['product.uom']
packaging = product_uom.search(packaging_domain)
condition = packaging and packaging.product_id
packaging_read = product_uom._load_pos_data_read(packaging, config) if condition else []
# account.tax loading
account_tax = self.env['account.tax']
tax_domain = Domain(account_tax._check_company_domain(config.company_id.id))
tax_domain &= Domain('id', 'in', product_tmpls.taxes_id.ids)
tax_read = account_tax._load_pos_data_read(account_tax.search(tax_domain), config)
return {
**pricelists,
'account.tax': tax_read,
'product.product': product_read,
'product.template': product_tmpl_read,
'product.uom': packaging_read,
'product.combo': combo_read,
'product.combo.item': combo_item_read,
'product.template.attribute.value': product_tmpl_attr_value_read,
'product.template.attribute.line': product_tmpl_attr_line_read,
'product.template.attribute.exclusion': product_tmpl_exclusion_read,
}
@api.model
def _load_pos_data_fields(self, config_id):
return [
'id', 'display_name', 'standard_price', 'categ_id', 'pos_categ_ids', 'taxes_id', 'barcode', 'name', 'list_price', 'is_favorite',
'default_code', 'to_weight', 'uom_id', 'description_sale', 'description', 'tracking', 'type', 'service_tracking', 'is_storable',
'write_date', 'color', 'pos_sequence', 'available_in_pos', 'attribute_line_ids', 'active', 'image_128', 'combo_ids', 'product_variant_ids', 'public_description',
'pos_optional_product_ids', 'sequence', 'product_tag_ids'
]
@api.model
def _load_pos_data_search_read(self, data, config):
limit_count = config.get_limited_product_count()
pos_limited_loading = self.env.context.get('pos_limited_loading', True)
if limit_count and pos_limited_loading:
query = self._search(self._load_pos_data_domain(data, config), bypass_access=True)
sql = SQL(
"""
WITH pm AS (
SELECT pp.product_tmpl_id,
MAX(sml.write_date) date
FROM stock_move_line sml
JOIN product_product pp ON sml.product_id = pp.id
GROUP BY pp.product_tmpl_id
)
SELECT product_template.id
FROM %s
LEFT JOIN pm ON product_template.id = pm.product_tmpl_id
WHERE %s
ORDER BY product_template.is_favorite DESC NULLS LAST,
CASE WHEN product_template.type = 'service' THEN 1 ELSE 0 END DESC,
pm.date DESC NULLS LAST,
product_template.write_date DESC
LIMIT %s
""",
query.from_clause,
query.where_clause or SQL("TRUE"),
limit_count,
)
product_tmpl_ids = [r[0] for r in self.env.execute_query(sql)]
products = self._load_product_with_domain([('id', 'in', product_tmpl_ids)])
else:
domain = self._load_pos_data_domain(data, config)
products = self._load_product_with_domain(domain)
product_combo = products.filtered(lambda p: p['type'] == 'combo')
products += product_combo.combo_ids.combo_item_ids.product_id.product_tmpl_id
special_products = config._get_special_products().filtered(
lambda product: not product.sudo().company_id
or product.sudo().company_id == self.env.company
)
products += special_products.product_tmpl_id
if config.tip_product_id:
tip_company_id = config.tip_product_id.sudo().company_id
if not tip_company_id or tip_company_id == self.env.company:
products += config.tip_product_id.product_tmpl_id
# Ensure optional products are loaded when configured.
if products.filtered(lambda p: p.pos_optional_product_ids):
products |= products.mapped("pos_optional_product_ids")
# Ensure products from loaded orders are loaded
if data.get('pos.order.line'):
products += self.env['product.product'].browse([l['product_id'] for l in data['pos.order.line']]).product_tmpl_id
return self._load_pos_data_read(products, config)
@api.model
def _load_pos_data_read(self, records, config):
read_records = super()._load_pos_data_read(records, config)
self._process_pos_ui_product_product(read_records, config)
return read_records
def _load_product_with_domain(self, domain, load_archived=False, offset=0, limit=0):
context = {**self.env.context, 'display_default_code': False, 'active_test': not load_archived, 'bin_size': True}
domain = self._server_date_to_domain(domain)
return self.with_context(context).search(
domain,
order='sequence,default_code,name',
offset=offset,
limit=limit if limit else False
)
def _process_pos_ui_product_product(self, products, config_id):
def filter_taxes_on_company(product_taxes, taxes_by_company):
"""
Filter the list of tax ids on a single company starting from the current one.
If there is no tax in the result, it's filtered on the parent company and so
on until a non empty result is found.
"""
taxes, comp = None, self.env.company
while not taxes and comp:
taxes = list(set(product_taxes) & set(taxes_by_company[comp.id]))
comp = comp.parent_id
return taxes
taxes = self.env['account.tax'].search(self.env['account.tax']._check_company_domain(self.env.company))
# group all taxes by company in a dict where:
# - key: ID of the company
# - values: list of tax ids
taxes_by_company = defaultdict(set)
if self.env.company.parent_id:
for tax in taxes:
taxes_by_company[tax.company_id.id].add(tax.id)
different_currency = config_id.currency_id != self.env.company.currency_id
self._add_archived_combinations(products)
for product in products:
if different_currency:
product['list_price'] = self.env.company.currency_id._convert(product['list_price'], config_id.currency_id, self.env.company, fields.Date.today())
product['standard_price'] = self.env.company.currency_id._convert(product['standard_price'], config_id.currency_id, self.env.company, fields.Date.today())
product['image_128'] = bool(product['image_128'])
if len(taxes_by_company) > 1 and len(product['taxes_id']) > 1:
product['taxes_id'] = filter_taxes_on_company(product['taxes_id'], taxes_by_company)
def _add_archived_combinations(self, products):
""" Add archived combinations to the product template data. """
product_data = {product['id']: product for product in products}
for product_tmpl in self.browse(product_data.keys()):
product = product_data[product_tmpl.id]
attribute_exclusions = product_tmpl._get_attribute_exclusions()
product['_archived_combinations'] = attribute_exclusions['archived_combinations']
excluded = {}
for ptav_id, ptav_ids in attribute_exclusions['exclusions'].items():
for ptav_id2 in set(ptav_ids) - excluded.keys():
excluded[ptav_id] = ptav_id2
product['_archived_combinations'].extend(excluded.items())
@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(_(
"To delete a product, make sure all point of sale sessions are closed.\n\n"
"Deleting a product available in a session would be like attempting to snatch a hamburger from a customers hand mid-bite; chaos will ensue as ketchup and mayo go flying everywhere!",
))
@api.ondelete(at_uninstall=False)
def _unlink_except_special_product(self):
self._check_is_special_product()
def _ensure_unused_in_pos(self):
open_pos_sessions = self.env['pos.session'].sudo().search([('state', '!=', 'closed')])
used_products = open_pos_sessions.order_ids.filtered(lambda o: o.state == "draft").lines.product_id.product_tmpl_id
if used_products & self:
raise UserError(_(
"Hold up! Archiving products while POS sessions are active is like pulling a plate mid-meal.\n"
"Make sure to close all sessions first to avoid any issues.",
))
def _check_is_special_product(self):
special_products = self.env['pos.config'].sudo()._get_special_products().product_tmpl_id
for product in self:
if product in special_products:
raise UserError(_("You cannot archive a product that is set as a special product in a Point of Sale configuration. Please change the configuration first."))
def action_archive(self):
self._ensure_unused_in_pos()
self._check_is_special_product()
return super().action_archive()
@api.onchange('sale_ok')
def _onchange_sale_ok(self):
if not self.sale_ok:
self.available_in_pos = False
@api.onchange('available_in_pos')
def _onchange_available_in_pos(self):
if self.available_in_pos and not self.sale_ok:
self.sale_ok = True
@api.constrains('available_in_pos')
def _check_combo_inclusions(self):
for product in self:
if not product.available_in_pos:
combo_name = self.env['product.combo.item'].sudo().search([('product_id', 'in', product.product_variant_ids.ids)], limit=1).combo_id.name
if combo_name:
raise UserError(_('You must first remove this product from the %s combo', combo_name))
def get_product_info_pos(self, price, quantity, pos_config_id, product_variant_id=False):
self.ensure_one()
config = self.env['pos.config'].browse(pos_config_id)
product_variant = self.env['product.product'].browse(product_variant_id) if product_variant_id else False
template_or_variant = product_variant or self.product_variant_id
# Tax related
tax_to_use = self.env['account.tax']
company = config.company_id
while not tax_to_use and company:
tax_to_use = self.taxes_id.filtered(lambda tax: tax.company_id.id == company.id)
if not tax_to_use:
company = company.sudo().parent_id
taxes = tax_to_use.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(template_or_variant, quantity) if pricelists else False
pricelist_list = [{'name': pl.name, 'price': price_per_pricelist_id[pl.id]} for pl in pricelists]
# Warehouses
warehouse_list = [
{'id': w.id,
'name': w.name,
'available_quantity': template_or_variant.with_context({'warehouse_id': w.id}).qty_available,
'free_qty': template_or_variant.with_context({'warehouse_id': w.id}).free_qty,
'forecasted_quantity': template_or_variant.with_context({'warehouse_id': w.id}).virtual_available,
'uom': template_or_variant.uom_name}
for w in self.env['stock.warehouse'].search([('company_id', '=', config.company_id.id)])]
if config.picking_type_id.warehouse_id:
# Sort the warehouse_list, prioritizing config.picking_type_id.warehouse_id
warehouse_list = sorted(
warehouse_list,
key=lambda w: w['id'] != config.picking_type_id.warehouse_id.id
)
# Suppliers
key = itemgetter('partner_id')
supplier_list = []
for _key, group in groupby(sorted(self.seller_ids, key=key), key=key):
for s in 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({
'id': s.id,
'name': s.partner_id.name,
'delay': s.delay,
'price': s.price
})
break
# Variants
variant_list = [{'name': attribute_line.attribute_id.name,
'values': [{'name': attr_name, 'search': f'{self.name} {attr_name}'} for attr_name in 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,
'optional_products': self.pos_optional_product_ids.read(['id', 'name', 'list_price']),
}

View file

@ -0,0 +1,15 @@
from odoo import api, models
class ProductUom(models.Model):
_name = 'product.uom'
_inherit = ['product.uom', 'pos.load.mixin']
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'barcode', 'product_id', 'uom_id']
@api.model
def _load_pos_data_domain(self, data, config):
product_ids = [product['id'] for product in data['product.product']]
return [('product_id', 'in', product_ids)]

View file

@ -0,0 +1,443 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import timedelta
import pytz
from odoo import api, fields, models, _
from odoo.fields import Domain
from odoo.tools import SQL
class ReportPoint_Of_SaleReport_Saledetails(models.AbstractModel):
_name = 'report.point_of_sale.report_saledetails'
_description = 'Point of Sale Details'
def _get_date_start_and_date_stop(self, date_start, date_stop):
if date_start:
date_start = fields.Datetime.from_string(date_start)
else:
# start by default today 00:00:00
user_tz = self.env.tz
today = user_tz.localize(fields.Datetime.from_string(fields.Date.context_today(self)))
date_start = today.astimezone(pytz.timezone('UTC')).replace(tzinfo=None)
if date_stop:
date_stop = fields.Datetime.from_string(date_stop)
# avoid a date_stop smaller than date_start
if (date_stop < date_start):
date_stop = date_start + timedelta(days=1, seconds=-1)
else:
# stop by default today 23:59:59
date_stop = date_start + timedelta(days=1, seconds=-1)
return date_start, date_stop
def _get_domain(self, date_start=False, date_stop=False, config_ids=False, session_ids=False):
domain = Domain('state', 'in', ['paid', 'done'])
if (session_ids):
domain &= Domain('session_id', 'in', session_ids)
else:
date_start, date_stop = self._get_date_start_and_date_stop(date_start, date_stop)
domain &= Domain('date_order', '>=', fields.Datetime.to_string(date_start))
domain &= Domain('date_order', '<=', fields.Datetime.to_string(date_stop))
if config_ids:
domain &= Domain('config_id', 'in', config_ids)
return domain
@api.model
def get_sale_details(self, date_start=False, date_stop=False, config_ids=False, session_ids=False, **kwargs):
""" Serialise the orders of the requested time period, configs and sessions.
:param date_start: The dateTime to start, default today 00:00:00.
:type date_start: str.
:param date_stop: The dateTime to stop, default date_start + 23:59:59.
:type date_stop: str.
:param config_ids: Pos Config id's to include.
:type config_ids: list of numbers.
:param session_ids: Pos Config id's to include.
:type session_ids: list of numbers.
:returns: dict -- Serialised sales.
"""
if (not session_ids):
date_start, date_stop = self._get_date_start_and_date_stop(date_start, date_stop)
domain = self._get_domain(date_start, date_stop, config_ids, session_ids, **kwargs)
orders = self.env['pos.order'].search(domain)
if config_ids:
config_currencies = self.env['pos.config'].search([('id', 'in', config_ids)]).mapped('currency_id')
else:
config_currencies = self.env['pos.session'].search([('id', 'in', session_ids)]).mapped('config_id.currency_id')
# If all the pos.config have the same currency, we can use it, else we use the company currency
if config_currencies and all(i == config_currencies.ids[0] for i in config_currencies.ids):
user_currency = config_currencies[0]
else:
user_currency = self.env.company.currency_id
total = 0.0
products_sold = {}
taxes = {
'base_amount': 0.0,
'taxes': {},
}
refund_done = {}
refund_taxes = {
'base_amount': 0.0,
'taxes': {},
}
for order in orders:
if user_currency != order.pricelist_id.currency_id:
total += order.pricelist_id.currency_id._convert(
order.amount_total, user_currency, order.company_id, order.date_order or fields.Date.today())
else:
total += order.amount_total
currency = order.session_id.currency_id
for line in order.lines:
if not line.order_id.is_refund:
products_sold, taxes = self._get_products_and_taxes_dict(line, products_sold, taxes, currency)
else:
refund_done, refund_taxes = self._get_products_and_taxes_dict(line, refund_done, refund_taxes, currency)
taxes_info = self._get_taxes_info(taxes)
refund_taxes_info = self._get_taxes_info(refund_taxes)
taxes = taxes['taxes']
refund_taxes = refund_taxes['taxes']
payment_ids = self.env["pos.payment"].search([('pos_order_id', 'in', orders.ids)]).ids
if payment_ids:
method_name = self.env['pos.payment.method']._field_to_sql('method', 'name')
self.env.cr.execute(SQL("""
SELECT method.id as id, payment.session_id as session, %(method_name)s as name, method.is_cash_count as cash,
sum(amount) total, method.journal_id journal_id
FROM pos_payment AS payment,
pos_payment_method AS method
WHERE payment.payment_method_id = method.id
AND payment.id IN %(payment_ids)s
GROUP BY method.name, method.is_cash_count, payment.session_id, method.id, journal_id
ORDER BY method.id, payment.session_id
""", method_name=method_name, payment_ids=tuple(payment_ids)))
payments = self.env.cr.dictfetchall()
else:
payments = []
configs = []
sessions = []
if config_ids:
configs = self.env['pos.config'].search([('id', 'in', config_ids)])
if session_ids:
sessions = self.env['pos.session'].search([('id', 'in', session_ids)])
else:
sessions = self.env['pos.session'].search([('config_id', 'in', configs.ids), ('start_at', '>=', date_start), ('stop_at', '<=', date_stop)])
else:
sessions = self.env['pos.session'].search([('id', 'in', session_ids)])
for session in sessions:
configs.append(session.config_id)
for payment in payments:
payment['count'] = False
for session in sessions:
cash_counted = 0
if session.cash_register_balance_end_real:
cash_counted = session.cash_register_balance_end_real
is_cash_method = False
for payment in payments:
account_payments = self.env['account.payment'].search([('pos_session_id', '=', session.id)])
if payment['session'] == session.id:
if not payment['cash']:
ref_value = "Closing difference in %s (%s)" % (payment['name'], session.name)
account_move = self.env['account.move'].search([("ref", "=", ref_value)], limit=1)
if account_move:
payment_method = self.env['pos.payment.method'].browse(payment['id'])
is_loss = any(l.account_id == payment_method.journal_id.loss_account_id for l in account_move.line_ids)
is_profit = any(l.account_id == payment_method.journal_id.profit_account_id for l in account_move.line_ids)
payment['final_count'] = payment['total']
payment['money_difference'] = -account_move.amount_total if is_loss else account_move.amount_total
payment['money_counted'] = payment['final_count'] + payment['money_difference']
payment['cash_moves'] = []
if is_profit:
move_name = 'Difference observed during the counting (Profit)'
payment['cash_moves'] = [{'name': move_name, 'amount': payment['money_difference']}]
elif is_loss:
move_name = 'Difference observed during the counting (Loss)'
payment['cash_moves'] = [{'name': move_name, 'amount': payment['money_difference']}]
payment['count'] = True
elif payment['id'] in account_payments.mapped('pos_payment_method_id.id'):
account_payment = account_payments.filtered(lambda p: p.pos_payment_method_id.id == payment['id'])
payment['final_count'] = payment['total']
payment['money_counted'] = sum(account_payment.mapped('amount_signed'))
payment['money_difference'] = payment['money_counted'] - payment['final_count']
payment['cash_moves'] = []
if payment['money_difference'] > 0:
move_name = 'Difference observed during the counting (Profit)'
payment['cash_moves'] = [{'name': move_name, 'amount': payment['money_difference']}]
elif payment['money_difference'] < 0:
move_name = 'Difference observed during the counting (Loss)'
payment['cash_moves'] = [{'name': move_name, 'amount': payment['money_difference']}]
payment['count'] = True
else:
is_cash_method = True
payment['final_count'] = payment['total'] + session.cash_register_balance_start + session.cash_real_transaction
payment['money_counted'] = cash_counted
payment['money_difference'] = payment['money_counted'] - payment['final_count']
cash_moves = self.env['account.bank.statement.line'].search([('pos_session_id', '=', session.id)])
cash_in_out_list = []
cash_in_count = 0
cash_out_count = 0
if session.cash_register_balance_start > 0:
cash_in_out_list.append({
'name': _('Cash Opening'),
'amount': session.cash_register_balance_start,
})
for cash_move in cash_moves:
if cash_move.amount > 0:
cash_in_count += 1
name = f'Cash in {cash_in_count}'
else:
cash_out_count += 1
name = f'Cash out {cash_out_count}'
if cash_move.move_id.journal_id.id == payment['journal_id']:
cash_in_out_list.append({
'name': cash_move.payment_ref if cash_move.payment_ref else name,
'amount': cash_move.amount
})
payment['cash_moves'] = cash_in_out_list
payment['count'] = True
if not is_cash_method:
cash_name = _('Cash %(session_name)s', session_name=session.name)
previous_session = self.env['pos.session'].search([('id', '<', session.id), ('state', '=', 'closed'), ('config_id', '=', session.config_id.id)], limit=1)
final_count = previous_session.cash_register_balance_end_real + session.cash_real_transaction
cash_difference = session.cash_register_balance_end_real - final_count
cash_moves = self.env['account.bank.statement.line'].search([('pos_session_id', '=', session.id)], order='date asc')
cash_in_out_list = []
if previous_session.cash_register_balance_end_real > 0:
cash_in_out_list.append({
'name': _('Cash Opening'),
'amount': previous_session.cash_register_balance_end_real,
})
# If there is a cash difference, we remove the last cash move which is the cash difference
if session.currency_id.round(cash_difference) != 0:
cash_moves = cash_moves[:-1]
for cash_move in cash_moves:
cash_in_out_list.append({
'name': cash_move.payment_ref,
'amount': cash_move.amount
})
payments.insert(0, {
'name': cash_name,
'total': 0,
'final_count': final_count,
'money_counted': session.cash_register_balance_end_real,
'money_difference': cash_difference,
'cash_moves': cash_in_out_list,
'count': True,
'session': session.id,
})
products = []
refund_products = []
for category_name, product_list in products_sold.items():
category_dictionnary = {
'name': category_name,
'products': sorted([{
'product_id': product.id,
'product_name': product.display_name,
'barcode': product.barcode,
'quantity': qty,
'price_unit': price_unit,
'discount': discount,
'uom': product.uom_id.name,
'total_paid': product_total,
'base_amount': base_amount,
'combo_products_label': combo_products_label,
} for (product, price_unit, discount), (qty, product_total, base_amount, combo_products_label) in product_list.items()], key=lambda l: l['product_name']),
}
products.append(category_dictionnary)
products = sorted(products, key=lambda l: str(l['name']))
for category_name, product_list in refund_done.items():
category_dictionnary = {
'name': category_name,
'products': sorted([{
'product_id': product.id,
'product_name': product.display_name,
'barcode': product.barcode,
'quantity': qty,
'price_unit': price_unit,
'discount': discount,
'uom': product.uom_id.name,
'total_paid': product_total,
'base_amount': base_amount,
'combo_products_label': combo_products_label,
} for (product, price_unit, discount), (qty, product_total, base_amount, combo_products_label) in product_list.items()], key=lambda l: l['product_name']),
}
refund_products.append(category_dictionnary)
refund_products = sorted(refund_products, key=lambda l: str(l['name']))
products, products_info = self.with_context(config_id=configs[0].id if len(configs) > 0 else False)._get_total_and_qty_per_category(products)
refund_products, refund_info = self.with_context(config_id=configs[0].id if len(configs) > 0 else False)._get_total_and_qty_per_category(refund_products)
currency = {
'symbol': user_currency.symbol,
'position': True if user_currency.position == 'after' else False,
'total_paid': user_currency.round(total),
'precision': user_currency.decimal_places,
}
session_name = False
if len(sessions) == 1:
state = sessions[0].state
date_start = sessions[0].start_at
date_stop = sessions[0].stop_at
session_name = sessions[0].name
else:
state = "multiple"
config_names = []
for config in configs:
config_names.append(config.name)
discount_number = len(orders.filtered(lambda o: o.lines.filtered(lambda l: l.discount > 0)))
discount_amount = sum(l._get_discount_amount() for l in orders.lines.filtered(lambda l: l.discount > 0))
invoiceList = []
invoiceTotal = 0
totalPaymentsAmount = 0
for session in sessions:
invoiceList.append({
'name': session.name,
'invoices': session._get_invoice_total_list(),
})
invoiceTotal += session._get_total_invoice()
totalPaymentsAmount += session.total_payments_amount
payments_per_method = {}
for payment in payments:
if payment.get('id'):
method_name = self.env['pos.payment.method'].browse(payment['id']).name
payment['name'] = method_name + ' ' + self.env['pos.session'].browse(payment['session']).name
if payments_per_method.get(payment['id']):
payments_per_method[payment['id']]['total'] += payment['total']
else:
payments_per_method[payment['id']] = {
'name': method_name,
'total': payment['total'],
}
return {
'opening_note': sessions[0].opening_notes if len(sessions) == 1 else False,
'closing_note': sessions[0].closing_notes if len(sessions) == 1 else False,
'state': state,
'currency': currency,
'nbr_orders': len(orders),
'date_start': date_start,
'date_stop': date_stop,
'session_name': session_name or False,
'config_names': config_names,
'payments': payments,
'company_name': self.env.company.name,
'taxes': list(taxes.values()),
'taxes_info': taxes_info,
'products': products,
'products_info': products_info,
'refund_taxes': list(refund_taxes.values()),
'refund_taxes_info': refund_taxes_info,
'refund_info': refund_info,
'refund_products': refund_products,
'discount_number': discount_number,
'discount_amount': discount_amount,
'invoiceList': invoiceList,
'invoiceTotal': invoiceTotal,
'total_paid': totalPaymentsAmount,
'payments_per_method': payments_per_method.values(),
'show_payment_per_method': not session_ids,
}
def _get_product_total_amount(self, line):
return line.currency_id.round(line.price_unit * line.qty * (100 - line.discount) / 100.0)
def _get_products_and_taxes_dict(self, line, products, taxes, currency):
key2 = (line.product_id, line.price_unit, line.discount)
key1 = line.product_id.product_tmpl_id.pos_categ_ids[0].name if len(line.product_id.product_tmpl_id.pos_categ_ids) else _('Not Categorized')
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
products.setdefault(key1, {})
products[key1].setdefault(key2, [0.0, 0.0, 0.0, ''])
products[key1][key2][0] = round(products[key1][key2][0] + abs(line.qty), precision)
products[key1][key2][1] += self._get_product_total_amount(line)
products[key1][key2][2] += line.price_subtotal
# Name of each combo products along with the combo
if line.combo_line_ids:
combo_products_label = ' (' + ", ".join(line.combo_line_ids.product_id.mapped('name')) + ')'
products[key1][key2][3] = combo_products_label
if line.tax_ids_after_fiscal_position:
line_taxes = line.tax_ids_after_fiscal_position.sudo().compute_all(line.price_unit * (1-(line.discount or 0.0)/100.0), currency, line.qty, product=line.product_id, partner=line.order_id.partner_id or False)
base_amounts = {}
for tax in line_taxes['taxes']:
taxes['taxes'].setdefault(tax['id'], {'name': tax['name'], 'tax_amount': 0.0, 'base_amount': 0.0})
taxes['taxes'][tax['id']]['tax_amount'] += tax['amount']
base_amounts[tax['id']] = tax['base']
for tax_id, base_amount in base_amounts.items():
taxes['taxes'][tax_id]['base_amount'] += currency.round(base_amount)
else:
taxes['taxes'].setdefault(0, {'name': _('No Taxes'), 'tax_amount': 0.0, 'base_amount': 0.0})
taxes['taxes'][0]['base_amount'] += line.price_subtotal_incl
refund_sign = -1 if line.order_id.is_refund else 1
taxes['base_amount'] += line.price_subtotal * refund_sign
return products, taxes
def _get_total_and_qty_per_category(self, categories):
all_qty = 0
all_total = 0
for category_dict in categories:
qty_cat = 0
total_cat = 0
for product in category_dict['products']:
qty_cat += product['quantity']
total_cat += product['base_amount']
category_dict['total'] = total_cat
category_dict['qty'] = qty_cat
# IMPROVEMENT: It would be better if the `products` are grouped by pos.order.line.id.
unique_products = list({tuple(sorted(product.items())): product for category in categories for product in category['products']}.values())
all_qty = sum([product['quantity'] for product in unique_products])
all_total = sum([product['base_amount'] for product in unique_products])
return categories, {'total': all_total, 'qty': all_qty}
def _prepare_get_sale_details_args_kwargs(self, data):
configs = self.env['pos.config'].browse(data['config_ids'])
args = (data['date_start'], data['date_stop'], configs.ids, data['session_ids'])
kwargs = {}
return args, kwargs
@api.model
def _get_report_values(self, docids, data=None):
data = dict(data or {})
# initialize data keys with their value if provided, else None
data.update({
#If no data is provided it means that the report is called from the PoS, and docids represent the session_id
'session_ids': data.get('session_ids') or (docids if not data.get('config_ids') and not data.get('date_start') and not data.get('date_stop') else None),
'config_ids': data.get('config_ids'),
'date_start': data.get('date_start'),
'date_stop': data.get('date_stop'),
})
args, kwargs = self._prepare_get_sale_details_args_kwargs(data)
data.update(self.get_sale_details(*args, **kwargs))
return data
def _get_taxes_info(self, taxes):
total_tax_amount = 0
total_base_amount = taxes['base_amount']
for tax in taxes['taxes'].values():
total_tax_amount += tax['tax_amount']
return {'tax_amount': total_tax_amount, 'base_amount': total_base_amount}

View file

@ -1,40 +1,67 @@
# -*- coding: utf-8 -*-
from odoo import api, models, fields, _
from odoo.exceptions import ValidationError
from odoo.fields import Domain
class ResCompany(models.Model):
_inherit = 'res.company'
_name = 'res.company'
_inherit = ['res.company', 'pos.load.mixin']
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",
('closing', 'At the session closing'),
('real', 'In real time'),
], default='real', 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.")
string='Self-service invoicing',
default=True,
help="Print information on the receipt to allow the customer to easily access the invoice anytime, from Odoo's portal.")
point_of_sale_ticket_unique_code = fields.Boolean(
string='Generate a code on ticket',
help="Add a 5-digit code on the receipt to allow the user to request the invoice for an order on the portal.")
point_of_sale_ticket_portal_url_display_mode = fields.Selection([
('qr_code', 'QR code'),
('url', 'URL'),
('qr_code_and_url', 'QR code + URL'),
], default='qr_code_and_url',
string='Print',
help="Choose how the URL to the portal will be print on the receipt.",
required=True)
@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.
@api.model
def _load_pos_data_domain(self, data, config):
return [('id', '=', config.company_id.id)]
@api.model
def _load_pos_data_fields(self, config):
return [
'id', 'currency_id', 'email', 'website', 'company_registry', 'vat', 'name', 'phone', 'partner_id',
'country_id', 'state_id', 'tax_calculation_rounding_method', 'nomenclature_id', 'point_of_sale_use_ticket_qr_code',
'point_of_sale_ticket_unique_code', 'point_of_sale_ticket_portal_url_display_mode', 'street', 'city', 'zip',
'account_fiscal_country_id',
]
@api.constrains('fiscalyear_lock_date', 'tax_lock_date', 'sale_lock_date', 'hard_lock_date')
def validate_lock_dates(self):
""" This constrains makes it impossible to change the relevant lock dates if
some open POS session would violate them. Without that, these POS sessions
could not be closed (since the closing entries violate the lock dates).
"""
pos_session_model = self.env['pos.session'].sudo()
for record in self:
record = record.with_context(ignore_exceptions=True)
fiscal_lock_date = max(record.user_fiscalyear_lock_date, record.user_hard_lock_date)
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),
]
Domain("company_id", "child_of", record.id)
& Domain("state", "!=", "closed")
& Domain.OR((
Domain("start_at", "<=", fiscal_lock_date),
Domain("start_at", "<=", record.user_tax_lock_date),
# The `config_id.journal_id.type` is either 'sale' or 'misc'
Domain("config_id.journal_id.type", "=", 'sale')
& Domain("start_at", "<=", record.user_sale_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))
raise ValidationError(_("Please close all the point of sale sessions in this period before closing it. Open sessions are: %s ", sessions_str))

View file

@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
import logging
from odoo import api, fields, models
import logging
from odoo.addons.point_of_sale.models.pos_config import format_epson_certified_domain
_logger = logging.getLogger(__name__)
@ -30,20 +29,33 @@ class ResConfigSettings(models.TransientModel):
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.")
sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax", related='company_id.account_sale_tax_id', readonly=False, check_company=True)
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.")
module_pos_viva_com = fields.Boolean(string="Viva.com Payment Terminal", help="The transactions are processed by Viva.com on terminal or tap on phone.")
module_pos_razorpay = fields.Boolean(string="Razorpay Payment Terminal", help="The transactions are processed by Razorpay. Set your Razorpay credentials on the related payment method.")
module_pos_mercado_pago = fields.Boolean(string="Mercado Pago Payment Terminal", help="The transactions are processed by Mercado Pago. Set your Mercado Pago credentials on the related payment method.")
module_pos_pine_labs = fields.Boolean(string="Pine Labs Payment Terminal", help="The transactions are processed by Pine Labs. Set your Pine Labs credentials on the related payment method.")
module_pos_qfpay = fields.Boolean(string="QFPay Payment Terminal", help="The transactions are processed by QFPay. Set your QFPay credentials on the related payment method.")
module_pos_pricer = fields.Boolean(string="Pricer electronic price tags", help="Display the price of your products through electronic price tags")
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)
account_default_pos_receivable_account_id = fields.Many2one(string='Default Account Receivable (PoS)', related='company_id.account_default_pos_receivable_account_id', readonly=False, check_company=True)
barcode_nomenclature_id = fields.Many2one('barcode.nomenclature', related='company_id.nomenclature_id', readonly=False)
is_kiosk_mode = fields.Boolean(string="Is Kiosk Mode", default=False)
pos_customer_display_bg_img = fields.Image(related='pos_config_id.customer_display_bg_img', readonly=False)
pos_customer_display_bg_img_name = fields.Char(related='pos_config_id.customer_display_bg_img_name', readonly=False)
# pos.config fields
pos_use_presets = fields.Boolean(related='pos_config_id.use_presets', readonly=False)
pos_default_preset_id = fields.Many2one('pos.preset', related='pos_config_id.default_preset_id', readonly=False)
pos_available_preset_ids = fields.Many2many('pos.preset', related='pos_config_id.available_preset_ids', readonly=False)
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_module_pos_appointment = fields.Boolean(related="pos_config_id.module_pos_appointment", readonly=False)
pos_module_pos_avatax = fields.Boolean(related='pos_config_id.module_pos_avatax', readonly=False)
pos_is_order_printer = fields.Boolean(compute='_compute_pos_printer', store=True, readonly=False)
pos_printer_ids = fields.Many2many(related='pos_config_id.printer_ids', 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)
@ -52,20 +64,18 @@ class ResConfigSettings(models.TransientModel):
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_default_fiscal_position_id = fields.Many2one('account.fiscal.position', string='Default Fiscal Position', compute='_compute_pos_fiscal_positions', readonly=False, store=True, check_company=True)
pos_fiscal_position_ids = fields.Many2many('account.fiscal.position', string='Fiscal Positions', compute='_compute_pos_fiscal_positions', readonly=False, store=True, check_company=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_group_by_categ = fields.Boolean(related='pos_config_id.iface_group_by_categ', 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)
@ -74,49 +84,59 @@ class ResConfigSettings(models.TransientModel):
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_proxy_ip = fields.Char(string='IP Address', related="pos_config_id.proxy_ip", readonly=False)
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)
pos_auto_validate_terminal_payment = fields.Boolean(related='pos_config_id.auto_validate_terminal_payment', readonly=False, string="Automatically validates orders paid with a payment terminal.")
pos_trusted_config_ids = fields.Many2many(related='pos_config_id.trusted_config_ids', readonly=False, domain="[('id', '!=', pos_config_id), ('module_pos_restaurant', '=', False)]")
point_of_sale_ticket_unique_code = fields.Boolean(related='company_id.point_of_sale_ticket_unique_code', readonly=False)
pos_show_product_images = fields.Boolean(related='pos_config_id.show_product_images', readonly=False)
pos_show_category_images = fields.Boolean(related='pos_config_id.show_category_images', readonly=False)
point_of_sale_ticket_portal_url_display_mode = fields.Selection(related='company_id.point_of_sale_ticket_portal_url_display_mode', readonly=False, required=True)
pos_note_ids = fields.Many2many(related='pos_config_id.note_ids', readonly=False)
pos_module_pos_sms = fields.Boolean(related="pos_config_id.module_pos_sms", readonly=False)
pos_is_closing_entry_by_product = fields.Boolean(related='pos_config_id.is_closing_entry_by_product', readonly=False)
pos_order_edit_tracking = fields.Boolean(related="pos_config_id.order_edit_tracking", readonly=False)
pos_basic_receipt = fields.Boolean(related='pos_config_id.basic_receipt', readonly=False)
pos_fallback_nomenclature_id = fields.Many2one(related='pos_config_id.fallback_nomenclature_id', domain="[('id', '!=', barcode_nomenclature_id)]", readonly=False)
group_pos_preset = fields.Boolean(string="Presets", implied_group="point_of_sale.group_pos_preset", help="Hide or show the Presets menu in the Point of Sale configuration.")
pos_epson_printer_ip = fields.Char(related='pos_config_id.epson_printer_ip', readonly=False)
pos_use_fast_payment = fields.Boolean(related='pos_config_id.use_fast_payment', readonly=False)
pos_fast_payment_method_ids = fields.Many2many(related='pos_config_id.fast_payment_method_ids', 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
def open_payment_method_form(self):
bank_journal = self.env['account.journal'].search([('type', '=', 'bank'), ('company_id', 'in', self.env.company.parent_ids.ids)], limit=1)
return {
'type': 'ir.actions.act_window',
'res_model': 'pos.payment.method',
'views': [(False, 'form')],
'target': 'current',
'context': {
'default_config_ids': self.env.context.get('config_ids', False) or False,
'default_payment_method_type': 'terminal',
'default_use_payment_terminal': self.env.context.get('selection', False),
'default_journal_id': bank_journal.id if bank_journal else False,
'default_name': f"Bank {self.env.context.get('provider_name', False)}",
}
}
@api.model_create_multi
def create(self, vals_list):
@ -135,6 +155,9 @@ class ResConfigSettings(models.TransientModel):
if vals.get('pos_use_pricelist'):
vals['group_product_pricelist'] = True
if vals.get('pos_use_presets') is not None:
vals["group_pos_preset"] = bool(self.env["pos.config"].search_count([("use_presets", "=", True), ("id", "!=", pos_config_id)])) or vals['pos_use_presets']
for field in self._fields.values():
if field.name == 'pos_config_id':
continue
@ -162,8 +185,7 @@ class ResConfigSettings(models.TransientModel):
# 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)
pos_config.with_context(from_settings_view=True).write(pos_fields_vals)
return result
@ -189,15 +211,37 @@ class ResConfigSettings(models.TransientModel):
'context': {'pos_config_open_modal': True, 'pos_config_create_mode': True},
}
def action_pos_printer_dialog(self):
return {
'view_mode': 'form',
'res_model': 'pos.printer',
'type': 'ir.actions.act_window',
'target': 'new',
'res_id': False,
}
def pos_close_ui(self):
return self.pos_open_ui()
def pos_open_ui(self):
if self._context.get('pos_config_id'):
pos_config_id = self._context['pos_config_id']
if self.env.context.get('pos_config_id'):
pos_config_id = self.env.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
return res_config.pos_iface_print_via_proxy or (
res_config.pos_other_devices
and bool(res_config.pos_epson_printer_ip)
)
@api.depends('pos_module_pos_restaurant', 'pos_config_id')
def _compute_pos_printer(self):
for res_config in self:
res_config.update({
'pos_is_order_printer': res_config.pos_config_id.is_order_printer,
})
@api.depends('pos_limit_categories', 'pos_config_id')
def _compute_pos_iface_available_categ_ids(self):
@ -207,14 +251,6 @@ class ResConfigSettings(models.TransientModel):
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:
@ -223,7 +259,7 @@ class ResConfigSettings(models.TransientModel):
else:
res_config.pos_selectable_categ_ids = self.env['pos.category'].search([])
@api.depends('pos_iface_print_via_proxy', 'pos_config_id')
@api.depends('pos_iface_print_via_proxy', 'pos_config_id', 'pos_epson_printer_ip', 'pos_other_devices')
def _compute_pos_iface_cashdrawer(self):
for res_config in self:
if self._is_cashdrawer_displayed(res_config):
@ -263,10 +299,13 @@ class ResConfigSettings(models.TransientModel):
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)])
pricelists_in_current_currency = self.env['product.pricelist'].search([
*self.env['product.pricelist']._check_company_domain(res_config.pos_config_id.company_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]
res_config.pos_pricelist_id = False
res_config.pos_available_pricelist_ids = res_config.pos_config_id.available_pricelist_ids
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
@ -275,9 +314,6 @@ class ResConfigSettings(models.TransientModel):
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:
@ -286,14 +322,6 @@ class ResConfigSettings(models.TransientModel):
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:
@ -318,10 +346,18 @@ class ResConfigSettings(models.TransientModel):
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
@api.onchange('pos_trusted_config_ids')
def _onchange_trusted_config_ids(self):
for config in self:
removed_trusted_configs = set(config.pos_config_id.trusted_config_ids.ids) - set(config.pos_trusted_config_ids.ids)
for old in config.pos_config_id.trusted_config_ids:
if config.pos_config_id.id not in old.trusted_config_ids.ids:
old._add_trusted_config_id(config.pos_config_id)
if old.id in removed_trusted_configs:
old._remove_trusted_config_id(config.pos_config_id)
@api.onchange("pos_epson_printer_ip")
def _onchange_epson_printer_ip(self):
for rec in self:
if rec.pos_epson_printer_ip:
rec.pos_epson_printer_ip = format_epson_certified_domain(rec.pos_epson_printer_ip)

View file

@ -0,0 +1,10 @@
from odoo import models, api
class ResCountry(models.Model):
_name = 'res.country'
_inherit = ['res.country', 'pos.load.mixin']
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'code', 'vat_label']

View file

@ -0,0 +1,10 @@
from odoo import models, api
class ResCountryState(models.Model):
_name = 'res.country.state'
_inherit = ['res.country.state', 'pos.load.mixin']
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'code', 'country_id']

View file

@ -0,0 +1,18 @@
from odoo import models, api
class ResCurrency(models.Model):
_name = 'res.currency'
_inherit = ['res.currency', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data, config):
company_currency_id = config.company_id.currency_id.id
config_currency_id = config.currency_id.id
if company_currency_id != config_currency_id:
return [('id', 'in', [company_currency_id, config_currency_id])]
return [('id', '=', config_currency_id)]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'symbol', 'position', 'rounding', 'rate', 'decimal_places', 'iso_numeric']

View file

@ -0,0 +1,10 @@
from odoo import models, api
class ResLang(models.Model):
_name = 'res.lang'
_inherit = ['res.lang', 'pos.load.mixin']
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'code', 'flag_image_url', 'display_name']

View file

@ -1,11 +1,11 @@
# -*- 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 odoo.exceptions import ValidationError
class ResPartner(models.Model):
_inherit = 'res.partner'
_name = 'res.partner'
_inherit = ['res.partner', 'pos.load.mixin']
pos_order_count = fields.Integer(
compute='_compute_pos_order',
@ -13,25 +13,96 @@ class ResPartner(models.Model):
groups="point_of_sale.group_pos_user",
)
pos_order_ids = fields.One2many('pos.order', 'partner_id', readonly=True)
pos_contact_address = fields.Char('PoS Address', compute='_compute_pos_contact_address')
invoice_emails = fields.Char(compute='_compute_invoice_emails', readonly=True)
fiscal_position_id = fields.Many2one(
'account.fiscal.position',
string='Automatic Fiscal Position',
compute='_compute_fiscal_position_id',
help="Fiscal positions are used to adapt taxes and accounts for particular "
"customers or sales orders/invoices. The default value comes from the customer.",
)
@api.depends(lambda self: self._display_address_depends())
def _compute_pos_contact_address(self):
for partner in self:
partner.pos_contact_address = partner._display_address(without_company=True)
def _compute_application_statistics_hook(self):
data_list = super()._compute_application_statistics_hook()
if not self.env.user.has_group('point_of_sale.group_pos_user'):
return data_list
for partner in self.filtered('pos_order_count'):
stat_info = {'iconClass': 'fa-shopping-bag', 'value': partner.pos_order_count, 'label': _('Shopping cart'), 'tagClass': 'o_tag_color_7'}
data_list[partner.id].append(stat_info)
return data_list
@api.model
def get_new_partner(self, config_id, domain, offset):
config = self.env['pos.config'].browse(config_id)
if len(domain) == 0:
limited_partner_ids = {partner[0] for partner in config.get_limited_partners_loading(offset)}
domain += [('id', 'in', list(limited_partner_ids))]
new_partners = self.search(domain)
else:
# If search domain is not empty, we need to search inside all partners
new_partners = self.search(domain, offset=offset, limit=100)
fiscal_positions = new_partners.fiscal_position_id
return {
'res.partner': self._load_pos_data_read(new_partners, config),
'account.fiscal.position': self.env['account.fiscal.position']._load_pos_data_read(fiscal_positions, config),
}
@api.model
def _load_pos_data_domain(self, data, config):
# Collect partner IDs from loaded orders
loaded_order_partner_ids = {order['partner_id'] for order in data['pos.order']}
# Extract partner IDs from the tuples returned by get_limited_partners_loading
limited_partner_ids = {partner[0] for partner in config.get_limited_partners_loading()}
limited_partner_ids.add(self.env.user.partner_id.id) # Ensure current user is included
partner_ids = limited_partner_ids.union(loaded_order_partner_ids)
return [('id', 'in', list(partner_ids))]
def _compute_fiscal_position_id(self):
for partner in self:
partner.fiscal_position_id = self.env['account.fiscal.position'].with_company(self.env.company)._get_fiscal_position(partner)
@api.model
def _load_pos_data_fields(self, config):
return [
'id', 'name', 'street', 'street2', 'city', 'state_id', 'country_id', 'vat', 'lang', 'phone', 'zip', 'email',
'barcode', 'write_date', 'property_product_pricelist', 'parent_name', 'pos_contact_address',
'invoice_emails', 'fiscal_position_id', 'is_company', 'property_account_receivable_id',
]
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'])
all_partners = self.with_context(active_test=False).search_fetch(
[('id', 'child_of', self.ids)],
['parent_id'],
)
pos_order_data = self.env['pos.order']._read_group(
domain=[('partner_id', 'in', all_partners.ids)],
fields=['partner_id'], groupby=['partner_id']
groupby=['partner_id'], aggregates=['__count']
)
self_ids = set(self._ids)
self.pos_order_count = 0
for group in pos_order_data:
partner = self.browse(group['partner_id'][0])
for partner, count in pos_order_data:
while partner:
if partner in self:
partner.pos_order_count += group['partner_id_count']
if partner.id in self_ids:
partner.pos_order_count += count
partner = partner.parent_id
@api.depends('email', 'child_ids.type', 'child_ids.email')
def _compute_invoice_emails(self):
for record in self:
emails = [record.email] if record.email else []
emails.extend([child.email for child in record.child_ids if child.type == "invoice" and child.email])
record.invoice_emails = ', '.join(emails) if emails else ''
def action_view_pos_order(self):
'''
This function returns an action that displays the pos orders from partner.
@ -43,25 +114,13 @@ class ResPartner(models.Model):
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
def open_commercial_entity(self):
return {
**super().open_commercial_entity(),
**({'target': 'new'} if self.env.context.get('target') == 'new' else {}),
}
@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)
)
def _unlink_if_pos_no_orders(self):
if self.sudo().pos_order_ids:
raise ValidationError(_('You cannot delete a customer that has point of sales orders. You can archive it instead.'))

View file

@ -0,0 +1,22 @@
from odoo import models, api
class ResUsers(models.Model):
_name = 'res.users'
_inherit = ['res.users', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data, config):
return [('id', '=', self.env.uid)]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name', 'partner_id', 'all_group_ids']
@api.model
def _load_pos_data_read(self, records, config):
read_records = super()._load_pos_data_read(records, config)
if read_records:
read_records[0]['_role'] = 'manager' if config.group_pos_manager_id.id in read_records[0]['all_group_ids'] else 'cashier'
del read_records[0]['all_group_ids']
return read_records

View file

@ -0,0 +1,17 @@
from odoo import api, models
class ResourceCalendarAttendance(models.Model):
_name = 'resource.calendar.attendance'
_inherit = ['resource.calendar.attendance', 'pos.load.mixin']
@api.model
def _load_pos_data_domain(self, data, config):
attendance_ids = []
for preset in data['pos.preset']:
attendance_ids += preset['attendance_ids']
return [('id', 'in', attendance_ids)]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'hour_from', 'hour_to', 'dayofweek', 'day_period']

View file

@ -3,13 +3,14 @@
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_is_zero, float_compare
from odoo.tools import float_is_zero
from itertools import groupby
from collections import defaultdict
class StockPicking(models.Model):
_inherit='stock.picking'
_inherit = 'stock.picking'
pos_session_id = fields.Many2one('pos.session', index=True)
pos_order_id = fields.Many2one('pos.order', index=True)
@ -22,6 +23,7 @@ class StockPicking(models.Model):
'move_type': 'direct',
'location_id': location_id,
'location_dest_id': location_dest_id,
'state': 'draft',
}
@ -30,7 +32,7 @@ class StockPicking(models.Model):
"""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))
stockable_lines = lines.filtered(lambda l: l.product_id.type == 'consu' and not l.product_id.uom_id.is_zero(l.qty))
if not stockable_lines:
return pickings
positive_lines = stockable_lines.filtered(lambda l: l.qty > 0)
@ -74,42 +76,46 @@ class StockPicking(models.Model):
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,
'never_product_template_attribute_value_ids': first_line.attribute_value_ids.filtered(lambda a: a.attribute_id.create_variant == 'no_variant'),
}
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)
def get_grouping_key(line):
return (line.product_id.id, tuple(sorted(line.attribute_value_ids.ids)))
lines_by_product_and_attrs = groupby(sorted(lines, key=get_grouping_key), key=get_grouping_key)
move_vals = []
for dummy, olines in lines_by_product:
for _product, olines in lines_by_product_and_attrs:
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)
confirmed_moves.picked = 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
if lines and lines[0].order_id.refunded_order_id.picking_ids:
returned_lines_picking = lines[0].order_id.refunded_order_id.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
returnable_qty_by_product[(move_line.product_id.id, move_line.owner_id.id or 0)] = move_line.quantity
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
returnable_qty_by_product[keys] -= move.quantity
def _send_confirmation_email(self):
@ -117,46 +123,10 @@ class StockPicking(models.Model):
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'
_name = 'stock.picking.type'
_inherit = ['stock.picking.type', 'pos.load.mixin']
@api.depends('warehouse_id')
def _compute_hide_reservation_method(self):
@ -172,25 +142,32 @@ class StockPickingType(models.Model):
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))
raise ValidationError(_("You cannot archive '%(picking_type)s' as it is used by POS configuration '%(config)s'.", picking_type=picking_type.name, config=pos_config.name))
class ProcurementGroup(models.Model):
_inherit = 'procurement.group'
@api.model
def _load_pos_data_domain(self, data, config):
return [('id', '=', config.picking_type_id.id)]
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'use_create_lots', 'use_existing_lots']
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
vals = super()._get_new_picking_values()
orders = self.reference_ids.pos_order_ids
if orders:
order = orders.filtered(lambda o: o.is_refund and o.state == 'paid')[:1] or orders[:1]
vals['pos_session_id'] = order.session_id.id
vals['pos_order_id'] = order.id
return vals
def _key_assign_picking(self):
keys = super(StockMove, self)._key_assign_picking()
return keys + (self.group_id.pos_order_id,)
return keys + (self.reference_ids.pos_order_ids,)
@api.model
def _prepare_lines_data_dict(self, order_lines):
@ -199,22 +176,6 @@ class StockMove(models.Model):
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.
@ -232,7 +193,7 @@ class StockMove(models.Model):
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),
'|', ('company_id', '=', False), ('company_id', '=', moves[0].picking_type_id.company_id.id),
('product_id', 'in', lines.product_id.ids),
('name', 'in', lots.mapped('lot_name')),
])
@ -252,56 +213,81 @@ class StockMove(models.Model):
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)
# Check for any conversion issues in the moves before setting quantities
uoms_with_issues = set()
for move in moves_to_assign.filtered(lambda m: m.product_uom_qty and m.product_uom != m.product_id.uom_id):
converted_qty = move.product_uom._compute_quantity(
move.product_uom_qty,
move.product_id.uom_id,
rounding_method='HALF-UP'
)
if not converted_qty:
uoms_with_issues.add(
(move.product_uom.name, move.product_id.uom_id.name)
)
if uoms_with_issues:
error_message_lines = [
_("Conversion Error: The following unit of measure conversions result in a zero quantity due to rounding:")
]
for uom_from, uom_to in uoms_with_issues:
error_message_lines.append(_(' - From "%(uom_from)s" to "%(uom_to)s"', uom_from=uom_from, uom_to=uom_to))
error_message_lines.append(
_("\nThis issue occurs because the quantity becomes zero after rounding during the conversion. "
"To fix this, adjust the conversion factors or rounding method to ensure that even the smallest quantity in the original unit "
"does not round down to zero in the target unit.")
)
raise UserError('\n'.join(error_message_lines))
for move in moves_to_assign:
move.quantity = move.product_uom_qty
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:
move.move_line_ids.unlink()
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())
qty = 1 if line.product_id.tracking == 'serial' else abs(line.qty)
if existing_lots:
existing_lot = existing_lots.filtered_domain([('product_id', '=', line.product_id.id), ('name', '=', lot.lot_name)])
quant = self.env['stock.quant']
quants = self.env['stock.quant']
if existing_lot:
quant = self.env['stock.quant'].search(
quants = 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,
})
qty_left_to_assign = qty
for quant in quants:
if qty_left_to_assign <= 0:
break
qty_chg = min(qty_left_to_assign, quant.quantity)
ml_vals = dict(move._prepare_move_line_vals(qty_chg))
qty_left_to_assign -= qty_chg
ml_vals.update({
'quant_id': quant.id,
})
move_lines_to_create.append(ml_vals)
if qty_left_to_assign > 0:
ml_vals = dict(move._prepare_move_line_vals(qty_left_to_assign))
ml_vals.update({
'lot_name': existing_lot.name,
'lot_id': existing_lot.id,
})
move_lines_to_create.append(ml_vals)
else:
ml_vals = dict(move._prepare_move_line_vals(qty))
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})
self.env['stock.move.line'].create(move_lines_to_create)
else:
for move in moves_remaining:
for line in lines_data[move.product_id.id]['order_lines']:
@ -313,7 +299,5 @@ class StockMove(models.Model):
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
move._update_reserved_quantity(qty, move.location_id, lot_id=existing_lot)
continue

View file

@ -0,0 +1,9 @@
from odoo import fields, models
class StockReference(models.Model):
_inherit = 'stock.reference'
pos_order_ids = fields.Many2many(
'pos.order', 'stock_reference_pos_order_rel', 'reference_id',
'pos_order_id', string="PoS Orders")

View file

@ -1,15 +0,0 @@
# -*- 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

@ -3,16 +3,16 @@
from odoo import models, fields, api, _
class Warehouse(models.Model):
class StockWarehouse(models.Model):
_inherit = "stock.warehouse"
pos_type_id = fields.Many2one('stock.picking.type', string="Point of Sale Operation Type")
pos_type_id = fields.Many2one('stock.picking.type', string="Point of Sale Operation Type", copy=False)
def _get_sequence_values(self, name=False, code=False):
sequence_values = super(Warehouse, self)._get_sequence_values(name=name, code=code)
sequence_values = super()._get_sequence_values(name=name, code=code)
sequence_values.update({
'pos_type_id': {
'name': self.name + ' ' + _('Picking POS'),
'name': _('%(name)s Picking POS', name=self.name),
'prefix': self.code + '/' + (self.pos_type_id.sequence_code or 'POS') + '/',
'padding': 5,
'company_id': self.company_id.id,
@ -21,14 +21,14 @@ class Warehouse(models.Model):
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 = super()._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, max_sequence = super()._get_picking_type_create_values(max_sequence)
picking_type_create_values.update({
'pos_type_id': {
'name': _('PoS Orders'),
@ -38,14 +38,13 @@ class Warehouse(models.Model):
'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)])
warehouses = self.env['stock.warehouse'].with_context(active_test=False).search([('pos_type_id', '=', False)])
for warehouse in warehouses:
new_vals = warehouse._create_or_update_sequences_and_picking_types()
warehouse.write(new_vals)

View file

@ -0,0 +1,14 @@
from odoo import api, fields, models
class UomUom(models.Model):
_name = 'uom.uom'
_inherit = ['uom.uom', 'pos.load.mixin']
is_pos_groupable = fields.Boolean(string='Group Products in POS', help="Check if you want to group products of this unit in point of sale orders")
@api.model
def _load_pos_data_fields(self, config):
taxes = self.env['account.tax'].search(self.env['account.tax']._check_company_domain(config.company_id.id))
product_uom_fields = taxes._eval_taxes_computation_prepare_product_uom_fields()
return list(product_uom_fields.union({'id', 'name', 'factor', 'is_pos_groupable', 'parent_path', 'rounding'}))