diff --git a/.gitignore b/.gitignore index 9e493a3..6c01083 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ *.swp tags -/.vscode +**/.vscode /.idea /node_modules /__pycache__ diff --git a/kenya_compliance/hooks.py b/kenya_compliance/hooks.py index f12e631..71728c5 100644 --- a/kenya_compliance/hooks.py +++ b/kenya_compliance/hooks.py @@ -42,7 +42,7 @@ # page_js = {"page" : "public/js/file.js"} # include js in doctype views -# doctype_js = {"doctype" : "public/js/doctype.js"} +doctype_js = {"Sales Invoice": "kenya_compliance/overrides/client/sales_invoice.js"} # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} @@ -144,7 +144,7 @@ # } "Sales Invoice": { "on_submit": [ - "kenya_compliance.kenya_compliance.interceptors.invoice_on_submit" + "kenya_compliance.kenya_compliance.overrides.server.sales_invoice.on_submit" ] } } diff --git a/kenya_compliance/kenya_compliance/doctype/doctype_names_mapping.py b/kenya_compliance/kenya_compliance/doctype/doctype_names_mapping.py index e208ce0..f08f35e 100644 --- a/kenya_compliance/kenya_compliance/doctype/doctype_names_mapping.py +++ b/kenya_compliance/kenya_compliance/doctype/doctype_names_mapping.py @@ -8,6 +8,8 @@ LAST_REQUEST_DATE_DOCTYPE_NAME: Final[str] = "Navari KRA eTims Last Request Date" ROUTES_TABLE_DOCTYPE_NAME: Final[str] = "Navari KRA eTims Route Table" ROUTES_TABLE_CHILD_DOCTYPE_NAME: Final[str] = "Navari KRA eTims Route Table Item" +ITEM_CLASSIFICATIONS_DOCTYPE_NAME: Final[str] = "Navari KRA eTims Item Classification" +TAXATION_TYPE_DOCTYPE_NAME: Final[str] = "Navari KRA eTims Taxation Type" # Global Variables SANDBOX_SERVER_URL: Final[str] = "https://etims-api-sbx.kra.go.ke/etims-api" diff --git a/kenya_compliance/kenya_compliance/doctype/navari_kra_etims_communication_key/navari_kra_etims_communication_key.json b/kenya_compliance/kenya_compliance/doctype/navari_kra_etims_communication_key/navari_kra_etims_communication_key.json index a27cf02..d5e725f 100644 --- a/kenya_compliance/kenya_compliance/doctype/navari_kra_etims_communication_key/navari_kra_etims_communication_key.json +++ b/kenya_compliance/kenya_compliance/doctype/navari_kra_etims_communication_key/navari_kra_etims_communication_key.json @@ -15,14 +15,13 @@ "fieldtype": "Data", "in_list_view": 1, "label": "Communication Key", - "read_only": 1, "reqd": 1 } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-02-21 03:54:59.139563", + "modified": "2024-03-06 09:51:57.506571", "modified_by": "Administrator", "module": "Kenya Compliance", "name": "Navari KRA eTims Communication Key", diff --git a/kenya_compliance/kenya_compliance/handlers.py b/kenya_compliance/kenya_compliance/handlers.py index bf89544..86e72a0 100644 --- a/kenya_compliance/kenya_compliance/handlers.py +++ b/kenya_compliance/kenya_compliance/handlers.py @@ -25,7 +25,7 @@ def fetch_communication_key(response: dict[str, str]) -> str | None: communication_key, datetime.now() ) - return communication_key + return saved_key.cmckey except KeyError as error: etims_logger.exception(error) diff --git a/kenya_compliance/kenya_compliance/interceptors.py b/kenya_compliance/kenya_compliance/interceptors.py deleted file mode 100644 index 55f766c..0000000 --- a/kenya_compliance/kenya_compliance/interceptors.py +++ /dev/null @@ -1,41 +0,0 @@ -import asyncio - -import frappe -from frappe.model.document import Document - -from .handlers import handle_errors, update_last_request_date -from .utils import ( - build_common_payload, - get_route_path, - get_server_url, - make_post_request, -) - - -def invoice_on_submit(doc: Document, method: str) -> None: - # TODO: Add environment identifier - environment = "Sandbox" - payload = build_common_payload(doc, environment) - server_url = get_server_url(doc, environment) - - if payload and server_url: - route_path = get_route_path("CodeSearchReq") - response = asyncio.run( - make_post_request( - f"{server_url}{route_path}", - data=payload, - ) - ) - - if response: - response_code = response["resultCd"] - update_last_request_date(response["resultDt"]) - - if response_code == "000": - frappe.msgprint( - msg=response["resultMsg"], - title="Success", - indicator="green", - ) - else: - handle_errors(response, doc) diff --git a/kenya_compliance/kenya_compliance/overrides/client/sales_invoice.js b/kenya_compliance/kenya_compliance/overrides/client/sales_invoice.js new file mode 100644 index 0000000..204a167 --- /dev/null +++ b/kenya_compliance/kenya_compliance/overrides/client/sales_invoice.js @@ -0,0 +1,53 @@ +const doctype = "Sales Invoice"; +const childDoctype = "Sales Invoice Item"; + +frappe.ui.form.on(doctype, { + refresh: function (frm) { + frm.fields_dict.items.grid.get_field("item_classification_code").get_query = + function (doc, cdt, cdn) { + const itemDescription = locals[cdt][cdn].description; + const descriptionText = parseItemDescriptText(itemDescription); + + return { + filters: [ + [ + "Navari KRA eTims Item Classification", + "itemclsnm", + "like", + `%${descriptionText}%`, + ], + ], + }; + }; + }, +}); + +frappe.ui.form.on(childDoctype, { + item_classification_code: async function (frm, cdt, cdn) { + const itemClassificationCode = locals[cdt][cdn].item_classification_code; + + if (itemClassificationCode) { + const response = await frappe.db.get_value( + "Navari KRA eTims Item Classification", + { itemclscd: itemClassificationCode }, + ["*"] + ); + + frappe.model.set_value( + cdt, + cdn, + "taxation_type", + response.message?.taxtycd + ); + } + }, +}); + +function parseItemDescriptText(description) { + const temp = document.createElement("div"); + + temp.innerHTML = description; + const descriptionText = temp.textContent || temp.innerText; + + return descriptionText; +} diff --git a/kenya_compliance/kenya_compliance/overrides/server/sales_invoice.py b/kenya_compliance/kenya_compliance/overrides/server/sales_invoice.py new file mode 100644 index 0000000..98cd03f --- /dev/null +++ b/kenya_compliance/kenya_compliance/overrides/server/sales_invoice.py @@ -0,0 +1,62 @@ +import asyncio + +import aiohttp +import frappe +from frappe.model.document import Document + +from ...logger import etims_logger +from ...utils import ( + build_headers, + get_last_request_date, + get_route_path, + get_server_url, + make_post_request, + queue_request, +) + + +def on_submit(doc: Document, method: str) -> None: + """Intercepts submit event for document""" + error_messages = None + headers = build_headers(doc) + last_request_date = get_last_request_date() + + if headers and last_request_date: + server_url = get_server_url(doc) + route_path = get_route_path("CodeSearchReq") + + if server_url and route_path: + url = f"{server_url}{route_path}" + + try: + # TODO: Run job in background + response = asyncio.run( + make_post_request(url, {"lastReqDt": "20230101000000"}, headers) + ) + + if response: + print(f"{response}") + raise Exception + except aiohttp.client_exceptions.ClientConnectorError as error: + etims_logger.exception(error, exc_info=True) + frappe.throw( + "Connection failed", + error, + title="Connection Error", + ) + + elif not headers: + error_messages = ( + "Headers not set for %s. Please ensure the tax Id is properly set" + % doc.name + ) + etims_logger.error(error_messages) + frappe.throw(error_messages, title="Incorrect Setup") + + elif not last_request_date: + error_messages = ( + "Last Request Date is not set for %s. Please ensure it is properly set" + % doc.name + ) + etims_logger.error(error_messages) + frappe.throw(error_messages, title="Incorrect Setup") diff --git a/kenya_compliance/kenya_compliance/utils.py b/kenya_compliance/kenya_compliance/utils.py index c6041df..7be301f 100644 --- a/kenya_compliance/kenya_compliance/utils.py +++ b/kenya_compliance/kenya_compliance/utils.py @@ -2,6 +2,7 @@ import re from datetime import datetime +from typing import Any, Callable, Literal import aiohttp import frappe @@ -14,6 +15,7 @@ ROUTES_TABLE_DOCTYPE_NAME, SETTINGS_DOCTYPE_NAME, ) +from .logger import etims_logger def is_valid_kra_pin(pin: str) -> bool: @@ -46,13 +48,16 @@ async def make_get_request(url: str) -> dict[str, str]: async def make_post_request( - url: str, data: dict[str, str] | None = None + url: str, + data: dict[str, str] | None = None, + headers: dict[str, str | int] | None = None, ) -> dict[str, str]: """Make an Asynchronous POST Request to specified URL Args: url (str): The URL data (dict[str, str] | None, optional): Data to send to server. Defaults to None. + headers (dict[str, str | int] | None, optional): Headers to set. Defaults to None. Returns: dict: The Server Response @@ -60,7 +65,7 @@ async def make_post_request( # TODO: Refactor to a more efficient handling of creation of the session object # as described in documentation async with aiohttp.ClientSession() as session: - async with session.post(url, json=data) as response: + async with session.post(url, json=data, headers=headers) as response: return await response.json() @@ -98,11 +103,16 @@ def get_communication_key(doctype: str = COMMUNICATION_KEYS_DOCTYPE_NAME) -> str Returns: str: The fetched communication key """ + error_messages = None communication_key = frappe.db.get_single_value(doctype, "cmckey") if communication_key: return communication_key + error_messages = "No Communication Key found in %s" % doctype + etims_logger.error(error_messages) + frappe.throw(error_messages, title="Incorrect Setup") + def build_datetime_from_string( date_string: str, format: str = "%Y-%m-%d %H:%M:%S" @@ -181,13 +191,13 @@ def get_current_user_timezone(current_user: str) -> str | None: def get_route_path( - search_field: str = "CodeSearchReq", + search_field: str, routes_table_doctype: str = ROUTES_TABLE_CHILD_DOCTYPE_NAME, ) -> str | None: """Searches and retrieves the route path from the KRA eTims Route Table Navari doctype Args: - search_field (str): The field to search + search_field (str): The field to search. routes_table (str, optional): _description_. Defaults to ROUTES_TABLE_CHILD_DOCTYPE_NAME. Returns: @@ -221,8 +231,9 @@ def get_environment_settings( environment (str, optional): The environment state. Defaults to "Sandbox". Returns: - _type_: _description_ + Document | None: The fetched document. """ + error_message = None query = f""" SELECT sandbox, server_url, @@ -234,12 +245,15 @@ def get_environment_settings( FROM `tab{doctype}` WHERE name like '{company_pin}-{environment}%' """ - setting_doctype = frappe.db.sql(query, as_dict=True) if setting_doctype: return setting_doctype[0] + error_message = "No environment setting created. Please ensure a valid Navari eTims Integration Setting record exists" + etims_logger.error(error_message) + frappe.throw(error_message, title="Incorrect Setup") + def get_server_url(document: Document, environment: str = "Sandbox") -> str | None: settings = get_environment_settings( @@ -252,21 +266,66 @@ def get_server_url(document: Document, environment: str = "Sandbox") -> str | No return server_url -def build_common_payload( +def build_headers( document: Document, environment: str = "Sandbox" ) -> dict[str, str] | None: + """Builds the header required for communication with the eTims Server + + Args: + document (Document): The document to fetch setting information from + environment (str, optional): Variable denoting environment of current instance. Defaults to "Sandbox". + + Returns: + dict[str, str] | None: The headers as a dictionary + """ settings = get_environment_settings( document.company_tax_id, environment=environment ) + # TODO: Handle no communication key and request date communication_key = get_communication_key() - last_request_date = get_last_request_date() - if settings and communication_key and last_request_date: + if settings and communication_key: payload = { "tin": settings.get("tin"), "bhfId": settings.get("bhfid"), "cmcKey": communication_key, - "lastReqDt": last_request_date, } return payload + + +def queue_request( + function_to_queue: Callable[[str, dict[str, str]], dict[str, str]], + url: str, + payload: dict, + headers: dict, + success_callback: Callable[[Any, Any, Any], Any], + failure_callback: Callable[[Any, Any, Any, Any, Any], Any], + queue_type: Literal["default", "short", "long"], +) -> Any: + """Queues a request function to the Redis Queue + + Args: + function_to_queue (Callable[[str, dict[str, str]], dict[str, str]]): The function to queue + url (str): The target request url + payload (dict): The json request data + headers (dict): The request headers + success_callback (Callable[[Any, Any, Any], Any]): Callback to handle successful responses + failure_callback (Callable[[Any, Any, Any, Any, Any], Any]): Callback to handle failure responses + queue_type (Literal["default", "short", "long"]): Type of Queue to enqueue job to + + Returns: + Any: The job id of current job + """ + job = frappe.enqueue( + function_to_queue, + url=url, + data=payload, + headers=headers, + is_async=True, + queue=queue_type, + on_success=success_callback, + on_failure=failure_callback, + ) + + return job.id