chore: import enterprise addons 19.0 bulk snapshot

This commit is contained in:
1b-agent committed 2026-09-16 10:13:08 +00:00
commit ca4af030d3
39951 files changed
+15570005

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
from . import pos_blackbox_be
from . import pos_config
from . import res_config_settings
from . import pos_session
from . import res_users
from . import pos_order
from . import report_sale_details
from . import pos_payment_method
from . import pos_clock_in_out
from . import pos_order_line
from . import ir_ui_view
from . import user_report
from . import module
from . import pos_blackbox_log_device
+24
View File
@@ -0,0 +1,24 @@
from odoo import models, api
class IrUiView(models.Model):
_name = 'ir.ui.view'
_inherit = ['pos.load.mixin', 'ir.ui.view']
@api.model
def _load_pos_data_fields(self, config):
return ['id', 'name']
@api.model
def _load_pos_data_search_read(self, data, config):
res = super()._load_pos_data_search_read(data, config)
# Loading views ID here instead of '_load_pos_data_domain' to avoid Access Error on 'ir.ui.view'
res.append({
"id": self.env.ref('l10n_be_pos_blackbox.pos_session_reports_list_view').id,
"name": "pos_session_reports_list_view",
})
return res
@api.model
def _load_pos_data_domain(self, data, config):
return False
+41
View File
@@ -0,0 +1,41 @@
from odoo import models
from odoo.exceptions import UserError
from odoo.tools.translate import _
class IrModuleModule(models.Model):
_inherit = "ir.module.module"
def _blackbox_uninstall_error(self, module_name, bbox_pos_config, replace_by=None):
message = _(
"The module '%(module_name)s' cannot be uninstalled because a Blackbox PoS configuration is still active.\n"
"Blocking PoS configuration: %(configs)s"
) % {
'module_name': module_name,
'configs': ", ".join(bbox_pos_config.mapped("name")),
}
if replace_by:
message += _(
"\n\nIf you want to remove this functionality, please uninstall the '%(replace_by)s' module instead."
) % {'replace_by': replace_by}
return UserError(message)
def module_uninstall(self):
bbox_pos_config = self.env['pos.config'].search([('l10n_be_blackbox_be_id', '!=', False)], limit=1)
if bbox_pos_config:
modules_to_remove = self.mapped('name')
if (module := self.filtered(lambda m: m.name == "l10n_be_pos_blackbox")):
raise self._blackbox_uninstall_error(module.shortdesc, bbox_pos_config)
elif (module := self.filtered(lambda m: m.name == "l10n_be_pos_blackbox_settle_due")) and "pos_settle_due" not in modules_to_remove:
raise self._blackbox_uninstall_error(module.shortdesc, bbox_pos_config, replace_by="pos_settle_due")
elif (module := self.filtered(lambda m: m.name == "l10n_be_pos_blackbox_loyalty")) and "pos_loyalty" not in modules_to_remove:
raise self._blackbox_uninstall_error(module.shortdesc, bbox_pos_config, replace_by="pos_loyalty")
elif (module := self.filtered(lambda m: m.name == "l10n_be_pos_blackbox_self_order")) and "pos_self_order" not in modules_to_remove:
raise self._blackbox_uninstall_error(module.shortdesc, bbox_pos_config, replace_by="pos_self_order")
elif (module := self.filtered(lambda m: m.name == "l10n_be_pos_blackbox_urban_piper")) and "pos_urban_piper" not in modules_to_remove:
raise self._blackbox_uninstall_error(module.shortdesc, bbox_pos_config, replace_by="pos_urban_piper")
elif (module := self.filtered(lambda m: m.name == "l10n_be_pos_blackbox_hr")) and "pos_hr" not in modules_to_remove:
raise self._blackbox_uninstall_error(module.shortdesc, bbox_pos_config, replace_by="pos_hr")
return super().module_uninstall()
@@ -0,0 +1,36 @@
from odoo import models, fields, api
class PosBlackboxBe(models.Model):
_name = "pos.blackbox.be"
_inherit = ["pos.load.mixin"]
_description = "POS Blackbox BE"
name = fields.Char(compute="_compute_name")
fdm_id = fields.Char(
string="FDM ID",
required=True,
help="The ID of the blackbox is written on the back of the device.",
)
pos_config_ids = fields.One2many(
comodel_name="pos.config",
inverse_name="l10n_be_blackbox_be_id",
string="POS Configurations",
help="The POS configurations that are linked to this blackbox.",
)
local_ip = fields.Char(
string="Local IP",
help="The local IP will automatically be set when the blackbox is registered.",
)
use_lna = fields.Boolean(string="Use Local Network Access")
@api.model
def _load_pos_data_domain(self, data, config):
if not data.get('pos.config'):
return False
return [('id', '=', data['pos.config'][0]['l10n_be_blackbox_be_id'])]
@api.depends("fdm_id")
def _compute_name(self):
for record in self:
record.name = f"Blackbox - {record.fdm_id}"
@@ -0,0 +1,53 @@
from odoo import api, models, fields
class PosBlackboxLogDevice(models.Model):
_name = 'pos.blackbox.log.device'
_description = 'POS Blackbox Log Device'
device = fields.Char(string='Device Identifier', required=True)
_device_unique = models.UniqueIndex('(device)')
@api.model_create_multi
def create(self, vals_list):
records = self.browse()
for vals in vals_list:
if vals.get('device'):
existing = self.search([('device', '=', vals['device'])], limit=1)
if existing:
records |= existing
else:
records |= super().create([vals])
return records
@api.model
def log_device(self, config_id, device):
"""Authorize (and register) the physical device opening a POS
Ensure that a device already linked to a blackbox POS may no longer open a non-blackbox POS.
``device`` is an identifier persisted in the browser's local storage and passed when opening POS UI.
- Config with a blackbox -> always authorized
The device is registered only once the config has produced at least one signed order,
so the very first opening (before any sale) does not yet lock the device
- Config without a blackbox -> authorized only if the device has never been registered by a blackbox POS.
:param int config_id:
:param str device: identifier of the physical device
:return: "True" if allowed, "False" if blocked
"""
if not device:
return False
config = self.env['pos.config'].browse(config_id)
signed_order_count = self.env['pos.order'].search_count([
('config_id', '=', config_id),
('l10n_be_short_signature', '!=', False),
], limit=1)
if config.l10n_be_blackbox_be_id:
if signed_order_count:
self.create({'device': device})
return True
return not self.search_count([('device', '=', device)], limit=1)
@@ -0,0 +1,12 @@
from odoo import models, fields
class PosClockInOut(models.Model):
# This model stores clock in and clock out events for users/employees in POS sessions. It's used in the user report at the end of a session.
_name = 'pos.clock.in.out'
_description = 'POS Clock In/Out Event of user/employee'
type = fields.Selection([('in', 'Clock In'), ('out', 'Clock Out')], required=True, string='Type')
session_id = fields.Many2one('pos.session', required=True, string='POS Session')
user_id = fields.Many2one('res.users', string='User')
event_date_time = fields.Datetime(required=True, string='Event DateTime', default=fields.Datetime.now)
+243
View File
@@ -0,0 +1,243 @@
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError, UserError
from odoo.service.common import exp_version
import requests
from requests.exceptions import RequestException
FALLBACK_POS_ID = "CDEM0000000001"
class PosConfig(models.Model):
_inherit = "pos.config"
l10n_be_report_counter = fields.Integer(help="Count the number of reports generated for this POS configuration.", default=1)
l10n_be_blackbox_be_id = fields.Many2one(
comodel_name="pos.blackbox.be",
string="POS Blackbox BE",
help="The blackbox that is linked to this POS configuration.",
)
l10n_be_pos_id = fields.Char(
string="POS ID",
store=True,
compute="_compute_pos_id",
readonly=True,
help="The ID of the POS configuration. This ID is used to identify the POS configuration in the blackbox.",
)
l10n_be_training_mode = fields.Boolean(
string="Training Mode",
help="If checked, the POS will be in training mode.",
default=False,
)
pos_version = fields.Char('Odoo Version', compute='_compute_odoo_version')
establishment_number = fields.Char(string="Establishment Number", help="The establishment number of the company using this POS configuration.")
@api.depends('l10n_be_blackbox_be_id')
def _compute_pos_id(self):
for config in self:
if config.l10n_be_blackbox_be_id and (not config.l10n_be_pos_id or config.l10n_be_pos_id == FALLBACK_POS_ID):
try:
config.l10n_be_pos_id = self._get_pos_id_from_fdm()
except UserError:
config.l10n_be_pos_id = FALLBACK_POS_ID
def _get_pos_id_from_fdm(self):
"""Retrieve POS ID from FDM service"""
db_uuid = self.env['ir.config_parameter'].sudo().get_param('database.uuid')
if not db_uuid:
raise UserError(_("Database UUID not found."))
endpoint = "https://fdm.odoo.com/_fdm/pos/sequence/"
url = f"{endpoint}{db_uuid}"
try:
response = requests.get(url, timeout=60)
response.raise_for_status()
return response.text
except RequestException as e:
raise UserError(
_("Failed to retrieve POS ID from FDM service:\n%s", e)
)
def action_request_pos_id(self):
self.ensure_one()
if self.l10n_be_pos_id and self.l10n_be_pos_id != FALLBACK_POS_ID:
raise UserError(_("A valid POS ID is already set for this configuration."))
try:
self.l10n_be_pos_id = self._get_pos_id_from_fdm()
except UserError:
self.l10n_be_pos_id = FALLBACK_POS_ID
self.env.cr.commit()
raise
def write(self, vals):
if 'l10n_be_blackbox_be_id' in vals:
for config in self:
if config.has_active_session and config.l10n_be_blackbox_be_id.id != vals['l10n_be_blackbox_be_id']:
raise UserError(_(
"You cannot change the blackbox of the POS configuration %s while it has an open session. "
"Please close the session first.",
config.name,
))
if 'l10n_be_pos_id' in vals:
for config in self:
if config.has_active_session and config.l10n_be_pos_id != vals['l10n_be_pos_id']:
raise UserError(_(
"You cannot change the POS ID of the POS configuration %s while it has an open session. "
"Please close the session first.",
config.name,
))
if (vals.get('l10n_be_blackbox_be_id') or self.filtered("l10n_be_pos_id")):
# TODO-FW-19.1: adapt based on this PR: https://github.com/odoo/odoo/pull/229561
cash_rounding = self._create_default_cashrounding()
if cash_rounding:
vals['cash_rounding'] = True
vals['rounding_method'] = cash_rounding.id
vals['only_round_cash_method'] = True
vals['iface_print_auto'] = True
vals['iface_print_skip_screen'] = True
return super().write(vals)
def _check_l10n_be_before_opening(self):
for config in self.filtered('l10n_be_pos_id'):
config._check_blackbox_required()
for config in self.filtered('l10n_be_blackbox_be_id'):
config._check_cashier_l10n_be_insz_or_bis_number()
config._check_cash_rounding()
config._check_iface_printers()
config._check_config_company_data()
config._check_blackbox()
def open_ui(self):
self._check_l10n_be_before_opening()
return super().open_ui()
def _check_blackbox_required(self):
for config in self:
if config._is_blackbox_v2_required() and not config.l10n_be_blackbox_be_id:
raise ValidationError(_(
"This POS has a registered POS ID and requires a blackbox to be configured."
))
def _is_blackbox_v2_required(self):
return bool(self.l10n_be_pos_id)
def _check_iface_printers(self):
for config in self:
if not config.iface_print_auto:
raise ValidationError(_("Automatic Receipt Printing must be activated"))
if not config.iface_print_skip_screen:
raise ValidationError(_("Skip Preview Screen must be activated"))
def _check_config_company_data(self):
for config in self:
if not config.establishment_number:
raise ValidationError(_("An establishment number must be set for this POS configuration."))
if not config.company_id.vat:
raise ValidationError(_("A VAT number must be set for the company using this POS configuration."))
if not config.l10n_be_pos_id:
raise ValidationError(_("A POS ID must be set for this POS configuration."))
def _check_cashier_l10n_be_insz_or_bis_number(self):
for config in self:
if not self.env.user.l10n_be_insz_or_bis_number and not config.module_pos_hr:
raise ValidationError(
_(
"%(user)s must have an INSZ or BIS number.",
user=self.env.user.name,
)
)
def _check_cash_rounding(self):
if self.payment_method_ids.filtered(lambda p: p.is_cash_count):
if not self.cash_rounding:
raise ValidationError(_("Cash rounding must be enabled"))
if (
self.rounding_method.rounding != 0.05
or self.rounding_method.rounding_method != "HALF-UP"
):
raise ValidationError(
_('The rounding method must be set to 0.05 and "Nearest"')
)
@api.constrains('l10n_be_training_mode')
def _check_l10n_be_training_mode(self):
for record in self:
signed_order = self.env['pos.order'].search_count([
('session_id.config_id', '=', record.id),
('l10n_be_short_signature', '!=', False),
], limit=1)
if signed_order and record.l10n_be_training_mode:
raise ValidationError(_(
"You cannot enable training mode on a POS configuration that has already processed orders."
))
def _check_blackbox(self):
for config in self:
if not config.l10n_be_blackbox_be_id.local_ip:
raise ValidationError(_(
"The blackbox must have a local IP address configured."
))
@api.model
def _create_default_cashrounding(self):
cash_rounding = self.env.ref('l10n_be_pos_blackbox.default_l10n_be_cash_rounding', raise_if_not_found=False)
if cash_rounding:
return cash_rounding
if self.env.company.chart_template == "be_comp":
profit_account = self.env.ref(f'account.{self.env.company.id}_a743', raise_if_not_found=False)
loss_account = self.env.ref(f'account.{self.env.company.id}_a643', raise_if_not_found=False)
if profit_account and loss_account:
cash_rounding = self.env['account.cash.rounding'].create({
'name': _('Belgian Cash Rounding'),
'rounding_method': 'HALF-UP',
'rounding': 0.05,
'profit_account_id': profit_account.id,
'loss_account_id': loss_account.id,
})
self.env['ir.model.data']._update_xmlids([
{
'xml_id': 'l10n_be_pos_blackbox.default_l10n_be_cash_rounding',
'record': cash_rounding,
'noupdate': True,
}
])
return cash_rounding
return False
def _compute_odoo_version(self):
self.pos_version = exp_version()['server_serie']
@api.constrains('establishment_number')
def _check_pos_establishment_number(self):
for config in self:
if config.establishment_number and not config.is_valid_est_number(config.establishment_number):
raise ValidationError(
_(
"Invalid establishment number.\n\n"
"An establishment number must meet the following rules:\n"
"- It must contain exactly 10 digits\n"
"- The first digit must be between 2 and 8\n"
)
)
@api.model
def is_valid_est_number(self, value):
""" Validate an establishment unit number (estNo).
Rules:
- Length = 10
- Digits only
- Starts with a digit from '2' to '8'
- Must pass the modulo 97 check
- Example: 8789456149
"""
if not value:
return False
if not value.isdigit() or len(value) != 10:
return False
base = int(value[:8])
key = int(value[8:])
expected_key = 97 - (base % 97)
return key == expected_key
+126
View File
@@ -0,0 +1,126 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
class PosOrder(models.Model):
_inherit = "pos.order"
# FDM Signature fields (Normal event)
l10n_be_fdm_id = fields.Char(string="FDM ID", readonly=True)
l10n_be_fdm_date_time = fields.Datetime(string="FDM Date Time", readonly=True)
l10n_be_pos_id = fields.Char(string="POS ID", readonly=True)
l10n_be_terminal_id = fields.Char(string="Terminal ID", readonly=True)
l10n_be_device_id = fields.Char(string="Device ID", readonly=True)
l10n_be_pos_date_time = fields.Datetime(string="POS Date Time", readonly=True)
l10n_be_event_label = fields.Char(string="Event Label", readonly=True)
l10n_be_event_counter = fields.Integer(string="Event Counter", readonly=True)
l10n_be_total_counter = fields.Integer(string="Total Counter", readonly=True)
l10n_be_short_signature = fields.Char(string="Short Signature", readonly=True)
l10n_be_verification_url = fields.Char(string="Verification URL", readonly=True)
l10n_be_vat_calc = fields.Json(string="VAT Calculation", readonly=True)
l10n_be_footer = fields.Json(string="VAT receipt footer", readonly=True)
# FDM Signature fields (Invoice event)
l10n_be_I_total_counter = fields.Integer(string="I Total Counter", readonly=True)
l10n_be_I_event_counter = fields.Integer(string="I Event Counter", readonly=True)
l10n_be_I_event_label = fields.Char(string="I Event Label", readonly=True)
l10n_be_I_fdm_date_time = fields.Datetime(string="I FDM Date Time", readonly=True)
l10n_be_I_fdm_id = fields.Char(string="I FDM ID", readonly=True)
l10n_be_last_transaction_by_line = fields.Json(string="Last Transaction By Line uuid", readonly=True)
"""
Details of "l10n_be_last_transaction_by_line"
---------------------------------------------
Stores a snapshot of the last successfully signed transaction state, keyed by
order line UUID. It is used to compute the **delta** between the current order
state and the previously signed state, so that only the necessary corrections
(new, modified, or deleted lines) are sent to the Belgian Blackbox FDM device.
Structure::
{
"<line_uuid>": {
"transLine": {
# Full TransactionLineInput payload as sent to the FDM
"lineType": "SINGLE_PRODUCT" | "COMPOSITE_PRODUCT",
"mainProduct": { ... },
"subProducts": [ ... ], # only for COMPOSITE_PRODUCT
"lineTotal": float,
},
"lineInfos": {
# Snapshot of the pricing state at the time of the last sign
"priceUnit": float, # unit price before discount
"discount": float, # per-line discount percentage
"globalDiscount": float, # order-level global discount percentage
"qty": float, # signed quantity (0 means already corrected)
},
},
...
}
Usage:
Read during ``generateSignOrderInput()`` and ``generateSignCanceledOrderInput()``
to determine which lines are new, modified (price/quantity change), or deleted
since the previous sign call.
Written (via ``order.uiState.transactionLinesMap``) after each successful
``generateSignOrderInput()`` call, so it always reflects the last committed
FDM state.
- **New line** (UUID absent from this field): sent as-is.
- **Modified line** (UUID present but payload differs): a CORRECTION or
PRICE_CHANGE negation line is emitted first, followed by the updated line.
- **Deleted line** (UUID present here but absent from current order): a
CORRECTION negation line is emitted.
- **Cancel order**: all entries with ``lineInfos.qty != 0`` are negated and
sent as CORRECTION lines.
"""
l10n_be_grouping_id = fields.Json(string="Grouping ID by line uuid", readonly=True)
"""
Details of "l10n_be_grouping_id"
--------------------------------
Persists the monotonically-incrementing ``groupingId`` counter and the
per-line grouping-ID assignments across multiple ``signOrder`` calls on the
same order. The Blackbox FDM uses ``groupingId`` values to associate related
price-change entries (e.g. all DISCOUNT lines for the same product) with each
other across separate sign requests.
Structure::
{
"groupingIdCount": int, # global counter; incremented by _nextGroupingId()
"globalDiscountGId": int | None, # shared groupingId for ORDER-scope discount entries
"roundingAdaptationGId": int | None, # shared groupingId for ROUNDING_ADAPTATION entries
"lines": {
"<line_uuid>": {
"unitPriceChangeGId": int | None, # groupingId for UNIT_PRICE_CHANGE entries
"menuDiscountGId": int | None, # groupingId for MENU_DISCOUNT entries
"discountGId": int | None, # groupingId for DISCOUNT_* entries
},
...
}
}
Usage:
- Lazily initialised on first access via ``_getGroupingState()``.
- ``_nextGroupingId()`` atomically increments ``groupingIdCount`` and returns
the new value so each new group gets a unique, stable identifier.
- When a line's pricing attributes change between sign calls
(price-unit, per-line discount, or global discount), the relevant slot
(``unitPriceChangeGId``, ``menuDiscountGId``, ``discountGId``) is reset to
``None``, which forces a fresh ``groupingId`` to be allocated for
the corrected entries — ensuring the FDM can distinguish the new price-change group
from the previously reported one.
- Because this field is stored on the order record (readonly from the UI),
the counter and assignments survive page reloads and re-opens of the same
order.
"""
old_pos_blackbox_be_data = fields.Json(string="Old POS Blackbox BE Data", readonly=True, copy=False)
def _get_cashier(self):
self.ensure_one()
return self.user_id
@api.ondelete(at_uninstall=False)
def unlink_if_blackboxed(self):
for order in self:
if order.config_id.l10n_be_pos_id or order.old_pos_blackbox_be_data:
raise UserError(self.env._("Deleting of registered orders is not allowed."))
@@ -0,0 +1,13 @@
from odoo import models, fields, api
class PosOrderLine(models.Model):
_inherit = 'pos.order.line'
l10n_be_vats = fields.Json(string="l10n_be VATs JSON")
@api.model
def _load_pos_data_fields(self, config):
params = super()._load_pos_data_fields(config)
params += ['l10n_be_vats']
return params
@@ -0,0 +1,16 @@
from odoo import models
class PosPaymentMethod(models.Model):
_inherit = 'pos.payment.method'
def _get_mapped_payment_type(self):
""" Returns the payment type mapped for blackbox purposes. """
self.ensure_one()
type_map = {
'cash': 'CASH',
'bank': 'CARD_UNKNOWN',
'pay_later': 'CUSTOMER_CREDIT',
'online': 'ONLINE',
}
return type_map.get(self.type, "OTHER")
+600
View File
@@ -0,0 +1,600 @@
from odoo import models, fields, api, Command
from collections import defaultdict
from itertools import groupby
import base64
import uuid
import pytz
class PosSession(models.Model):
_inherit = "pos.session"
l10n_be_users_clocked_ids = fields.Many2many(
"res.users",
"users_session_clocking_info",
string="Clocked In Users",
)
pos_clock_in_out_ids = fields.One2many('pos.clock.in.out', 'session_id', string='POS Clock In/Out')
l10n_be_N_event_counter = fields.Integer()
l10n_be_N_event_amount = fields.Monetary()
l10n_be_NR_event_counter = fields.Integer()
l10n_be_NR_event_amount = fields.Monetary()
l10n_be_P_event_counter = fields.Integer()
l10n_be_P_event_amount = fields.Monetary()
l10n_be_I_event_counter = fields.Integer()
l10n_be_I_event_amount = fields.Monetary()
l10n_be_T_event_counter = fields.Integer()
l10n_be_T_event_amount = fields.Monetary()
l10n_be_sign_drawer_open_amount = fields.Integer()
l10n_be_correction_qty_count = fields.Integer()
l10n_be_correction_ticket_count = fields.Integer()
l10n_be_correction_amount = fields.Monetary()
l10n_be_price_change_qty_count = fields.Integer()
l10n_be_price_change_ticket_count = fields.Integer()
l10n_be_price_change_amount = fields.Monetary()
l10n_be_turnover_z_report_fdm_ref = fields.Json(string="Turnover Z Report FDM Reference")
l10n_be_user_z_report_fdm_ref = fields.Json(string="User Z Report FDM Reference")
booking_period_id = fields.Char(string='Booking Period ID', default=lambda self: str(uuid.uuid4()), readonly=True, copy=False)
first_fdm_ref = fields.Json(string="First FDM Reference", readonly=True, copy=False)
last_fdm_ref = fields.Json(string="Last FDM Reference", readonly=True, copy=False)
old_pos_blackbox_be_data = fields.Json(string="Old POS Blackbox BE Data", readonly=True, copy=False)
# TODO-manv: in master store this field
use_blackbox = fields.Boolean(string="Uses Blackbox", compute="_compute_use_blackbox")
@api.depends("pos_clock_in_out_ids")
def _compute_use_blackbox(self):
for session in self:
session.use_blackbox = bool(session.pos_clock_in_out_ids)
@api.model
def _load_pos_data_models(self, config_id):
return super()._load_pos_data_models(config_id) + ['pos.blackbox.be', 'ir.ui.view']
@api.model
def _load_pos_data_fields(self, config_id):
return super()._load_pos_data_fields(config_id) + ['l10n_be_users_clocked_ids', 'booking_period_id']
def is_session_closable(self, bank_payment_method_diffs=None):
self.ensure_one()
result = self._cannot_close_session(dict(bank_payment_method_diffs or []))
return not bool(result)
def set_work_in_out_cashier(self, cashier_id, action="in"):
self.ensure_one()
cashier = self.env['res.users'].browse(cashier_id)
clocked_ids = self.l10n_be_users_clocked_ids
if action == 'in' and cashier not in clocked_ids:
self.l10n_be_users_clocked_ids |= cashier
self._create_clock_events(cashier, 'in')
elif action == 'out' and cashier in clocked_ids:
self.l10n_be_users_clocked_ids -= cashier
self._create_clock_events(cashier, 'out')
def work_out_all_cashiers(self):
self.ensure_one()
clocked_ids = self.l10n_be_users_clocked_ids
self._create_clock_events(clocked_ids, 'out')
self.l10n_be_users_clocked_ids = [Command.clear()]
return clocked_ids.mapped('l10n_be_insz_or_bis_number')
def increment_l10n_be_counters(self, event_label, amount):
self.ensure_one()
if event_label == "N":
self.l10n_be_N_event_counter += 1
self.l10n_be_N_event_amount += self.currency_id.round(amount)
if amount < 0:
self.l10n_be_NR_event_counter += 1
self.l10n_be_NR_event_amount += self.currency_id.round(amount)
elif event_label == "P":
self.l10n_be_P_event_counter += 1
self.l10n_be_P_event_amount += self.currency_id.round(amount)
elif event_label == "I":
self.l10n_be_I_event_counter += 1
self.l10n_be_I_event_amount += self.currency_id.round(amount)
elif event_label == "T":
self.l10n_be_T_event_counter += 1
self.l10n_be_T_event_amount += self.currency_id.round(amount)
def handle_blackbox_signature(self, signatures_batch):
""" Set the first and last FDM reference for the session based on the given blackbox signature. """
self.ensure_one()
for data in signatures_batch:
blackbox_signature = data.get('signature')
amount = data.get('amount', 0)
is_self_session = data.get('isSelfSession')
data = data.get('data', {})
if blackbox_signature.get('fdmRef') is not None and blackbox_signature['fdmRef'].get('eventLabel') is not None:
if is_self_session and blackbox_signature['fdmRef']['eventLabel'] not in ['R', 'C']:
if not self.first_fdm_ref:
self.first_fdm_ref = blackbox_signature['fdmRef']
self.last_fdm_ref = blackbox_signature['fdmRef']
if blackbox_signature['eventOperation'] == 'REPORT_TURNOVER_Z':
self.l10n_be_turnover_z_report_fdm_ref = blackbox_signature['fdmRef']
self.config_id.l10n_be_report_counter += 1
elif blackbox_signature['eventOperation'] == 'REPORT_USER_Z':
self.l10n_be_user_z_report_fdm_ref = blackbox_signature['fdmRef']
self.config_id.l10n_be_report_counter += 1
elif blackbox_signature['eventOperation'] == 'DRAWER_OPEN':
self.l10n_be_sign_drawer_open_amount += 1
if blackbox_signature['fdmRef']['eventLabel'] in ['N', 'P', 'I', 'T']:
self.increment_l10n_be_counters(
blackbox_signature['fdmRef']['eventLabel'],
amount,
)
# If it's a signOrder, we check if there was a negQuantityReason "CORRECTION", or "PRICE_CHANGE"
if blackbox_signature['eventOperation'] == 'ORDER':
transaction_lines = data.get('transaction', {}).get('transactionLines', [])
has_correction = False
has_price_change = False
correction_qty = 0
correction_amount = 0.0
price_change_qty = 0
price_change_amount = 0.0
for line in transaction_lines:
main_product = line.get('mainProduct', {})
neg_reason = main_product.get('negQuantityReason')
quantity = main_product.get('quantity', 0)
line_total = line.get('lineTotal', 0.0)
if neg_reason == 'CORRECTION':
has_correction = True
correction_qty += abs(quantity)
correction_amount += line_total
elif neg_reason == 'PRICE_CHANGE':
has_price_change = True
price_change_qty += abs(quantity)
price_change_amount += line_total
if has_correction:
self.l10n_be_correction_qty_count += correction_qty
self.l10n_be_correction_ticket_count += 1
self.l10n_be_correction_amount += correction_amount
if has_price_change:
self.l10n_be_price_change_qty_count += price_change_qty
self.l10n_be_price_change_ticket_count += 1
self.l10n_be_price_change_amount += price_change_amount
def get_l10n_be_user_report_data(self, before_closing=False):
""" Return all data used in the user report """
self.ensure_one()
orders = self.env["pos.order"].search([
("session_id", "=", self.id),
('state', 'in', ['paid', 'done']),
('l10n_be_short_signature', '!=', False)
])
user_report_data = {
'posDevices': self._get_l10n_be_pos_devices(orders),
'fdmDevices': self._get_l10n_be_fdm_devices(orders),
'users': self._get_l10n_be_users_data(orders),
}
if self.state == "closed" or before_closing:
stop_at_dt = self.stop_at or fields.Datetime.now()
user_report_data["reportBookingDate"] = stop_at_dt.date().isoformat()
user_report_data["reportNo"] = self.config_id.l10n_be_report_counter
user_report_data["l10n_be_user_z_report_fdm_ref"] = self.l10n_be_user_z_report_fdm_ref
user_report_data["stop_at"] = stop_at_dt
return user_report_data
def generate_l10n_z_reports_attachments(self, sale_details_data, user_report_data):
""" Generates both the Sale Details Z report and the User Z report and attaches them to the session """
self.ensure_one()
report_env = self.with_context(skip_sale_details_compute=True)
sale_pdf, _ = report_env.env['ir.actions.report']._render_qweb_pdf(
'point_of_sale.sale_details_report',
data=sale_details_data,
)
sale_attachment_name = f'{self.name.replace(" ", "_")}_Sale_Details_Report_Z.pdf'
sale_attach = self.env['ir.attachment'].create({
'name': sale_attachment_name,
'type': 'binary',
'datas': base64.b64encode(sale_pdf),
'res_model': 'pos.session',
'res_id': self.id,
'mimetype': 'application/pdf',
})
self.message_post(
body="Sale Details Report Z has been generated.",
attachment_ids=[sale_attach.id],
)
user_pdf, _ = report_env.env['ir.actions.report']._render_qweb_pdf(
'l10n_be_pos_blackbox.user_report_template',
[self.id],
data=user_report_data,
)
user_attachment_name = f'{self.name.replace(" ", "_")}_Users_Report_Z.pdf'
user_attach = self.env['ir.attachment'].create({
'name': user_attachment_name,
'type': 'binary',
'datas': base64.b64encode(user_pdf),
'res_model': 'pos.session',
'res_id': self.id,
'mimetype': 'application/pdf',
})
self.message_post(
body="User Report Z has been generated.",
attachment_ids=[user_attach.id],
)
def _create_clock_events(self, cashiers, action):
self.ensure_one()
if not cashiers:
return
vals_list = []
for cashier in cashiers:
vals = {
'type': action,
'session_id': self.id,
}
if self.config_id.module_pos_hr:
vals['employee_id'] = cashier.id
else:
vals['user_id'] = cashier.id
vals_list.append(vals)
self.env['pos.clock.in.out'].create(vals_list)
def _get_l10n_be_cashier_social_events(self, cashier_clock_events):
""" Return the social events (clock in/out) of a cashier. """
social_events = []
for event in cashier_clock_events:
social_events.append({
'inOut': event.type.upper(),
'posDateTime': self.rfc3339_datetime(event.event_date_time),
})
return social_events
def _get_l10n_be_users_data(self, orders):
""" Return summarized users (cashiers) information from the given orders. """
users = []
# itertools.groupby() only groups consecutive elements with the same key.
orders = orders.sorted(lambda order: order._get_cashier().id)
for cashier, group in groupby(orders, lambda order: order._get_cashier()):
insz = cashier.sudo().l10n_be_insz_or_bis_number or cashier.sudo().employee_id.l10n_be_insz_or_bis_number
cashier_clock_events = cashier.pos_clock_in_out_ids.filtered(lambda e: e.session_id == self)
group_orders = list(group)
if cashier_clock_events:
first_pos_dt = min(cashier_clock_events.mapped('event_date_time'))
last_pos_dt = max(cashier_clock_events.mapped('event_date_time'))
else:
# FIXME: this is a temporary fallback for mobile orders without clock events
# TODO-manv: FW-19.1: directly contact OBOX via backend to sign mobile orders
# We will also first ensure that the cashier is clocked in (for self-ordering), so we should always have clock events for the cashier
first_pos_dt = min(order.date_order for order in group_orders)
last_pos_dt = max(order.date_order for order in group_orders)
cashier_data = {
'employeeId': insz,
'totalAmount': 0,
'socialEvents': self._get_l10n_be_cashier_social_events(cashier_clock_events),
'firstPosDateTime': self.rfc3339_datetime(first_pos_dt),
'lastPosDateTime': self.rfc3339_datetime(last_pos_dt),
# Leading '_' keeps this field for reports only; it's filtered out before sending to the blackbox in js side (see 'extractFdmData()').
'_name': cashier.name,
}
cashier_payments = {}
for order in group_orders:
cashier_data['totalAmount'] = self.currency_id.round(cashier_data['totalAmount'] + order.amount_paid)
for payment in order.payment_ids:
pm_id = str(payment.payment_method_id.id)
pm_entry = cashier_payments.get(pm_id)
if pm_entry:
cashier_payments[pm_id]['amount']['normalAmount'] = self.currency_id.round(cashier_payments[pm_id]['amount']['normalAmount'] + payment.amount)
cashier_payments[pm_id]['amount']['totalAmount'] = self.currency_id.round(cashier_payments[pm_id]['amount']['totalAmount'] + payment.amount)
else:
cashier_payments[pm_id] = {
'id': pm_id,
'name': (payment.payment_method_id.name or "").strip(),
'type': payment.payment_method_id._get_mapped_payment_type(),
'amount': {
'type': "PAYMENT",
'totalAmount': self.currency_id.round(payment.amount),
'normalAmount': self.currency_id.round(payment.amount),
'negativeCorrections': 0,
'positiveCorrections': 0,
'correctionsCount': 0,
}
}
cashier_data['payments'] = list(cashier_payments.values())
users.append(cashier_data)
return users
def _get_l10n_be_invoices(self, data_invoices):
"""Return summarized invoices information from the given data invoices."""
invoices = []
for invoice in data_invoices:
invoices.append({
"invoiceNo": f"invoice-{invoice.get('id')}",
"amount": invoice.get("total"),
})
return invoices
def _get_l10n_be_payments(self, data_payments):
"""Return summarized payments information from the given data payments."""
payments = []
for payment in data_payments:
pm = self.env['pos.payment.method'].browse(payment.get("id"))
payments.append({
"id": str(payment.get("id")),
"name": (payment.get("name") or "").strip(),
"type": pm._get_mapped_payment_type(),
"amount": {
"type": "PAYMENT",
"totalAmount": payment.get("total"),
"normalAmount": payment.get("total"),
"negativeCorrections": 0,
"positiveCorrections": 0,
"correctionsCount": 0,
}
})
return payments
def _get_l10n_be_vats(self, data_taxes, data_refund_taxes):
"""Return summarized VAT information from the given data taxes and refund taxes."""
vats = defaultdict(lambda: {
"taxableAmount": 0,
"vatAmount": 0,
"totalAmount": 0,
"rate": 0,
"label": ""
})
for tax in (*data_taxes, *data_refund_taxes):
label = tax.get("identification_letter")
base, tax_amount = tax.get("base_amount", 0), tax.get("tax_amount", 0)
total = base + tax_amount
vat = vats[label]
vat.update(label=label, rate=tax.get("rate"))
vat["taxableAmount"] += base
vat["vatAmount"] += tax_amount
vat["totalAmount"] += total
for vat in vats.values():
vat["taxableAmount"] = self.currency_id.round(vat["taxableAmount"])
vat["vatAmount"] = self.currency_id.round(vat["vatAmount"])
vat["totalAmount"] = self.currency_id.round(vat["totalAmount"])
return list(vats.values())
def _get_l10n_be_pos_devices(self, orders):
"""Return summarized POS devices information from the given orders.
Each entry groups orders by (posId, terminalId) and provides:
- posId: the POS ID (l10n_be_pos_id)
- terminalId: the terminal ID (l10n_be_terminal_id)
- firstPosDateTime: earliest l10n_be_pos_date_time
- lastPosDateTime: latest l10n_be_pos_date_time
"""
pos_devices_map = defaultdict(list)
for order in orders:
pos_id = order.l10n_be_pos_id
terminal_id = order.l10n_be_terminal_id
if not pos_id or not terminal_id:
continue
pos_devices_map[pos_id, terminal_id].append(order.l10n_be_pos_date_time)
pos_devices = []
for (pos_id, terminal_id), date_list in pos_devices_map.items():
first_dt = min(date_list)
last_dt = max(date_list)
pos_devices.append({
"posId": pos_id,
"terminalId": terminal_id,
"firstPosDateTime": self.rfc3339_datetime(first_dt),
"lastPosDateTime": self.rfc3339_datetime(last_dt),
})
return pos_devices
def _get_l10n_be_fdm_devices(self, orders):
"""Return summarized FDM device information from the given orders.
Each entry groups orders by FDM ID and provides:
- fdmId: the FDM ID (l10n_be_fdm_id)
- firstFdmDateTime: earliest l10n_be_fdm_date_time
- firstTotalCounter: total counter at that first FDM datetime
- lastFdmDateTime: latest l10n_be_fdm_date_time
- lastTotalCounter: total counter at that last FDM datetime
"""
fdm_map = defaultdict(list)
for order in orders:
fdm_id = order.l10n_be_fdm_id
if not fdm_id:
continue
fdm_map[fdm_id].append(order)
fdm_devices = []
for fdm_id, fdm_orders in fdm_map.items():
fdm_orders.sort(key=lambda o: o.l10n_be_fdm_date_time)
first_order = fdm_orders[0]
last_order = fdm_orders[-1]
fdm_devices.append({
"fdmId": fdm_id,
"firstFdmDateTime": self.rfc3339_datetime_utc(first_order.l10n_be_fdm_date_time),
"firstTotalCounter": first_order.l10n_be_total_counter,
"lastFdmDateTime": self.rfc3339_datetime_utc(last_order.l10n_be_fdm_date_time),
"lastTotalCounter": last_order.l10n_be_total_counter,
})
return fdm_devices
def _get_l10n_be_departments(self, product_data, refund_product_data):
"""Return summarized departments (category) information from the given product data. """
departments = []
def add_department_id(products):
for dept in products:
first_dep_product = self.env["product.product"].browse(dept['products'][0]['product_id'])
is_dep = first_dep_product and first_dep_product.pos_categ_ids
dep_id = first_dep_product.pos_categ_ids[0].id if is_dep else 0
dept['id'] = dep_id
add_department_id(product_data)
add_department_id(refund_product_data)
merged_products = defaultdict(dict)
# Start with product_data as positive
for row in product_data:
rid = row["id"]
if not merged_products[rid]:
merged_products[rid]["id"] = rid
merged_products[rid]["name"] = row.get("name")
merged_products[rid]["total"] = merged_products[rid].get("total", 0) + row.get("total", 0)
# Subtract refund_product_data
for row in refund_product_data:
rid = row["id"]
if not merged_products[rid]:
merged_products[rid]["id"] = rid
merged_products[rid]["name"] = row.get("name")
merged_products[rid]["total"] = merged_products[rid].get("total", 0) - row.get("total", 0)
merged_products = list(merged_products.values())
for dept in merged_products:
departments.append({
"departmentId": str(dept["id"]),
"departmentName": (dept.get("name") or "").strip(),
"amount": self.currency_id.round(dept.get("total")),
})
return departments
def _get_l10n_be_turnover_transactions(self):
"""Return turnover transactions summary for the session."""
return [
{
"eventLabel": "N",
"ticketCount": self.l10n_be_N_event_counter,
"amount": self.l10n_be_N_event_amount,
},
{
"eventLabel": "P",
"ticketCount": self.l10n_be_P_event_counter,
"amount": self.l10n_be_P_event_amount,
},
{
"eventLabel": "I",
"ticketCount": self.l10n_be_I_event_counter,
"amount": self.l10n_be_I_event_amount,
},
{
"eventLabel": "T",
"ticketCount": self.l10n_be_T_event_counter,
"amount": self.l10n_be_T_event_amount,
}
]
def _get_l10n_be_neg_quantities(self, orders):
""" Return negative quantities summary from the given orders. """
res = []
refund_orders = orders.filtered(lambda o: o.is_refund)
if refund_orders:
res.append({
"negQuantityReason": "REFUND",
"ticketCount": len(refund_orders),
"negQuantityCount": len(refund_orders.mapped('lines')),
"amount": sum(refund_orders.mapped('amount_paid')),
})
if self.l10n_be_correction_ticket_count:
res.append({
"negQuantityReason": "CORRECTION",
"ticketCount": self.l10n_be_correction_ticket_count,
"negQuantityCount": self.l10n_be_correction_qty_count,
"amount": self.currency_id.round(self.l10n_be_correction_amount),
})
if self.l10n_be_price_change_ticket_count:
res.append({
"negQuantityReason": "PRICE_CHANGE",
"ticketCount": self.l10n_be_price_change_ticket_count,
"negQuantityCount": self.l10n_be_price_change_qty_count,
"amount": self.currency_id.round(self.l10n_be_price_change_amount),
})
return res
def _get_l10n_be_price_changes(self, orders):
"""Return summarized PUBLIC price changes from given orders."""
price_changes = []
vats = orders.lines.mapped('l10n_be_vats')
totals = defaultdict(lambda: {'negative': 0.0, 'positive': 0.0})
for vat_list in vats:
if not vat_list:
continue
for vat in vat_list:
label = vat.get('label')
for change in vat.get('priceChanges', []):
if change.get('type') != 'PUBLIC':
continue
key = (change['id'], change['name'], change['type'], label)
amount = change.get('amount', 0.0)
if amount < 0:
totals[key]['negative'] += amount
elif amount > 0:
totals[key]['positive'] += amount
grouped = defaultdict(list)
for (cid, name, ctype, label), vals in totals.items():
grouped[cid, name, ctype].append({
'label': label,
'negative': round(vals['negative'], 2),
'positive': round(vals['positive'], 2),
})
for (cid, name, ctype), amounts in grouped.items():
price_changes.append({
'id': str(cid),
'name': name,
'type': ctype,
'amount': amounts,
})
return price_changes
def rfc3339_datetime(self, dt):
"""
Convert a datetime object to an RFC 3339 formatted string.
This is the required format to communicate POS date time
Example:
>>> rfc3339_datetime(datetime(2024, 6, 1, 12, 0, 0))
'2024-06-01T12:00:00.000+02:00'
"""
brussels = pytz.timezone("Europe/Brussels")
local_dt = brussels.localize(dt) if dt.tzinfo is None else dt.astimezone(brussels)
return local_dt.isoformat(timespec='milliseconds')
def rfc3339_datetime_utc(self, dt):
"""
Convert a datetime to an RFC 3339 UTC datetime formatted string.
This is the required format to communicate FDM date time
Example:
>>> rfc3339_datetime_utc(datetime(2024, 6, 1, 12, 0, 0))
'2024-06-01T10:00:00.000Z'
"""
brussels = pytz.timezone("Europe/Brussels")
local_dt = brussels.localize(dt) if dt.tzinfo is None else dt.astimezone(brussels)
utc_dt = local_dt.astimezone(pytz.UTC)
return utc_dt.isoformat(timespec='milliseconds').replace('+00:00', 'Z')
@@ -0,0 +1,187 @@
from odoo import _, models, api, fields
from odoo.exceptions import UserError
class ReportPoint_Of_SaleReport_Saledetails(models.AbstractModel):
_inherit = "report.point_of_sale.report_saledetails"
@api.model
def get_sale_details(self, date_start=False, date_stop=False, config_ids=False, session_ids=False, **kwargs):
before_closing = kwargs.pop("before_closing", False)
data = super().get_sale_details(
date_start, date_stop, config_ids, session_ids, **kwargs
)
if before_closing:
data["state"] = "closed"
data["date_stop"] = fields.Datetime.now()
sessions = []
configs = []
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)
totalPaymentsAmount = 0
for session in sessions:
totalPaymentsAmount += session.total_payments_amount
if len(sessions) == 1:
session = sessions[0]
config = session.config_id
if session.use_blackbox:
orders = self.env["pos.order"].search([
("session_id", "=", session.id),
('state', 'in', ['paid', 'done']),
('l10n_be_short_signature', '!=', False)
])
data = self._l10n_be_set_default_belgian_taxes_if_empty(data, "taxes")
data = self._l10n_be_set_default_belgian_taxes_if_empty(data, "refund_taxes")
cash_payment_methods = session.payment_method_ids.filtered(lambda pm: pm.type == 'cash')
if cash_payment_methods:
default_cash_payment_method_id = cash_payment_methods[0]
total_payment = sum(
orders.payment_ids.filtered(lambda p: p.payment_method_id == default_cash_payment_method_id).mapped('amount')
)
total_cash_end = total_payment + session.cash_register_balance_start
else:
total_cash_end = 0
negQuantities = session._get_l10n_be_neg_quantities(orders)
negRefunds = next((d for d in negQuantities if d.get("negQuantityReason") == "REFUND"), {})
negCorrections = next((d for d in negQuantities if d.get("negQuantityReason") == "CORRECTION"), {})
negPriceChanges = next((d for d in negQuantities if d.get("negQuantityReason") == "PRICE_CHANGE"), {})
report_data = {
"l10n_be_pos_blackbox": True,
"cashier_name": session.user_id.name,
"establishment_number": config.establishment_number,
"l10n_be_insz_or_bis_number": session.user_id.l10n_be_insz_or_bis_number,
"l10n_be_N_event_counter": session.l10n_be_N_event_counter,
"l10n_be_N_event_amount": session.l10n_be_N_event_amount,
"l10n_be_NR_event_counter": session.l10n_be_NR_event_counter,
"l10n_be_NR_event_amount": session.l10n_be_NR_event_amount,
"l10n_be_P_event_counter": session.l10n_be_P_event_counter,
"l10n_be_P_event_amount": session.l10n_be_P_event_amount,
"l10n_be_I_event_counter": session.l10n_be_I_event_counter,
"l10n_be_I_event_amount": session.l10n_be_I_event_amount,
"l10n_be_T_event_counter": session.l10n_be_T_event_counter,
"l10n_be_T_event_amount": session.l10n_be_T_event_amount,
"l10n_be_sign_drawer_open_amount": session.l10n_be_sign_drawer_open_amount,
"CashBoxStartAmount": session.cash_register_balance_start,
"CashBoxEndAmount": total_cash_end,
"cashRegisterID": config.name,
"fdmID": config.l10n_be_blackbox_be_id.name,
"posID": config.l10n_be_pos_id,
"bookingPeriodId": session.name,
"bookingDate": session.start_at,
"firstFdmRef": session.first_fdm_ref,
"lastFdmRef": session.last_fdm_ref,
"posDevices": session._get_l10n_be_pos_devices(orders),
"fdmDevices": session._get_l10n_be_fdm_devices(orders),
"turnoverTransactions": session._get_l10n_be_turnover_transactions(),
"departments": session._get_l10n_be_departments(data["products"], data["refund_products"]),
"negQuantities": negQuantities,
"negRefunds": negRefunds,
"negCorrections": negCorrections,
"negPriceChanges": negPriceChanges,
"l10_be_payments": session._get_l10n_be_payments(data['payments_per_method']),
"vats": session._get_l10n_be_vats(data["taxes"], data["refund_taxes"]),
"drawersOpenCount": len(session.statement_line_ids),
"priceChanges": session._get_l10n_be_price_changes(orders),
"l10_be_invoices": session._get_l10n_be_invoices(data['invoiceList'][0]['invoices']),
"l10n_be_turnover_z_report_fdm_ref": session.l10n_be_turnover_z_report_fdm_ref,
}
if session.state == "closed" or before_closing:
report_data["reportBookingDate"] = (session.stop_at or fields.Datetime.now()).date().isoformat()
report_data["reportNo"] = config.l10n_be_report_counter
data.update(report_data)
data["total_paid"] = totalPaymentsAmount
return data
@api.model
def _get_report_values(self, docids, data=None):
if (docids and len(docids) == 1) or (data and data.get('session_ids') and len(data['session_ids']) == 1):
session = self.env['pos.session'].browse(docids) if len(docids) == 1 else self.env['pos.session'].browse(data['session_ids'][0])
if session.exists() and session.use_blackbox and not data['context'].get('called_from_pos') and not self.env.context.get('skip_sale_details_compute'):
# Only authorize report generation from the POS (because we need to 'signReport' to the Belgian Blackbox )
raise UserError(_("You cannot print the Sale details report for a POS session with a Belgian Blackbox configured from the backend. Please print it from the POS interface instead.\nYou can also download Z report from the chatter of closed sessions."))
if self.env.context.get('skip_sale_details_compute'):
return data
return super()._get_report_values(docids, data=data)
def _get_product_total_amount(self, line):
return line.price_subtotal_incl
def _get_total_and_qty_per_category(self, categories):
res_cat, res_total = super()._get_total_and_qty_per_category(categories)
config_id = self.env.context.get('config_id')
if config_id and self.env['pos.config'].browse(config_id).l10n_be_blackbox_be_id:
for cat in res_cat:
total_cat = 0
for product in cat['products']:
total_cat += product['total_paid']
cat['total'] = total_cat
unique_products = list({tuple(sorted(product.items())): product for category in categories for product in category['products']}.values())
res_total['total'] = sum(product['total_paid'] for product in unique_products)
return res_cat, res_total
def _l10n_be_set_default_belgian_taxes_if_empty(self, data, taxes_name):
for tax in data[taxes_name]:
tax_used = self.env['account.tax'].search([
('name', '=', tax['name']),
('type_tax_use', '=', 'sale'),
], limit=1)
tax['identification_letter'] = tax_used.tax_group_id.pos_receipt_label or 'D'
tax['rate'] = tax_used.amount
letter_set = ['A', 'B', 'C', 'D']
for tax in data[taxes_name]:
if tax['identification_letter'] in letter_set:
letter_set.remove(tax['identification_letter'])
for letter in letter_set:
if letter == 'A':
data[taxes_name].append({
'name': '21%',
'tax_amount': 0.0,
'base_amount': 0.0,
'identification_letter': letter,
'rate': 21.0
})
if letter == 'B':
data[taxes_name].append({
'name': '12%',
'tax_amount': 0.0,
'base_amount': 0.0,
'identification_letter': letter,
'rate': 12.0
})
if letter == 'C':
data[taxes_name].append({
'name': '6%',
'tax_amount': 0.0,
'base_amount': 0.0,
'identification_letter': letter,
'rate': 6.0
})
if letter == 'D':
data[taxes_name].append({
'name': '0%',
'tax_amount': 0.0,
'base_amount': 0.0,
'identification_letter': letter,
'rate': 0.0
})
data[taxes_name] = sorted(data[taxes_name], key=lambda d: d['identification_letter'], reverse=True)
return data
@@ -0,0 +1,30 @@
from odoo import fields, models, api
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
pos_l10n_be_blackbox_be_id = fields.Many2one(related="pos_config_id.l10n_be_blackbox_be_id", readonly=False)
pos_l10n_be_training_mode = fields.Boolean(related="pos_config_id.l10n_be_training_mode", readonly=False)
pos_l10n_be_country_code = fields.Char(string="POS Country Code", related="pos_config_id.company_id.country_id.code", readonly=True)
pos_establishment_number = fields.Char(string="POS Establishment Number", related="pos_config_id.establishment_number", readonly=False)
pos_l10n_be_pos_id = fields.Char(string="POS ID", related="pos_config_id.l10n_be_pos_id", readonly=True)
@api.onchange('pos_l10n_be_blackbox_be_id')
def _onchange_pos_l10n_be_blackbox_be_id(self):
for res_config in self:
if res_config.pos_l10n_be_blackbox_be_id:
# TODO-FW-19.1: adapt based on this PR: https://github.com/odoo/odoo/pull/229561
cash_rounding = self.env['pos.config']._create_default_cashrounding()
if cash_rounding:
res_config.pos_cash_rounding = True
res_config.pos_rounding_method = cash_rounding
res_config.pos_only_round_cash_method = True
res_config.pos_iface_print_auto = True
res_config.pos_iface_print_skip_screen = True
def action_request_pos_id(self):
self.ensure_one()
if not self.pos_config_id:
return
return self.pos_config_id.action_request_pos_id()
+43
View File
@@ -0,0 +1,43 @@
from odoo.exceptions import ValidationError
from odoo import models, fields, api
from odoo.tools.translate import _
class ResUsers(models.Model):
_inherit = "res.users"
l10n_be_insz_or_bis_number = fields.Char("INSZ or BIS number", help="Belgian National Register number")
pos_clock_in_out_ids = fields.One2many('pos.clock.in.out', 'user_id', string='POS Clock In/Out Events')
@api.model
def _load_pos_data_fields(self, config):
result = super()._load_pos_data_fields(config)
if config.l10n_be_blackbox_be_id:
result += ['l10n_be_insz_or_bis_number']
return result
@api.constrains("l10n_be_insz_or_bis_number")
def _check_l10n_be_insz_or_bis_number(self):
for emp in self:
insz_number = emp.l10n_be_insz_or_bis_number
if insz_number and not emp.is_valid_l10n_be_insz_or_bis_number(insz_number):
raise ValidationError(_("The National Register Number is not valid."))
@api.model
def is_valid_l10n_be_insz_or_bis_number(self, number):
if not number:
return False
if len(number) != 11 or not number.isdigit():
return False
partial_number = number[:-2]
modulo = int(partial_number) % 97
if modulo == 97 - int(number[-2:]):
return True
# Allow employee and user born after 2000.
partial_number = '2' + partial_number
modulo = int(partial_number) % 97
return modulo == 97 - int(number[-2:])
@@ -0,0 +1,12 @@
from odoo import models, api
class UserReport(models.AbstractModel):
_name = "report.l10n_be_pos_blackbox.user_report_template"
_description = 'POS Session user report'
@api.model
def _get_report_values(self, docids, data=None):
session = self.env['pos.session'].browse(docids).ensure_one()
values = data if data and self.env.context.get('skip_sale_details_compute') else session.get_l10n_be_user_report_data()
return {"data": values, "docs": session}