From 852c65704277412159938861412319af19b8831d Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:49:51 +0300 Subject: [PATCH 01/13] fix: read batches from stock helpers and guard shared endpoints --- csf_tz/custom_api.py | 134 ++++++++++++++++++------------------------- 1 file changed, 55 insertions(+), 79 deletions(-) diff --git a/csf_tz/custom_api.py b/csf_tz/custom_api.py index 9447ac0f..39ff231b 100644 --- a/csf_tz/custom_api.py +++ b/csf_tz/custom_api.py @@ -13,8 +13,6 @@ from frappe import _ from frappe.desk.form.linked_with import get_linked_docs, get_linked_doctypes from frappe.model.mapper import get_mapped_doc -from frappe.query_builder import DocType -from frappe.query_builder.functions import Sum from frappe.utils import add_days, cint, flt, getdate, nowdate, nowtime, unique from csf_tz import console @@ -79,53 +77,28 @@ def check_msg(msg): def get_stock_ledger_entries(item_code): - if get_version() == 12: - conditions = f" and sle.item_code = '{item_code}'" - else: - conditions = f" and sle.is_cancelled = 0 and sle.item_code = '{item_code}'" - return frappe.db.sql( - f""" - select sle.batch_no, sle.item_code, sle.warehouse, sle.qty_after_transaction as actual_qty - from `tabStock Ledger Entry` sle - inner join ( - SELECT IF(batch_no IS NULL, '', batch_no) as batch_no, item_code, warehouse, max(posting_datetime) as posting_datetime - from `tabStock Ledger Entry` - group by IF(batch_no IS NULL, '', batch_no), item_code, warehouse) as sle_max - on if(sle.batch_no IS NULL, '', sle.batch_no) = sle_max.batch_no - and sle.item_code = sle_max.item_code - and sle.warehouse = sle_max.warehouse - and sle.posting_datetime = sle_max.posting_datetime - where sle.docstatus = 1 {conditions} - order by sle.warehouse, sle.item_code, sle.batch_no""", - as_dict=1, + """Current balance per warehouse and batch. Batch balances come from Serial and Batch Bundles.""" + if frappe.get_cached_value("Item", item_code, "has_batch_no"): + return [ + frappe._dict( + batch_no=row.batch_no, item_code=item_code, warehouse=row.warehouse, actual_qty=row.qty + ) + for row in get_batch_qty( + item_code=item_code, for_stock_levels=True, consider_negative_batches=True + ) + ] + bins = frappe.get_all( + "Bin", filters={"item_code": item_code}, fields=["warehouse", "actual_qty"], order_by="warehouse" ) - - -def get_version(): - branch_name = get_app_branch("erpnext") - if "12" in branch_name: - return 12 - elif "13" in branch_name: - return 13 - else: - return 13 - - -def get_app_branch(app): - """Returns branch of an app""" - import subprocess - - try: - branch = subprocess.check_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=f"../apps/{app}") - branch = branch.decode("utf-8") - branch = branch.strip() - return branch - except Exception: - return "" + return [ + frappe._dict(batch_no="", item_code=item_code, warehouse=row.warehouse, actual_qty=row.actual_qty) + for row in bins + ] @frappe.whitelist() def get_item_info(item_code: Any): + frappe.has_permission("Item", throw=True) sle = get_stock_ledger_entries(item_code) iwb_map = {} float_precision = cint(frappe.db.get_default("float_precision")) or 3 @@ -162,6 +135,7 @@ def get_item_info(item_code: Any): @frappe.whitelist() def get_item_prices(item_code: Any, currency: Any, customer: Any = None, company: Any = None): + frappe.has_permission("Sales Invoice", throw=True) item_code = f"'{item_code}'" currency = f"'{currency}'" unique_records = int(frappe.db.get_single_value("CSF TZ Settings", "unique_records")) @@ -207,6 +181,7 @@ def get_item_prices(item_code: Any, currency: Any, customer: Any = None, company @frappe.whitelist() def get_item_prices_custom(filters: Any = None, start: Any = 0, limit: Any = 20): + frappe.has_permission("Sales Invoice", throw=True) if isinstance(filters, str): # If filters is a string, deserialize it import json @@ -402,7 +377,7 @@ def get_linked_docs_info(doctype, docname): if linked_doc: for key, value in linked_doc.items(): if key != "Activity Log": - for val in value: + for val in value["docs"]: dco_info = { "doctype": key, "docname": val.name, @@ -563,6 +538,7 @@ def get_delivery_note_item_count(item_row_name, sales_invoice): @frappe.whitelist() def get_pending_sales_invoice(*args): + frappe.has_permission("Sales Invoice", throw=True) filters = args[5] start = cint(args[3]) page_length = cint(args[4]) @@ -653,7 +629,7 @@ def get_list_pending_sales_invoice(invoice_name=None, warehouse=None): def create_delivery_note_for_all_pending_sales_invoice(doc=None, method=None): - company_list = frappe.get_all("Company", fiters={"enabled_auto_create_delivery_notes": 1}, pluck="name") + company_list = frappe.get_all("Company", filters={"enabled_auto_create_delivery_notes": 1}, pluck="name") invoices = get_list_pending_sales_invoice() for i in invoices: if i.company not in company_list: @@ -1215,35 +1191,21 @@ def get_item_duplicates(source_doc): def get_batch_per_item(item_code, posting_date, warehouse): - """ "fetch batch details for item code and warehouse""" - - sle = DocType("Stock Ledger Entry") - ba = DocType("Batch") - - batch_query = ( - frappe.qb.from_(sle) - .inner_join(ba) - .on(sle.batch_no == ba.batch_id) - .select( - sle.batch_no, - sle.warehouse, - Sum(sle.actual_qty).as_("qty"), - ba.stock_uom, - ba.expiry_date, - ) - .where( - (sle.item_code == item_code) - & (sle.is_cancelled == 0) - & (sle.batch_no != "") - & (ba.expiry_date >= posting_date) + """Unexpired batch balances of the item in the warehouse, read from Serial and Batch Bundles.""" + batch_records = [] + for row in get_batch_qty(item_code=item_code, warehouse=warehouse, for_stock_levels=True): + expiry_date, stock_uom = frappe.db.get_value("Batch", row.batch_no, ["expiry_date", "stock_uom"]) + if not expiry_date or getdate(expiry_date) < getdate(posting_date): + continue + batch_records.append( + frappe._dict( + batch_no=row.batch_no, + warehouse=row.warehouse, + qty=row.qty, + stock_uom=stock_uom, + expiry_date=expiry_date, + ) ) - ) - - if warehouse: - batch_query = batch_query.where(sle.warehouse == warehouse) - - batch_records = batch_query.run(as_dict=True) - return batch_records @@ -1700,15 +1662,17 @@ def create_item_tax_template(abbr: Any): {"title": "Zanzibar VAT Tax 0%", "tax_type": f"VAT Payable Account - {abbr}"}, ] + company = get_company_by_abbr(abbr) for item_tax_template_info in item_tax_template_list: existing_template = frappe.db.exists( "Item Tax Template", - {"title": item_tax_template_info.get("title")}, + {"title": item_tax_template_info.get("title"), "company": company}, ) if not existing_template: item_tax_template_doc = frappe.new_doc("Item Tax Template") item_tax_template_doc.title = item_tax_template_info.get("title") + item_tax_template_doc.company = company item_tax_template_doc.append( "taxes", {"tax_type": item_tax_template_info.get("tax_type"), "tax_rate": ""}, @@ -1720,6 +1684,13 @@ def create_item_tax_template(abbr: Any): return "Tax Template added successfully." +def get_company_by_abbr(abbr): + company = frappe.db.get_value("Company", {"abbr": abbr}, "name") + if not company: + frappe.throw(_("No Company found with abbreviation {0}").format(abbr)) + return company + + @frappe.whitelist() def create_tax_category(): tax_category_list = ["Sales", "Non Taxable", "Purchase"] @@ -1747,7 +1718,7 @@ def linking_tax_template(doctype: Any, default_tax_template: Any, abbr: Any): item_list = frappe.db.get_all("Item", filters=default_tax_template) for item in item_list: - item_doc = frappe.get_doc("Item", item.name, fields=["default_tax_template"]) + item_doc = frappe.get_doc("Item", item.name) if item_doc.default_tax_template == f"Tanzania VAT 18% - {abbr}": item_doc.append( "taxes", @@ -1968,8 +1939,7 @@ def make_salary_components_and_structure(abbr: Any): "account": f"PAYE Payable - {abbr}", }, ] - # frappe.throw(str(salary_components_list)) - + company = get_company_by_abbr(abbr) for salary_component in salary_components_list: existing_salary_component = frappe.db.exists( "Salary Component", @@ -1983,7 +1953,9 @@ def make_salary_components_and_structure(abbr: Any): salary_component_doc.abbr = salary_component.get("abbr") salary_component_doc.remove_if_zero_valued = salary_component.get("remove_if_zero_valued") salary_component_doc.do_not_include_in_total = salary_component.get("do_not_include_in_total") - salary_component_doc.append("accounts", {"account": salary_component.get("account")}) + salary_component_doc.append( + "accounts", {"company": company, "account": salary_component.get("account")} + ) salary_component_doc.insert() else: continue @@ -1994,6 +1966,8 @@ def make_salary_components_and_structure(abbr: Any): if not existing_salary_strusture: salary_structure_doc = frappe.new_doc("Salary Structure") salary_structure_doc.name = salary_structure_doc_name + salary_structure_doc.company = company + salary_structure_doc.currency = frappe.get_cached_value("Company", company, "default_currency") salary_structure_doc.is_active = "Yes" for salary_components in salary_components_earnings_list: salary_structure_doc.append( @@ -2028,6 +2002,7 @@ def make_salary_components_and_structure(abbr: Any): @frappe.whitelist() def get_item_prices_custom_po(filters: Any = None, start: Any = 0, limit: Any = 20): + frappe.has_permission("Purchase Invoice", throw=True) if isinstance(filters, str): # If filters is a string, deserialize it import json @@ -2093,6 +2068,7 @@ def get_item_prices_custom_po(filters: Any = None, start: Any = 0, limit: Any = @frappe.whitelist() def get_item_prices_po(item_code: Any, currency: Any, customer: Any = None, company: Any = None): + frappe.has_permission("Purchase Invoice", throw=True) item_code = f"'{item_code}'" currency = f"'{currency}'" unique_records = int(frappe.db.get_single_value("CSF TZ Settings", "unique_records")) From f2da967f25ec77caf2cb414daf673bc07388a69d Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:50:02 +0300 Subject: [PATCH 02/13] fix: validate Purchase Order budget with the order handler --- csf_tz/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csf_tz/hooks.py b/csf_tz/hooks.py index 95d2a6e8..c2225ce6 100755 --- a/csf_tz/hooks.py +++ b/csf_tz/hooks.py @@ -187,7 +187,7 @@ }, "Purchase Order": { "validate": [ - "csf_tz.csftz_hooks.budget.check_budget_for_purchase_invoice", + "csf_tz.csftz_hooks.budget.check_budget_for_purchase_order", ], }, "Material Request": { From 17b75addcfc7820d7094c7447cdffb469f51fa1b Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:50:08 +0300 Subject: [PATCH 03/13] fix: return the order response even when the request fails --- csf_tz/api/selcom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csf_tz/api/selcom.py b/csf_tz/api/selcom.py index 490c83f1..d8ad499a 100644 --- a/csf_tz/api/selcom.py +++ b/csf_tz/api/selcom.py @@ -44,7 +44,7 @@ def create_order_minimal(): # API endpoint orderPath = "/checkout/create-order-minimal" - # Send order request + response = {} try: response = client.postFunc(orderPath, orderDict) if response.get("resultcode") != "000": From 6a2965404289402124ecf884b58e6119613ce99b Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:50:19 +0300 Subject: [PATCH 04/13] fix: check employee permission before building the contact QR --- csf_tz/csftz_hooks/employee_contact_qr.py | 1 + 1 file changed, 1 insertion(+) diff --git a/csf_tz/csftz_hooks/employee_contact_qr.py b/csf_tz/csftz_hooks/employee_contact_qr.py index 9c62e180..f88c7c21 100644 --- a/csf_tz/csftz_hooks/employee_contact_qr.py +++ b/csf_tz/csftz_hooks/employee_contact_qr.py @@ -8,6 +8,7 @@ @frappe.whitelist() def generate_contact_qr(employee): + frappe.has_permission("Employee", "read", employee, throw=True) employee_doc = frappe.get_doc("Employee", employee) # Retrieve contact details From 5e019cb288b7850061f99947d4ece95b3a84c5bc Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:50:24 +0300 Subject: [PATCH 05/13] fix: recompute tracker totals and status after submit --- .../foreign_import_transaction.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/csf_tz/csf_tz/doctype/foreign_import_transaction/foreign_import_transaction.py b/csf_tz/csf_tz/doctype/foreign_import_transaction/foreign_import_transaction.py index 599fc1f5..9e9a8f51 100644 --- a/csf_tz/csf_tz/doctype/foreign_import_transaction/foreign_import_transaction.py +++ b/csf_tz/csf_tz/doctype/foreign_import_transaction/foreign_import_transaction.py @@ -5,6 +5,8 @@ from frappe.model.document import Document from frappe.utils import flt +from csf_tz.csftz_hooks.exchange_calculations import get_payment_foreign_amount_and_rate + class ForeignImportTransaction(Document): def validate(self): @@ -15,6 +17,10 @@ def validate(self): def on_submit(self): self.update_status("Active") + def before_update_after_submit(self): + self.calculate_totals() + self.set_status() + def on_cancel(self): self.cancel_related_journal_entries() self.update_status("Cancelled") @@ -107,14 +113,12 @@ def add_payment_detail(self, payment_entry): payment_row = self.append("payments", {}) payment_row.payment_entry = payment_entry payment_row.payment_date = payment_doc.posting_date - payment_row.payment_amount_foreign = payment_doc.paid_amount + paid_amount, payment_rate = get_payment_foreign_amount_and_rate(self, payment_doc) + payment_row.payment_amount_foreign = paid_amount payment_row.payment_amount_base = payment_doc.base_paid_amount - payment_row.payment_exchange_rate = payment_doc.source_exchange_rate + payment_row.payment_exchange_rate = payment_rate - # Calculate exchange difference original_rate = flt(self.original_exchange_rate) - payment_rate = flt(payment_doc.source_exchange_rate) - paid_amount = flt(payment_doc.paid_amount) if original_rate != payment_rate: exchange_diff = paid_amount * (payment_rate - original_rate) @@ -131,7 +135,7 @@ def add_lcv_detail(self, lcv_name): lcv_row.landed_cost_voucher = lcv_name lcv_row.lcv_date = lcv_doc.posting_date lcv_row.lcv_amount_base = lcv_doc.total_taxes_and_charges - lcv_row.exchange_rate_used = flt(lcv_doc.get("conversion_rate", 1)) + lcv_row.exchange_rate_used = flt(lcv_doc.get("conversion_rate")) or 1 # Get allocated amount from LCV items allocated_amount = 0 From 1277504b75f80ec8f87bb19995d02ea3709aa3e3 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:50:29 +0300 Subject: [PATCH 06/13] fix: allow computed tracker fields to change after submit --- .../foreign_import_transaction/foreign_import_transaction.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/csf_tz/csf_tz/doctype/foreign_import_transaction/foreign_import_transaction.json b/csf_tz/csf_tz/doctype/foreign_import_transaction/foreign_import_transaction.json index 6e967230..cd707d6b 100644 --- a/csf_tz/csf_tz/doctype/foreign_import_transaction/foreign_import_transaction.json +++ b/csf_tz/csf_tz/doctype/foreign_import_transaction/foreign_import_transaction.json @@ -96,6 +96,7 @@ "read_only": 1 }, { + "allow_on_submit": 1, "default": "Draft", "fieldname": "status", "fieldtype": "Select", @@ -267,7 +268,7 @@ "write": 1 } ], - "sort_field": "modified", + "sort_field": "creation", "sort_order": "DESC", "states": [], "title_field": "purchase_invoice", From 736fb96674497b7ffcf3d116709ec51d853eb820 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:50:29 +0300 Subject: [PATCH 07/13] fix: stop fetching computed payment detail fields --- .../foreign_import_payment_details.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/csf_tz/csf_tz/doctype/foreign_import_payment_details/foreign_import_payment_details.json b/csf_tz/csf_tz/doctype/foreign_import_payment_details/foreign_import_payment_details.json index cb9bb2d0..b68b47e4 100644 --- a/csf_tz/csf_tz/doctype/foreign_import_payment_details/foreign_import_payment_details.json +++ b/csf_tz/csf_tz/doctype/foreign_import_payment_details/foreign_import_payment_details.json @@ -38,7 +38,6 @@ "fieldtype": "Column Break" }, { - "fetch_from": "payment_entry.paid_amount", "fieldname": "payment_amount_foreign", "fieldtype": "Currency", "in_list_view": 1, @@ -58,7 +57,6 @@ "fieldtype": "Section Break" }, { - "fetch_from": "payment_entry.source_exchange_rate", "fieldname": "payment_exchange_rate", "fieldtype": "Float", "label": "Payment Exchange Rate", @@ -70,12 +68,14 @@ "fieldtype": "Column Break" }, { + "allow_on_submit": 1, "fieldname": "exchange_difference", "fieldtype": "Currency", "label": "Exchange Difference", "read_only": 1 }, { + "allow_on_submit": 1, "default": "0", "fieldname": "journal_entry_created", "fieldtype": "Check", @@ -96,4 +96,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} From ddbdb86d614b1b89e6c8dbb4774a80f6938d462e Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:50:30 +0300 Subject: [PATCH 08/13] fix: identify the worker that claims a vehicle sync task --- .../csf_tz/doctype/vehicle_sync_task/queue.py | 145 ++++++++++-------- 1 file changed, 77 insertions(+), 68 deletions(-) diff --git a/csf_tz/csf_tz/doctype/vehicle_sync_task/queue.py b/csf_tz/csf_tz/doctype/vehicle_sync_task/queue.py index b49b9650..2ac1cf5d 100644 --- a/csf_tz/csf_tz/doctype/vehicle_sync_task/queue.py +++ b/csf_tz/csf_tz/doctype/vehicle_sync_task/queue.py @@ -1,8 +1,12 @@ +import os +import socket + import frappe BATCH_SIZE = 1 SUCCESS_INTERVAL_SECONDS = 60 * 60 * 24 MAX_CALLS_PER_MINUTE = 1 +WORKER_ID = f"{socket.gethostname()}:{os.getpid()}" def _now(): @@ -10,57 +14,61 @@ def _now(): def claim_batch(doctype, limit=BATCH_SIZE): - try: - now = _now() - Task = frappe.qb.DocType(doctype) - - rows = ( - frappe.qb.from_(Task) - .select(Task.name) - .where( - (Task.status == "Pending") & - ((Task.next_run_at.isnull()) | (Task.next_run_at <= now)) & - ((Task.is_deleted.isnull()) | (Task.is_deleted == 0)) - ) - .orderby(Task.priority, order=frappe.qb.terms.Order.desc) - .orderby(Task.name) - .limit(limit) - ).run(as_dict=True) - - if not rows: - rows = ( - frappe.qb.from_(Task) - .select(Task.name) - .where( - (Task.status == "Failed") & - (Task.next_run_at <= now) & - ((Task.is_deleted.isnull()) | (Task.is_deleted == 0)) - ) - .orderby(Task.priority, order=frappe.qb.terms.Order.desc) - .orderby(Task.name) - .limit(limit) - ).run(as_dict=True) - - if not rows: - return [] - - claimed = [] - for row in rows: - frappe.db.set_value(doctype, row["name"], { - "status": "Processing", - "claimed_by": WORKER_ID, - "claimed_at": now, - "last_run_at": now, - }) - data = frappe.db.get_value(doctype, row["name"], ["name", "vehicle_no"], as_dict=True) - claimed.append(data) - return claimed - except Exception as e: - frappe.log_error( - title="Queue Claim Batch Failed", - message=f"Error claiming batch from {doctype}: {str(e)}" - ) - return [] + try: + now = _now() + Task = frappe.qb.DocType(doctype) + + rows = ( + frappe.qb.from_(Task) + .select(Task.name) + .where( + (Task.status == "Pending") + & ((Task.next_run_at.isnull()) | (Task.next_run_at <= now)) + & ((Task.is_deleted.isnull()) | (Task.is_deleted == 0)) + ) + .orderby(Task.priority, order=frappe.qb.terms.Order.desc) + .orderby(Task.name) + .limit(limit) + ).run(as_dict=True) + + if not rows: + rows = ( + frappe.qb.from_(Task) + .select(Task.name) + .where( + (Task.status == "Failed") + & (Task.next_run_at <= now) + & ((Task.is_deleted.isnull()) | (Task.is_deleted == 0)) + ) + .orderby(Task.priority, order=frappe.qb.terms.Order.desc) + .orderby(Task.name) + .limit(limit) + ).run(as_dict=True) + + if not rows: + return [] + + claimed = [] + for row in rows: + frappe.db.set_value( + doctype, + row["name"], + { + "status": "Processing", + "claimed_by": WORKER_ID, + "claimed_at": now, + "last_run_at": now, + }, + ) + data = frappe.db.get_value(doctype, row["name"], ["name", "vehicle_no"], as_dict=True) + claimed.append(data) + return claimed + except Exception as e: + frappe.log_error( + title="Queue Claim Batch Failed", message=f"Error claiming batch from {doctype}: {str(e)}" + ) + return [] + def mark_done(doctype, task): try: @@ -86,23 +94,24 @@ def mark_done(doctype, task): def mark_failed(doctype, task, err_msg): - try: - values = { - "status": "Failed", - "attempts": 0, - "backoff_exp": 0, - "last_error": err_msg[:1000], - "last_run_at": _now(), - "claimed_by": "", - "claimed_at": None, - "next_run_at": _now(), - } - frappe.db.set_value(doctype, task["name"], values) - except Exception as e: - frappe.log_error( - title="Queue Mark Failed Error", - message=f"Error marking task {task.get('name')} as failed in {doctype}: {str(e)}" - ) + try: + values = { + "status": "Failed", + "attempts": 0, + "backoff_exp": 0, + "last_error": err_msg[:1000], + "last_run_at": _now(), + "claimed_by": "", + "claimed_at": None, + "next_run_at": _now(), + } + frappe.db.set_value(doctype, task["name"], values) + except Exception as e: + frappe.log_error( + title="Queue Mark Failed Error", + message=f"Error marking task {task.get('name')} as failed in {doctype}: {str(e)}", + ) + def reset_stuck_tasks(doctype, timeout_minutes=10): try: From 02231a2ba7221e96a3850161b9e98e32ab236cb4 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:50:42 +0300 Subject: [PATCH 09/13] style: indent the vehicle sync processor with tabs --- .../doctype/vehicle_sync_task/processor.py | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/csf_tz/csf_tz/doctype/vehicle_sync_task/processor.py b/csf_tz/csf_tz/doctype/vehicle_sync_task/processor.py index 8fd0ee46..0c97edaf 100644 --- a/csf_tz/csf_tz/doctype/vehicle_sync_task/processor.py +++ b/csf_tz/csf_tz/doctype/vehicle_sync_task/processor.py @@ -47,42 +47,42 @@ def _acquire_rate_limit_slot(): @frappe.whitelist() def run_vehicle_batch(): - processed = 0 - errors = 0 - - queue.reset_stuck_tasks(TASK_DOCTYPE, timeout_minutes=10) - tasks = queue.claim_batch(TASK_DOCTYPE, limit=queue.BATCH_SIZE) - - if not tasks: - return {"status": "no_tasks", "message": "No pending vehicle sync tasks"} - - for task in tasks: - if not _acquire_rate_limit_slot(): - queue.mark_failed(TASK_DOCTYPE, task, "TPF per-minute limit reached for this site") - continue - - result = sync_vehicle_fines(task["vehicle_no"]) - status = result.get("status") - - if status == "success": - queue.mark_done(TASK_DOCTYPE, task) - processed += 1 - continue - - queue.mark_failed( - TASK_DOCTYPE, - task, - result.get("message") or status or "Unhandled sync error", - ) - errors += 1 - - frappe.db.commit() - return { - "status": "completed", - "processed": processed, - "errors": errors, - "claimed": len(tasks), - } + processed = 0 + errors = 0 + + queue.reset_stuck_tasks(TASK_DOCTYPE, timeout_minutes=10) + tasks = queue.claim_batch(TASK_DOCTYPE, limit=queue.BATCH_SIZE) + + if not tasks: + return {"status": "no_tasks", "message": "No pending vehicle sync tasks"} + + for task in tasks: + if not _acquire_rate_limit_slot(): + queue.mark_failed(TASK_DOCTYPE, task, "TPF per-minute limit reached for this site") + continue + + result = sync_vehicle_fines(task["vehicle_no"]) + status = result.get("status") + + if status == "success": + queue.mark_done(TASK_DOCTYPE, task) + processed += 1 + continue + + queue.mark_failed( + TASK_DOCTYPE, + task, + result.get("message") or status or "Unhandled sync error", + ) + errors += 1 + + frappe.db.commit() + return { + "status": "completed", + "processed": processed, + "errors": errors, + "claimed": len(tasks), + } @frappe.whitelist() From 6e1ba12a5accecbd12770b5ac630d0a548692648 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:50:47 +0300 Subject: [PATCH 10/13] style: format the vehicle fine record and drop an unused import --- .../vehicle_fine_record.py | 264 +++++++++--------- 1 file changed, 129 insertions(+), 135 deletions(-) diff --git a/csf_tz/csf_tz/doctype/vehicle_fine_record/vehicle_fine_record.py b/csf_tz/csf_tz/doctype/vehicle_fine_record/vehicle_fine_record.py index d5d6f0bc..330e73b6 100644 --- a/csf_tz/csf_tz/doctype/vehicle_fine_record/vehicle_fine_record.py +++ b/csf_tz/csf_tz/doctype/vehicle_fine_record/vehicle_fine_record.py @@ -5,7 +5,6 @@ import hashlib import json import re -from time import sleep import frappe import requests @@ -112,140 +111,135 @@ def check_fine_all_vehicles(batch_size=20): def sync_vehicle_fines(number_plate): - number_plate = normalize_number_plate(number_plate) - - if not number_plate: - return { - "status": "invalid", - "message": "Missing number plate", - "fine_list": [], - } - - if not is_valid_number_plate(number_plate): - return { - "status": "invalid", - "message": f"Skipping invalid plate: {number_plate}", - "fine_list": [], - } - - url = "https://tms.tpf.go.tz/api/OffenceCheck" - headers = { - "Content-Type": "application/json", - "Accept": "*/*", - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", - "Origin": "https://tms.tpf.go.tz", - "Referer": "https://tms.tpf.go.tz/", - "Connection": "keep-alive", - } - payload = {"vehicle": number_plate} - - try: - response = requests.post(url, json=payload, headers=headers, timeout=30) - if response.status_code == 429: - return { - "status": "rate_limited", - "message": f"TPF rate limited {number_plate}", - "fine_list": [], - } - response.raise_for_status() - except requests.exceptions.RequestException as exc: - frappe.logger().warning(f"[VehicleFine] TPF request failed for {number_plate}: {exc}") - return { - "status": "retryable_error", - "message": str(exc), - "fine_list": [], - } - - try: - result = response.json() - result = decode_tpf_response(result) - except Exception: - frappe.log_error( - title="TPF API: Invalid JSON", - message=( - f"Non-JSON response for {number_plate}: " - f"{response.text[:500]}" - ), - ) - return { - "status": "error", - "message": "Invalid JSON response", - "fine_list": [], - } - - data = result.get("pending_transactions", []) - fine_list = [] - - if data: - fine_list = [fine.get("reference") for fine in data if fine.get("reference")] - if not fine_list: - return {"status": "success", "message": "No fine references", "fine_list": fine_list} - - stale_filters = { - "vehicle": number_plate, - "status": ["!=", "PAID"], - "reference": ["not in", fine_list], - } - for record in frappe.get_all( - "Vehicle Fine Record", filters=stale_filters, pluck="name" - ): - old_status = frappe.db.get_value("Vehicle Fine Record", record, "status") - frappe.db.set_value("Vehicle Fine Record", record, "status", "PAID") - _notify_vehicle_fine_status_change(record, number_plate, old_status, "PAID") - - existing_refs = frappe.get_all( - "Vehicle Fine Record", - filters={"vehicle": number_plate, "reference": ["in", fine_list]}, - pluck="reference", - ) - for fine in data: - fine_ref = fine.get("reference") - if not fine_ref or fine_ref in existing_refs: - continue - charge = fine.get("charge") or fine.get("amount") - penalty = fine.get("penalty") - try: - doc = frappe.get_doc( - { - "doctype": "Vehicle Fine Record", - "vehicle": number_plate, - "reference": fine_ref, - "status": fine.get("status") or "PENDING", - "licence": fine.get("licence"), - "location": fine.get("location"), - "officer": fine.get("officer"), - "charge": charge, - "penalty": penalty, - "total": fine.get("total") or (flt(charge) + flt(penalty)), - "offence": fine.get("offence"), - "issued_date": fine.get("issued_date") or fine.get("date"), - } - ) - doc.insert(ignore_permissions=True) - _notify_vehicle_fine_new(doc) - except frappe.exceptions.DuplicateEntryError: - pass - except Exception: - frappe.log_error( - title=f"Error creating fine record for {number_plate}", - message=frappe.get_traceback(), - ) - else: - for record in frappe.get_all( - "Vehicle Fine Record", - filters={"vehicle": number_plate, "status": ["!=", "PAID"]}, - pluck="name", - ): - old_status = frappe.db.get_value("Vehicle Fine Record", record, "status") - frappe.db.set_value("Vehicle Fine Record", record, "status", "PAID") - _notify_vehicle_fine_status_change(record, number_plate, old_status, "PAID") - - frappe.db.commit() - return { - "status": "success", - "message": f"Processed fine sync for {number_plate}", - "fine_list": fine_list, - } + number_plate = normalize_number_plate(number_plate) + + if not number_plate: + return { + "status": "invalid", + "message": "Missing number plate", + "fine_list": [], + } + + if not is_valid_number_plate(number_plate): + return { + "status": "invalid", + "message": f"Skipping invalid plate: {number_plate}", + "fine_list": [], + } + + url = "https://tms.tpf.go.tz/api/OffenceCheck" + headers = { + "Content-Type": "application/json", + "Accept": "*/*", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + "Origin": "https://tms.tpf.go.tz", + "Referer": "https://tms.tpf.go.tz/", + "Connection": "keep-alive", + } + payload = {"vehicle": number_plate} + + try: + response = requests.post(url, json=payload, headers=headers, timeout=30) + if response.status_code == 429: + return { + "status": "rate_limited", + "message": f"TPF rate limited {number_plate}", + "fine_list": [], + } + response.raise_for_status() + except requests.exceptions.RequestException as exc: + frappe.logger().warning(f"[VehicleFine] TPF request failed for {number_plate}: {exc}") + return { + "status": "retryable_error", + "message": str(exc), + "fine_list": [], + } + + try: + result = response.json() + result = decode_tpf_response(result) + except Exception: + frappe.log_error( + title="TPF API: Invalid JSON", + message=(f"Non-JSON response for {number_plate}: {response.text[:500]}"), + ) + return { + "status": "error", + "message": "Invalid JSON response", + "fine_list": [], + } + + data = result.get("pending_transactions", []) + fine_list = [] + + if data: + fine_list = [fine.get("reference") for fine in data if fine.get("reference")] + if not fine_list: + return {"status": "success", "message": "No fine references", "fine_list": fine_list} + + stale_filters = { + "vehicle": number_plate, + "status": ["!=", "PAID"], + "reference": ["not in", fine_list], + } + for record in frappe.get_all("Vehicle Fine Record", filters=stale_filters, pluck="name"): + old_status = frappe.db.get_value("Vehicle Fine Record", record, "status") + frappe.db.set_value("Vehicle Fine Record", record, "status", "PAID") + _notify_vehicle_fine_status_change(record, number_plate, old_status, "PAID") + + existing_refs = frappe.get_all( + "Vehicle Fine Record", + filters={"vehicle": number_plate, "reference": ["in", fine_list]}, + pluck="reference", + ) + for fine in data: + fine_ref = fine.get("reference") + if not fine_ref or fine_ref in existing_refs: + continue + charge = fine.get("charge") or fine.get("amount") + penalty = fine.get("penalty") + try: + doc = frappe.get_doc( + { + "doctype": "Vehicle Fine Record", + "vehicle": number_plate, + "reference": fine_ref, + "status": fine.get("status") or "PENDING", + "licence": fine.get("licence"), + "location": fine.get("location"), + "officer": fine.get("officer"), + "charge": charge, + "penalty": penalty, + "total": fine.get("total") or (flt(charge) + flt(penalty)), + "offence": fine.get("offence"), + "issued_date": fine.get("issued_date") or fine.get("date"), + } + ) + doc.insert(ignore_permissions=True) + _notify_vehicle_fine_new(doc) + except frappe.exceptions.DuplicateEntryError: + pass + except Exception: + frappe.log_error( + title=f"Error creating fine record for {number_plate}", + message=frappe.get_traceback(), + ) + else: + for record in frappe.get_all( + "Vehicle Fine Record", + filters={"vehicle": number_plate, "status": ["!=", "PAID"]}, + pluck="name", + ): + old_status = frappe.db.get_value("Vehicle Fine Record", record, "status") + frappe.db.set_value("Vehicle Fine Record", record, "status", "PAID") + _notify_vehicle_fine_status_change(record, number_plate, old_status, "PAID") + + frappe.db.commit() + return { + "status": "success", + "message": f"Processed fine sync for {number_plate}", + "fine_list": fine_list, + } @frappe.whitelist() From 96161df05fbc7d05f8fa6dbde82782b84a05ec1e Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:50:52 +0300 Subject: [PATCH 11/13] fix: attach KCB payment files to their own fields --- .../doctype/kcb_payments_initiation/kcb_payments_initiation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/csf_tz/kcb/doctype/kcb_payments_initiation/kcb_payments_initiation.py b/csf_tz/kcb/doctype/kcb_payments_initiation/kcb_payments_initiation.py index ece645b6..a07d9a0f 100644 --- a/csf_tz/kcb/doctype/kcb_payments_initiation/kcb_payments_initiation.py +++ b/csf_tz/kcb/doctype/kcb_payments_initiation/kcb_payments_initiation.py @@ -63,6 +63,7 @@ def before_save(self): "file_name": f"{file_base_name}.txt", "attached_to_doctype": "KCB Payments Initiation", "attached_to_name": self.name, + "attached_to_field": "payment_file", "content": file_content, "folder": "Home", } @@ -75,6 +76,7 @@ def before_save(self): "file_name": f"{file_base_name}.txt.gpg", "attached_to_doctype": "KCB Payments Initiation", "attached_to_name": self.name, + "attached_to_field": "encrypted_file", "content": encrypted_data, "folder": "Home", } From 67297550d6442e1f628b318bdeb949b3d62e7cb2 Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:50:57 +0300 Subject: [PATCH 12/13] fix: fetch the employee bank fields that exist --- .../doctype/stanbic_payments_info/stanbic_payments_info.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csf_tz/stanbic/doctype/stanbic_payments_info/stanbic_payments_info.json b/csf_tz/stanbic/doctype/stanbic_payments_info/stanbic_payments_info.json index df10c481..8b0c8d0d 100644 --- a/csf_tz/stanbic/doctype/stanbic_payments_info/stanbic_payments_info.json +++ b/csf_tz/stanbic/doctype/stanbic_payments_info/stanbic_payments_info.json @@ -68,7 +68,7 @@ "label": "Beneficiary bank name" }, { - "fetch_from": "employee.bank_country_code", + "fetch_from": "employee.custom_bank_country_code", "fieldname": "beneficiary_bank_country_code", "fieldtype": "Data", "label": "Beneficiary bank country code", @@ -121,7 +121,7 @@ "label": "Beneficiary bank sort code" }, { - "fetch_from": "employee.employee_country_code", + "fetch_from": "employee.custom_employee_country_code", "fieldname": "beneficiary_country", "fieldtype": "Data", "label": "Beneficiary Country" From a430b136660965629743f2f50503515cdb25b55e Mon Sep 17 00:00:00 2001 From: av-dev2 Date: Wed, 26 Aug 2026 11:50:57 +0300 Subject: [PATCH 13/13] fix: skip stock entry types the site already has --- csf_tz/patches/create_the_stock_entry_type.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/csf_tz/patches/create_the_stock_entry_type.py b/csf_tz/patches/create_the_stock_entry_type.py index ca410662..e086adae 100644 --- a/csf_tz/patches/create_the_stock_entry_type.py +++ b/csf_tz/patches/create_the_stock_entry_type.py @@ -8,6 +8,8 @@ def execute(): ] for stock_entry_type_data in stock_entry_type_list: + if frappe.db.exists("Stock Entry Type", stock_entry_type_data["name"]): + continue stock_entry_doc = frappe.new_doc("Stock Entry Type") stock_entry_doc.name = stock_entry_type_data["name"] stock_entry_doc.purpose = stock_entry_type_data["purpose"]