mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-27 04:12:05 +02:00
19.0 vanilla
This commit is contained in:
parent
79f83631d5
commit
73afc09215
6267 changed files with 1534193 additions and 1130106 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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']
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
@ -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')]
|
||||
|
|
@ -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')
|
||||
))
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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.'))
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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 []
|
||||
|
|
@ -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']
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.'))
|
||||
|
|
@ -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)
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
@ -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']
|
||||
|
|
@ -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']
|
||||
|
|
@ -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."))
|
||||
|
|
@ -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']
|
||||
|
|
@ -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']
|
||||
|
|
@ -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 customer’s 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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 customer’s 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']),
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
@ -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}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
|
|
@ -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']
|
||||
|
|
@ -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']
|
||||
|
|
@ -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']
|
||||
|
|
@ -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.'))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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']
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue