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 00000000000..6214e6b9b37 --- /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 (starting byte) (length byte) + and (message number byte) at the start and two (checksum + bytes), and the line-feed byte at the end. + :param msgs: A list of byte strings representing the and + 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 and ) 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 ff550da19bb..acc6fd8a530 100644 --- a/addons/l10n_ke/__manifest__.py +++ b/addons/l10n_ke/__manifest__.py @@ -16,6 +16,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 00000000000..94683c37cb1 --- /dev/null +++ b/addons/l10n_ke/data/account_tax_report_data.xml @@ -0,0 +1,206 @@ + + + + Tax Report + + + + country + + + Base + base + + + VAT + tax + + + + + 1. Taxable Sales (General Rate 16%) + 1 + box_1 + + + base + tax_tags + 16% Sales Base + + + tax + tax_tags + 16% Sales Tax + + + + + 2. Taxable Sales (Other Rate 8%) + 2 + box_2 + + + base + tax_tags + 8% Sales Base + + + tax + tax_tags + 8% Sales Tax + + + + + 3. Sales (Zero Rated 0%) + 3 + box_3 + + + base + tax_tags + Zero Rated Sales Base + + + + + 4. Sales (Exempt) + 4 + box_4 + + + base + tax_tags + Exempt Sales Base + + + + + 5. Total Sales + 5 + box_5 + + + base + aggregation + box_1.base + box_2.base + box_3.base + box_4.base + + + tax + aggregation + box_1.tax + box_2.tax + + + + + 6. Total Output VAT + 6 + box_6 + + + base + aggregation + box_1.base + box_2.base + box_3.base + + + tax + aggregation + box_1.tax + box_2.tax + + + + + 7. Taxable Purchases (General Rate 16%) + 7 + box_7 + + + base + tax_tags + 16% Purchases Base + + + tax + tax_tags + 16% Purchases Tax + + + + + 8. Taxable Purchases (Other Rate 8%) + 8 + box_8 + + + base + tax_tags + 8% purchases Base + + + tax + tax_tags + 8% Purchases Tax + + + + + 9. Purchases (Zero Rated 0%) + 9 + box_9 + + + base + tax_tags + Zero Rated Purchases Base + + + + + 10. Purchases (Exempt) + 10 + box_10 + + + base + tax_tags + Exempt Purchases Base + + + + + 11. Total Purchases + 11 + box_11 + + + base + aggregation + box_7.base + box_8.base + box_9.base + box_10.base + + + tax + aggregation + box_7.tax + box_8.tax + + + + + 12. Total Input VAT + 12 + box_12 + + + base + aggregation + box_7.base + box_8.base + box_9.base + + + tax + aggregation + box_7.tax + box_8.tax + + + + + + diff --git a/addons/l10n_ke/data/account_tax_template_data.xml b/addons/l10n_ke/data/account_tax_template_data.xml index 7f11bf14a74..12e7ae6be55 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 @@ 16 @@ -32,17 +42,27 @@ 8 @@ -55,12 +75,24 @@ 0 @@ -72,12 +104,24 @@ 0 @@ -273,17 +317,27 @@ 16 @@ -296,17 +350,27 @@ 8 @@ -319,12 +383,24 @@ 0 @@ -336,12 +412,24 @@ 0 diff --git a/addons/l10n_ke_edi_tremol/__init__.py b/addons/l10n_ke_edi_tremol/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /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 00000000000..4adae9254a2 --- /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 00000000000..b090c18797a --- /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 00000000000..51ac1a5a85e --- /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 and + 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 and the + 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 00000000000..7a59d27ae83 --- /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 00000000000..69c502ac18b --- /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 00000000000..3614cae3e15 --- /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 00000000000..9a1c9788800 --- /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 00000000000..8d88d67ac26 --- /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 00000000000..7c4ae0096b2 --- /dev/null +++ b/addons/l10n_ke_edi_tremol/views/account_move_view.xml @@ -0,0 +1,58 @@ + + + + l10n.ke.inherit.account.move.form + account.move + + + + + +