# Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import models, fields, api, _ from odoo.exceptions import UserError from odoo.fields import Domain from datetime import datetime import hashlib import re class PosOrder(models.Model): _inherit = "pos.order" blackbox_date = fields.Char( "Fiscal Data Module date", help="Date returned by the Fiscal Data Module.", readonly=True, ) blackbox_time = fields.Char( "Fiscal Data Module time", help="Time returned by the Fiscal Data Module.", readonly=True, ) blackbox_pos_receipt_time = fields.Datetime("Receipt time", compute="_compute_blackbox_pos_receipt_time") blackbox_ticket_counters = fields.Char( "Fiscal Data Module ticket counters", help="Ticket counter returned by the Fiscal Data Module (format: counter / total event type)", readonly=True, ) blackbox_unique_fdm_production_number = fields.Char( "Fiscal Data Module ID", help="Unique ID of the blackbox that handled this order", readonly=True, ) blackbox_vsc_identification_number = fields.Char( "VAT Signing Card ID", help="Unique ID of the VAT signing card that handled this order", readonly=True, ) blackbox_signature = fields.Char( "Electronic signature", help="Electronic signature returned by the Fiscal Data Module", readonly=True, ) blackbox_order_sequence = fields.Char( "Blackbox order sequence", readonly=True, ) blackbox_tax_category_a = fields.Monetary( readonly=True, string="Total tax for category A", help="This is the total amount of the 21% tax", ) blackbox_tax_category_b = fields.Monetary( readonly=True, string="Total tax for category B", help="This is the total amount of the 12% tax", ) blackbox_tax_category_c = fields.Monetary( readonly=True, string="Total tax for category C", help="This is the total amount of the 6% tax", ) blackbox_tax_category_d = fields.Monetary( readonly=True, string="Total tax for category D", help="This is the total amount of the 0% tax", ) plu_hash = fields.Char(help="Eight last characters of PLU hash") pos_version = fields.Char(help="Version of Odoo that created the order") is_clock = fields.Boolean("Is clock in/out", compute='_compute_is_clock') @api.depends("blackbox_date", "blackbox_time") def _compute_blackbox_pos_receipt_time(self): for order in self: if order.blackbox_date and order.blackbox_time: order.blackbox_pos_receipt_time = datetime.strptime( order.blackbox_date + " " + order.blackbox_time, "%d-%m-%Y %H:%M:%S" ) else: order.blackbox_pos_receipt_time = False def _compute_is_clock(self): work_products_set = set(self.env['pos.config']._get_work_products().ids) for order in self: order.is_clock = bool(work_products_set & set(order.lines.product_id.ids)) @api.ondelete(at_uninstall=False) def unlink_if_blackboxed(self): for order in self: if order.config_id.certified_blackbox_identifier: raise UserError(_("Deleting of registered orders is not allowed.")) @api.model def create_log(self, orders): for order in orders: self.env["pos_blackbox_be.log"].sudo().create([{ "action": "create", "model_name": self._name, "record_name": order['pos_reference'], "description": self._create_log_description(order), }]) self.env['pos.session'].browse(order['session_id'])._update_pro_forma(order) def _create_log_description(self, order): currency_id = self.env['res.currency'].browse(order['currency_id']) lines = [] total = 0 rounding_applied = 0 hash_string = "" title = "" for line in order["lines"]: line_description = "{qty} x {product_name}: {price}".format( qty=line["qty"], product_name=line["product_name"], price=currency_id.round(line["price_subtotal_incl"]), ) if line["discount"]: line_description += " (disc: {discount}%)".format( discount=line["discount"] ) lines.append(line_description) total = ( currency_id.round(order["amount_total"]) if order['state'] == "draft" else currency_id.round(order["amount_paid"]) ) rounding_applied = ( 0 if order['state'] == "draft" else currency_id.round(order["amount_total"] - order["amount_paid"] + order["change"]) ) hash_string = order["plu_hash"] sale_type = "" if currency_id.round(order["amount_total"]) > 0: sale_type = " SALES" elif currency_id.round(order["amount_total"]) < 0: sale_type = " REFUNDS" else: if len(order["lines"]) == 0 or order["lines"][0]["qty"] >= 0: sale_type = " SALES" else: sale_type = " REFUNDS" title = ("PRO FORMA" if order['state'] == "draft" else "NORMAL") + sale_type order_type = order['config_name'] + ( " Pro Forma/" if order['state'] == "draft" else " Pos Order/" ) description = ''' {title} Date: {create_date} Internal Ref: {pos_reference} Sequence: {blackbox_sequence} Cashier: {cashier_name} Order lines: {lines} Total: {total} Rounding: {rounding_applied} Ticket Counter: {ticket_counters} Hash: {hash} POS Version: {pos_version} FDM ID: {fdm_id} POS ID: {config_name} FDM Identifier: {fdmIdentifier} FDM Signature: {fdmSignature} '''.format( title=title, create_date=order['create_date'], cashier_name=order['employee_name'], lines="\n* " + "\n* ".join(lines), total=total, pos_reference=order['pos_reference'], blackbox_sequence=order_type + str(order['blackbox_order_sequence']).zfill(5), hash=hash_string, pos_version=order['pos_version'], ticket_counters=order['blackbox_ticket_counters'], fdm_id=order['blackbox_unique_fdm_production_number'], config_name=order['config_name'], fdmIdentifier=order['certified_blackbox_identifier'], rounding_applied=rounding_applied, fdmSignature=order['blackbox_signature'], ) return description def _refund(self): work_products_ids = self.env['pos.config']._get_work_products().ids for order in self: if order.config_id.certified_blackbox_identifier: for line in order.lines: if line.product_id.id in work_products_ids: raise UserError(_("Refunding of WORK IN/WORK OUT orders is not allowed.")) return super()._refund() def action_pos_order_cancel(self): cancellable_orders = self.filtered(lambda order: order.state == 'draft') if cancellable_orders: for order in cancellable_orders: order.write({ 'blackbox_tax_category_a': 0, 'blackbox_tax_category_b': 0, 'blackbox_tax_category_c': 0, 'blackbox_tax_category_d': 0, }) return super().action_pos_order_cancel() @api.model def search_paid_order_ids(self, config_id, domain, limit, offset): domain = Domain('lines.product_id', 'not in', self.env['pos.config']._get_work_products().ids) & Domain(domain) return super().search_paid_order_ids(config_id, domain, limit, offset) def _get_tax_amount_by_percent(self, tax_percent): return sum( line.price_subtotal_incl - line.price_subtotal for line in self.lines if line.tax_ids.amount_type == 'percent' and line.tax_ids.amount == tax_percent ) def _get_plu(self): return hashlib.sha1(b''.join([line.blackbox_plu.encode('utf8') for line in self.lines])).hexdigest()[-8:] # Circulaire AG Fisc Nr. 33/2016 Art. 43.l. take the last eight characters of the hash def _create_order_for_blackbox(self, clock=False, clock_in=True): now = datetime.now() self.write({ 'blackbox_order_sequence': self.config_id.get_NS_sequence_next(), 'blackbox_tax_category_a': self._get_tax_amount_by_percent(21), 'blackbox_tax_category_b': self._get_tax_amount_by_percent(12), 'blackbox_tax_category_c': self._get_tax_amount_by_percent(6), 'blackbox_tax_category_d': self._get_tax_amount_by_percent(0), 'plu_hash': self._get_plu(), }) return { 'date': now.strftime('%Y%m%d'), 'ticket_time': now.strftime('%H%M%S'), 'insz_or_bis_number': self.sudo().config_id.current_session_id.user_id.insz_or_bis_number, 'ticket_number': str(self.blackbox_order_sequence).lstrip('0'), 'type': 'NS', 'receipt_total': f"{abs(self.amount_total * 100):.0f}", 'vat1': f"{abs(self.blackbox_tax_category_a * 100):03.0f}" if self.blackbox_tax_category_a else '', 'vat2': f"{abs(self.blackbox_tax_category_b * 100):03.0f}" if self.blackbox_tax_category_b else '', 'vat3': f"{abs(self.blackbox_tax_category_c * 100):03.0f}" if self.blackbox_tax_category_c else '', 'vat4': f"{abs(self.blackbox_tax_category_d * 100):03.0f}" if self.blackbox_tax_category_d else '', 'plu': self.plu_hash, 'clock': ('in' if clock_in else 'out') if clock else False, } def _update_from_blackbox(self, data): date_str = data.get("date") time_str = data.get("time") self.write({ 'blackbox_signature': data.get("signature"), 'blackbox_date': f"{date_str[6:]}-{date_str[4:6]}-{date_str[:4]}", 'blackbox_time': f"{time_str[:2]}:{time_str[2:4]}:{time_str[4:]}", 'blackbox_ticket_counters': "NS " + data.get("ticket_counter") + "/" + data.get("total_ticket_counter"), 'blackbox_unique_fdm_production_number': data.get("fdm_number"), 'blackbox_vsc_identification_number': data.get("vsc"), }) def _send_self_order_receipt(self): self.ensure_one() if not self.config_id._uses_blackbox_v1(): return super()._send_self_order_receipt() # If we're using a blackbox, we will not send the receipt by mail (not call super()) if self.source == 'mobile' and self.state == 'paid' and not self.blackbox_signature: # If the order is from mobile and paid but not yet signed, we try to send it to the blackbox to get it signed self.config_id._send_order_to_blackbox(self) class PosOrderLine(models.Model): _inherit = "pos.order.line" blackbox_plu = fields.Char("PLU hash", compute='_compute_plu_line') def _compute_plu_line(self): for line in self: if line.order_id.config_id._uses_blackbox_v1(): qty = line.qty description = line.product_id.display_name price = round(abs(line.price_subtotal_incl) * 100, 2) tax_labels = line.tax_ids.tax_group_id.mapped(lambda tg: tg.pos_receipt_label) tax_label = tax_labels[0] if len(tax_labels) > 0 else 'D' qty = line._prepare_number_for_plu(qty, 4) description = line._prepare_description_for_plu(description) price = line._prepare_number_for_plu(price, 8) line.blackbox_plu = qty + description + price + tax_label else: line.blackbox_plu = '' @api.model def _generate_translation_table(self): special_car = { ('Ä', 'Å', 'Â', 'Á', 'À', 'â', 'ä', 'á', 'à', 'ã'): 'A', ('Æ', 'æ'): 'AE', 'ß': 'SS', ('ç', 'Ç'): 'C', ('Î', 'Ï', 'Í', 'Ì', 'ï', 'î', 'ì', 'í'): 'I', '€': 'E', ('Ê', 'Ë', 'É', 'È', 'ê', 'ë', 'é', 'è'): 'E', ('Û', 'Ü', 'Ú', 'Ù', 'ü', 'û', 'ú', 'ù'): 'U', ('Ô', 'Ö', 'Ó', 'Ò', 'ö', 'ô', 'ó', 'ò'): 'O', ('Œ', 'œ'): 'OE', ('ñ', 'Ñ'): 'N', ('ý', 'Ý', 'ÿ'): 'Y' } complete_table = {} for (key, value) in special_car.items(): for char_key in key: complete_table[char_key] = value return complete_table @api.model def _replace_hash_and_sign_chars(self, string): translation_table = self._generate_translation_table() replaced_char = [translation_table[char] if char in translation_table else char.upper() for char in string] return ''.join(replaced_char) @api.model def _filter_allowed_hash_and_sign_chars(self, string): return re.sub(r'[^A-Z0-9]', '', string) @api.model def _prepare_number_for_plu(self, number, length): number = f"{abs(number):.0f}" number = self._replace_hash_and_sign_chars(number) number = self._filter_allowed_hash_and_sign_chars(number) number = number[-length:] return number.zfill(length) @api.model def _prepare_description_for_plu(self, description): description = self._replace_hash_and_sign_chars(description) description = self._filter_allowed_hash_and_sign_chars(description) description = description[-20:] return description.ljust(20) @api.onchange("qty") def _onchange_qty(self): for line in self: if line.order_id.config_id.certified_blackbox_identifier and line.qty < 0: raise UserError(_("Setting a negative quantity is not allowed on a certified Point of Sale."))