From b0be9e074a1ecf1f6de7a03c71df0c63868501e3 Mon Sep 17 00:00:00 2001 From: dbkosky <dako@odoo.com> Date: Fri, 28 Oct 2022 09:15:44 +0000 Subject: [PATCH] [ADD] l10n_ke_edi_tremol: KE fiscal device This module implements communication with the fiscal device for submitting invoices to the KRA (Kenya Revenue Authority). The device supported in this module is the Tremol G03. -- COMMUNICATION FLOW SUMMARY -- 1. "Send Invoice To Device" on account move is clicked 2. l10n_ke_action_cu_post triggers a client action, with serialised invoice data. (more details below) 3. The client action uses the 'post_send' function defined in javascript to forward the request to the /hw_proxy/l10n_ke_cu_send endpoint on the proxy server (more details below) 4. The proxy server wraps the serialised data with the appropriate bytes (for instance, a couple of a checksum bytes), and sends them to the device through serial communication (more details below) 5. The data returned from the fiscal device is communicated back in the request. The client action 'post_send' then triggers the 'l10n_ke_cu_response' with an rpc call, with the aforementioned data from the fiscal device (more details) 2. The module l10n_ke_edi_tremol inherits from the account_move model in order to provide methods for serialising the data of the account_move and sending it to the proxy server. Fields have been added to the product template to define the HS Code and HS Name (data which is required by the KRA in some circumstances). A field has also been added to the company defining the address of the proxy server. The fields added on the account move are populated by the data returned by the fiscal device, this includes the device serial number, the invoice number on the device, the URL of the invoice on the KRA web portal, and the date/time the invoice was signed. 3. Communication between the client database and the proxy server is defined using a client action defined in l10n_ke_edi_tremol/static/src/js/send_invoice.js. This allows users who aren't on-premise to communicate with the device, provided the proxy is accessible on the network that the user is on. 4. The proxy server is an intermediary server that should be connected to the tremol G03, and running the IOT drivers from hw_drivers. The driver that supports communication between the proxy server and the fiscal device has been defined in this commit inside of hw_drivers/iot_handlers/drivers//L10nKeEDISerialDriver.py. The proxy server can be run on the IOT box, or on odoo community by running: ./odoo-bin addons-path=.... -d dbname --load hw_drivers --proxy-mode ** all messages are encoded/decoded with cp1251, as defined in the protocol. The company vat code is sent along with the request to compare that sent with that of the device. The 'serial_number' of the device is always returned along with the request, since it is required for the invoice details, and it is retrieved as part of the query to find the registered VAT code on the device. --- DATA and VIEWS --- product_view: adds HS Name, and HS Code on the product product and product template form views. report_invoice: adds to the invoice qweb template such that a section including the fiscal device / KRA details is included at the bottom of the invoice when the invoice is rendered as a pdf. res_config_settings_view: adds makes the proxy address field editable from the config settings. account_move_view: add a tab for the tremol device details and the qr code on the account move form view. The KRA invoice number is also added as an optional field on the account move tree view, and the invoice search view is inherited to make this field searchable too. (l10n_ke) account_tax_report_data, account_tax_template_data: The tax report is defined for Kenya, and tax tags that link to the lines on this tax report are defined on the existing taxes. This allows the classification of the tax, between zero-rated and exempt, during the serialisation. closes odoo/odoo#106653 Task-id: 2950308 Signed-off-by: Josse Colpaert <jco@odoo.com> --- .../drivers/L10nKeEDISerialDriver.py | 228 ++++++++++++++++++ addons/l10n_ke/__manifest__.py | 1 + .../l10n_ke/data/account_tax_report_data.xml | 206 ++++++++++++++++ .../data/account_tax_template_data.xml | 136 +++++++++-- addons/l10n_ke_edi_tremol/__init__.py | 1 + addons/l10n_ke_edi_tremol/__manifest__.py | 27 +++ addons/l10n_ke_edi_tremol/models/__init__.py | 5 + .../l10n_ke_edi_tremol/models/account_move.py | 228 ++++++++++++++++++ addons/l10n_ke_edi_tremol/models/product.py | 32 +++ .../l10n_ke_edi_tremol/models/res_company.py | 14 ++ .../models/res_config_settings.py | 10 + .../l10n_ke_edi_tremol/models/res_partner.py | 16 ++ .../static/src/js/send_invoice.js | 34 +++ .../views/account_move_view.xml | 58 +++++ .../l10n_ke_edi_tremol/views/product_view.xml | 33 +++ .../views/report_invoice.xml | 32 +++ .../views/res_config_settings_view.xml | 33 +++ .../views/res_partner_views.xml | 15 ++ 18 files changed, 1085 insertions(+), 24 deletions(-) create mode 100644 addons/hw_drivers/iot_handlers/drivers/L10nKeEDISerialDriver.py create mode 100644 addons/l10n_ke/data/account_tax_report_data.xml create mode 100644 addons/l10n_ke_edi_tremol/__init__.py create mode 100644 addons/l10n_ke_edi_tremol/__manifest__.py create mode 100644 addons/l10n_ke_edi_tremol/models/__init__.py create mode 100644 addons/l10n_ke_edi_tremol/models/account_move.py create mode 100644 addons/l10n_ke_edi_tremol/models/product.py create mode 100644 addons/l10n_ke_edi_tremol/models/res_company.py create mode 100644 addons/l10n_ke_edi_tremol/models/res_config_settings.py create mode 100644 addons/l10n_ke_edi_tremol/models/res_partner.py create mode 100644 addons/l10n_ke_edi_tremol/static/src/js/send_invoice.js create mode 100644 addons/l10n_ke_edi_tremol/views/account_move_view.xml create mode 100644 addons/l10n_ke_edi_tremol/views/product_view.xml create mode 100644 addons/l10n_ke_edi_tremol/views/report_invoice.xml create mode 100644 addons/l10n_ke_edi_tremol/views/res_config_settings_view.xml create mode 100644 addons/l10n_ke_edi_tremol/views/res_partner_views.xml diff --git a/addons/hw_drivers/iot_handlers/drivers/L10nKeEDISerialDriver.py b/addons/hw_drivers/iot_handlers/drivers/L10nKeEDISerialDriver.py new file mode 100644 index 000000000000..6214e6b9b379 --- /dev/null +++ b/addons/hw_drivers/iot_handlers/drivers/L10nKeEDISerialDriver.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import serial +import time +import struct +import json +from functools import reduce + +from odoo import http +from odoo.addons.hw_drivers.iot_handlers.drivers.SerialBaseDriver import SerialDriver, SerialProtocol, serial_connection +from odoo.addons.hw_drivers.main import iot_devices + +_logger = logging.getLogger(__name__) + +TremolG03Protocol = SerialProtocol( + name='Tremol G03', + baudrate=115200, + bytesize=serial.EIGHTBITS, + stopbits=serial.STOPBITS_ONE, + parity=serial.PARITY_NONE, + timeout=3, + writeTimeout=0.2, + measureRegexp=None, + statusRegexp=None, + commandTerminator=b'', + commandDelay=0.4, + measureDelay=0.4, + newMeasureDelay=0.2, + measureCommand=b'', + emptyAnswerValid=False, +) + +STX = 0x02 +ETX = 0x0A +ACK = 0x06 +NACK = 0x15 + +FD_ERRORS = { + 0x30: 'OK', + 0x32: 'Registers overflow', + 0x33: 'Clock failure or incorrect date & time', + 0x34: 'Opened fiscal receipt', + 0x39: 'Incorrect password', + 0x3b: '24 hours block - missing Z report', + 0x3d: 'Interrupt power supply in fiscal receipt (one time until status is read)', + 0x3e: 'Overflow EJ', + 0x3f: 'Insufficient conditions', +} + +COMMAND_ERRORS = { + 0x30: 'OK', + 0x31: 'Invalid command', + 0x32: 'Illegal command', + 0x33: 'Z daily report is not zero', + 0x34: 'Syntax error', + 0x35: 'Input registers orverflow', + 0x36: 'Zero input registers', + 0x37: 'Unavailable transaction for correction', + 0x38: 'Insufficient amount on hand', +} + + +class TremolG03Driver(SerialDriver): + """Driver for the Kenyan Tremol G03 fiscal device.""" + + _protocol = TremolG03Protocol + + def __init__(self, identifier, device): + super().__init__(identifier, device) + self.device_type = 'fiscal_data_module' + self.message_number = 0 + + @classmethod + def get_default_device(cls): + fiscal_devices = list(filter(lambda d: iot_devices[d].device_type == 'fiscal_data_module', iot_devices)) + return len(fiscal_devices) and iot_devices[fiscal_devices[0]] + + @classmethod + def supported(cls, device): + """Checks whether the device, which port info is passed as argument, is supported by the driver. + + :param device: path to the device + :type device: str + :return: whether the device is supported by the driver + :rtype: bool + """ + protocol = cls._protocol + try: + protocol = cls._protocol + with serial_connection(device['identifier'], protocol) as connection: + connection.write(b'\x09') + time.sleep(protocol.commandDelay) + response = connection.read(1) + if response == b'\x40': + return True + + except serial.serialutil.SerialTimeoutException: + pass + except Exception: + _logger.exception('Error while probing %s with protocol %s', device, protocol.name) + + # ---------------- + # HELPERS + # ---------------- + + @staticmethod + def generate_checksum(message): + """ Generate the checksum bytes for the bytes provided. + + :param message: bytes representing the part of the message from which the checksum is calculated + :returns: two checksum bytes calculated from the message + + This checksum is calculated as: + 1) XOR of all bytes of the bytes + 2) Conversion of the one XOR byte into the two bytes of the checksum by + adding 30h to each half-byte of the XOR + + eg. to_check = \x12\x23\x34\x45\x56 + XOR of all bytes in to_check = \x16 + checksum generated as \x16 -> \x31 \x36 + """ + xor = reduce(lambda a, b: a ^ b, message) + return bytes([(xor >> 4) + 0x30, (xor & 0xf) + 0x30]) + + # ---------------- + # COMMUNICATION + # ---------------- + + def send(self, msgs): + """ Send and receive messages to/from the fiscal device over serial connection + + Generate the wrapped message from the msgs and send them to the device. + The wrapping contains the <STX> (starting byte) <LEN> (length byte) + and <NBL> (message number byte) at the start and two <CS> (checksum + bytes), and the <ETX> line-feed byte at the end. + :param msgs: A list of byte strings representing the <CMD> and <DATA> + components of the serial message. + :return: A list of the responses (if any) from the device. If the + response is an ack, it wont be part of this list. + """ + + with self._device_lock: + replies = [] + for msg in msgs: + self.message_number += 1 + core_message = struct.pack('BB%ds' % (len(msg)), len(msg) + 34, self.message_number + 32, msg) + request = struct.pack('B%ds2sB' % (len(core_message)), STX, core_message, self.generate_checksum(core_message), ETX) + time.sleep(self._protocol.commandDelay) + self._connection.write(request) + time.sleep(self._protocol.measureDelay) + response = self._connection.read_all() + if not response: + self.data['status'] = "no response" + _logger.error("Sent request: %s,\n Received no response", request) + self.abort_post() + break + if response[0] == ACK: + # In the case where either byte is not 0x30, there has been an error + if response[2] != 0x30 or response[3] != 0x30: + self.data['status'] = response[2:4].decode('cp1251') + _logger.error( + "Sent request: %s,\n Received fiscal device error: %s \n Received command error: %s", + request, FD_ERRORS.get(response[2], 'Unknown fiscal device error'), + COMMAND_ERRORS.get(response[3], 'Unknown command error'), + ) + self.abort_post() + break + replies.append('') + elif response[0] == NACK: + self.data['status'] = "Received NACK" + _logger.error("Sent request: %s,\n Received NACK \x15", request) + self.abort_post() + break + elif response[0] == 0x02: + self.data['status'] = "ok" + size = response[1] - 35 + reply = response[4:4 + size] + replies.append(reply.decode('cp1251')) + return {'replies': replies, 'status': self.data['status']} + + def abort_post(self): + """ Cancel the posting of the invoice + + In the event of an error, it is better to try to cancel the posting of + the invoice, since the state of the invoice on the device will remain + open otherwise, blocking further invoices being sent. + """ + self.message_number += 1 + abort = struct.pack('BBB', 37, self.message_number + 32, 0x39) + request = struct.pack('B3s2sB', STX, abort, self.generate_checksum(abort), ETX) + time.sleep(self._protocol.commandDelay) + self._connection.write(request) + time.sleep(self._protocol.measureDelay) + response = self._connection.read_all() + if response and response[0] == 0x02: + self.data['status'] += "\n The invoice could not be cancelled." + else: + self.data['status'] += "\n The invoice was successfully cancelled" + + +class TremolG03Controller(http.Controller): + + @http.route('/hw_proxy/l10n_ke_cu_send', type='http', auth='none', cors='*', csrf=False, save_session=False, methods=['POST']) + def l10n_ke_cu_send(self, messages, company_vat): + """ Posts the messages sent to this endpoint to the fiscal device connected to the server + + :param messages: The messages (consisting of <CMD> and <DATA>) to + send to the fiscal device. + :returns: Dictionary containing a list of the responses from + fiscal device and status of the fiscal device. + """ + device = TremolG03Driver.get_default_device() + if device: + # First run the command to get the fiscal device numbers + device_numbers = device.send([b'\x60']) + # If the vat doesn't match, abort + if device_numbers['status'] != 'ok': + return device_numbers + serial_number, device_vat, _dummy = device_numbers['replies'][0].split(';') + if device_vat != company_vat: + return json.dumps({'status': 'The company vat number does not match that of the device'}) + messages = json.loads(messages) + resp = json.dumps({**device.send([msg.encode('cp1251') for msg in messages]), 'serial_number': serial_number}) + return resp + else: + return json.dumps({'status': 'The fiscal device is not connected to the proxy server'}) diff --git a/addons/l10n_ke/__manifest__.py b/addons/l10n_ke/__manifest__.py index 03f11c74ad2d..3484373977d6 100644 --- a/addons/l10n_ke/__manifest__.py +++ b/addons/l10n_ke/__manifest__.py @@ -17,6 +17,7 @@ This provides a base chart of accounts and taxes template for use in Odoo. 'data/account.account.template.csv', 'data/l10n_ke_chart_data.xml', 'data/account_tax_group_data.xml', + 'data/account_tax_report_data.xml', 'data/account_tax_template_data.xml', 'data/account_fiscal_position_template.xml', 'data/account_chart_template_configure_data.xml', diff --git a/addons/l10n_ke/data/account_tax_report_data.xml b/addons/l10n_ke/data/account_tax_report_data.xml new file mode 100644 index 000000000000..94683c37cb19 --- /dev/null +++ b/addons/l10n_ke/data/account_tax_report_data.xml @@ -0,0 +1,206 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="tax_report_ke" model="account.report"> + <field name="name">Tax Report</field> + <field name="root_report_id" ref="account.generic_tax_report"/> + <field name="country_id" ref="base.ke"/> + <field name="filter_fiscal_position" eval="True"/> + <field name="availability_condition">country</field> + <field name="column_ids"> + <record id="tax_report_base_column" model="account.report.column"> + <field name="name">Base</field> + <field name="expression_label">base</field> + </record> + <record id="tax_report_tax_column" model="account.report.column"> + <field name="name">VAT</field> + <field name="expression_label">tax</field> + </record> + </field> + <field name="line_ids"> + <record id="tax_report_line_general_rate_sales" model="account.report.line"> + <field name="name">1. Taxable Sales (General Rate 16%)</field> + <field name="sequence">1</field> + <field name="code">box_1</field> + <field name="expression_ids"> + <record id="tax_report_general_rate_sales_base_tag" model="account.report.expression"> + <field name="label">base</field> + <field name="engine">tax_tags</field> + <field name="formula">16% Sales Base</field> + </record> + <record id="tax_report_general_rate_sales_tax_tag" model="account.report.expression"> + <field name="label">tax</field> + <field name="engine">tax_tags</field> + <field name="formula">16% Sales Tax</field> + </record> + </field> + </record> + <record id="tax_report_line_other_rate_sales" model="account.report.line"> + <field name="name">2. Taxable Sales (Other Rate 8%)</field> + <field name="sequence">2</field> + <field name="code">box_2</field> + <field name="expression_ids"> + <record id="tax_report_other_rate_sales_base_tag" model="account.report.expression"> + <field name="label">base</field> + <field name="engine">tax_tags</field> + <field name="formula">8% Sales Base</field> + </record> + <record id="tax_report_other_rate_sales_tax_tag" model="account.report.expression"> + <field name="label">tax</field> + <field name="engine">tax_tags</field> + <field name="formula">8% Sales Tax</field> + </record> + </field> + </record> + <record id="tax_report_line_zero_rated_sales" model="account.report.line"> + <field name="name">3. Sales (Zero Rated 0%)</field> + <field name="sequence">3</field> + <field name="code">box_3</field> + <field name="expression_ids"> + <record id="tax_report_zero_rated_sales_base_tag" model="account.report.expression"> + <field name="label">base</field> + <field name="engine">tax_tags</field> + <field name="formula">Zero Rated Sales Base</field> + </record> + </field> + </record> + <record id="tax_report_line_exempt_sales" model="account.report.line"> + <field name="name">4. Sales (Exempt)</field> + <field name="sequence">4</field> + <field name="code">box_4</field> + <field name="expression_ids"> + <record id="tax_report_exempt_sales_base_tag" model="account.report.expression"> + <field name="label">base</field> + <field name="engine">tax_tags</field> + <field name="formula">Exempt Sales Base</field> + </record> + </field> + </record> + <record id="tax_report_line_total_sales" model="account.report.line"> + <field name="name">5. Total Sales</field> + <field name="sequence">5</field> + <field name="code">box_5</field> + <field name="expression_ids"> + <record id="tax_report_total_sales_base_tag" model="account.report.expression"> + <field name="label">base</field> + <field name="engine">aggregation</field> + <field name="formula">box_1.base + box_2.base + box_3.base + box_4.base</field> + </record> + <record id="tax_report_total_sales_tax_tag" model="account.report.expression"> + <field name="label">tax</field> + <field name="engine">aggregation</field> + <field name="formula">box_1.tax + box_2.tax</field> + </record> + </field> + </record> + <record id="tax_report_line_output_vat" model="account.report.line"> + <field name="name">6. Total Output VAT</field> + <field name="sequence">6</field> + <field name="code">box_6</field> + <field name="expression_ids"> + <record id="tax_report_output_vat_base_tag" model="account.report.expression"> + <field name="label">base</field> + <field name="engine">aggregation</field> + <field name="formula">box_1.base + box_2.base + box_3.base</field> + </record> + <record id="tax_report_output_vat_tax_tag" model="account.report.expression"> + <field name="label">tax</field> + <field name="engine">aggregation</field> + <field name="formula">box_1.tax + box_2.tax</field> + </record> + </field> + </record> + <record id="tax_report_line_general_rate_purchases" model="account.report.line"> + <field name="name">7. Taxable Purchases (General Rate 16%)</field> + <field name="sequence">7</field> + <field name="code">box_7</field> + <field name="expression_ids"> + <record id="tax_report_general_rate_purchases_base_tag" model="account.report.expression"> + <field name="label">base</field> + <field name="engine">tax_tags</field> + <field name="formula">16% Purchases Base</field> + </record> + <record id="tax_report_general_rate_purchases_tax_tag" model="account.report.expression"> + <field name="label">tax</field> + <field name="engine">tax_tags</field> + <field name="formula">16% Purchases Tax</field> + </record> + </field> + </record> + <record id="tax_report_line_other_rate_purchases" model="account.report.line"> + <field name="name">8. Taxable Purchases (Other Rate 8%)</field> + <field name="sequence">8</field> + <field name="code">box_8</field> + <field name="expression_ids"> + <record id="tax_report_other_rate_purchases_base_tag" model="account.report.expression"> + <field name="label">base</field> + <field name="engine">tax_tags</field> + <field name="formula">8% purchases Base</field> + </record> + <record id="tax_report_other_rate_purchases_tax_tag" model="account.report.expression"> + <field name="label">tax</field> + <field name="engine">tax_tags</field> + <field name="formula">8% Purchases Tax</field> + </record> + </field> + </record> + <record id="tax_report_line_zero_rated_purchases" model="account.report.line"> + <field name="name">9. Purchases (Zero Rated 0%)</field> + <field name="sequence">9</field> + <field name="code">box_9</field> + <field name="expression_ids"> + <record id="tax_report_zero_rated_purchases_base_tag" model="account.report.expression"> + <field name="label">base</field> + <field name="engine">tax_tags</field> + <field name="formula">Zero Rated Purchases Base</field> + </record> + </field> + </record> + <record id="tax_report_line_exempt_purchases" model="account.report.line"> + <field name="name">10. Purchases (Exempt)</field> + <field name="sequence">10</field> + <field name="code">box_10</field> + <field name="expression_ids"> + <record id="tax_report_exempt_purchases_base_tag" model="account.report.expression"> + <field name="label">base</field> + <field name="engine">tax_tags</field> + <field name="formula">Exempt Purchases Base</field> + </record> + </field> + </record> + <record id="tax_report_line_total_purchases" model="account.report.line"> + <field name="name">11. Total Purchases</field> + <field name="sequence">11</field> + <field name="code">box_11</field> + <field name="expression_ids"> + <record id="tax_report_total_purchases_base_tag" model="account.report.expression"> + <field name="label">base</field> + <field name="engine">aggregation</field> + <field name="formula">box_7.base + box_8.base + box_9.base + box_10.base</field> + </record> + <record id="tax_report_total_purchases_tax_tag" model="account.report.expression"> + <field name="label">tax</field> + <field name="engine">aggregation</field> + <field name="formula">box_7.tax + box_8.tax</field> + </record> + </field> + </record> + <record id="tax_report_line_input_vat" model="account.report.line"> + <field name="name">12. Total Input VAT</field> + <field name="sequence">12</field> + <field name="code">box_12</field> + <field name="expression_ids"> + <record id="tax_report_input_vat_base_tag" model="account.report.expression"> + <field name="label">base</field> + <field name="engine">aggregation</field> + <field name="formula">box_7.base + box_8.base + box_9.base</field> + </record> + <record id="tax_report_input_vat_tax_tag" model="account.report.expression"> + <field name="label">tax</field> + <field name="engine">aggregation</field> + <field name="formula">box_7.tax + box_8.tax</field> + </record> + </field> + </record> + </field> + </record> +</odoo> diff --git a/addons/l10n_ke/data/account_tax_template_data.xml b/addons/l10n_ke/data/account_tax_template_data.xml index 7f11bf14a74b..12e7ae6be559 100644 --- a/addons/l10n_ke/data/account_tax_template_data.xml +++ b/addons/l10n_ke/data/account_tax_template_data.xml @@ -9,17 +9,27 @@ <field name="amount">16</field> <field name="tax_group_id" ref="tax_group_16"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), (0,0, { + 'repartition_type': 'base', + 'plus_report_expression_ids': [ref('tax_report_general_rate_sales_base_tag')], + }), + (0,0, { + 'factor_percent': 100, 'repartition_type': 'tax', 'account_id': ref('ke2200'), + 'plus_report_expression_ids': [ref('tax_report_general_rate_sales_tax_tag')], }), ]"/> <field name="refund_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), (0,0, { + 'repartition_type': 'base', + 'minus_report_expression_ids': [ref('tax_report_general_rate_sales_base_tag')], + }), + (0,0, { + 'factor_percent': 100, 'repartition_type': 'tax', 'account_id': ref('ke2200'), + 'minus_report_expression_ids': [ref('tax_report_general_rate_sales_tax_tag')], }), ]"/> </record> @@ -32,17 +42,27 @@ <field name="amount">8</field> <field name="tax_group_id" ref="tax_group_8"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), (0,0, { + 'repartition_type': 'base', + 'plus_report_expression_ids': [ref('tax_report_other_rate_sales_base_tag')], + }), + (0,0, { + 'factor_percent': 100, 'repartition_type': 'tax', 'account_id': ref('ke2200'), + 'plus_report_expression_ids': [ref('tax_report_other_rate_sales_tax_tag')], }), ]"/> <field name="refund_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), (0,0, { + 'repartition_type': 'base', + 'minus_report_expression_ids': [ref('tax_report_other_rate_sales_base_tag')], + }), + (0,0, { + 'factor_percent': 100, 'repartition_type': 'tax', 'account_id': ref('ke2200'), + 'minus_report_expression_ids': [ref('tax_report_other_rate_sales_tax_tag')], }), ]"/> </record> @@ -55,12 +75,24 @@ <field name="amount">0</field> <field name="tax_group_id" ref="tax_group_0"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), - (0,0, {'repartition_type': 'tax'}), + (0,0, { + 'repartition_type': 'base', + 'plus_report_expression_ids': [ref('tax_report_zero_rated_sales_base_tag')], + }), + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + }), ]"/> <field name="refund_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), - (0,0, {'repartition_type': 'tax'}), + (0,0, { + 'repartition_type': 'base', + 'minus_report_expression_ids': [ref('tax_report_zero_rated_sales_base_tag')], + }), + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + }), ]"/> </record> <record id="STEX" model="account.tax.template"> @@ -72,12 +104,24 @@ <field name="amount">0</field> <field name="tax_group_id" ref="tax_group_0"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), - (0,0, {'repartition_type': 'tax'}), + (0,0, { + 'repartition_type': 'base', + 'plus_report_expression_ids': [ref('tax_report_exempt_sales_base_tag')], + }), + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + }), ]"/> <field name="refund_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), - (0,0, {'repartition_type': 'tax'}), + (0,0, { + 'repartition_type': 'base', + 'minus_report_expression_ids': [ref('tax_report_exempt_sales_base_tag')], + }), + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + }), ]"/> </record> <record id="SWT3" model="account.tax.template"> @@ -273,17 +317,27 @@ <field name="amount">16</field> <field name="tax_group_id" ref="tax_group_16"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), (0,0, { + 'repartition_type': 'base', + 'plus_report_expression_ids': [ref('tax_report_general_rate_purchases_base_tag')], + }), + (0,0, { + 'factor_percent': 100, 'repartition_type': 'tax', 'account_id': ref('ke1110'), + 'plus_report_expression_ids': [ref('tax_report_general_rate_purchases_tax_tag')], }), ]"/> <field name="refund_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), (0,0, { + 'repartition_type': 'base', + 'minus_report_expression_ids': [ref('tax_report_general_rate_purchases_base_tag')], + }), + (0,0, { + 'factor_percent': 100, 'repartition_type': 'tax', 'account_id': ref('ke1110'), + 'minus_report_expression_ids': [ref('tax_report_general_rate_purchases_tax_tag')], }), ]"/> </record> @@ -296,17 +350,27 @@ <field name="amount">8</field> <field name="tax_group_id" ref="tax_group_8"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), (0,0, { + 'repartition_type': 'base', + 'plus_report_expression_ids': [ref('tax_report_other_rate_purchases_base_tag')], + }), + (0,0, { + 'factor_percent': 100, 'repartition_type': 'tax', 'account_id': ref('ke1110'), + 'plus_report_expression_ids': [ref('tax_report_other_rate_purchases_tax_tag')], }), ]"/> <field name="refund_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), (0,0, { + 'repartition_type': 'base', + 'minus_report_expression_ids': [ref('tax_report_other_rate_purchases_base_tag')], + }), + (0,0, { + 'factor_percent': 100, 'repartition_type': 'tax', 'account_id': ref('ke1110'), + 'minus_report_expression_ids': [ref('tax_report_other_rate_purchases_tax_tag')], }), ]"/> </record> @@ -319,12 +383,24 @@ <field name="amount">0</field> <field name="tax_group_id" ref="tax_group_0"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), - (0,0, {'repartition_type': 'tax'}), + (0,0, { + 'repartition_type': 'base', + 'plus_report_expression_ids': [ref('tax_report_zero_rated_purchases_base_tag')], + }), + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + }), ]"/> <field name="refund_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), - (0,0, {'repartition_type': 'tax'}), + (0,0, { + 'repartition_type': 'base', + 'minus_report_expression_ids': [ref('tax_report_zero_rated_purchases_base_tag')], + }), + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + }), ]"/> </record> <record id="PTEX" model="account.tax.template"> @@ -336,12 +412,24 @@ <field name="amount">0</field> <field name="tax_group_id" ref="tax_group_0"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), - (0,0, {'repartition_type': 'tax'}), + (0,0, { + 'repartition_type': 'base', + 'plus_report_expression_ids': [ref('tax_report_exempt_purchases_base_tag')], + }), + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + }), ]"/> <field name="refund_repartition_line_ids" eval="[(5, 0, 0), - (0,0, {'repartition_type': 'base'}), - (0,0, {'repartition_type': 'tax'}), + (0,0, { + 'repartition_type': 'base', + 'minus_report_expression_ids': [ref('tax_report_exempt_purchases_base_tag')], + }), + (0,0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + }), ]"/> </record> <record id="PWT3" model="account.tax.template"> diff --git a/addons/l10n_ke_edi_tremol/__init__.py b/addons/l10n_ke_edi_tremol/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /dev/null +++ b/addons/l10n_ke_edi_tremol/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/l10n_ke_edi_tremol/__manifest__.py b/addons/l10n_ke_edi_tremol/__manifest__.py new file mode 100644 index 000000000000..4adae9254a28 --- /dev/null +++ b/addons/l10n_ke_edi_tremol/__manifest__.py @@ -0,0 +1,27 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': "Kenya Tremol Device EDI Integration", + 'summary': """ + Kenya Tremol Device EDI Integration + """, + 'description': """ + This module integrates with the Kenyan G03 Tremol control unit device to the KRA through TIMS. + """, + 'author': 'Odoo', + 'category': 'Accounting/Localizations/EDI', + 'version': '1.0', + 'license': 'LGPL-3', + 'depends': ['l10n_ke'], + 'data': [ + 'views/account_move_view.xml', + 'views/product_view.xml', + 'views/report_invoice.xml', + 'views/res_config_settings_view.xml', + 'views/res_partner_views.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'l10n_ke_edi_tremol/static/src/js/send_invoice.js', + ], + }, +} diff --git a/addons/l10n_ke_edi_tremol/models/__init__.py b/addons/l10n_ke_edi_tremol/models/__init__.py new file mode 100644 index 000000000000..b090c18797a2 --- /dev/null +++ b/addons/l10n_ke_edi_tremol/models/__init__.py @@ -0,0 +1,5 @@ +from . import account_move +from . import product +from . import res_company +from . import res_config_settings +from . import res_partner diff --git a/addons/l10n_ke_edi_tremol/models/account_move.py b/addons/l10n_ke_edi_tremol/models/account_move.py new file mode 100644 index 000000000000..51ac1a5a85e3 --- /dev/null +++ b/addons/l10n_ke_edi_tremol/models/account_move.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging +import json +import re +from datetime import datetime + +from odoo import models, fields, _ +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +class AccountMove(models.Model): + _inherit = 'account.move' + + l10n_ke_cu_datetime = fields.Datetime(string='CU Signing Date and Time', copy=False) + l10n_ke_cu_serial_number = fields.Char(string='CU Serial Number', copy=False) + l10n_ke_cu_invoice_number = fields.Char(string='CU Invoice Number', copy=False) + l10n_ke_cu_qrcode = fields.Char(string='CU QR Code', copy=False) + + # ------------------------------------------------------------------------- + # HELPERS + # ------------------------------------------------------------------------- + + def _l10n_ke_fmt(self, string, length, ljust=True): + """ Function for common formatting behaviour + + :param string: string to be formatted/encoded + :param length: integer length to justify (if enabled), and then truncate the string to + :param ljust: boolean representing whether the string should be justified + :returns: byte-string justified/truncated, with all non-alphanumeric characters removed + """ + if not string: + string = '' + return re.sub('[^A-Za-z0-9 ]+', '', str(string)).encode('cp1251').ljust(length if ljust else 0)[:length] + + # ------------------------------------------------------------------------- + # CHECKS + # ------------------------------------------------------------------------- + + def _l10n_ke_validate_move(self): + """ Returns list of errors related to misconfigurations + + Find misconfigurations on the move, the lines of the move, and the + taxes on those lines that would result in rejection by the KRA. + """ + self.ensure_one() + errors = [] + # The credit note should refer to the control unit number (receipt number) of the original + # invoice to which it relates. + if self.move_type == 'out_refund' and not self.reversed_entry_id.l10n_ke_cu_invoice_number: + errors.append(_("This credit note must reference the previous invoice, and this previous invoice must have already been submitted.")) + + for line in self.invoice_line_ids.filtered(lambda l: l.display_type == 'product'): + if not line.tax_ids or len(line.tax_ids) > 1: + errors.append(_("On line %s, you must select one and only one tax.", line.name)) + else: + if line.tax_ids.amount == 0 and not (line.product_id and line.product_id.l10n_ke_hsn_code and line.product_id.l10n_ke_hsn_name): + errors.append(_("On line %s, a product with a HS Code and HS Name must be selected, since the tax is 0%% or exempt.", line.name)) + + for tax in self.invoice_line_ids.tax_ids: + if tax.amount not in (16, 8, 0): + errors.append(_("Tax '%s' is used, but only taxes of 16%%, 8%%, 0%% or Exempt can be sent. Please reconfigure or change the tax.", tax.name)) + + return errors + + # ------------------------------------------------------------------------- + # SERIALISERS + # ------------------------------------------------------------------------- + + def _l10n_ke_cu_open_invoice_message(self): + """ Serialise the required fields for opening an invoice + + :returns: a list containing one byte-string representing the <CMD> and + <DATA> of the message sent to the fiscal device. + """ + headquarter_address = (self.commercial_partner_id.street or '') + (self.commercial_partner_id.street2 or '') + customer_address = (self.partner_id.street or '') + (self.partner_id.street2 or '') + postcode_and_city = (self.partner_id.zip or '') + '' + (self.partner_id.city or '') + invoice_elements = [ + b'1', # Reserved - 1 symbol with value '1' + b' 0', # Reserved - 6 symbols with value ‘ 0’ + b'0', # Reserved - 1 symbol with value '0' + b'1' if self.move_type == 'out_invoice' else b'A', # 1 symbol with value '1' (new invoice), 'A' (credit note), or '@' (debit note) + self._l10n_ke_fmt(self.commercial_partner_id.name, 30), # 30 symbols for Company name + self._l10n_ke_fmt(self.commercial_partner_id.vat, 14), # 14 Symbols for the client PIN number + self._l10n_ke_fmt(headquarter_address, 30), # 30 Symbols for customer headquarters + self._l10n_ke_fmt(customer_address, 30), # 30 Symbols for the address + self._l10n_ke_fmt(postcode_and_city, 30), # 30 symbols for the customer post code and city + self._l10n_ke_fmt('', 30), # 30 symbols for the exemption number + ] + if self.move_type == 'out_refund': + invoice_elements.append(self._l10n_ke_fmt(self.reversed_entry_id.l10n_ke_cu_invoice_number, 19)), # 19 symbols for related invoice number + invoice_elements.append(re.sub('[^A-Za-z0-9 ]+', '', self.name)[-15:].ljust(15).encode('cp1251')) # 15 symbols for trader system invoice number + + # Command: Open fiscal record (0x30) + return [b'\x30' + b';'.join(invoice_elements)] + + def _l10n_ke_cu_lines_messages(self): + """ Serialise the data of each line on the invoice + + This function transforms the lines in order to handle the differences + between the KRA expected data and the lines in odoo. + + If a discount line (as a negative line) has been added to the invoice + lines, find a suitable line/lines to distribute the discount accross + + :returns: List of byte-strings representing each command <CMD> and the + <DATA> of the line, which will be sent to the fiscal device + in order to add a line to the opened invoice. + """ + def is_discount_line(line): + return line.price_unit < 0.0 + + def is_candidate(discount_line, other_line): + """ If the of one line match those of the discount line, the discount can be distributed accross that line """ + discount_taxes = discount_line.tax_ids.flatten_taxes_hierarchy() + other_line_taxes = other_line.tax_ids.flatten_taxes_hierarchy() + return set(discount_taxes.ids) == set(other_line_taxes.ids) + + lines = self.invoice_line_ids.filtered(lambda l: l.display_type == 'product' and l.quantity and l.price_total) + # The device expects all monetary values in Kenyan Shillings + if self.currency_id == self.company_id.currency_id: + currency_rate = 1 + else: + currency_rate = abs(self.invoice_line_ids[0].balance / self.invoice_line_ids[0].price_subtotal) + + discount_dict = {line.id: line.discount for line in lines if line.price_total > 0} + for line in lines: + if not is_discount_line(line): + continue + # Search for non-discount lines + candidate_vals_list = [l for l in lines if not is_discount_line(l) and is_candidate(l, line)] + candidate_vals_list = sorted(candidate_vals_list, key=lambda x: x.price_unit * x.quantity, reverse=True) + line_to_discount = abs(line.price_unit * line.quantity) + for candidate in candidate_vals_list: + still_to_discount = abs(candidate.price_unit * candidate.quantity * (100.0 - discount_dict[candidate.id]) / 100.0) + if line_to_discount >= still_to_discount: + discount_dict[candidate.id] = 100.0 + line_to_discount -= still_to_discount + else: + rest_to_discount = abs((line_to_discount / (candidate.price_unit * candidate.quantity)) * 100.0) + discount_dict[candidate.id] += rest_to_discount + break + + vat_class = {16.0: 'A', 8.0: 'B'} + msgs = [] + for line in self.invoice_line_ids.filtered(lambda l: l.display_type == 'product' and l.quantity and l.price_total > 0 and not discount_dict.get(l.id) >= 100): + # Here we use the original discount of the line, since it the distributed discount has not been applied in the price_total + price = round(line.price_total / line.quantity * 100 / (100 - line.discount), 2) * currency_rate + percentage = line.tax_ids[0].amount + + # Letter to classify tax, 0% taxes are handled conditionally, as the tax can be zero-rated or exempt + letter = '' + if percentage in vat_class: + letter = vat_class[percentage] + else: + report_line_ids = line.tax_ids.invoice_repartition_line_ids.tag_ids._get_related_tax_report_expressions().report_line_id.ids + try: + exempt_report_line = self.env.ref('l10n_ke.tax_report_line_exempt_sales') + except ValueError: + raise UserError(_("Tax exempt report line cannot be found, please update the l10n_ke module.")) + letter = 'E' if exempt_report_line.id in report_line_ids else 'C' + + uom = line.product_uom_id and line.product_uom_id.name or '' + hscode = re.sub('[^0-9.]+', '', line.product_id.l10n_ke_hsn_code)[:10].ljust(10).encode('cp1251') if letter not in ('A', 'B') else b''.ljust(10) + hsname = re.sub('[^0-9.]+', '', line.product_id.l10n_ke_hsn_name)[:20].ljust(20).encode('cp1251') if letter not in ('A', 'B') else b''.ljust(20) + line_data = b';'.join([ + self._l10n_ke_fmt(line.name, 36), # 36 symbols for the article's name + self._l10n_ke_fmt(letter, 1), # 1 symbol for article's vat class ('A', 'B', 'C', 'D', or 'E') + str(price)[:13].encode('cp1251'), # 1 to 13 symbols for article's price + self._l10n_ke_fmt(uom, 3), # 3 symbols for unit of measure + hscode, # 10 symbols for HS code in the format xxxx.xx.xx (can be empty) + hsname, # 20 symbols for the HS name (can be empty) + str(percentage).encode('cp1251')[:5] # up to 5 symbols for vat rate + ]) + # 1 to 10 symbols for quantity + line_data += b'*' + str(line.quantity).encode('cp1251')[:10] + if discount_dict.get(line.id): + # 1 to 7 symbols for percentage of discount/addition + discount_sign = b'-' if discount_dict[line.id] > 0 else b'+' + discount = discount_sign + str(abs(discount_dict[line.id])).encode('cp1251')[:6] + line_data += b',' + discount + b'%' + + # Command: Sale of article (0x31) + msgs += [b'\x31' + line_data] + return msgs + + def _l10n_ke_get_cu_messages(self): + self.ensure_one() + msgs = self._l10n_ke_cu_open_invoice_message() + msgs += self._l10n_ke_cu_lines_messages() + # Command: Close fiscal reciept (0x38) + msgs += [b'\x38'] + # Command: Read date and time (0x68) + msgs += [b'\x68'] + return msgs + + # ------------------------------------------------------------------------- + # POST COMMANDS / RECEIVE DATA + # ------------------------------------------------------------------------- + + def l10n_ke_action_cu_post(self): + self.ensure_one() + # Check the configuration of the invoice + errors = self._l10n_ke_validate_move() + if errors: + raise UserError(_("Invalid invoice configuration:\n\n%s") % '\n'.join(errors)) + return { + 'type': 'ir.actions.client', + 'tag': 'post_send', + 'params': { + 'messages': json.dumps([m.decode('cp1251') for m in self._l10n_ke_get_cu_messages()]), + 'move_id': self.id, + 'proxy_address': self.company_id.l10n_ke_cu_proxy_address, + 'company_vat': self.company_id.vat, + } + } + + def l10n_ke_cu_response(self, response): + move = self.browse(response['move_id']) + replies = [msg for msg in response['replies']] + move.update({ + 'l10n_ke_cu_serial_number': response['serial_number'], + 'l10n_ke_cu_invoice_number': replies[-2].split(';')[0], + 'l10n_ke_cu_qrcode': replies[-2].split(';')[1].strip(), + 'l10n_ke_cu_datetime': datetime.strptime(replies[-1], '%d-%m-%Y %H:%M'), + }) diff --git a/addons/l10n_ke_edi_tremol/models/product.py b/addons/l10n_ke_edi_tremol/models/product.py new file mode 100644 index 000000000000..7a59d27ae83d --- /dev/null +++ b/addons/l10n_ke_edi_tremol/models/product.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + l10n_ke_hsn_code = fields.Char( + string='HSN code', + help="Product code needed in case of not 16%. ", + ) + l10n_ke_hsn_name = fields.Char( + string='HSN description', + help="Product code description needed in case of not 16%. ", + ) + +class ProductProduct(models.Model): + _inherit = "product.product" + + l10n_ke_hsn_code = fields.Char( + string='HSN code', + related='product_tmpl_id.l10n_ke_hsn_code', + help="Product code needed in case of not 16%. ", + readonly=False, + ) + l10n_ke_hsn_name = fields.Char( + string='HSN description', + related='product_tmpl_id.l10n_ke_hsn_name', + help="Product code description needed in case of not 16%. ", + readonly=False, + ) diff --git a/addons/l10n_ke_edi_tremol/models/res_company.py b/addons/l10n_ke_edi_tremol/models/res_company.py new file mode 100644 index 000000000000..69c502ac18b6 --- /dev/null +++ b/addons/l10n_ke_edi_tremol/models/res_company.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + l10n_ke_cu_proxy_address = fields.Char( + default="http://localhost:8069", + string='Control Unit Proxy Address', + help='The address of the proxy server for the control unit.', + ) diff --git a/addons/l10n_ke_edi_tremol/models/res_config_settings.py b/addons/l10n_ke_edi_tremol/models/res_config_settings.py new file mode 100644 index 000000000000..3614cae3e150 --- /dev/null +++ b/addons/l10n_ke_edi_tremol/models/res_config_settings.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + l10n_ke_cu_proxy_address = fields.Char(related='company_id.l10n_ke_cu_proxy_address', readonly=False) diff --git a/addons/l10n_ke_edi_tremol/models/res_partner.py b/addons/l10n_ke_edi_tremol/models/res_partner.py new file mode 100644 index 000000000000..9a1c9788800c --- /dev/null +++ b/addons/l10n_ke_edi_tremol/models/res_partner.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + l10n_ke_exemption_number = fields.Char( + string='Exemption Number', + help='The exemption number of the partner. Provided by the Kenyan government.', + ) + + def _commercial_fields(self): + return super()._commercial_fields() + ['l10n_ke_exemption_number'] diff --git a/addons/l10n_ke_edi_tremol/static/src/js/send_invoice.js b/addons/l10n_ke_edi_tremol/static/src/js/send_invoice.js new file mode 100644 index 000000000000..8d88d67ac260 --- /dev/null +++ b/addons/l10n_ke_edi_tremol/static/src/js/send_invoice.js @@ -0,0 +1,34 @@ +odoo.define('l10n_ke_edi_tremol.action_post_send_invoice', function (require) { + const core = require('web.core'); + const ajax = require('web.ajax'); + const Dialog = require('web.Dialog'); + var rpc = require('web.rpc'); + var _t = core._t; + + async function post_send(parent, {params}) { + const move_id = params.move_id; + await ajax.post(params.proxy_address + '/hw_proxy/l10n_ke_cu_send', params).then(function (res) { + const res_obj = JSON.parse(res); + if (res_obj.status != "ok") { + Dialog.alert(this, "Posting the invoice has failed, with the message: \n" + res_obj.status); + } else { + rpc.query({ + model: 'account.move', + method: 'l10n_ke_cu_response', + args: [[], {'replies': res_obj.replies, 'serial_number': res_obj.serial_number, 'move_id': move_id}], + }).then(function () { + parent.services.action.doAction({ + 'type': 'ir.actions.client', + 'tag': 'reload', + }); + }, function () { + Dialog.alert(this, _t("Error trying to connect to Odoo. Check your internet connection")); + }) + } + }, function () { + Dialog.alert(this, _t("Error trying to connect to the middleware. Is the middleware running?")); + }) + } + core.action_registry.add('post_send', post_send); + return post_send; +}); diff --git a/addons/l10n_ke_edi_tremol/views/account_move_view.xml b/addons/l10n_ke_edi_tremol/views/account_move_view.xml new file mode 100644 index 000000000000..7c4ae0096b2c --- /dev/null +++ b/addons/l10n_ke_edi_tremol/views/account_move_view.xml @@ -0,0 +1,58 @@ +<odoo> + <data> + <record id="l10n_ke_inherit_account_move_form" model="ir.ui.view"> + <field name="name">l10n.ke.inherit.account.move.form</field> + <field name="model">account.move</field> + <field name="inherit_id" ref="account.view_move_form"/> + <field name="priority" eval="40"/> + <field name="arch" type="xml"> + <xpath expr="//header/button[@name='action_post']" position="after"> + <field name="l10n_ke_cu_qrcode" invisible="1"/> + <button name="l10n_ke_action_cu_post" type="object" + class="oe_highlight" + groups="account.group_account_manager" + string="Send Invoice To Device" + attrs="{'invisible': ['|', '|', '|', ('country_code', '!=', 'KE'), ('l10n_ke_cu_qrcode', '!=', False), ('state', '!=', 'posted'), ('move_type', 'not in', ['out_invoice', 'out_refund'])]}"/> + </xpath> + <xpath expr="//group[@id='header_right_group']" position="inside"> + <field name="l10n_ke_cu_invoice_number" attrs="{'invisible': [('country_code', '!=', 'KE')]}" readonly="1"/> + </xpath> + <notebook position="inside"> + <page string="Tremol GO3 Control Unit" attrs="{'invisible': [('country_code', '!=', 'KE')]}"> + <group> + <group> + <field name="l10n_ke_cu_qrcode" widget="url" readonly="1"/> + <field name="l10n_ke_cu_serial_number" readonly="1"/> + <field name="l10n_ke_cu_datetime" readonly="1"/> + </group> + </group> + </page> + </notebook> + </field> + </record> + + <record id="l10n_ke_inherit_account_move_tree_view" model="ir.ui.view"> + <field name="name">l10n.ke.inherit.account.move.tree</field> + <field name="model">account.move</field> + <field name="inherit_id" ref="account.view_out_invoice_tree" /> + <field name="arch" type="xml"> + <field name="state" position="after"> + <field name="l10n_ke_cu_invoice_number" optional="hide"/> + </field> + </field> + </record> + + <record id="l10n_ke_inherit_account_move_search_view" model="ir.ui.view"> + <field name="name">l10n.ke.inherit.account.move.search</field> + <field name="model">account.move</field> + <field name="inherit_id" ref="account.view_account_invoice_filter" /> + <field name="arch" type="xml"> + <xpath expr="//field[@name='journal_id']" position="after"> + <field name="l10n_ke_cu_invoice_number" string="Kenya CU Invoice Number" filter_domain="[('l10n_ke_cu_invoice_number', 'ilike', self')]" /> + </xpath> + </field> + </record> + </data> +</odoo> + + diff --git a/addons/l10n_ke_edi_tremol/views/product_view.xml b/addons/l10n_ke_edi_tremol/views/product_view.xml new file mode 100644 index 000000000000..5829232e1e82 --- /dev/null +++ b/addons/l10n_ke_edi_tremol/views/product_view.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="l10n_ke_inherit_product_template_form_view" model="ir.ui.view"> + + <field name="name">l10n.ke.inherit.product.template.form.inherit</field> + <field name="model">product.template</field> + <field name="inherit_id" ref="account.product_template_form_view"/> + <field name="arch" type="xml"> + <xpath expr="//page[@name='invoicing']//group[@name='accounting']" position="inside"> + <group name="HS Code" string="HS Code" attrs="{'invisible': [('product_variant_count', '>', 1), ('is_product_variant', '=', False)]}"> + <field name="l10n_ke_hsn_code"/> + <field name="l10n_ke_hsn_name"/> + </group> + </xpath> + </field> + </record> + + <record id="l10n_ke_inherit_product_product_form_view" model="ir.ui.view"> + <field name="name">l10n.ke.inherit.product.product.form</field> + <field name="model">product.product</field> + <field name="inherit_id" ref="product.product_variant_easy_edit_view"/> + <field name="arch" type="xml"> + <xpath expr="//sheet" position="inside"> + <group> + <group name="HS Code" string="HS Code"> + <field name="l10n_ke_hsn_code"/> + <field name="l10n_ke_hsn_name"/> + </group> + </group> + </xpath> + </field> + </record> +</odoo> diff --git a/addons/l10n_ke_edi_tremol/views/report_invoice.xml b/addons/l10n_ke_edi_tremol/views/report_invoice.xml new file mode 100644 index 000000000000..c7491ec27797 --- /dev/null +++ b/addons/l10n_ke_edi_tremol/views/report_invoice.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="l10n_ke_invoice" inherit_id="account.report_invoice_document"> + <xpath expr="//div[@id='qrcode']" position="before"> + <div t-if="o.country_code == 'KE'" id="l10n_ke_control_unit_information" style="page-break-inside:avoid;"> + <b>Kenyan Control Unit Info</b> + <div class="row mt-4 mb-4"> + <div class="col-auto col-3 mw-100 mb-2"> + <p> + <b>Invoice Number: </b><br></br> + <span t-field="o.l10n_ke_cu_invoice_number"/> + </p> + <p> + <b>Serial Number: </b><br></br> + <span t-field="o.l10n_ke_cu_serial_number"/> + </p> + <p> + <b>Date and Time of Signing: </b><br></br> + <span t-field="o.l10n_ke_cu_datetime"/> + </p> + </div> + <div class="col-auto col-3 mw-100 mb-2"> + <p t-if="o.l10n_ke_cu_qrcode"> + <strong class="text-center">TIMS URL</strong><br/><br/> + <img style="display:block;" t-att-src="'/report/barcode/?barcode_type=%s&value=%s&width=%s&height=%s' % ('QR', quote_plus(o.l10n_ke_cu_qrcode), 130, 130)" alt="QR Code"/> + </p> + </div> + </div> + </div> + </xpath> + </template> +</odoo> diff --git a/addons/l10n_ke_edi_tremol/views/res_config_settings_view.xml b/addons/l10n_ke_edi_tremol/views/res_config_settings_view.xml new file mode 100644 index 000000000000..7b7c899f3972 --- /dev/null +++ b/addons/l10n_ke_edi_tremol/views/res_config_settings_view.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="res_config_settings_view_form" model="ir.ui.view"> + <field name="name">l10n.ke.tremol.inherit.res.config.settings.form</field> + <field name="model">res.config.settings</field> + <field name="inherit_id" ref="account.res_config_settings_view_form"/> + <field name="arch" type="xml"> + <xpath expr="//div[@id='account_vendor_bills']" position="after"> + <div attrs="{'invisible':[('country_code', '!=', 'KE')]}"> + <h2>Kenya TIMS Integration</h2> + <div class="row mt16 o_settings_container" id="l10n_ke_cu_details"> + <div class="col-12 col-lg-6 o_setting_box"> + <div class="o_setting_right_pane"> + <span class="o_form_label">Tremol Device Settings</span> + <span class="fa fa-lg fa-building-o" title="Values set here are company-specific." aria-label="Values set here are company-specific." groups="base.group_multi_company" role="img"/> + <div class="text-muted"> + The tremol device makes use of a proxy server, which can be running locally on your computer or on an IoT Box. + The proxy server must be on the same network as the fiscal device. + </div> + <div class="content-group"> + <div class="row mt8"> + <label for="l10n_ke_cu_proxy_address" class="col-lg-5 o_light_label"/> + <field name="l10n_ke_cu_proxy_address"/> + </div> + </div> + </div> + </div> + </div> + </div> + </xpath> + </field> + </record> +</odoo> diff --git a/addons/l10n_ke_edi_tremol/views/res_partner_views.xml b/addons/l10n_ke_edi_tremol/views/res_partner_views.xml new file mode 100644 index 000000000000..31c65cf33f38 --- /dev/null +++ b/addons/l10n_ke_edi_tremol/views/res_partner_views.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="res_partner_view_form" model="ir.ui.view"> + <field name="name">l10n.ke.tremol.inherit.res.partner.form</field> + <field name="model">res.partner</field> + <field name="inherit_id" ref="account.view_partner_property_form"/> + <field name="arch" type="xml"> + <group name="accounting_entries" position="after"> + <group string="Kenya Accounting Details" name="l10n_ke_details"> + <field name="l10n_ke_exemption_number"/> + </group> + </group> + </field> + </record> +</odoo> -- GitLab