From af6fa93943ccec22e439f3c5fa74cfb3d51ca508 Mon Sep 17 00:00:00 2001 From: roshmol Date: Tue, 19 May 2026 14:25:57 +0000 Subject: [PATCH 1/2] feat:no of tasks --- aqrar_ext/api/__init__.py | 27 ++ .../branch_configuration.json | 10 +- .../__init__.py | 0 .../branch_configuration_mode_of_payment.json | 42 +++ .../branch_configuration_mode_of_payment.py | 9 + .../report/work_flow_approval/__init__.py | 0 .../work_flow_approval/work_flow_approval.js | 109 +++++++ .../work_flow_approval.json | 28 ++ .../work_flow_approval/work_flow_approval.py | 215 +++++++++++++ aqrar_ext/hooks.py | 285 +++++++++++++++++- aqrar_ext/overrides/__init__.py | 0 aqrar_ext/overrides/item.py | 65 ++++ aqrar_ext/overrides/payment_entry.py | 54 ++++ aqrar_ext/overrides/quotation.py | 32 ++ aqrar_ext/public/js/workflowapproval.js | 62 ++++ 15 files changed, 936 insertions(+), 2 deletions(-) create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/__init__.py create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.json create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.py create mode 100644 aqrar_ext/aqrar_ext/report/work_flow_approval/__init__.py create mode 100644 aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.js create mode 100644 aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.json create mode 100644 aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.py create mode 100644 aqrar_ext/overrides/__init__.py create mode 100644 aqrar_ext/overrides/item.py create mode 100644 aqrar_ext/overrides/payment_entry.py create mode 100644 aqrar_ext/overrides/quotation.py create mode 100644 aqrar_ext/public/js/workflowapproval.js diff --git a/aqrar_ext/api/__init__.py b/aqrar_ext/api/__init__.py index e69de29..b8c406c 100644 --- a/aqrar_ext/api/__init__.py +++ b/aqrar_ext/api/__init__.py @@ -0,0 +1,27 @@ +# import frappe + +# @frappe.whitelist() +# def get_item_uoms(item_code): +# try: +# item = frappe.get_doc("Item", item_code) +# uoms = [item.stock_uom] +# for u in item.uoms: +# if u.uom not in uoms: +# uoms.append(u.uom) +# return uoms +# except Exception: +# return [] + +import frappe + +@frappe.whitelist() +def get_item_uoms(item_code): + try: + item = frappe.get_doc("Item", item_code) + uoms = [item.stock_uom] + for u in item.uoms: + if u.uom not in uoms: + uoms.append(u.uom) + return uoms + except Exception: + return [] diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.json b/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.json index 86e13d5..e8be9fb 100644 --- a/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.json +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.json @@ -9,6 +9,7 @@ "column_break_aubg", "company", "section_break_dlxc", + "mode_of_payment", "warehouse", "cost_center", "user" @@ -53,12 +54,19 @@ "fieldtype": "Table", "label": "User", "options": "Branch Configuration User" + }, + { + "description": "Cash + Bank Modes of Payment allowed for this branch. On Sales Invoice for Branch Users,payment rows whose MoP type is Cash or Bank are constrained to this list (or swapped to the first matching row); BNPL is left untouched.", + "fieldname": "mode_of_payment", + "fieldtype": "Table", + "label": "Mode of Payment", + "options": "Branch Configuration Mode of Payment" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-04-27 16:07:12.519306", + "modified": "2026-05-16 19:23:57.892929", "modified_by": "Administrator", "module": "Aqrar Ext", "name": "Branch Configuration", diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/__init__.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.json b/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.json new file mode 100644 index 0000000..c4b7d84 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.json @@ -0,0 +1,42 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-05-16 19:14:41.500922", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "mode_of_payment", + "type" + ], + "fields": [ + { + "fieldname": "mode_of_payment", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Mode of Payment", + "options": "Mode of Payment" + }, + { + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-05-16 19:17:59.460731", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Branch Configuration Mode of Payment", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.py new file mode 100644 index 0000000..acaaee6 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Enfono and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BranchConfigurationModeofPayment(Document): + pass diff --git a/aqrar_ext/aqrar_ext/report/work_flow_approval/__init__.py b/aqrar_ext/aqrar_ext/report/work_flow_approval/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.js b/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.js new file mode 100644 index 0000000..61b3fc8 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.js @@ -0,0 +1,109 @@ +let wf_action = ""; + +frappe.query_reports["Work Flow Approval"] = { + filters: [ + { fieldname: "user", label: "User", fieldtype: "Link", options: "User", default: frappe.session.user }, + { fieldname: "company", label: "Company", fieldtype: "Link", options: "Company" }, + { + fieldname: "doctype", label: "Document Type", fieldtype: "Link", options: "DocType", + get_query: function() { + return { + query: "aqrar_ext.aqrar_ext.report.work_flow_approval.work_flow_approval.get_workflow_doctypes" + }; + }, + on_change: function() { + wf_action = ""; + $("#wf-action-select").val(""); + load_actions(); + frappe.query_report.refresh(); + } + }, + { fieldname: "from_date", label: "From Date", fieldtype: "Date" }, + { fieldname: "to_date", label: "To Date", fieldtype: "Date" } + ], + + after_datatable_render: function(dt) { + $(dt.wrapper).find(".dt-cell--col-0").each(function(i) { + if (i === 0) return; + let $c = $(this).css({ "text-align": "center", "cursor": "pointer" }); + if (!$c.find('input[type="checkbox"]').length) + $c.html(''); + $c.off("click").on("click", function(e) { + if (!$(e.target).is("input")) + $c.find('input[type="checkbox"]').prop("checked", v => !v); + }); + }); + }, + + onload: function(report) { + setTimeout(function() { + if (report.page.page_form.length && !$("#wf-action-select").length) { + report.page.page_form.append(` +
+ +
`); + $("#wf-action-select").on("change", function() { wf_action = $(this).val(); }); + } + load_actions(); + }, 800); + + report.page.add_inner_button("Apply Workflow Action", function() { + let docs = []; + + $(report.datatable.wrapper).find("input.row-check:checked").each(function() { + let idx = parseInt($(this).closest(".dt-row").attr("data-row-index")); + if (!isNaN(idx) && report.data[idx]) + docs.push({ doctype: report.data[idx].doctype, name: report.data[idx].name }); + }); + + if (!docs.length) { + $(report.datatable.wrapper).find(".dt-row").each(function(i) { + if ($(this).find("input.row-check").prop("checked") && report.data[i]) + docs.push({ doctype: report.data[i].doctype, name: report.data[i].name }); + }); + } + + if (!docs.length) return frappe.msgprint(__("Please select at least one document.")); + + let action = $("#wf-action-select").val() || wf_action; + if (!action) return frappe.msgprint(__("Please select a Workflow Action.")); + + frappe.confirm( + __("Apply {0} to {1} document(s)?", [action, docs.length]), + () => frappe.call({ + method: "aqrar_ext.aqrar_ext.report.work_flow_approval.work_flow_approval.apply_bulk_workflow", + args: { docs: JSON.stringify(docs), action }, + freeze: true, + freeze_message: __("Applying..."), + callback: r => { + if (!r.exc) { + frappe.msgprint({ title: __("Result"), message: r.message, indicator: "green" }); + frappe.query_report.refresh(); + } + } + }) + ); + }); + } +}; + +function load_actions() { + let doctype = frappe.query_report.get_filter_value("doctype"); + let method = doctype + ? "aqrar_ext.aqrar_ext.report.work_flow_approval.work_flow_approval.get_workflow_actions" + : "aqrar_ext.aqrar_ext.report.work_flow_approval.work_flow_approval.get_all_workflow_actions"; + let args = doctype ? { doctype } : {}; + + frappe.call({ + method: method, + args: args, + callback: r => { + let $s = $("#wf-action-select").empty().append(''); + (r.message || []).forEach(a => $s.append(``)); + } + }); +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.json b/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.json new file mode 100644 index 0000000..69ab969 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.json @@ -0,0 +1,28 @@ +{ + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2026-05-16 19:26:30.257054", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letter_head": null, + "modified": "2026-05-16 19:26:30.257054", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Work Flow Approval", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Workflow", + "report_name": "Work Flow Approval", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + } + ], + "timeout": 0 +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.py b/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.py new file mode 100644 index 0000000..a38efe8 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.py @@ -0,0 +1,215 @@ +# Copyright (c) 2026, enfono and contributors +# For license information, please see license.txt + +import frappe + + +def execute(filters=None): + return get_columns(), get_data(filters) + + +def get_columns(): + return [ + { "label": "User", "fieldname": "owner", "fieldtype": "Link", "options": "User", "width": 180 }, + { "label": "Company", "fieldname": "company", "fieldtype": "Link", "options": "Company", "width": 150 }, + { "label": "DocType", "fieldname": "doctype", "fieldtype": "Data", "width": 150 }, + { "label": "Document", "fieldname": "name", "fieldtype": "Dynamic Link", "options": "doctype", "width": 200 }, + { "label": "Status", "fieldname": "workflow_state", "fieldtype": "Data", "width": 150 }, + { "label": "Created Date", "fieldname": "creation", "fieldtype": "Datetime", "width": 180 } + ] + + +def get_data(filters): + filters = filters or {} + + if filters.get("doctype"): + return get_doctype_data(filters.get("doctype"), filters) + + workflows = frappe.db.get_all("Workflow", + filters={"is_active": 1}, + fields=["document_type"] + ) + + all_data = [] + seen = set() + for w in workflows: + dt = w.document_type + if not dt or dt in seen: + continue + seen.add(dt) + try: + all_data.extend(get_doctype_data(dt, filters)) + except Exception: + continue + + all_data.sort(key=lambda x: x.get("creation") or "", reverse=True) + return all_data + + +def get_doctype_data(doctype, filters): + if not frappe.db.has_column(doctype, "workflow_state"): + return [] + + has_company = frappe.db.has_column(doctype, "company") + conditions = "workflow_state = 'Pending'" + values = {} + + if filters.get("company") and has_company: + conditions += " AND company = %(company)s" + values["company"] = filters["company"] + + if filters.get("from_date"): + conditions += " AND creation >= %(from_date)s" + values["from_date"] = filters["from_date"] + + if filters.get("to_date"): + conditions += " AND creation <= %(to_date)s" + values["to_date"] = filters["to_date"] + + fields = ("company, " if has_company else "") + "name, workflow_state, owner, creation" + data = frappe.db.sql(f""" + SELECT {fields} FROM `tab{doctype}` + WHERE {conditions} ORDER BY creation DESC + """, values, as_dict=True) + + for d in data: + d["doctype"] = doctype + if not has_company: + d["company"] = "" + + return data + + +@frappe.whitelist() +def get_workflow_actions(doctype): + wf = frappe.db.get_all("Workflow", + filters={"document_type": doctype, "is_active": 1}, + fields=["name"], limit=1) + + if not wf: + return [] + + seen, actions = set(), [] + for t in frappe.get_doc("Workflow", wf[0].name).transitions: + if t.action and t.action not in seen: + seen.add(t.action) + actions.append(t.action) + return actions + + +@frappe.whitelist() +def get_all_workflow_actions(): + workflows = frappe.db.get_all("Workflow", + filters={"is_active": 1}, + fields=["name"] + ) + + seen, actions = set(), [] + for w in workflows: + try: + for t in frappe.get_doc("Workflow", w.name).transitions: + if t.action and t.action not in seen: + seen.add(t.action) + actions.append(t.action) + except Exception: + continue + return actions + + +@frappe.whitelist() +def apply_bulk_workflow(docs, action): + import json + from frappe.model.workflow import apply_workflow + + results = [] + for d in json.loads(docs): + try: + apply_workflow(frappe.get_doc(d["doctype"], d["name"]), action) + results.append(f" {d['name']} — Success") + except Exception as e: + results.append(f" {d['name']} — {str(e)}") + + frappe.db.commit() + return "
".join(results) + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_workflow_doctypes(doctype, txt, searchfield, start, page_len, filters): + workflows = frappe.db.get_all("Workflow", + filters={"is_active": 1}, + fields=["document_type"], + limit=100 + ) + + doctypes = [[w.document_type, w.document_type] for w in workflows + if w.document_type and txt.lower() in w.document_type.lower()] + + return doctypes + + +@frappe.whitelist() +def get_pending_approval_count(user=None): + user = user or frappe.session.user + + if user == "Administrator": + return _count_all_pending() + + user_roles = set(frappe.get_roles(user)) + + workflows = frappe.db.get_all("Workflow", + filters={"is_active": 1}, + fields=["name", "document_type"] + ) + + total = 0 + seen = set() + for w in workflows: + dt = w.document_type + if not dt or dt in seen: + continue + seen.add(dt) + + if not frappe.db.has_column(dt, "workflow_state"): + continue + + try: + wf_doc = frappe.get_cached_doc("Workflow", w.name) + except Exception: + continue + + approver_roles = set() + for state in wf_doc.states: + if state.state == "Pending" and state.allow_edit: + approver_roles.add(state.allow_edit) + + if approver_roles and not (user_roles & approver_roles): + continue + + try: + total += frappe.db.count(dt, filters={"workflow_state": "Pending"}) or 0 + except Exception: + continue + + return total + + +def _count_all_pending(): + workflows = frappe.db.get_all("Workflow", + filters={"is_active": 1}, + fields=["document_type"] + ) + total = 0 + seen = set() + for w in workflows: + dt = w.document_type + if not dt or dt in seen: + continue + seen.add(dt) + if not frappe.db.has_column(dt, "workflow_state"): + continue + try: + total += frappe.db.count(dt, filters={"workflow_state": "Pending"}) or 0 + except Exception: + continue + return total \ No newline at end of file diff --git a/aqrar_ext/hooks.py b/aqrar_ext/hooks.py index cde1460..5b66895 100644 --- a/aqrar_ext/hooks.py +++ b/aqrar_ext/hooks.py @@ -4,11 +4,294 @@ app_description = "Customizations and Extensions for Aqrar" app_email = "nah@enfono.com" app_license = "mit" +# required_apps = [] + +# Includes in +# ------------------ + +# include js, css files in header of desk.html +# app_include_css = "/assets/develop/css/develop.css" app_include_js = [ "/assets/aqrar_ext/js/sales_invoice_pos_total_popup.js", + "/assets/sf_trading/js/workflowapproval.js", ] +# include js, css files in header of web template +# web_include_css = "/assets/develop/css/develop.css" +# web_include_js = "/assets/develop/js/develop.js" + +# include custom scss in every website theme (without file extension ".scss") +# website_theme_scss = "develop/public/scss/website" + +# include js, css files in header of web form +# webform_include_js = {"doctype": "public/js/doctype.js"} +# webform_include_css = {"doctype": "public/css/doctype.css"} + +# include js in page +# page_js = {"page" : "public/js/file.js"} + +# include js in doctype views +# doctype_js = {"doctype" : "public/js/doctype.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"} + +# Svg Icons +# ------------------ +# include app icons in desk +# app_include_icons = "develop/public/icons.svg" + +# Home Pages +# ---------- + +# application home page (will override Website Settings) +# home_page = "login" + +# website user home page (by Role) +# role_home_page = { +# "Role": "home_page" +# } + +# Generators +# ---------- + +# automatically create page for each record of this doctype +# website_generators = ["Web Page"] + +# Jinja +# ---------- + +# add methods and filters to jinja environment +# jinja = { +# "methods": "develop.utils.jinja_methods", +# "filters": "develop.utils.jinja_filters" +# } + +# Installation +# ------------ + +# before_install = "develop.install.before_install" +# after_install = "develop.install.after_install" + +# Uninstallation +# ------------ + +# before_uninstall = "develop.uninstall.before_uninstall" +# after_uninstall = "develop.uninstall.after_uninstall" + +# Integration Setup +# ------------------ +# To set up dependencies/integrations with other apps +# Name of the app being installed is passed as an argument + +# before_app_install = "develop.utils.before_app_install" +# after_app_install = "develop.utils.after_app_install" + +# Integration Cleanup +# ------------------- +# To clean up dependencies/integrations with other apps +# Name of the app being uninstalled is passed as an argument + +# before_app_uninstall = "develop.utils.before_app_uninstall" +# after_app_uninstall = "develop.utils.after_app_uninstall" + +# Desk Notifications +# ------------------ +# See frappe.core.notifications.get_notification_config + +# notification_config = "develop.notifications.get_notification_config" + +# Permissions +# ----------- +# Permissions evaluated in scripted ways + +# permission_query_conditions = { +# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", +# } +# +# has_permission = { +# "Event": "frappe.desk.doctype.event.event.has_permission", +# } + +# DocType Class +# --------------- +# Override standard doctype classes +override_doctype_class = { + "Payment Entry": "aqrar_ext.overrides.payment_entry.CustomPaymentEntry", + "Item": "aqrar_ext.overrides.item.CustomItem", + "Quotation": "aqrar_ext.overrides.quotation.CustomQuotation" +} +# override_doctype_class = { +# "ToDo": "custom_app.overrides.CustomToDo" +# } +# override_doctype_class = { +# "Leave Allocation": "develop.test.CustomLeaveAllocation" +# } + +# Document Events +# --------------- +# Hook on document methods and events + +# doc_events = { +# "*": { +# "on_update": "method", +# "on_cancel": "method", +# "on_trash": "method" +# } +# } +# In your_app/hooks.py +# doc_events = { +# "User": { +# "on_update": "develop.test.create_user_in_external_system", +# "after_insert": "develop.test.create_user_in_external_system" +# } +# } +# doc_events = { +# "User": { +# "on_update": "develop.rest.update_user_credentials", +# } +# } + +# In your hooks.py file, add the following + +# doc_events = { +# "Item": { +# "on_update": "develop.test.send_created_item_details", +# "after_insert": "develop.test.send_created_item_details" +# } +# } +# doc_events = { +# "Payment Entry": { +# "validate": "aqrar_ext.aqrar_ext.overrides.payment_entry.validate" +# } +# } + +# Scheduled Tasks +# --------------- + +# scheduler_events = { +# "all": [ +# "develop.tasks.all" +# ], +# "daily": [ +# "develop.tasks.daily" +# ], +# "hourly": [ +# "develop.tasks.hourly" +# ], +# "weekly": [ +# "develop.tasks.weekly" +# ], +# "monthly": [ +# "develop.tasks.monthly" +# ], +# } +# scheduler_events = { +# "cron": { +# "*/2 * * * *": [ +# "develop.api.sync_customers_from_external_api" +# ], +# "*/3 * * * *": [ +# "develop.api.sync_sales_orders_from_external_api" +# ] +# } +# } + + +# scheduler_events = { +# "cron": { +# "0 2 1 * *": [ +# "develop.test.allocate_comp_off" +# ] +# } +# } + + +# Testing +# ------- + +# before_tests = "develop.install.before_tests" + +# Overriding Methods +# ------------------------------ +# +# override_whitelisted_methods = { +# "frappe.desk.doctype.event.event.get_events": "develop.event.get_events" +# } +# override_whitelisted_methods = { +# "frappe.core.doctype.user.user.sign_up": "develop.rest.custom_signup", +# "frappe.core.doctype.user.user.login": "develop.rest.custom_login", +# "get_eoi_with_units": "develop.api.get_eoi_with_units", +# "user_cred": "develop.rest.user_cred" +# } + +# each overriding function accepts a `data` argument; +# generated from the base implementation of the doctype dashboard, +# along with any modifications made in other Frappe apps +# override_doctype_dashboards = { +# "Task": "develop.task.get_dashboard_data" +# } + +# exempt linked doctypes from being automatically cancelled +# +# auto_cancel_exempted_doctypes = ["Auto Repeat"] + +# Ignore links to specified DocTypes when deleting documents +# ----------------------------------------------------------- + +# ignore_links_on_delete = ["Communication", "ToDo"] + +# Request Events +# ---------------- +# before_request = ["develop.utils.before_request"] +# after_request = ["develop.utils.after_request"] + +# Job Events +# ---------- +# before_job = ["develop.utils.before_job"] +# after_job = ["develop.utils.after_job"] + +# User Data Protection +# -------------------- + +# user_data_fields = [ +# { +# "doctype": "{doctype_1}", +# "filter_by": "{filter_by}", +# "redact_fields": ["{field_1}", "{field_2}"], +# "partial": 1, +# }, +# { +# "doctype": "{doctype_2}", +# "filter_by": "{filter_by}", +# "partial": 1, +# }, +# { +# "doctype": "{doctype_3}", +# "strict": False, +# }, +# { +# "doctype": "{doctype_4}" +# } +# ] + +# Authentication and authorization +# -------------------------------- + +# auth_hooks = [ +# "develop.auth.validate" +# ] + +# Automatically update python controller files with type annotations for this app. +# export_python_type_annotations = True + +# default_log_clearing_doctypes = { +# "Logging DocType Name": 30 # days to retain logs +# } +whitelist_methods = [ + "aqrar_ext.api.api.get_item_uoms" +] fixtures = [ { "dt": "Custom Field", @@ -23,4 +306,4 @@ ] ] }, -] +] \ No newline at end of file diff --git a/aqrar_ext/overrides/__init__.py b/aqrar_ext/overrides/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/overrides/item.py b/aqrar_ext/overrides/item.py new file mode 100644 index 0000000..67ade9b --- /dev/null +++ b/aqrar_ext/overrides/item.py @@ -0,0 +1,65 @@ +import frappe +from erpnext.stock.doctype.item.item import Item + + +class CustomItem(Item): + + def validate(self): + self._skip_uom_validation = True + super().validate() + + def validate_uom(self): + if self.is_new(): + return + + old_uom = frappe.db.get_value("Item", self.name, "stock_uom") + + if not old_uom or old_uom == self.stock_uom: + return + + sle_count = frappe.db.count( + "Stock Ledger Entry", + filters={ + "item_code": self.name, + "is_cancelled": 0 + } + ) + + if sle_count == 0: + return + + # Check admin using session user directly + user = frappe.session.user + is_admin = user == "Administrator" + + if not is_admin: + # Check System Manager role via DB + has_role = frappe.db.exists( + "Has Role", + {"parent": user, "role": "System Manager"} + ) + is_admin = bool(has_role) + + if not is_admin: + frappe.throw( + "Default UOM cannot be changed after stock transactions exist. " + "This item has " + str(sle_count) + " stock ledger entries. " + "Contact your administrator to override.", + title="UOM Locked" + ) + + elif not self.custom_uom_override_reason: + frappe.throw( + "Please use the Override UOM (Admin) button and provide " + "a reason before changing the Default UOM.", + title="Override Reason Required" + ) + + else: + comment_text = ( + "UOM Override by " + str(user) + + " | Old UOM: " + str(old_uom) + + " | New UOM: " + str(self.stock_uom) + + " | Reason: " + str(self.custom_uom_override_reason) + ) + self.add_comment("Info", comment_text) diff --git a/aqrar_ext/overrides/payment_entry.py b/aqrar_ext/overrides/payment_entry.py new file mode 100644 index 0000000..52bed1c --- /dev/null +++ b/aqrar_ext/overrides/payment_entry.py @@ -0,0 +1,54 @@ +import frappe +from frappe import _ +from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry + + +class CustomPaymentEntry(PaymentEntry): + + def validate(self): + super().validate() + self.validate_bank_reference() + + def before_submit(self): + if hasattr(super(), 'before_submit'): + super().before_submit() + self.validate_bank_reference_uniqueness() + + def validate_bank_reference(self): + """Req 63: Mandatory for Bank Transfer / Cheque""" + bank_modes = ["Bank Transfer", "Cheque"] + if self.mode_of_payment in bank_modes: + if not self.custom__bank_reference_no: + frappe.throw( + _("Bank Reference No is mandatory for {0} payments.").format( + self.mode_of_payment + ), + title=_("Missing Bank Reference No") + ) + + def validate_bank_reference_uniqueness(self): + """Req 64: Unique reference per bank account""" + bank_modes = ["Bank Transfer", "Cheque"] + if self.mode_of_payment in bank_modes and self.custom__bank_reference_no: + duplicate = frappe.db.get_value( + "Payment Entry", + { + "custom__bank_reference_no": self.custom__bank_reference_no, + "bank_account": self.bank_account, + "docstatus": 1, + "name": ("!=", self.name) + }, + "name" + ) + if duplicate: + frappe.throw( + _( + "Bank Reference No {0} already exists for " + "bank account {1} in Payment Entry {2}." + ).format( + self.custom__bank_reference_no, + self.bank_account, + duplicate + ), + title=_("Duplicate Bank Reference No") + ) diff --git a/aqrar_ext/overrides/quotation.py b/aqrar_ext/overrides/quotation.py new file mode 100644 index 0000000..c63d6f6 --- /dev/null +++ b/aqrar_ext/overrides/quotation.py @@ -0,0 +1,32 @@ +import frappe +from erpnext.selling.doctype.quotation.quotation import Quotation + + +class CustomQuotation(Quotation): + + def validate(self): + super().validate() + self.validate_customer_specific_items() + + def validate_customer_specific_items(self): + """Req 95: Warn if TM items are added to wrong customer""" + customer = self.party_name + + for item in self.items: + if not item.item_code: + continue + if not str(item.item_code).startswith("TM-"): + continue + + visibility = frappe.db.get_value( + "Item", item.item_code, "custom_item_visibility" + ) + + if visibility == "Customer-Specific": + frappe.msgprint( + "Item " + str(item.item_code) + " is a " + "Customer-Specific item. Ensure this Quotation " + "is for the correct customer: " + str(customer) + ".", + title="Customer-Specific Item", + indicator="blue" + ) diff --git a/aqrar_ext/public/js/workflowapproval.js b/aqrar_ext/public/js/workflowapproval.js new file mode 100644 index 0000000..ca0e372 --- /dev/null +++ b/aqrar_ext/public/js/workflowapproval.js @@ -0,0 +1,62 @@ + +// Adds a pending count badge next to the "Work Flow Approval" shortcut +// on any workspace, matching the native ERPNext shortcut count style. + +frappe.provide("aqrar_ext.wf_shortcut"); + +aqrar_ext.wf_shortcut.update_count = function() { + let $links = $('.shortcut-widget-box a, .widget.shortcut-widget-box .widget-title') + .filter(function() { + return $(this).text().trim().toLowerCase().includes("work flow approval") + || $(this).text().trim().toLowerCase().includes("workflow approval"); + }); + + if (!$links.length) return; + + frappe.call({ + method: "aqrar_ext.aqrar_ext.report.work_flow_approval.work_flow_approval.get_pending_approval_count", + callback: function(r) { + let count = r.message || 0; + + $('.shortcut-widget-box').each(function() { + let $box = $(this); + let txt = $box.find('.widget-title, a').first().text().trim().toLowerCase(); + if (!txt.includes("work flow approval") && !txt.includes("workflow approval")) return; + + // Remove any previous injected badge + $box.find('.wf-pending-count').remove(); + + // Build badge in the same style as ERPNext's native shortcut count + let $badge = $(`${count}`); + + // Append to the title/link + let $target = $box.find('.widget-title').first(); + if (!$target.length) $target = $box.find('a').first(); + $target.append($badge); + }); + } + }); +}; + +// Run after route changes (workspace navigation) and on initial load +$(document).on("page-change app_ready", function() { + setTimeout(aqrar_ext.wf_shortcut.update_count, 600); + setTimeout(aqrar_ext.wf_shortcut.update_count, 1500); // retry for slow loads +}); + +frappe.after_ajax(function() { + setTimeout(aqrar_ext.wf_shortcut.update_count, 1000); +}); + +// Refresh every 60 seconds while the page is open +setInterval(aqrar_ext.wf_shortcut.update_count, 60000); \ No newline at end of file From a197f82a5686f85e587d2a2923009c8b69e3e684 Mon Sep 17 00:00:00 2001 From: roshmol Date: Tue, 2 Jun 2026 06:11:34 +0000 Subject: [PATCH 2/2] initial commit with upstream --- .editorconfig | 21 + .eslintrc | 124 +++ .github/workflows/ci.yml | 106 +++ .github/workflows/linter.yml | 61 ++ .gitignore | 7 + .pre-commit-config.yaml | 69 ++ README.md | 41 + aqrar_ext/__init__.py | 1 + aqrar_ext/api/__init__.py | 1 + aqrar_ext/api/branch_config.py | 21 + aqrar_ext/api/price_history.py | 303 ++++++++ aqrar_ext/api/sales_invoice.py | 284 +++++++ aqrar_ext/api/sales_invoice_payment.py | 151 ++++ aqrar_ext/aqrar_ext/__init__.py | 0 aqrar_ext/aqrar_ext/doctype/__init__.py | 0 .../doctype/branch_configuration/__init__.py | 0 .../branch_configuration.js | 42 + .../branch_configuration.json | 85 +++ .../branch_configuration.py | 231 ++++++ .../test_branch_configuration.py | 10 + .../__init__.py | 0 .../branch_configuration_cost_center.json | 35 + .../branch_configuration_cost_center.py | 9 + .../__init__.py | 0 .../branch_configuration_mode_of_payment.json | 42 + .../branch_configuration_mode_of_payment.py | 9 + .../branch_configuration_user/__init__.py | 0 .../branch_configuration_user.json | 35 + .../branch_configuration_user.py | 9 + .../__init__.py | 0 .../branch_configuration_warehouse.json | 35 + .../branch_configuration_warehouse.py | 9 + .../doctype/custom_quote/__init__.py | 0 .../doctype/custom_quote/custom_quote.js | 90 +++ .../doctype/custom_quote/custom_quote.json | 187 +++++ .../doctype/custom_quote/custom_quote.py | 9 + .../doctype/custom_quote/test_custom_quote.py | 21 + .../doctype/custom_quote_item/__init__.py | 0 .../custom_quote_item/custom_quote_item.json | 85 +++ .../custom_quote_item/custom_quote_item.py | 8 + aqrar_ext/aqrar_ext/overrides/__init__.py | 0 aqrar_ext/aqrar_ext/overrides/stock_ledger.py | 34 + aqrar_ext/aqrar_ext/report/__init__.py | 0 .../report/customer_statement/__init__.py | 0 .../customer_statement.html | 229 ++++++ .../customer_statement/customer_statement.js | 40 + .../customer_statement.json | 49 ++ .../customer_statement/customer_statement.py | 299 ++++++++ .../report/daily_report_combined/__init__.py | 0 .../daily_report_combined.js | 20 + .../daily_report_combined.json | 34 + .../daily_report_combined.py | 593 +++++++++++++++ .../aqrar_ext/report/dcr_report/__init__.py | 0 .../aqrar_ext/report/dcr_report/dcr_report.js | 65 ++ .../report/dcr_report/dcr_report.json | 34 + .../aqrar_ext/report/dcr_report/dcr_report.py | 547 +++++++++++++ .../report/work_flow_approval/__init__.py | 0 .../work_flow_approval/work_flow_approval.js | 109 +++ .../work_flow_approval.json | 28 + .../work_flow_approval/work_flow_approval.py | 215 ++++++ aqrar_ext/config/__init__.py | 0 aqrar_ext/events/__init__.py | 0 aqrar_ext/events/material_request.py | 59 ++ aqrar_ext/events/purchase_receipt.py | 29 + aqrar_ext/fixtures/custom_field.json | 686 +++++++++++++++++ aqrar_ext/fixtures/mode_of_payment.json | 56 ++ aqrar_ext/fixtures/print_format.json | 36 + aqrar_ext/fixtures/workflow.json | 135 ++++ aqrar_ext/hooks.py | 90 +++ aqrar_ext/modules.txt | 1 + aqrar_ext/overrides/__init__.py | 0 aqrar_ext/overrides/item.py | 65 ++ aqrar_ext/overrides/payment_entry.py | 54 ++ aqrar_ext/overrides/quotation.py | 32 + aqrar_ext/patches.txt | 6 + aqrar_ext/patches/__init__.py | 0 .../patches/fix_stock_entry_naming_series.py | 31 + aqrar_ext/public/.gitkeep | 0 aqrar_ext/public/js/customer_price_history.js | 720 ++++++++++++++++++ aqrar_ext/public/js/customer_statement.js | 71 ++ aqrar_ext/public/js/item_selector.js | 305 ++++++++ aqrar_ext/public/js/item_selector_hook.js | 43 ++ .../public/js/material_request_custom.js | 91 +++ .../public/js/purchase_receipt_final_grn.js | 198 +++++ .../js/sales_invoice_pos_total_popup.js | 620 +++++++++++++++ aqrar_ext/public/js/stock_ledger_override.js | 37 + aqrar_ext/public/js/workflowapproval.js | 62 ++ aqrar_ext/templates/__init__.py | 0 aqrar_ext/templates/pages/__init__.py | 0 license.txt | 21 + pyproject.toml | 30 + 91 files changed, 7915 insertions(+) create mode 100644 .editorconfig create mode 100644 .eslintrc create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/linter.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 README.md create mode 100644 aqrar_ext/__init__.py create mode 100644 aqrar_ext/api/__init__.py create mode 100644 aqrar_ext/api/branch_config.py create mode 100644 aqrar_ext/api/price_history.py create mode 100644 aqrar_ext/api/sales_invoice.py create mode 100644 aqrar_ext/api/sales_invoice_payment.py create mode 100644 aqrar_ext/aqrar_ext/__init__.py create mode 100644 aqrar_ext/aqrar_ext/doctype/__init__.py create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration/__init__.py create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.js create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.json create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.py create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration/test_branch_configuration.py create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_cost_center/__init__.py create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_cost_center/branch_configuration_cost_center.json create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_cost_center/branch_configuration_cost_center.py create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/__init__.py create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.json create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.py create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_user/__init__.py create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_user/branch_configuration_user.json create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_user/branch_configuration_user.py create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_warehouse/__init__.py create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_warehouse/branch_configuration_warehouse.json create mode 100644 aqrar_ext/aqrar_ext/doctype/branch_configuration_warehouse/branch_configuration_warehouse.py create mode 100644 aqrar_ext/aqrar_ext/doctype/custom_quote/__init__.py create mode 100644 aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.js create mode 100644 aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.json create mode 100644 aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.py create mode 100644 aqrar_ext/aqrar_ext/doctype/custom_quote/test_custom_quote.py create mode 100644 aqrar_ext/aqrar_ext/doctype/custom_quote_item/__init__.py create mode 100644 aqrar_ext/aqrar_ext/doctype/custom_quote_item/custom_quote_item.json create mode 100644 aqrar_ext/aqrar_ext/doctype/custom_quote_item/custom_quote_item.py create mode 100644 aqrar_ext/aqrar_ext/overrides/__init__.py create mode 100644 aqrar_ext/aqrar_ext/overrides/stock_ledger.py create mode 100644 aqrar_ext/aqrar_ext/report/__init__.py create mode 100644 aqrar_ext/aqrar_ext/report/customer_statement/__init__.py create mode 100644 aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.html create mode 100644 aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.js create mode 100644 aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.json create mode 100644 aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.py create mode 100644 aqrar_ext/aqrar_ext/report/daily_report_combined/__init__.py create mode 100644 aqrar_ext/aqrar_ext/report/daily_report_combined/daily_report_combined.js create mode 100644 aqrar_ext/aqrar_ext/report/daily_report_combined/daily_report_combined.json create mode 100644 aqrar_ext/aqrar_ext/report/daily_report_combined/daily_report_combined.py create mode 100644 aqrar_ext/aqrar_ext/report/dcr_report/__init__.py create mode 100644 aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.js create mode 100644 aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.json create mode 100644 aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.py create mode 100644 aqrar_ext/aqrar_ext/report/work_flow_approval/__init__.py create mode 100644 aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.js create mode 100644 aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.json create mode 100644 aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.py create mode 100644 aqrar_ext/config/__init__.py create mode 100644 aqrar_ext/events/__init__.py create mode 100644 aqrar_ext/events/material_request.py create mode 100644 aqrar_ext/events/purchase_receipt.py create mode 100644 aqrar_ext/fixtures/custom_field.json create mode 100644 aqrar_ext/fixtures/mode_of_payment.json create mode 100644 aqrar_ext/fixtures/print_format.json create mode 100644 aqrar_ext/fixtures/workflow.json create mode 100644 aqrar_ext/hooks.py create mode 100644 aqrar_ext/modules.txt create mode 100644 aqrar_ext/overrides/__init__.py create mode 100644 aqrar_ext/overrides/item.py create mode 100644 aqrar_ext/overrides/payment_entry.py create mode 100644 aqrar_ext/overrides/quotation.py create mode 100644 aqrar_ext/patches.txt create mode 100644 aqrar_ext/patches/__init__.py create mode 100644 aqrar_ext/patches/fix_stock_entry_naming_series.py create mode 100644 aqrar_ext/public/.gitkeep create mode 100644 aqrar_ext/public/js/customer_price_history.js create mode 100644 aqrar_ext/public/js/customer_statement.js create mode 100644 aqrar_ext/public/js/item_selector.js create mode 100644 aqrar_ext/public/js/item_selector_hook.js create mode 100644 aqrar_ext/public/js/material_request_custom.js create mode 100644 aqrar_ext/public/js/purchase_receipt_final_grn.js create mode 100644 aqrar_ext/public/js/sales_invoice_pos_total_popup.js create mode 100644 aqrar_ext/public/js/stock_ledger_override.js create mode 100644 aqrar_ext/public/js/workflowapproval.js create mode 100644 aqrar_ext/templates/__init__.py create mode 100644 aqrar_ext/templates/pages/__init__.py create mode 100644 license.txt create mode 100644 pyproject.toml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b901702 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# Root editor config file +root = true + +# Common settings +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +# python, js indentation settings +[{*.py,*.js,*.vue,*.css,*.scss,*.html}] +indent_style = tab +indent_size = 4 +max_line_length = 99 + +# JSON files - mostly doctype schema files +[{*.json}] +insert_final_newline = false +indent_style = space +indent_size = 2 diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..c5e7d68 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,124 @@ +{ + "env": { + "browser": true, + "node": true, + "es2022": true + }, + "parserOptions": { + "sourceType": "module" + }, + "extends": "eslint:recommended", + "rules": { + "indent": "off", + "brace-style": "off", + "no-mixed-spaces-and-tabs": "off", + "no-useless-escape": "off", + "space-unary-ops": ["error", { "words": true }], + "linebreak-style": "off", + "quotes": ["off"], + "semi": "off", + "camelcase": "off", + "no-unused-vars": "off", + "no-console": ["warn"], + "no-extra-boolean-cast": ["off"], + "no-control-regex": ["off"], + }, + "root": true, + "globals": { + "frappe": true, + "Vue": true, + "SetVueGlobals": true, + "__": true, + "repl": true, + "Class": true, + "locals": true, + "cint": true, + "cstr": true, + "cur_frm": true, + "cur_dialog": true, + "cur_page": true, + "cur_list": true, + "cur_tree": true, + "msg_dialog": true, + "is_null": true, + "in_list": true, + "has_common": true, + "posthog": true, + "has_words": true, + "validate_email": true, + "open_web_template_values_editor": true, + "validate_name": true, + "validate_phone": true, + "validate_url": true, + "get_number_format": true, + "format_number": true, + "format_currency": true, + "comment_when": true, + "open_url_post": true, + "toTitle": true, + "lstrip": true, + "rstrip": true, + "strip": true, + "strip_html": true, + "replace_all": true, + "flt": true, + "precision": true, + "CREATE": true, + "AMEND": true, + "CANCEL": true, + "copy_dict": true, + "get_number_format_info": true, + "strip_number_groups": true, + "print_table": true, + "Layout": true, + "web_form_settings": true, + "$c": true, + "$a": true, + "$i": true, + "$bg": true, + "$y": true, + "$c_obj": true, + "refresh_many": true, + "refresh_field": true, + "toggle_field": true, + "get_field_obj": true, + "get_query_params": true, + "unhide_field": true, + "hide_field": true, + "set_field_options": true, + "getCookie": true, + "getCookies": true, + "get_url_arg": true, + "md5": true, + "$": true, + "jQuery": true, + "moment": true, + "hljs": true, + "Awesomplete": true, + "Sortable": true, + "Showdown": true, + "Taggle": true, + "Gantt": true, + "Slick": true, + "Webcam": true, + "PhotoSwipe": true, + "PhotoSwipeUI_Default": true, + "io": true, + "JsBarcode": true, + "L": true, + "Chart": true, + "DataTable": true, + "Cypress": true, + "cy": true, + "it": true, + "describe": true, + "expect": true, + "context": true, + "before": true, + "beforeEach": true, + "after": true, + "qz": true, + "localforage": true, + "extend_cscript": true + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ebb45fd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,106 @@ + +name: CI + +on: + push: + branches: + - develop + pull_request: + +concurrency: + group: develop-aqrar_ext-${{ github.event.number }} + cancel-in-progress: true + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + name: Server + + services: + redis-cache: + image: redis:alpine + ports: + - 13000:6379 + redis-queue: + image: redis:alpine + ports: + - 11000:6379 + mariadb: + image: mariadb:10.6 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3 + + steps: + - name: Clone + uses: actions/checkout@v3 + + - name: Find tests + run: | + echo "Finding tests" + grep -rn "def test" > /dev/null + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: 18 + check-latest: true + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py', '**/setup.cfg') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: 'echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT' + + - uses: actions/cache@v4 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Install MariaDB Client + run: sudo apt-get install mariadb-client-10.6 + + - name: Setup + run: | + pip install frappe-bench + bench init --skip-redis-config-generation --skip-assets --python "$(which python)" ~/frappe-bench + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'" + mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + + - name: Install + working-directory: /home/runner/frappe-bench + run: | + bench get-app aqrar_ext $GITHUB_WORKSPACE + bench setup requirements --dev + bench new-site --db-root-password root --admin-password admin test_site + bench --site test_site install-app aqrar_ext + bench build + env: + CI: 'Yes' + + - name: Run Tests + working-directory: /home/runner/frappe-bench + run: | + bench --site test_site set-config allow_tests true + bench --site test_site run-tests --app aqrar_ext + env: + TYPE: server diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml new file mode 100644 index 0000000..6d38c64 --- /dev/null +++ b/.github/workflows/linter.yml @@ -0,0 +1,61 @@ + +name: Linters + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + linter: + name: 'Frappe Linter' + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: pip + - uses: pre-commit/action@v3.0.0 + + - name: Download Semgrep rules + run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules + + - name: Run Semgrep rules + run: | + pip install semgrep + semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness + + deps-vulnerable-check: + name: 'Vulnerable Dependency Check' + runs-on: ubuntu-latest + + steps: + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - uses: actions/checkout@v4 + + - name: Cache pip + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + + - name: Install and run pip-audit + run: | + pip install pip-audit + cd ${GITHUB_WORKSPACE} + pip-audit --desc on . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba04025 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +*.pyc +*.egg-info +*.swp +tags +node_modules +__pycache__ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..43cd4ec --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,69 @@ +exclude: 'node_modules|.git' +default_stages: [pre-commit] +fail_fast: false + + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + files: "aqrar_ext.*" + exclude: ".*json$|.*txt$|.*csv|.*md|.*svg" + - id: check-merge-conflict + - id: check-ast + - id: check-json + - id: check-toml + - id: check-yaml + - id: debug-statements + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.1 + hooks: + - id: ruff + name: "Run ruff import sorter" + args: ["--select=I", "--fix"] + + - id: ruff + name: "Run ruff linter" + + - id: ruff-format + name: "Run ruff formatter" + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.7.1 + hooks: + - id: prettier + types_or: [javascript, vue, scss] + # Ignore any files that might contain jinja / bundles + exclude: | + (?x)^( + aqrar_ext/public/dist/.*| + .*node_modules.*| + .*boilerplate.*| + aqrar_ext/templates/includes/.*| + aqrar_ext/public/js/lib/.* + )$ + + + - repo: https://github.com/pre-commit/mirrors-eslint + rev: v8.44.0 + hooks: + - id: eslint + types_or: [javascript] + args: ['--quiet'] + # Ignore any files that might contain jinja / bundles + exclude: | + (?x)^( + aqrar_ext/public/dist/.*| + cypress/.*| + .*node_modules.*| + .*boilerplate.*| + aqrar_ext/templates/includes/.*| + aqrar_ext/public/js/lib/.* + )$ + +ci: + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c5bd1a --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +### Aqrar EXT + +custom app for Aqrar + +### Installation + +You can install this app using the [bench](https://github.com/frappe/bench) CLI: + +```bash +cd $PATH_TO_YOUR_BENCH +bench get-app $URL_OF_THIS_REPO --branch develop +bench install-app aqrar_ext +``` + +### Contributing + +This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository: + +```bash +cd apps/aqrar_ext +pre-commit install +``` + +Pre-commit is configured to use the following tools for checking and formatting your code: + +- ruff +- eslint +- prettier +- pyupgrade + +### CI + +This app can use GitHub Actions for CI. The following workflows are configured: + +- CI: Installs this app and runs unit tests on every push to `develop` branch. +- Linters: Runs [Frappe Semgrep Rules](https://github.com/frappe/semgrep-rules) and [pip-audit](https://pypi.org/project/pip-audit/) on every pull request. + + +### License + +mit diff --git a/aqrar_ext/__init__.py b/aqrar_ext/__init__.py new file mode 100644 index 0000000..f102a9c --- /dev/null +++ b/aqrar_ext/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/aqrar_ext/api/__init__.py b/aqrar_ext/api/__init__.py new file mode 100644 index 0000000..f181409 --- /dev/null +++ b/aqrar_ext/api/__init__.py @@ -0,0 +1 @@ +from .price_history import get_last_sold_price, get_item_insights, get_item_price_history diff --git a/aqrar_ext/api/branch_config.py b/aqrar_ext/api/branch_config.py new file mode 100644 index 0000000..9a303d4 --- /dev/null +++ b/aqrar_ext/api/branch_config.py @@ -0,0 +1,21 @@ +# aqrar_ext: Get branch defaults for current user +import frappe + + +@frappe.whitelist() +def get_user_branch_defaults(): + """Return warehouse and cost_center from user's Branch Configuration.""" + user = frappe.session.user + result = frappe.db.sql(""" + SELECT bcw.warehouse, bcc.cost_center + FROM `tabBranch Configuration` bc + INNER JOIN `tabBranch Configuration User` bcu ON bcu.parent = bc.name + LEFT JOIN `tabBranch Configuration Warehouse` bcw ON bcw.parent = bc.name + LEFT JOIN `tabBranch Configuration Cost Center` bcc ON bcc.parent = bc.name + WHERE bcu.user = %s + LIMIT 1 + """, user, as_dict=True) + + if result: + return result[0] + return {} diff --git a/aqrar_ext/api/price_history.py b/aqrar_ext/api/price_history.py new file mode 100644 index 0000000..a2d9b11 --- /dev/null +++ b/aqrar_ext/api/price_history.py @@ -0,0 +1,303 @@ +import frappe +from frappe.utils import flt + + +@frappe.whitelist() +def get_last_sold_price(customer=None, item_code=None, source="sales"): + + if not item_code: + return {} + + if source == "purchase": + return get_last_purchase_price(customer, item_code) + + filters = { + "item_code": item_code + } + + customer_condition = "" + + if customer: + customer_condition = "AND si.customer = %(customer)s" + filters["customer"] = customer + + row = frappe.db.sql(f""" + SELECT + sii.rate + FROM `tabSales Invoice Item` sii + INNER JOIN `tabSales Invoice` si + ON si.name = sii.parent + WHERE + sii.item_code = %(item_code)s + AND si.docstatus = 1 + {customer_condition} + ORDER BY + si.posting_date DESC, + si.creation DESC + LIMIT 1 + """, filters, as_dict=True) + + # fallback general last sold price + if not row: + + row = frappe.db.sql(""" + SELECT + sii.rate + FROM `tabSales Invoice Item` sii + INNER JOIN `tabSales Invoice` si + ON si.name = sii.parent + WHERE + sii.item_code = %(item_code)s + AND si.docstatus = 1 + ORDER BY + si.posting_date DESC, + si.creation DESC + LIMIT 1 + """, filters, as_dict=True) + + return { + "last_price": flt(row[0].rate) if row else 0 + } + + +def get_last_purchase_price(supplier=None, item_code=None): + + filters = {"item_code": item_code} + supplier_condition = "" + + if supplier: + supplier_condition = "AND pi.supplier = %(supplier)s" + filters["supplier"] = supplier + + row = frappe.db.sql(f""" + SELECT + pii.rate + FROM `tabPurchase Invoice Item` pii + INNER JOIN `tabPurchase Invoice` pi + ON pi.name = pii.parent + WHERE + pii.item_code = %(item_code)s + AND pi.docstatus = 1 + {supplier_condition} + ORDER BY + pi.posting_date DESC, + pi.creation DESC + LIMIT 1 + """, filters, as_dict=True) + + if not row: + + row = frappe.db.sql(""" + SELECT + pii.rate + FROM `tabPurchase Invoice Item` pii + INNER JOIN `tabPurchase Invoice` pi + ON pi.name = pii.parent + WHERE + pii.item_code = %(item_code)s + AND pi.docstatus = 1 + ORDER BY + pi.posting_date DESC, + pi.creation DESC + LIMIT 1 + """, filters, as_dict=True) + + return { + "last_price": flt(row[0].rate) if row else 0 + } + + +@frappe.whitelist() +def get_item_insights(customer=None, item_code=None, company=None, source="sales"): + + stock = frappe.db.sql(""" + SELECT + warehouse, + projected_qty + FROM `tabBin` + WHERE item_code = %s + ORDER BY warehouse + """, item_code, as_dict=True) + + if source == "purchase": + price_history = frappe.db.sql(""" + SELECT + pi.supplier AS customer, + pii.rate, + pi.posting_date + FROM `tabPurchase Invoice Item` pii + INNER JOIN `tabPurchase Invoice` pi + ON pi.name = pii.parent + WHERE + pii.item_code = %s + AND pi.supplier = %s + AND pi.docstatus = 1 + ORDER BY + pi.posting_date DESC + LIMIT 10 + """, (item_code, customer), as_dict=True) + + last_purchase = frappe.db.sql(""" + SELECT + pii.rate + FROM `tabPurchase Invoice Item` pii + INNER JOIN `tabPurchase Invoice` pi + ON pi.name = pii.parent + WHERE + pii.item_code = %s + AND pi.docstatus = 1 + ORDER BY + pi.posting_date DESC + LIMIT 1 + """, item_code, as_dict=True) + + return { + "stock": stock, + "price_history": price_history, + "last_purchase_rate": + last_purchase[0].rate if last_purchase else 0 + } + + price_history = frappe.db.sql(""" + SELECT + si.customer, + sii.rate, + si.posting_date + FROM `tabSales Invoice Item` sii + INNER JOIN `tabSales Invoice` si + ON si.name = sii.parent + WHERE + sii.item_code = %s + AND si.customer = %s + AND si.docstatus = 1 + ORDER BY + si.posting_date DESC + LIMIT 10 + """, (item_code, customer), as_dict=True) + + purchase = frappe.db.sql(""" + SELECT + pii.rate + FROM `tabPurchase Invoice Item` pii + INNER JOIN `tabPurchase Invoice` pi + ON pi.name = pii.parent + WHERE + pii.item_code = %s + AND pi.docstatus = 1 + ORDER BY + pi.posting_date DESC + LIMIT 1 + """, item_code, as_dict=True) + + return { + "stock": stock, + "price_history": price_history, + "last_purchase_rate": + purchase[0].rate if purchase else 0 + } + + +@frappe.whitelist() +def get_item_price_history(item_code=None, source="sales", customer=None): + + if not item_code: + return {"history": [], "last_price": 0, "source": source} + + if source == "purchase": + supplier_cond = "" + params = [item_code] + if customer: + supplier_cond = "AND pi.supplier = %s" + params.append(customer) + + rows = frappe.db.sql(f""" + SELECT + pii.item_code, + pii.item_name, + pii.parent AS invoice, + pi.supplier AS party, + pii.rate, + pii.qty, + pi.posting_date + FROM `tabPurchase Invoice Item` pii + INNER JOIN `tabPurchase Invoice` pi + ON pi.name = pii.parent + WHERE + pii.item_code = %s + AND pi.docstatus = 1 + {supplier_cond} + ORDER BY + pi.posting_date DESC + LIMIT 100 + """, params, as_dict=True) + + last_price = frappe.db.sql(f""" + SELECT + pii.rate + FROM `tabPurchase Invoice Item` pii + INNER JOIN `tabPurchase Invoice` pi + ON pi.name = pii.parent + WHERE + pii.item_code = %s + AND pi.docstatus = 1 + {supplier_cond} + ORDER BY + pi.posting_date DESC, + pi.creation DESC + LIMIT 1 + """, params, as_dict=True) + + return { + "history": rows, + "last_price": last_price[0].rate if last_price else 0, + "source": "purchase" + } + + customer_cond = "" + params = [item_code] + if customer: + customer_cond = "AND si.customer = %s" + params.append(customer) + + rows = frappe.db.sql(f""" + SELECT + sii.item_code, + sii.item_name, + sii.parent AS invoice, + si.customer AS party, + sii.rate, + sii.qty, + si.posting_date + FROM `tabSales Invoice Item` sii + INNER JOIN `tabSales Invoice` si + ON si.name = sii.parent + WHERE + sii.item_code = %s + AND si.docstatus = 1 + {customer_cond} + ORDER BY + si.posting_date DESC + LIMIT 100 + """, params, as_dict=True) + + last_price = frappe.db.sql(f""" + SELECT + sii.rate + FROM `tabSales Invoice Item` sii + INNER JOIN `tabSales Invoice` si + ON si.name = sii.parent + WHERE + sii.item_code = %s + AND si.docstatus = 1 + {customer_cond} + ORDER BY + si.posting_date DESC, + si.creation DESC + LIMIT 1 + """, params, as_dict=True) + + return { + "history": rows, + "last_price": last_price[0].rate if last_price else 0, + "source": "sales" + } \ No newline at end of file diff --git a/aqrar_ext/api/sales_invoice.py b/aqrar_ext/api/sales_invoice.py new file mode 100644 index 0000000..dcca328 --- /dev/null +++ b/aqrar_ext/api/sales_invoice.py @@ -0,0 +1,284 @@ +import json + +import frappe +from frappe import _ +from frappe.utils import flt + + +@frappe.whitelist() +def get_payment_modes_with_account(company: str, mode_list: str | list = None): + """ + Return Mode of Payment names that are enabled and have a default Cash/Bank + account for the given company. + """ + if not company: + return [] + + if isinstance(mode_list, str): + try: + mode_list = json.loads(mode_list) if mode_list else None + except Exception: + mode_list = None + + has_account = frappe.db.sql( + """ + SELECT DISTINCT parent + FROM `tabMode of Payment Account` + WHERE company = %s AND default_account IS NOT NULL AND default_account != '' + """, + (company,), + as_list=True, + ) + modes_with_account = {r[0] for r in has_account} + + if mode_list is not None: + names = [ + m if isinstance(m, str) else (m.get("name") or m.get("mode_of_payment")) + for m in (mode_list or []) + ] + names = [n for n in names if n] + if not names: + return [] + enabled = frappe.get_all( + "Mode of Payment", + filters={"name": ["in", names], "enabled": 1}, + pluck="name", + ) + else: + enabled = frappe.get_all( + "Mode of Payment", + filters={"enabled": 1}, + pluck="name", + ) + + valid = [m for m in enabled if m in modes_with_account] + if mode_list is not None and names: + order = {m: i for i, m in enumerate(names)} + valid.sort(key=lambda m: order.get(m, 999)) + return valid + + +@frappe.whitelist() +def create_pos_payments_for_invoice(sales_invoice: str, payments: str | list): + """ + Create Payment Entry records for a submitted Sales Invoice, one per mode of payment. + + payments: JSON list or Python list of dicts: + [{ "mode_of_payment": "Cash", "amount": 100.0 }, ...] + """ + if not sales_invoice: + frappe.throw(_("Sales Invoice is required")) + + si = frappe.get_doc("Sales Invoice", sales_invoice) + if si.docstatus != 1: + frappe.throw( + _("Sales Invoice {0} must be submitted before creating payments.").format(si.name) + ) + + if isinstance(payments, str): + try: + payments = json.loads(payments) + except Exception: + frappe.throw(_("Invalid payments payload")) + + if not isinstance(payments, (list, tuple)) or not payments: + frappe.throw(_("No payment rows were provided.")) + + valid_rows: list[dict] = [] + for row in payments: + mode_of_payment = (row or {}).get("mode_of_payment") + amount = frappe.utils.flt((row or {}).get("amount")) + if not mode_of_payment or amount <= 0: + continue + valid_rows.append({"mode_of_payment": mode_of_payment, "amount": amount}) + + if not valid_rows: + frappe.throw( + _("No valid payment rows found (non-zero amounts with mode of payment).") + ) + + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account + + created: list[str] = [] + + for row in valid_rows: + si.reload() + outstanding = frappe.utils.flt(si.outstanding_amount) + amount = frappe.utils.flt(row["amount"]) + + if amount - outstanding > 0.5: + frappe.throw( + _( + "Payment amount {0} is greater than outstanding amount {1} for invoice {2}." + ).format(amount, outstanding, si.name) + ) + + pe = get_payment_entry("Sales Invoice", si.name) + pe.mode_of_payment = row["mode_of_payment"] + + bank_cash = get_bank_cash_account(row["mode_of_payment"], si.company) + pe.paid_to = bank_cash.get("account") + + if pe.paid_to: + acc = frappe.get_cached_value( + "Account", pe.paid_to, ["account_currency", "account_type"], as_dict=True + ) + if acc: + pe.paid_to_account_currency = acc.account_currency + pe.paid_to_account_type = acc.account_type + + pe.paid_amount = amount + pe.received_amount = amount + + if pe.references: + pe.references[0].allocated_amount = amount + + if not pe.posting_date: + pe.posting_date = si.posting_date + + pe.reference_no = si.name + pe.reference_date = si.posting_date + + pe.insert() + + pe.flags.ignore_validate = True + if hasattr(pe, "workflow_state"): + pe.workflow_state = "Pending" + + pe.submit() + created.append(pe.name) + + return created + + +def auto_create_payment_entry_on_submit(doc, method): + if doc.is_pos: + return + + if doc.is_return: + return + + if flt(doc.outstanding_amount) <= 0: + return + + if not doc.custom_payment_mode: + return + + if flt(doc.grand_total) <= 0: + return + + payment_mode = doc.custom_payment_mode + + # Cash is handled by the frontend popup (sales_invoice_pos_total_popup.js) + if payment_mode == "Cash": + return + + elif payment_mode == "Card": + partial = flt(doc.custom_partial_payment_amount or 0) + if partial > 0 and partial <= flt(doc.grand_total): + pay_amount = partial + else: + pay_amount = flt(doc.grand_total) + _create_and_submit_pe(doc, "Card", pay_amount) + + elif payment_mode == "Credit": + partial = flt(doc.custom_partial_payment_amount or 0) + if partial > 0 and partial <= flt(doc.grand_total): + _create_and_submit_pe(doc, "Cash", partial) + + +def _create_and_submit_pe(doc, mode_of_payment, amount): + amount = flt(amount) + if amount <= 0: + return + + # Re-fetch outstanding from DB to avoid stale in-memory value + outstanding = flt(frappe.db.get_value("Sales Invoice", doc.name, "outstanding_amount")) + if outstanding <= 0: + return + if amount - outstanding > 0.5: + amount = outstanding + + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account + + try: + bank_cash = get_bank_cash_account(mode_of_payment, doc.company) + except Exception: + frappe.log_error( + title="Auto Payment Entry: Missing Account", + message=_( + "No default account found for Mode of Payment '{0}' in company '{1}'. " + "Invoice {2} submitted without Payment Entry." + ).format(mode_of_payment, doc.company, doc.name), + ) + frappe.msgprint( + _( + "Payment Entry was not created. No default account configured for " + "Mode of Payment '{0}' in company '{1}'." + ).format(mode_of_payment, doc.company), + alert=True, + ) + return + + try: + pe = get_payment_entry("Sales Invoice", doc.name) + if not pe.references: + return + pe.mode_of_payment = mode_of_payment + pe.paid_to = bank_cash.get("account") + + if pe.paid_to: + acc = frappe.get_cached_value( + "Account", pe.paid_to, ["account_currency", "account_type"], as_dict=True + ) + if acc: + pe.paid_to_account_currency = acc.account_currency + pe.paid_to_account_type = acc.account_type + + pe.paid_amount = amount + pe.received_amount = amount + pe.references[0].allocated_amount = amount + pe.reference_no = doc.name + pe.reference_date = doc.posting_date + + pe.insert() + pe.submit() + + frappe.msgprint( + _("Payment Entry {0} created against {1} for {2}").format( + pe.name, doc.name, frappe.utils.fmt_money(amount, currency=doc.currency) + ), + alert=True, + ) + + except frappe.exceptions.ValidationError: + # If outstanding is already 0, invoice was paid by another process — skip silently + if flt(frappe.db.get_value("Sales Invoice", doc.name, "outstanding_amount")) <= 0: + return + # Re-raise other validation errors + frappe.log_error( + title="Auto Payment Entry Failed", + message=frappe.get_traceback(), + ) + frappe.msgprint( + _( + "Could not create Payment Entry for {0}. " + "Please create it manually." + ).format(doc.name), + alert=True, + ) + + except Exception: + frappe.log_error( + title="Auto Payment Entry Failed", + message=frappe.get_traceback(), + ) + frappe.msgprint( + _( + "Could not create Payment Entry for {0}. " + "Please create it manually." + ).format(doc.name), + alert=True, + ) diff --git a/aqrar_ext/api/sales_invoice_payment.py b/aqrar_ext/api/sales_invoice_payment.py new file mode 100644 index 0000000..f30ea16 --- /dev/null +++ b/aqrar_ext/api/sales_invoice_payment.py @@ -0,0 +1,151 @@ +import json + +import frappe +from frappe import _ + + +@frappe.whitelist() +def get_payment_modes_with_account(company: str, mode_list: str | list = None): + """ + Return Mode of Payment names that are enabled and have a default Cash/Bank + account for the given company. + """ + if not company: + return [] + + if isinstance(mode_list, str): + try: + mode_list = json.loads(mode_list) if mode_list else None + except Exception: + mode_list = None + + has_account = frappe.db.sql( + """ + SELECT DISTINCT parent + FROM `tabMode of Payment Account` + WHERE company = %s AND default_account IS NOT NULL AND default_account != '' + """, + (company,), + as_list=True, + ) + modes_with_account = {r[0] for r in has_account} + + if mode_list is not None: + names = [ + m if isinstance(m, str) else (m.get("name") or m.get("mode_of_payment")) + for m in (mode_list or []) + ] + names = [n for n in names if n] + if not names: + return [] + enabled = frappe.get_all( + "Mode of Payment", + filters={"name": ["in", names], "enabled": 1}, + pluck="name", + ) + else: + enabled = frappe.get_all( + "Mode of Payment", + filters={"enabled": 1}, + pluck="name", + ) + + valid = [m for m in enabled if m in modes_with_account] + if mode_list is not None and names: + order = {m: i for i, m in enumerate(names)} + valid.sort(key=lambda m: order.get(m, 999)) + return valid + + +@frappe.whitelist() +def create_pos_payments_for_invoice(sales_invoice: str, payments: str | list): + """ + Create Payment Entry records for a submitted Sales Invoice, one per mode of payment. + + payments: JSON list or Python list of dicts: + [{ "mode_of_payment": "Cash", "amount": 100.0 }, ...] + """ + if not sales_invoice: + frappe.throw(_("Sales Invoice is required")) + + si = frappe.get_doc("Sales Invoice", sales_invoice) + if si.docstatus != 1: + frappe.throw( + _("Sales Invoice {0} must be submitted before creating payments.").format(si.name) + ) + + if isinstance(payments, str): + try: + payments = json.loads(payments) + except Exception: + frappe.throw(_("Invalid payments payload")) + + if not isinstance(payments, (list, tuple)) or not payments: + frappe.throw(_("No payment rows were provided.")) + + valid_rows: list[dict] = [] + for row in payments: + mode_of_payment = (row or {}).get("mode_of_payment") + amount = frappe.utils.flt((row or {}).get("amount")) + if not mode_of_payment or amount <= 0: + continue + valid_rows.append({"mode_of_payment": mode_of_payment, "amount": amount}) + + if not valid_rows: + frappe.throw( + _("No valid payment rows found (non-zero amounts with mode of payment).") + ) + + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account + + created: list[str] = [] + + for row in valid_rows: + si.reload() + outstanding = frappe.utils.flt(si.outstanding_amount) + amount = frappe.utils.flt(row["amount"]) + + if amount - outstanding > 0.5: + frappe.throw( + _( + "Payment amount {0} is greater than outstanding amount {1} for invoice {2}." + ).format(amount, outstanding, si.name) + ) + + pe = get_payment_entry("Sales Invoice", si.name) + pe.mode_of_payment = row["mode_of_payment"] + + bank_cash = get_bank_cash_account(row["mode_of_payment"], si.company) + pe.paid_to = bank_cash.get("account") + + if pe.paid_to: + acc = frappe.get_cached_value( + "Account", pe.paid_to, ["account_currency", "account_type"], as_dict=True + ) + if acc: + pe.paid_to_account_currency = acc.account_currency + pe.paid_to_account_type = acc.account_type + + pe.paid_amount = amount + pe.received_amount = amount + + if pe.references: + pe.references[0].allocated_amount = amount + + if not pe.posting_date: + pe.posting_date = si.posting_date + + pe.reference_no = si.name + pe.reference_date = si.posting_date + + pe.insert() + + pe.flags.ignore_validate = True + if hasattr(pe, "workflow_state"): + pe.workflow_state = "Pending" + + pe.submit() + created.append(pe.name) + + return created diff --git a/aqrar_ext/aqrar_ext/__init__.py b/aqrar_ext/aqrar_ext/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/doctype/__init__.py b/aqrar_ext/aqrar_ext/doctype/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration/__init__.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.js b/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.js new file mode 100644 index 0000000..3a0d532 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.js @@ -0,0 +1,42 @@ +// Copyright (c) 2026, Enfono and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Branch Configuration", { + refresh(frm) { + set_child_filters(frm); + }, + company(frm) { + set_child_filters(frm); + + // Clear warehouse and cost center tables when company changes + // (they may belong to the old company) + if (frm.doc.warehouse && frm.doc.warehouse.length) { + frm.clear_table("warehouse"); + frm.refresh_field("warehouse"); + } + if (frm.doc.cost_center && frm.doc.cost_center.length) { + frm.clear_table("cost_center"); + frm.refresh_field("cost_center"); + } + } +}); + +function set_child_filters(frm) { + frm.set_query("warehouse", "warehouse", function () { + return { + filters: { + company: frm.doc.company, + is_group: 0 + } + }; + }); + + frm.set_query("cost_center", "cost_center", function () { + return { + filters: { + company: frm.doc.company, + is_group: 0 + } + }; + }); +} diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.json b/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.json new file mode 100644 index 0000000..86e13d5 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.json @@ -0,0 +1,85 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-04-27 16:00:37.587499", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "branch", + "column_break_aubg", + "company", + "section_break_dlxc", + "warehouse", + "cost_center", + "user" + ], + "fields": [ + { + "fieldname": "branch", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Branch", + "options": "Branch", + "reqd": 1 + }, + { + "fieldname": "column_break_aubg", + "fieldtype": "Column Break" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "section_break_dlxc", + "fieldtype": "Section Break" + }, + { + "fieldname": "warehouse", + "fieldtype": "Table", + "label": "Warehouse", + "options": "Branch Configuration Warehouse" + }, + { + "fieldname": "cost_center", + "fieldtype": "Table", + "label": "Cost Center", + "options": "Branch Configuration Cost Center" + }, + { + "fieldname": "user", + "fieldtype": "Table", + "label": "User", + "options": "Branch Configuration User" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-27 16:07:12.519306", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Branch Configuration", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.py new file mode 100644 index 0000000..1c80690 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.py @@ -0,0 +1,231 @@ +# User permissions are created for company, branch, warehouse and cost center. +# Company and first warehouse are set as is_default=1 so Frappe uses them as +# the user's Session Defaults instead of falling back to global defaults. +# The "Branch User" role is auto-assigned to users added here. + +import frappe +from frappe.model.document import Document + +BRANCH_USER_ROLE = "Branch User" + + +class BranchConfiguration(Document): + + def validate(self): + # Ensure warehouse and cost center belong to the selected company + if self.company: + for w in self.warehouse: + if w.warehouse: + wh_company = frappe.db.get_value("Warehouse", w.warehouse, "company") + if wh_company and wh_company != self.company: + frappe.throw( + f"Warehouse {w.warehouse} belongs to company {wh_company}, " + f"not {self.company}. Please select a warehouse from the correct company." + ) + + for c in self.cost_center: + if c.cost_center: + cc_company = frappe.db.get_value("Cost Center", c.cost_center, "company") + if cc_company and cc_company != self.company: + frappe.throw( + f"Cost Center {c.cost_center} belongs to company {cc_company}, " + f"not {self.company}. Please select a cost center from the correct company." + ) + + def before_save(self): + if self.is_new(): + return + + old_doc = self.get_doc_before_save() + + old_users = {d.user for d in old_doc.user} + new_users = {d.user for d in self.user} + + removed_users = old_users - new_users + + for user in removed_users: + # Delete company permission + if old_doc.get("company"): + delete_permission(user, "Company", old_doc.company) + + delete_permission(user, "Branch", old_doc.branch) + + for w in old_doc.warehouse: + delete_permission(user, "Warehouse", w.warehouse) + + for c in old_doc.cost_center: + delete_permission(user, "Cost Center", c.cost_center) + + # Remove role if user is not in any other Branch Configuration + # Find what role they had in this branch + old_role = None + for old_u in old_doc.user: + if old_u.user == user: + old_role = old_u.get("role") or BRANCH_USER_ROLE + break + _maybe_remove_role(user, old_role or BRANCH_USER_ROLE, exclude_branch=self.name) + + # Handle company change — remove old company permission for remaining users + old_company = old_doc.get("company") + new_company = self.get("company") + if old_company and old_company != new_company: + for u in self.user: + delete_permission(u.user, "Company", old_company) + + def on_update(self): + self.create_permissions() + + def create_permissions(self): + for u in self.user: + # Create company permission (marked as default so Session Defaults picks it) + if self.company: + create_permission(u.user, "Company", self.company, is_default=1) + + create_permission(u.user, "Branch", self.branch) + + for idx, w in enumerate(self.warehouse): + # First warehouse is the default + create_permission(u.user, "Warehouse", w.warehouse, is_default=1 if idx == 0 else 0) + + for idx, c in enumerate(self.cost_center): + # First cost center is the default + create_permission(u.user, "Cost Center", c.cost_center, is_default=1 if idx == 0 else 0) + + # Also grant access to the company's default cost center (used in tax templates) + # without marking it as default — the branch cost center stays as the user's default + if self.company: + company_default_cc = frappe.db.get_value("Company", self.company, "cost_center") + if company_default_cc: + create_permission(u.user, "Cost Center", company_default_cc, is_default=0) + + # Auto-assign the selected role (Branch User, Warehouse User, or Stock User) + selected_role = u.get("role") or BRANCH_USER_ROLE + _assign_role(u.user, selected_role) + + # Auto-set Module Profile to restrict sidebar modules + if selected_role == BRANCH_USER_ROLE: + _set_module_profile(u.user, "Branch User") + elif selected_role == "Damage User": + _set_module_profile(u.user, "Damage User") + + +def create_permission(user, allow, value, is_default=0): + if not value: + return + + existing = frappe.db.exists("User Permission", { + "user": user, + "allow": allow, + "for_value": value + }) + + if existing: + # Update is_default if needed — but only if no other default exists + if is_default and not _has_existing_default(user, allow, exclude_value=value): + frappe.db.set_value("User Permission", existing, "is_default", 1) + else: + # If we want to set default but one already exists, don't override it + if is_default and _has_existing_default(user, allow): + is_default = 0 + + doc = frappe.new_doc("User Permission") + doc.user = user + doc.allow = allow + doc.for_value = value + doc.is_default = is_default + doc.apply_to_all_doctypes = 1 + doc.insert(ignore_permissions=True) + + +def _has_existing_default(user, allow, exclude_value=None): + """Check if user already has a default User Permission for this allow type.""" + filters = { + "user": user, + "allow": allow, + "is_default": 1, + } + if exclude_value: + filters["for_value"] = ["!=", exclude_value] + + return frappe.db.exists("User Permission", filters) + + +def delete_permission(user, allow, value): + if not value: + return + + perms = frappe.get_all( + "User Permission", + filters={ + "user": user, + "allow": allow, + "for_value": value + }, + pluck="name" + ) + + for p in perms: + frappe.delete_doc("User Permission", p, ignore_permissions=True) + + +def _ensure_system_user(user): + """Website Users cannot hold desk roles. Upgrade to System User so role takes effect.""" + current_type = frappe.db.get_value("User", user, "user_type") + if current_type and current_type != "System User": + frappe.db.set_value("User", user, "user_type", "System User") + + +def _assign_role(user, role): + """Assign the specified role if not already assigned. Uses direct DB for reliability.""" + if not role or not frappe.db.exists("Role", role): + return + + # Desk roles (Branch User / Stock User / Damage User / Stock Manager) require + # System User user_type; Website Users silently lose role assignments. + _ensure_system_user(user) + + # Check if role already assigned + if frappe.db.exists("Has Role", {"parent": user, "role": role}): + return + + # Direct insert — more reliable than user_doc.add_roles() which can fail + # during Branch Configuration save context + frappe.get_doc({ + "doctype": "Has Role", + "parent": user, + "parenttype": "User", + "parentfield": "roles", + "role": role, + }).insert(ignore_permissions=True) + + +def _set_module_profile(user, profile_name): + """Set the Module Profile on a user if not already set.""" + if not frappe.db.exists("Module Profile", profile_name): + return + current = frappe.db.get_value("User", user, "module_profile") + if current != profile_name: + frappe.db.set_value("User", user, "module_profile", profile_name) + + +def _maybe_remove_role(user, role, exclude_branch=None): + """Remove role if user is not in any other Branch Configuration with the same role.""" + if not role: + return + + other_configs = frappe.get_all( + "Branch Configuration User", + filters={"user": user}, + fields=["parent", "role"], + ) + + # Check if user has this same role in any other Branch Configuration + has_role_elsewhere = any( + c.parent != exclude_branch and (c.get("role") or BRANCH_USER_ROLE) == role + for c in other_configs + ) + + if not has_role_elsewhere: + user_doc = frappe.get_doc("User", user) + if role in [r.role for r in user_doc.roles]: + user_doc.remove_roles(role) \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration/test_branch_configuration.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration/test_branch_configuration.py new file mode 100644 index 0000000..9d60b29 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration/test_branch_configuration.py @@ -0,0 +1,10 @@ + +# Copyright (c) 2026, Enfono and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestBranchConfiguration(FrappeTestCase): + pass diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_cost_center/__init__.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration_cost_center/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_cost_center/branch_configuration_cost_center.json b/aqrar_ext/aqrar_ext/doctype/branch_configuration_cost_center/branch_configuration_cost_center.json new file mode 100644 index 0000000..c52db0a --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration_cost_center/branch_configuration_cost_center.json @@ -0,0 +1,35 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-04-27 15:58:26.437401", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "cost_center" + ], + "fields": [ + { + "fieldname": "cost_center", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Cost Center", + "options": "Cost Center" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-04-27 16:00:06.125488", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Branch Configuration Cost Center", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_cost_center/branch_configuration_cost_center.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration_cost_center/branch_configuration_cost_center.py new file mode 100644 index 0000000..9071483 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration_cost_center/branch_configuration_cost_center.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Enfono and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BranchConfigurationCostCenter(Document): + pass diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/__init__.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.json b/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.json new file mode 100644 index 0000000..c4b7d84 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.json @@ -0,0 +1,42 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-05-16 19:14:41.500922", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "mode_of_payment", + "type" + ], + "fields": [ + { + "fieldname": "mode_of_payment", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Mode of Payment", + "options": "Mode of Payment" + }, + { + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-05-16 19:17:59.460731", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Branch Configuration Mode of Payment", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.py new file mode 100644 index 0000000..acaaee6 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration_mode_of_payment/branch_configuration_mode_of_payment.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Enfono and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BranchConfigurationModeofPayment(Document): + pass diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_user/__init__.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration_user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_user/branch_configuration_user.json b/aqrar_ext/aqrar_ext/doctype/branch_configuration_user/branch_configuration_user.json new file mode 100644 index 0000000..6407f69 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration_user/branch_configuration_user.json @@ -0,0 +1,35 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-04-27 15:59:26.431991", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-04-27 15:59:53.868823", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Branch Configuration User", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_user/branch_configuration_user.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration_user/branch_configuration_user.py new file mode 100644 index 0000000..88b3551 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration_user/branch_configuration_user.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Enfono and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BranchConfigurationUser(Document): + pass diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_warehouse/__init__.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration_warehouse/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_warehouse/branch_configuration_warehouse.json b/aqrar_ext/aqrar_ext/doctype/branch_configuration_warehouse/branch_configuration_warehouse.json new file mode 100644 index 0000000..18d1f0a --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration_warehouse/branch_configuration_warehouse.json @@ -0,0 +1,35 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-04-27 15:56:24.710705", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "warehouse" + ], + "fields": [ + { + "fieldname": "warehouse", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Warehouse", + "options": "Warehouse" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-04-27 15:57:09.914162", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Branch Configuration Warehouse", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration_warehouse/branch_configuration_warehouse.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration_warehouse/branch_configuration_warehouse.py new file mode 100644 index 0000000..a354d9b --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration_warehouse/branch_configuration_warehouse.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Enfono and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BranchConfigurationWarehouse(Document): + pass diff --git a/aqrar_ext/aqrar_ext/doctype/custom_quote/__init__.py b/aqrar_ext/aqrar_ext/doctype/custom_quote/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.js b/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.js new file mode 100644 index 0000000..f8821fc --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.js @@ -0,0 +1,90 @@ +// Copyright (c) 2026, Enfono and contributors +// For license information, please see license.txt + + +//Set valid till date to 30 days from posting date + +frappe.ui.form.on("Custom Quote", { + + onload(frm) { + if (frm.is_new() && !frm.doc.posting_date) { + frm.set_value( + "posting_date", + frappe.datetime.now_datetime() + ); + } + if (frm.is_new() && !frm.doc.valid_till) { + set_valid_till(frm); + } + }, + + posting_date(frm) { + set_valid_till(frm); + }, + + refresh(frm) { + calculate_net_total(frm); + } +}); + +//Set total vat tax include vat +frappe.ui.form.on("Custom Quote Item", { + + qty(frm, cdt, cdn) { + calculate_row(frm, cdt, cdn); + }, + + rate(frm, cdt, cdn) { + calculate_row(frm, cdt, cdn); + }, + + tax_rate(frm, cdt, cdn) { + calculate_row(frm, cdt, cdn); + }, + + items_remove(frm) { + calculate_net_total(frm); + } +}); + +function set_valid_till(frm) { + if (!frm.doc.posting_date) return; + + frm.set_value( + "valid_till", + frappe.datetime.add_days(frm.doc.posting_date, 30) + ); +} + +function calculate_row(frm, cdt, cdn) { + let row = locals[cdt][cdn]; + + let qty = row.qty || 0; + let rate = row.rate || 0; + let tax_rate = row.tax_rate || 0; + + row.total = qty * rate; + row.vat = (row.total * tax_rate) / 100; + row.total_incl_vat = row.total + row.vat; + + frm.refresh_field("items"); + + calculate_net_total(frm); +} + +//Set net total vat total grand total +function calculate_net_total(frm) { + let net_total = 0; + let vat_total = 0; + let grand_total = 0; + + (frm.doc.items || []).forEach(row => { + net_total += row.total || 0; + vat_total += row.vat || 0; + grand_total += row.total_incl_vat || 0; + }); + + frm.set_value("net_total", net_total); + frm.set_value("vat_total", vat_total); + frm.set_value("grand_total", grand_total); +} diff --git a/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.json b/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.json new file mode 100644 index 0000000..3adca7b --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.json @@ -0,0 +1,187 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "naming_series:", + "creation": "2026-02-04 11:58:43.758454", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "naming_series", + "company", + "cost_center", + "column_break_jamd", + "posting_date", + "valid_till", + "section_break_fewb", + "customer", + "customer_address", + "column_break_dmjs", + "attention", + "mob_no", + "rqf_no", + "section_break_dwhc", + "items", + "section_break_iajw", + "net_total", + "column_break_fobf", + "vat_total", + "column_break_sqjh", + "grand_total", + "section_break_xgds", + "select_terms", + "terms_and_conditions" + ], + "fields": [ + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "CQT-.YYYY.####" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "column_break_jamd", + "fieldtype": "Column Break" + }, + { + "fieldname": "posting_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Posting Date ", + "reqd": 1 + }, + { + "fieldname": "valid_till", + "fieldtype": "Date", + "label": "Valid Till" + }, + { + "fieldname": "section_break_fewb", + "fieldtype": "Section Break" + }, + { + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer" + }, + { + "fetch_from": "customer.primary_address", + "fieldname": "customer_address", + "fieldtype": "Text Editor", + "label": "Customer Address" + }, + { + "fieldname": "column_break_dmjs", + "fieldtype": "Column Break" + }, + { + "fieldname": "attention", + "fieldtype": "Data", + "label": "Attention" + }, + { + "fieldname": "mob_no", + "fieldtype": "Phone", + "label": "Mob No" + }, + { + "fieldname": "rqf_no", + "fieldtype": "Data", + "label": "RQF No" + }, + { + "fieldname": "section_break_dwhc", + "fieldtype": "Section Break" + }, + { + "fieldname": "items", + "fieldtype": "Table", + "label": "Items", + "options": "Custom Quote Item" + }, + { + "fieldname": "section_break_iajw", + "fieldtype": "Section Break", + "label": "Totals" + }, + { + "fieldname": "vat_total", + "fieldtype": "Currency", + "label": "VAT Total" + }, + { + "fieldname": "column_break_fobf", + "fieldtype": "Column Break" + }, + { + "fieldname": "net_total", + "fieldtype": "Currency", + "label": "Net Total" + }, + { + "fieldname": "column_break_sqjh", + "fieldtype": "Column Break" + }, + { + "fieldname": "grand_total", + "fieldtype": "Currency", + "label": "Grand Total" + }, + { + "fieldname": "section_break_xgds", + "fieldtype": "Section Break" + }, + { + "fieldname": "select_terms", + "fieldtype": "Link", + "label": "Select Terms", + "options": "Terms and Conditions" + }, + { + "fetch_from": "select_terms.terms", + "fieldname": "terms_and_conditions", + "fieldtype": "Text Editor", + "label": "Terms and Conditions" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-02-05 10:42:18.239467", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Custom Quote", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.py b/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.py new file mode 100644 index 0000000..0ff74c4 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.py @@ -0,0 +1,9 @@ +# Copyright (c) 2026, Enfono and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class CustomQuote(Document): + pass diff --git a/aqrar_ext/aqrar_ext/doctype/custom_quote/test_custom_quote.py b/aqrar_ext/aqrar_ext/doctype/custom_quote/test_custom_quote.py new file mode 100644 index 0000000..5af1886 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/custom_quote/test_custom_quote.py @@ -0,0 +1,21 @@ +# Copyright (c) 2026, Enfono and Contributors +# See license.txt + +import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + + +class UnitTestCustomQuote(UnitTestCase): + """ + Unit tests for CustomQuote. + Use this class for testing individual functions and methods. + """ + pass + + +class IntegrationTestCustomQuote(IntegrationTestCase): + """ + Integration tests for CustomQuote. + Use this class for testing interactions between multiple components. + """ + pass diff --git a/aqrar_ext/aqrar_ext/doctype/custom_quote_item/__init__.py b/aqrar_ext/aqrar_ext/doctype/custom_quote_item/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/doctype/custom_quote_item/custom_quote_item.json b/aqrar_ext/aqrar_ext/doctype/custom_quote_item/custom_quote_item.json new file mode 100644 index 0000000..252c8c5 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/custom_quote_item/custom_quote_item.json @@ -0,0 +1,85 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2026-02-04 12:04:27.593219", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "item", + "unit", + "qty", + "rate", + "total", + "tax_rate", + "vat", + "total_incl_vat" + ], + "fields": [ + { + "fieldname": "item", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Description" + }, + { + "fieldname": "unit", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Unit", + "options": "UOM" + }, + { + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Qty" + }, + { + "fieldname": "rate", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Rate" + }, + { + "fieldname": "total", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Total" + }, + { + "default": "15", + "fieldname": "tax_rate", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Tax Rate" + }, + { + "fieldname": "vat", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "VAT" + }, + { + "fieldname": "total_incl_vat", + "fieldtype": "Currency", + "in_list_view": 1, + "label": "Total Incl. VAT" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-02-04 13:00:17.426085", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Custom Quote Item", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/doctype/custom_quote_item/custom_quote_item.py b/aqrar_ext/aqrar_ext/doctype/custom_quote_item/custom_quote_item.py new file mode 100644 index 0000000..d757162 --- /dev/null +++ b/aqrar_ext/aqrar_ext/doctype/custom_quote_item/custom_quote_item.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, Enfono and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class CustomQuoteItem(Document): + pass diff --git a/aqrar_ext/aqrar_ext/overrides/__init__.py b/aqrar_ext/aqrar_ext/overrides/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/overrides/stock_ledger.py b/aqrar_ext/aqrar_ext/overrides/stock_ledger.py new file mode 100644 index 0000000..2269c3c --- /dev/null +++ b/aqrar_ext/aqrar_ext/overrides/stock_ledger.py @@ -0,0 +1,34 @@ +# aqrar_ext: Override Stock Ledger to support voucher_type filter + single item +from erpnext.stock.report.stock_ledger import stock_ledger + +_original_get_sle = stock_ledger.get_stock_ledger_entries +_original_get_items = stock_ledger.get_items + +PURCHASE_TYPES = ("Purchase Invoice", "Purchase Receipt") +SALE_TYPES = ("Sales Invoice", "Delivery Note") + + +def _patched_get_sle(filters, items): + result = _original_get_sle(filters, items) + voucher_type = filters.get("voucher_type") + if voucher_type and voucher_type != "All": + if voucher_type == "Purchase Only": + result = [r for r in result if r.voucher_type in PURCHASE_TYPES] + elif voucher_type == "Sale Only": + result = [r for r in result if r.voucher_type in SALE_TYPES] + elif voucher_type in ("Transfer Only", "Stock Entry Only"): + result = [r for r in result if r.voucher_type == "Stock Entry"] + return result + + +def _patched_get_items(filters): + # Convert single item_code string to list for compatibility + item_code = filters.get("item_code") + if item_code and isinstance(item_code, str): + filters = dict(filters) + filters["item_code"] = [item_code] + return _original_get_items(filters) + + +stock_ledger.get_stock_ledger_entries = _patched_get_sle +stock_ledger.get_items = _patched_get_items diff --git a/aqrar_ext/aqrar_ext/report/__init__.py b/aqrar_ext/aqrar_ext/report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/__init__.py b/aqrar_ext/aqrar_ext/report/customer_statement/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.html b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.html new file mode 100644 index 0000000..373be8a --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.html @@ -0,0 +1,229 @@ + + +
+ + +
+
+

{{ _("Customer Statement") }}

+

{{ customer_doc.customer_name }}

+

{{ _("Company") }}: {{ company }}

+
+
+

كشف حساب العميل

+

{{ customer_doc.get("customer_name_in_arabic") or customer_doc.customer_name }}

+

الشركة: {{ company }}

+
+
+ + +

+ {{ _("Period") }}: + {{ frappe.utils.formatdate(from_date) }} — {{ frappe.utils.formatdate(to_date) }} +

+ + +
+ + + + + + + + + + + + + + + + + + + + + +
{{ _("Opening Balance") }}{{ frappe.utils.fmt_money(opening, currency=company_currency) }}
{{ _("Total Invoiced") }}{{ frappe.utils.fmt_money(total_invoiced, currency=company_currency) }}
{{ _("Total Paid") }}{{ frappe.utils.fmt_money(total_paid, currency=company_currency) }}
{{ _("Total Credit Notes") }}{{ frappe.utils.fmt_money(total_credit, currency=company_currency) }}
{{ _("Closing Balance") }}{{ frappe.utils.fmt_money(closing, currency=company_currency) }}
+
+ + +
{{ _("Transactions") }}
+ + + + + + + + + + + + + + + + {% for row in data %} + {% set label = row.voucher_no %} + {% if label == _("Opening Balance") %} + + + + + + + {% elif label == _("Totals") %} + + + + + + + + + + {% elif label == _("Closing Balance") %} + + + + + + + {% elif row.voucher_type == "Sales Invoice" %} + + + + + + + + + + + + {% elif row.voucher_type == "Credit Note" %} + + + + + + + + + + + + {% elif row.voucher_type == "Payment Entry" %} + + + + + + + + + + + + {% endif %} + {% endfor %} + +
{{ _("Date") }}{{ _("Type") }}{{ _("Reference") }}{{ _("Due Date") }}{{ _("Age") }}{{ _("Invoiced") }}{{ _("Paid") }}{{ _("Credit Note") }}{{ _("Outstanding") }}
{{ label }}{{ frappe.utils.fmt_money(row.outstanding, currency=company_currency) }}
{{ label }}{{ frappe.utils.fmt_money(row.invoiced, currency=company_currency) }}{{ frappe.utils.fmt_money(row.paid, currency=company_currency) }}{{ frappe.utils.fmt_money(row.credit_note, currency=company_currency) }}{{ frappe.utils.fmt_money(row.outstanding, currency=company_currency) }}
{{ label }}{{ frappe.utils.fmt_money(row.outstanding, currency=company_currency) }}
{{ frappe.utils.formatdate(row.posting_date) }}{{ _(row.voucher_type) }}{{ row.voucher_no }}{{ frappe.utils.formatdate(row.due_date) }}{{ row.age }}{{ frappe.utils.fmt_money(row.invoiced, currency=company_currency) }}{{ frappe.utils.fmt_money(row.paid, currency=company_currency) }}{{ frappe.utils.fmt_money(row.outstanding, currency=company_currency) }}
{{ frappe.utils.formatdate(row.posting_date) }}{{ _(row.voucher_type) }}{{ row.voucher_no }}{{ frappe.utils.formatdate(row.due_date) }}{{ frappe.utils.fmt_money(row.credit_note, currency=company_currency) }}
{{ frappe.utils.formatdate(row.posting_date) }}{{ _(row.voucher_type) }}{{ row.voucher_no }}{{ frappe.utils.formatdate(row.posting_date) }}{{ frappe.utils.fmt_money(row.paid, currency=company_currency) }}
+ + + {% if vat and vat|length > 0 %} +
{{ _("VAT Summary") }}
+ + + + + + + + + + {% for v in vat %} + + + + + + {% endfor %} + + + + + + +
{{ _("VAT Rate") }}{{ _("Taxable Amount") }}{{ _("VAT Amount") }}
{{ v.rate }}%{{ frappe.utils.fmt_money(v.taxable_amount, currency=company_currency) }}{{ frappe.utils.fmt_money(v.tax_amount, currency=company_currency) }}
{{ _("Total") }}{{ frappe.utils.fmt_money(vat_total_taxable, currency=company_currency) }}{{ frappe.utils.fmt_money(vat_total_tax, currency=company_currency) }}
+ {% endif %} + + + {% if aging %} +
{{ _("Outstanding Aging") }}
+ + + + + + + + + + + + + + + + + + + + + +
{{ _("0-30 Days") }}{{ _("31-60 Days") }}{{ _("61-90 Days") }}{{ _("91-120 Days") }}{{ _("120+ Days") }}{{ _("Total Outstanding") }}
{{ frappe.utils.fmt_money(aging["0-30"], currency=company_currency) }}{{ frappe.utils.fmt_money(aging["31-60"], currency=company_currency) }}{{ frappe.utils.fmt_money(aging["61-90"], currency=company_currency) }}{{ frappe.utils.fmt_money(aging["91-120"], currency=company_currency) }}{{ frappe.utils.fmt_money(aging["120+"], currency=company_currency) }}{{ frappe.utils.fmt_money(closing, currency=company_currency) }}
+ {% endif %} + + + + +
diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.js b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.js new file mode 100644 index 0000000..5208028 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.js @@ -0,0 +1,40 @@ +frappe.query_reports["Customer Statement"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + reqd: 1, + default: frappe.defaults.get_user_default("Company"), + }, + { + fieldname: "customer", + label: __("Customer"), + fieldtype: "Link", + options: "Customer", + reqd: 1, + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + reqd: 1, + default: frappe.datetime.month_start(), + }, + { + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + reqd: 1, + default: frappe.datetime.month_end(), + }, + { + fieldname: "ageing_based_on", + label: __("Ageing Based On"), + fieldtype: "Select", + options: ["Posting Date", "Due Date"], + default: "Posting Date", + }, + ], +}; diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.json b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.json new file mode 100644 index 0000000..ade763e --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.json @@ -0,0 +1,49 @@ +{ + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2026-05-16 16:33:10.084273", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letter_head": null, + "modified": "2026-05-16 16:33:10.084273", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Customer Statement", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Customer", + "report_name": "Customer Statement", + "report_type": "Script Report", + "roles": [ + { + "role": "Stock User" + }, + { + "role": "Sales Manager" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Sales User" + }, + { + "role": "Stock Manager" + }, + { + "role": "Accounts User" + }, + { + "role": "Sales Master Manager" + }, + { + "role": "Branch User" + } + ], + "timeout": 0 +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.py b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.py new file mode 100644 index 0000000..3a5132e --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.py @@ -0,0 +1,299 @@ +import frappe +from frappe import _ +from frappe.utils import flt, getdate, formatdate + + +def execute(filters=None): + if not filters: + filters = {} + if not filters.get("customer"): + return [], [] + + return get_columns(), get_data(filters) + + +def get_columns(): + return [ + {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100}, + {"label": _("Voucher Type"), "fieldname": "voucher_type", "fieldtype": "Data", "width": 120}, + {"label": _("Voucher No"), "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", "width": 180}, + {"label": _("Due Date"), "fieldname": "due_date", "fieldtype": "Date", "width": 100}, + {"label": _("Age (Days)"), "fieldname": "age", "fieldtype": "Int", "width": 80}, + {"label": _("Invoiced"), "fieldname": "invoiced", "fieldtype": "Currency", "width": 120}, + {"label": _("Paid"), "fieldname": "paid", "fieldtype": "Currency", "width": 120}, + {"label": _("Credit Note"), "fieldname": "credit_note", "fieldtype": "Currency", "width": 120}, + {"label": _("Outstanding"), "fieldname": "outstanding", "fieldtype": "Currency", "width": 120}, + ] + + +def get_data(filters): + customer = filters.get("customer") + company = filters.get("company") + from_date = filters.get("from_date") + to_date = filters.get("to_date") + ageing_based_on = filters.get("ageing_based_on", "Posting Date") + + receivable_account = _get_receivable_account(customer, company) + opening = _get_opening_balance(customer, company, from_date, receivable_account) + rows = [] + + # Sales Invoices (non-return) + invoices = _get_sales_invoices(customer, company, from_date, to_date, is_return=0) + for inv in invoices: + paid = _get_paid_amount(inv.name) + age_date = inv.due_date if ageing_based_on == "Due Date" else inv.posting_date + age = (getdate(to_date) - getdate(age_date)).days if getdate(age_date) <= getdate(to_date) else 0 + outstanding = flt(inv.grand_total) - flt(paid) + rows.append({ + "posting_date": inv.posting_date, + "voucher_type": "Sales Invoice", + "voucher_no": inv.name, + "due_date": inv.due_date, + "age": age, + "invoiced": flt(inv.grand_total), + "paid": paid, + "credit_note": 0, + "outstanding": outstanding, + }) + + # Credit Notes (Sales Returns) + credit_notes = _get_sales_invoices(customer, company, from_date, to_date, is_return=1) + for cn in credit_notes: + rows.append({ + "posting_date": cn.posting_date, + "voucher_type": "Credit Note", + "voucher_no": cn.name, + "due_date": cn.due_date, + "age": 0, + "invoiced": 0, + "paid": 0, + "credit_note": abs(flt(cn.grand_total)), + "outstanding": 0, + }) + + # Payment Entries + payments = _get_payment_entries(customer, company, from_date, to_date) + for pe in payments: + rows.append({ + "posting_date": pe.posting_date, + "voucher_type": "Payment Entry", + "voucher_no": pe.name, + "due_date": pe.posting_date, + "age": 0, + "invoiced": 0, + "paid": flt(pe.paid_amount), + "credit_note": 0, + "outstanding": 0, + }) + + rows.sort(key=lambda r: r["posting_date"]) + + # Totals + total_invoiced = sum(r["invoiced"] for r in rows) + total_paid = sum(r["paid"] for r in rows) + total_credit_note = sum(r["credit_note"] for r in rows) + closing = flt(opening) + total_invoiced - total_paid - total_credit_note + + # Prepend opening + rows.insert(0, { + "posting_date": None, + "voucher_type": "", + "voucher_no": _("Opening Balance"), + "due_date": None, + "age": 0, + "invoiced": 0, + "paid": 0, + "credit_note": 0, + "outstanding": flt(opening), + }) + + # Totals row + rows.append({ + "posting_date": None, + "voucher_type": "", + "voucher_no": _("Totals"), + "due_date": None, + "age": 0, + "invoiced": total_invoiced, + "paid": total_paid, + "credit_note": total_credit_note, + "outstanding": closing, + }) + + # Closing row + rows.append({ + "posting_date": None, + "voucher_type": "", + "voucher_no": _("Closing Balance"), + "due_date": None, + "age": 0, + "invoiced": 0, + "paid": 0, + "credit_note": 0, + "outstanding": closing, + }) + + return rows + + +def _get_receivable_account(customer, company): + acc = frappe.db.get_value("Party Account", { + "parenttype": "Customer", "parent": customer, "company": company + }, "account") + if not acc: + acc = frappe.db.get_value("Company", company, "default_receivable_account") + return acc + + +def _get_opening_balance(customer, company, from_date, receivable_account): + result = frappe.db.sql(""" + SELECT SUM(debit) - SUM(credit) + FROM `tabGL Entry` + WHERE party_type = 'Customer' + AND party = %s + AND company = %s + AND account = %s + AND posting_date < %s + AND is_cancelled = 0 + """, (customer, company, receivable_account, from_date)) + return flt(result[0][0]) if result and result[0][0] else 0.0 + + +def _get_sales_invoices(customer, company, from_date, to_date, is_return=0): + return frappe.db.get_all("Sales Invoice", filters={ + "customer": customer, + "company": company, + "docstatus": 1, + "is_return": is_return, + "posting_date": ["between", [from_date, to_date]], + }, fields=["name", "posting_date", "due_date", "grand_total"], order_by="posting_date") + + +def _get_paid_amount(invoice_name): + result = frappe.db.sql(""" + SELECT SUM(per.allocated_amount) + FROM `tabPayment Entry Reference` per + INNER JOIN `tabPayment Entry` pe ON pe.name = per.parent + WHERE per.reference_name = %s + AND per.reference_doctype = 'Sales Invoice' + AND pe.docstatus = 1 + """, (invoice_name,)) + return flt(result[0][0]) if result and result[0][0] else 0.0 + + +def _get_payment_entries(customer, company, from_date, to_date): + return frappe.db.get_all("Payment Entry", filters={ + "party_type": "Customer", + "party": customer, + "company": company, + "docstatus": 1, + "posting_date": ["between", [from_date, to_date]], + "payment_type": "Receive", + }, fields=["name", "posting_date", "paid_amount"], order_by="posting_date") + + +@frappe.whitelist() +def get_pdf(customer, company=None, from_date=None, to_date=None): + """Generate statement PDF for download.""" + if not company: + company = frappe.defaults.get_user_default("Company") + + filters = frappe._dict({ + "customer": customer, + "company": company, + "from_date": from_date, + "to_date": to_date, + }) + _columns, data = execute(filters) + if not data: + frappe.throw(_("No transactions found for this customer in the selected period.")) + + customer_doc = frappe.get_doc("Customer", customer) + company_currency = frappe.db.get_value("Company", company, "default_currency") + + aging = _get_aging(data, to_date, "Posting Date") + vat = _get_vat_summary(customer, company, from_date, to_date) + vat_total_taxable = sum(flt(v.taxable_amount) for v in vat) + vat_total_tax = sum(flt(v.tax_amount) for v in vat) + + opening = flt(data[0]["outstanding"]) if data else 0 + closing = flt(data[-1]["outstanding"]) if data else 0 + total_invoiced = total_paid = total_credit = 0 + for row in data: + if row.get("voucher_no") == _("Totals"): + total_invoiced = flt(row.get("invoiced", 0)) + total_paid = flt(row.get("paid", 0)) + total_credit = flt(row.get("credit_note", 0)) + break + + template_path = frappe.get_app_path( + "aqrar_ext", "aqrar_ext", "report", "customer_statement", "customer_statement.html" + ) + with open(template_path) as f: + template_str = f.read() + + html = frappe.get_jenv().from_string(template_str).render({ + "data": data, + "customer_doc": customer_doc, + "company": company, + "company_currency": company_currency, + "from_date": from_date, + "to_date": to_date, + "opening": opening, + "closing": closing, + "total_invoiced": total_invoiced, + "total_paid": total_paid, + "total_credit": total_credit, + "aging": aging, + "vat": vat, + "vat_total_taxable": vat_total_taxable, + "vat_total_tax": vat_total_tax, + "print_date": formatdate(frappe.utils.today()), + }) + + from frappe.utils.pdf import get_pdf as _get_pdf + pdf = _get_pdf(html, {"orientation": "Portrait"}) + + frappe.local.response.filename = f"Customer_Statement_{customer}.pdf" + frappe.local.response.filecontent = pdf + frappe.local.response.type = "download" + + +def _get_aging(rows, to_date, ageing_based_on): + buckets = {"0-30": 0.0, "31-60": 0.0, "61-90": 0.0, "91-120": 0.0, "120+": 0.0} + for row in rows: + if row.get("voucher_type") != "Sales Invoice": + continue + outstanding = row.get("outstanding", 0) + if outstanding <= 0: + continue + age = row.get("age", 0) + if age <= 30: + buckets["0-30"] += outstanding + elif age <= 60: + buckets["31-60"] += outstanding + elif age <= 90: + buckets["61-90"] += outstanding + elif age <= 120: + buckets["91-120"] += outstanding + else: + buckets["120+"] += outstanding + return buckets + + +def _get_vat_summary(customer, company, from_date, to_date): + return frappe.db.sql(""" + SELECT + stc.rate, + SUM(stc.tax_amount) AS tax_amount, + SUM(si.net_total) AS taxable_amount + FROM `tabSales Taxes and Charges` stc + INNER JOIN `tabSales Invoice` si ON stc.parent = si.name + WHERE si.customer = %s + AND si.company = %s + AND si.posting_date BETWEEN %s AND %s + AND si.docstatus = 1 + AND si.is_return = 0 + GROUP BY stc.rate + ORDER BY stc.rate + """, (customer, company, from_date, to_date), as_dict=True) diff --git a/aqrar_ext/aqrar_ext/report/daily_report_combined/__init__.py b/aqrar_ext/aqrar_ext/report/daily_report_combined/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/report/daily_report_combined/daily_report_combined.js b/aqrar_ext/aqrar_ext/report/daily_report_combined/daily_report_combined.js new file mode 100644 index 0000000..e652061 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/daily_report_combined/daily_report_combined.js @@ -0,0 +1,20 @@ +// Copyright (c) 2026, Aravind R and contributors +// For license information, please see license.txt + +frappe.query_reports["Daily Report Combined"] = { + "filters" : [ + { + "fieldname": "date", + "label": "Date", + "fieldtype": "Date", + "default": frappe.datetime.get_today(), + "reqd": 1 + }, + { + "fieldname": "type", + "label": "Type", + "fieldtype": "Select", + "options": "\nCash Sales\nCard Sales\nCredit Sales\nCash Purchases\nCard Purchases\nCredit Purchases\nSales Return Cash\nSales Return Card\nSales Return Credit\nSales Returns\nPurchase Return Cash\nPurchase Return Card\nPurchase Return Credit\nPurchase Returns\nCustomer Receipts\nSupplier Payments\nBank Receipts\nBank Payments\nCash Receipts\nCash Payments", + } + ] +}; diff --git a/aqrar_ext/aqrar_ext/report/daily_report_combined/daily_report_combined.json b/aqrar_ext/aqrar_ext/report/daily_report_combined/daily_report_combined.json new file mode 100644 index 0000000..20f1755 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/daily_report_combined/daily_report_combined.json @@ -0,0 +1,34 @@ +{ + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2026-01-17 15:14:24.308525", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2026-01-17 17:35:00.531170", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Daily Report Combined", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "GL Entry", + "report_name": "Daily Report Combined", + "report_type": "Script Report", + "roles": [ + { + "role": "Accounts User" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Auditor" + } + ], + "timeout": 0 +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/report/daily_report_combined/daily_report_combined.py b/aqrar_ext/aqrar_ext/report/daily_report_combined/daily_report_combined.py new file mode 100644 index 0000000..ac8ce16 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/daily_report_combined/daily_report_combined.py @@ -0,0 +1,593 @@ +# Copyright (c) 2026, Enfono +# For license information, please see license.txt + +import frappe + + +def get_columns(): + return [ + {"fieldname": "document", "label": "Document", "fieldtype": "Data", "width": 140}, + { + "fieldname": "id", + "label": "ID", + "fieldtype": "Dynamic Link", + "options": "document", + "width": 180, + }, + {"fieldname": "status", "label": "Status", "fieldtype": "Data", "width": 120}, + { + "fieldname": "invoice_total", + "label": "Invoice Total", + "fieldtype": "Currency", + "width": 140, + }, + {"fieldname": "amount", "label": "Amount", "fieldtype": "Currency", "width": 140}, + ] + + +def execute(filters=None): + filters = filters or {} + TOTAL_ROW_TYPES = { + "Cash Sales", + "Card Sales", + "Credit Sales", + "Cash Purchases", + "Card Purchases", + "Credit Purchases", + "Sales Return Cash", + "Sales Return Card", + "Sales Return Credit", + "Purchase Return Cash", + "Purchase Return Card", + "Purchase Return Credit", + } + + date = filters.get("date") + report_type = filters.get("type") + + columns = get_columns() + + if not report_type: + data = [] + data.extend(get_all_invoices(date)) + data.extend(get_customer_receipts(date)) + data.extend(get_supplier_payments(date)) + data.extend(get_journal_entries(date)) + return columns, data + + if report_type == "Cash Sales": + data = get_cash_sales(date) + + elif report_type == "Card Sales": + data = get_card_sales(date) + + elif report_type == "Cash Purchases": + data = get_cash_purchases(date) + + elif report_type == "Card Purchases": + data = get_card_purchases(date) + + elif report_type == "Credit Sales": + data = get_credit_sales(date) + elif report_type == "Credit Purchases": + data = get_credit_purchases(date) + elif report_type == "Sales Return Cash": + data = get_sales_return_cash(date) + + elif report_type == "Sales Return Card": + data = get_sales_return_card(date) + + elif report_type == "Sales Return Credit": + data = get_sales_return_credit(date) + + elif report_type == "Purchase Return Cash": + data = get_purchase_return_cash(date) + + elif report_type == "Purchase Return Card": + data = get_purchase_return_card(date) + + elif report_type == "Purchase Return Credit": + data = get_purchase_return_credit(date) + + elif report_type == "Sales Returns": + data = get_sales_returns(date) + + elif report_type == "Purchase Returns": + data = get_purchase_returns(date) + + elif report_type == "Customer Receipts": + data = get_customer_receipts(date) + + elif report_type == "Supplier Payments": + data = get_supplier_payments(date) + + elif report_type in ( + "Bank Receipts", + "Bank Payments", + "Cash Receipts", + "Cash Payments" + ): + data = get_journal_entries(date, report_type) + + else: + data = [] + + if report_type in TOTAL_ROW_TYPES and data: + total_amount = sum(row.get("amount", 0) or 0 for row in data) + total_invoice_amount = sum(row.get("invoice_total", 0) or 0 for row in data) + + total_row = { + "document": "", + "id": "Total", + "status": "", + "invoice_total": total_invoice_amount, + "amount": total_amount, + } + + data.insert(0, total_row) + + return columns, data + + +# ---------------- SALES ---------------- # + +def get_cash_sales(date): + return frappe.db.sql(""" + SELECT + 'Sales Invoice' AS document, + si.name AS id, + si.status AS status, + si.grand_total AS invoice_total, + SUM(per.allocated_amount) AS amount + FROM `tabPayment Entry` pe + JOIN `tabPayment Entry Reference` per ON per.parent = pe.name + JOIN `tabSales Invoice` si ON si.name = per.reference_name + JOIN `tabMode of Payment` mop ON mop.name = pe.mode_of_payment + WHERE + pe.posting_date = %(date)s + AND pe.docstatus = 1 + AND per.reference_doctype = 'Sales Invoice' + AND si.docstatus = 1 + AND si.is_return = 0 + AND mop.type = 'Cash' + GROUP BY si.name + """, {"date": date}, as_dict=True) + + +def get_card_sales(date): + return frappe.db.sql(""" + SELECT + 'Sales Invoice' AS document, + si.name AS id, + si.status AS status, + si.grand_total AS invoice_total, + SUM(per.allocated_amount) AS amount + FROM `tabPayment Entry` pe + JOIN `tabPayment Entry Reference` per ON per.parent = pe.name + JOIN `tabSales Invoice` si ON si.name = per.reference_name + JOIN `tabMode of Payment` mop ON mop.name = pe.mode_of_payment + WHERE + pe.posting_date = %(date)s + AND pe.docstatus = 1 + AND per.reference_doctype = 'Sales Invoice' + AND si.docstatus = 1 + AND si.is_return = 0 + AND mop.type != 'Cash' + GROUP BY si.name + """, {"date": date}, as_dict=True) + +def get_credit_sales(date): + return frappe.db.sql(""" + SELECT + 'Sales Invoice' AS document, + si.name AS id, + 'Unpaid' AS status, + si.grand_total AS invoice_total, + si.grand_total AS amount + FROM `tabSales Invoice` si + WHERE + si.docstatus = 1 + AND si.is_return = 0 + AND si.posting_date = %(date)s + AND si.outstanding_amount = si.grand_total + """, {"date": date}, as_dict=True) + +def get_sales_returns(date): + return frappe.db.sql(""" + SELECT + 'Sales Return' AS document, + si.name AS id, + CASE + WHEN si.outstanding_amount = 0 THEN 'Paid' + WHEN si.outstanding_amount = si.grand_total THEN 'Unpaid' + ELSE 'Partially Paid' + END AS status, + si.grand_total AS invoice_total, + IFNULL(SUM( + CASE + WHEN pe.posting_date = %(date)s AND pe.docstatus = 1 + THEN per.allocated_amount + ELSE 0 + END + ), 0) AS amount + FROM `tabSales Invoice` si + LEFT JOIN `tabPayment Entry Reference` per + ON per.reference_doctype = 'Sales Invoice' + AND per.reference_name = si.name + LEFT JOIN `tabPayment Entry` pe + ON pe.name = per.parent + WHERE + si.docstatus = 1 + AND si.is_return = 1 + AND ( + si.posting_date = %(date)s + OR pe.posting_date = %(date)s + ) + GROUP BY si.name + """, {"date": date}, as_dict=True) + + +def get_sales_return_cash(date): + return frappe.db.sql(""" + SELECT + 'Sales Return' AS document, + si.name AS id, + CASE + WHEN si.outstanding_amount = 0 THEN 'Paid' + ELSE 'Partially Paid' + END AS status, + si.grand_total AS invoice_total, + SUM(per.allocated_amount) AS amount + FROM `tabPayment Entry` pe + JOIN `tabPayment Entry Reference` per ON per.parent = pe.name + JOIN `tabSales Invoice` si ON si.name = per.reference_name + JOIN `tabMode of Payment` mop ON mop.name = pe.mode_of_payment + WHERE + pe.posting_date = %(date)s + AND pe.docstatus = 1 + AND per.reference_doctype = 'Sales Invoice' + AND si.docstatus = 1 + AND si.is_return = 1 + AND mop.type = 'Cash' + GROUP BY si.name + """, {"date": date}, as_dict=True) + +def get_sales_return_card(date): + return frappe.db.sql(""" + SELECT + 'Sales Return' AS document, + si.name AS id, + CASE + WHEN si.outstanding_amount = 0 THEN 'Paid' + ELSE 'Partially Paid' + END AS status, + si.grand_total AS invoice_total, + SUM(per.allocated_amount) AS amount + FROM `tabPayment Entry` pe + JOIN `tabPayment Entry Reference` per ON per.parent = pe.name + JOIN `tabSales Invoice` si ON si.name = per.reference_name + JOIN `tabMode of Payment` mop ON mop.name = pe.mode_of_payment + WHERE + pe.posting_date = %(date)s + AND pe.docstatus = 1 + AND per.reference_doctype = 'Sales Invoice' + AND si.docstatus = 1 + AND si.is_return = 1 + AND mop.type != 'Cash' + GROUP BY si.name + """, {"date": date}, as_dict=True) + +def get_sales_return_credit(date): + return frappe.db.sql(""" + SELECT + 'Sales Return' AS document, + si.name AS id, + 'Unpaid' AS status, + si.grand_total AS invoice_total, + si.grand_total AS amount + FROM `tabSales Invoice` si + WHERE + si.docstatus = 1 + AND si.is_return = 1 + AND si.posting_date = %(date)s + AND si.outstanding_amount = si.grand_total + """, {"date": date}, as_dict=True) + + +# ---------------- PURCHASES ---------------- # + +def get_cash_purchases(date): + return frappe.db.sql(""" + SELECT + 'Purchase Invoice' AS document, + pi.name AS id, + pi.status AS status, + pi.grand_total AS invoice_total, + SUM(per.allocated_amount) AS amount + FROM `tabPayment Entry` pe + JOIN `tabPayment Entry Reference` per ON per.parent = pe.name + JOIN `tabPurchase Invoice` pi ON pi.name = per.reference_name + JOIN `tabMode of Payment` mop ON mop.name = pe.mode_of_payment + WHERE + pe.posting_date = %(date)s + AND pe.docstatus = 1 + AND per.reference_doctype = 'Purchase Invoice' + AND pi.docstatus = 1 + AND pi.is_return = 0 + AND mop.type = 'Cash' + GROUP BY pi.name + """, {"date": date}, as_dict=True) + + +def get_card_purchases(date): + return frappe.db.sql(""" + SELECT + 'Purchase Invoice' AS document, + pi.name AS id, + pi.status AS status, + pi.grand_total AS invoice_total, + SUM(per.allocated_amount) AS amount + FROM `tabPayment Entry` pe + JOIN `tabPayment Entry Reference` per ON per.parent = pe.name + JOIN `tabPurchase Invoice` pi ON pi.name = per.reference_name + JOIN `tabMode of Payment` mop ON mop.name = pe.mode_of_payment + WHERE + pe.posting_date = %(date)s + AND pe.docstatus = 1 + AND per.reference_doctype = 'Purchase Invoice' + AND pi.docstatus = 1 + AND pi.is_return = 0 + AND mop.type != 'Cash' + GROUP BY pi.name + """, {"date": date}, as_dict=True) + +def get_credit_purchases(date): + return frappe.db.sql(""" + SELECT + 'Purchase Invoice' AS document, + pi.name AS id, + 'Unpaid' AS status, + pi.grand_total AS invoice_total, + pi.grand_total AS amount + FROM `tabPurchase Invoice` pi + WHERE + pi.docstatus = 1 + AND pi.is_return = 0 + AND pi.posting_date = %(date)s + AND pi.outstanding_amount = pi.grand_total + """, {"date": date}, as_dict=True) + +def get_purchase_returns(date): + return frappe.db.sql(""" + SELECT + 'Purchase Return' AS document, + pi.name AS id, + CASE + WHEN pi.outstanding_amount = 0 THEN 'Paid' + WHEN pi.outstanding_amount = pi.grand_total THEN 'Unpaid' + ELSE 'Partially Paid' + END AS status, + pi.grand_total AS invoice_total, + IFNULL(SUM( + CASE + WHEN pe.posting_date = %(date)s AND pe.docstatus = 1 + THEN per.allocated_amount + ELSE 0 + END + ), 0) AS amount + FROM `tabPurchase Invoice` pi + LEFT JOIN `tabPayment Entry Reference` per + ON per.reference_doctype = 'Purchase Invoice' + AND per.reference_name = pi.name + LEFT JOIN `tabPayment Entry` pe + ON pe.name = per.parent + WHERE + pi.docstatus = 1 + AND pi.is_return = 1 + AND ( + pi.posting_date = %(date)s + OR pe.posting_date = %(date)s + ) + GROUP BY pi.name + """, {"date": date}, as_dict=True) + + +def get_purchase_return_cash(date): + return frappe.db.sql(""" + SELECT + 'Purchase Return' AS document, + pi.name AS id, + CASE + WHEN pi.outstanding_amount = 0 THEN 'Paid' + ELSE 'Partially Paid' + END AS status, + pi.grand_total AS invoice_total, + SUM(per.allocated_amount) AS amount + FROM `tabPayment Entry` pe + JOIN `tabPayment Entry Reference` per ON per.parent = pe.name + JOIN `tabPurchase Invoice` pi ON pi.name = per.reference_name + JOIN `tabMode of Payment` mop ON mop.name = pe.mode_of_payment + WHERE + pe.posting_date = %(date)s + AND pe.docstatus = 1 + AND per.reference_doctype = 'Purchase Invoice' + AND pi.docstatus = 1 + AND pi.is_return = 1 + AND mop.type = 'Cash' + GROUP BY pi.name + """, {"date": date}, as_dict=True) + +def get_purchase_return_card(date): + return frappe.db.sql(""" + SELECT + 'Purchase Return' AS document, + pi.name AS id, + CASE + WHEN pi.outstanding_amount = 0 THEN 'Paid' + ELSE 'Partially Paid' + END AS status, + pi.grand_total AS invoice_total, + SUM(per.allocated_amount) AS amount + FROM `tabPayment Entry` pe + JOIN `tabPayment Entry Reference` per ON per.parent = pe.name + JOIN `tabPurchase Invoice` pi ON pi.name = per.reference_name + JOIN `tabMode of Payment` mop ON mop.name = pe.mode_of_payment + WHERE + pe.posting_date = %(date)s + AND pe.docstatus = 1 + AND per.reference_doctype = 'Purchase Invoice' + AND pi.docstatus = 1 + AND pi.is_return = 1 + AND mop.type != 'Cash' + GROUP BY pi.name + """, {"date": date}, as_dict=True) + +def get_purchase_return_credit(date): + return frappe.db.sql(""" + SELECT + 'Purchase Return' AS document, + pi.name AS id, + 'Unpaid' AS status, + pi.grand_total AS invoice_total, + pi.grand_total AS amount + FROM `tabPurchase Invoice` pi + WHERE + pi.docstatus = 1 + AND pi.is_return = 1 + AND pi.posting_date = %(date)s + AND pi.outstanding_amount = pi.grand_total + """, {"date": date}, as_dict=True) + + +# ---------------- TYPE EMPTY ---------------- # + +def get_all_invoices(date): + return frappe.db.sql(""" + SELECT + CASE + WHEN si.is_return = 1 THEN 'Sales Return' + ELSE 'Sales Invoice' + END AS document, + si.name AS id, + CASE + WHEN si.outstanding_amount = 0 THEN 'Paid' + WHEN si.outstanding_amount = si.grand_total THEN 'Unpaid' + ELSE 'Partially Paid' + END AS status, + si.grand_total AS invoice_total, + IFNULL(SUM(per.allocated_amount), 0) AS amount + FROM `tabSales Invoice` si + LEFT JOIN `tabPayment Entry Reference` per + ON per.reference_name = si.name + AND per.reference_doctype = 'Sales Invoice' + LEFT JOIN `tabPayment Entry` pe + ON pe.name = per.parent + AND pe.docstatus = 1 + AND pe.posting_date = %(date)s + WHERE + si.docstatus = 1 + AND si.posting_date = %(date)s + GROUP BY si.name + + UNION ALL + + SELECT + CASE + WHEN pi.is_return = 1 THEN 'Purchase Return' + ELSE 'Purchase Invoice' + END AS document, + pi.name AS id, + CASE + WHEN pi.outstanding_amount = 0 THEN 'Paid' + WHEN pi.outstanding_amount = pi.grand_total THEN 'Unpaid' + ELSE 'Partially Paid' + END AS status, + pi.grand_total AS invoice_total, + IFNULL(SUM(per.allocated_amount), 0) AS amount + FROM `tabPurchase Invoice` pi + LEFT JOIN `tabPayment Entry Reference` per + ON per.reference_name = pi.name + AND per.reference_doctype = 'Purchase Invoice' + LEFT JOIN `tabPayment Entry` pe + ON pe.name = per.parent + AND pe.docstatus = 1 + AND pe.posting_date = %(date)s + WHERE + pi.docstatus = 1 + AND pi.posting_date = %(date)s + GROUP BY pi.name + """, {"date": date}, as_dict=True) + +def get_customer_receipts(date): + return frappe.db.sql(""" + SELECT + 'Payment Entry' AS document, + pe.name AS id, + 'Paid' AS status, + pe.paid_amount AS invoice_total, + pe.paid_amount AS amount + FROM `tabPayment Entry` pe + LEFT JOIN `tabPayment Entry Reference` per + ON per.parent = pe.name + WHERE + pe.docstatus = 1 + AND pe.posting_date = %(date)s + AND pe.party_type = 'Customer' + AND per.name IS NULL + """, {"date": date}, as_dict=True) + +def get_supplier_payments(date): + return frappe.db.sql(""" + SELECT + 'Payment Entry' AS document, + pe.name AS id, + 'Paid' AS status, + pe.paid_amount AS invoice_total, + pe.paid_amount AS amount + FROM `tabPayment Entry` pe + LEFT JOIN `tabPayment Entry Reference` per + ON per.parent = pe.name + WHERE + pe.docstatus = 1 + AND pe.posting_date = %(date)s + AND pe.party_type = 'Supplier' + AND per.name IS NULL + """, {"date": date}, as_dict=True) + +def get_journal_entries(date, report_type=None): + conditions = "" + + if report_type == "Bank Receipts": + conditions = "acc.account_type = 'Bank' AND jea.debit > 0" + elif report_type == "Bank Payments": + conditions = "acc.account_type = 'Bank' AND jea.credit > 0" + elif report_type == "Cash Receipts": + conditions = "acc.account_type = 'Cash' AND jea.debit > 0" + elif report_type == "Cash Payments": + conditions = "acc.account_type = 'Cash' AND jea.credit > 0" + else: + conditions = """ + acc.account_type IN ('Bank', 'Cash') + AND (jea.debit > 0 OR jea.credit > 0) + """ + + return frappe.db.sql(f""" + SELECT + 'Journal Entry' AS document, + je.name AS id, + 'Posted' AS status, + (jea.debit + jea.credit) AS invoice_total, + CASE + WHEN jea.debit > 0 THEN jea.debit + ELSE jea.credit + END AS amount + FROM `tabJournal Entry` je + INNER JOIN `tabJournal Entry Account` jea + ON jea.parent = je.name + INNER JOIN `tabAccount` acc + ON acc.name = jea.account + WHERE + je.docstatus = 1 + AND je.posting_date = %(date)s + AND {conditions} + """, {"date": date}, as_dict=True) diff --git a/aqrar_ext/aqrar_ext/report/dcr_report/__init__.py b/aqrar_ext/aqrar_ext/report/dcr_report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.js b/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.js new file mode 100644 index 0000000..00f330a --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.js @@ -0,0 +1,65 @@ + +// Copyright (c) 2026, Aravind R and contributors +// For license information, please see license.txt + +//Filters for DCR Report +frappe.query_reports["DCR Report"] = { + "filters": [ + { + "fieldname": "date", + "label": "Date", + "fieldtype": "Date", + "default": frappe.datetime.get_today(), + "reqd": 0 + }, + { + "fieldname": "type", + "label": "Type", + "fieldtype": "Select", + "options": "\nCash Sales\nCard Sales\nCredit Sales\nCash Purchases\nCard Purchases\nCredit Purchases\nSales Return\nPurchase Return\nCustomer Receipts\nSupplier Payments\nBank Receipts\nBank Payments\nCash Receipts\nCash Payments\nJournal Entry\nInternal Transfer", + "reqd": 0 + }, + { + "fieldname": "company", + "label": "Company", + "fieldtype": "Link", + "options": "Company", + "reqd": 0 + }, + { + "fieldname": "cost_center", + "label": "Cost Center", + "fieldtype": "Link", + "options": "Cost Center", + "get_query": function() { + var company = frappe.query_report.get_filter_value("company"); + if (company) { + return { + "filters": { + "company": company + } + }; + } else { + return {}; + } +} + } + ], + + // Types into a clickable link + + "formatter": function(value, row, column, data, default_formatter) { + + value = default_formatter(value, row, column, data); + + if (column.fieldname === "type" && data && data.voucher_type && data.voucher_no) { + + return ` + ${value} + `; + } + + return value; + } +}; \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.json b/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.json new file mode 100644 index 0000000..668e2c6 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.json @@ -0,0 +1,34 @@ +{ + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2026-04-23 13:51:27.049678", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letter_head": null, + "modified": "2026-04-23 13:51:27.049678", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "DCR Report", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "GL Entry", + "report_name": "DCR Report", + "report_type": "Script Report", + "roles": [ + { + "role": "Accounts User" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Auditor" + } + ], + "timeout": 0 +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.py b/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.py new file mode 100644 index 0000000..18e0e89 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.py @@ -0,0 +1,547 @@ + +# Copyright (c) 2026, Aravind R and contributors +# For license information, please see license.txt +#DCR Report +import frappe +from frappe import _ + + +def execute(filters=None): + columns = get_columns() + data = get_data(filters) + return columns, data + + +def get_columns(): + return [ + { + "label": _("Type"), + "fieldname": "type", + "fieldtype": "Data", + "width": 350 + }, + { + "label": _("Total"), + "fieldname": "total", + "fieldtype": "Currency", + "width": 150 + }, + { + "label": _("Invoice"), + "fieldname": "invoice_count", + "fieldtype": "Int", + "width": 120 + }, + { + "label": _("Voucher Type"), + "fieldname": "voucher_type", + "fieldtype": "Data", + "width": 0, + "hidden": 1 + }, + { + "label": _("Voucher No"), + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "options": "voucher_type", + "width": 0, + "hidden": 1 + } + ] + + +def get_data(filters): + filters = filters or {} + + + filters["company"] = filters.get("company") if filters.get("company") else None + filters["cost_center"] = filters.get("cost_center") if filters.get("cost_center") else None + + date = filters.get("date") + type_filter = filters.get("type") + cost_center = filters.get("cost_center") + company = filters.get("company") + + types = [ + "Cash Sales", + "Card/Bank Sales", + "Credit Sales", + "Cash Purchases", + "Card/Bank Purchases", + "Credit Purchases", + "Sales Return", + "Purchase Return", + "Customer Receipts", + "Supplier Payments", + "Bank Receipts", + "Bank Payments", + "Cash Receipts", + "Cash Payments", + "Journal Entry", + "Internal Transfer", + ] + + # If type filter is selected, show only that type + if type_filter: + types = [type_filter] + + result = [] + + for t in types: + total = 0 + count = 0 + paid_rows = [] + + # Voucher type conditions + if t in ["Cash Sales", "Card/Bank Sales", "Credit Sales"]: + paid_rows = fetch_sales_invoices(t, date, company,cost_center) + + elif t in ["Cash Purchases", "Card/Bank Purchases", "Credit Purchases"]: + paid_rows = fetch_purchase_invoices(t, date, company,cost_center) + + elif t == "Sales Return": + paid_rows = get_sales_returns(date,company, cost_center) + elif t == "Purchase Return": + paid_rows = get_purchase_returns(date,company, cost_center) + + elif t == "Customer Receipts": + paid_rows = get_customer_receipts(date, company,cost_center) + elif t == "Supplier Payments": + paid_rows = get_supplier_payments(date, company,cost_center) + elif t == "Internal Transfer": + paid_rows = get_internal_transfers(date, company, cost_center) + + + elif t in ["Bank Receipts", "Bank Payments", "Cash Receipts", "Cash Payments", "Journal Entry"]: + paid_rows = get_journal_entries(date, t, company,cost_center) + + + total = sum(r.get("amount", 0) or 0 for r in paid_rows) + count = len(paid_rows) + + # Add total row + result.append({ + "type": t, + "total": total, + "invoice_count": count, + "indent": 0 + }) + + # Add individual invoice/payment rows + for row in paid_rows: + result.append({ + "type": f"{row.get('voucher_type', row.get('document',''))} {row.get('voucher_no', row.get('id',''))}", + "total": row.get("amount", 0), + "invoice_count": "", + "voucher_type": row.get("voucher_type", row.get("document","")), + "voucher_no": row.get("voucher_no", row.get("id","")), + "indent": 1 + }) + + return result + + +def fetch_sales_invoices(t, date,company, cost_center): + + if t == "Cash Sales": + + amount_field = """ + IFNULL( + CASE + WHEN si.is_pos = 1 THEN SUM(sip.amount) + ELSE SUM(per.allocated_amount) + END, + CASE + WHEN si.custom_payment_mode = 'Cash' THEN si.grand_total + ELSE 0 + END + ) + """ + + date_condition = """ + AND si.posting_date = %(date)s + AND ( + ( + si.is_pos = 0 + AND si.custom_payment_mode = 'Cash' + ) + OR + ( + si.is_pos = 0 + AND pe.posting_date <= si.posting_date + AND pe.mode_of_payment IN ( + SELECT name FROM `tabMode of Payment` + WHERE type = 'Cash' + ) + ) + OR + ( + si.is_pos = 1 + AND sip.mode_of_payment IN ( + SELECT name FROM `tabMode of Payment` + WHERE type = 'Cash' + ) + ) + ) + """ + + join_type = "LEFT" + + elif t == "Card/Bank Sales": + + amount_field = """ + IFNULL( + CASE + WHEN si.is_pos = 1 THEN SUM(sip.amount) + ELSE SUM(per.allocated_amount) + END, + CASE + WHEN si.custom_payment_mode = 'Card' THEN si.grand_total + ELSE 0 + END + ) + """ + + date_condition = """ + AND si.posting_date = %(date)s + AND ( + ( + si.is_pos = 0 + AND si.custom_payment_mode = 'Card' + ) + OR + ( + si.is_pos = 0 + AND pe.posting_date = %(date)s + AND pe.mode_of_payment IN ( + SELECT name FROM `tabMode of Payment` + WHERE type IN ('Bank','Card') + ) + ) + OR + ( + si.is_pos = 1 + AND sip.mode_of_payment IN ( + SELECT name FROM `tabMode of Payment` + WHERE type IN ('Bank','Card') + ) + ) + ) + """ + + join_type = "LEFT" + + else: # Credit Sales + amount_field = "si.grand_total" + date_condition = """ + AND si.posting_date = %(date)s + AND si.is_pos = 0 + AND si.custom_payment_mode = 'Credit' + AND NOT EXISTS ( + SELECT 1 + FROM `tabPayment Entry Reference` per2 + INNER JOIN `tabPayment Entry` pe2 + ON pe2.name = per2.parent + WHERE per2.reference_name = si.name + AND per2.reference_doctype = 'Sales Invoice' + AND pe2.docstatus = 1 + AND pe2.posting_date = si.posting_date + ) + """ + + join_type = "LEFT" + + + query = f""" + SELECT si.name AS voucher_no, + {amount_field} AS amount, + 'Sales Invoice' AS voucher_type + FROM `tabSales Invoice` si + {join_type} JOIN `tabPayment Entry Reference` per + ON per.reference_name = si.name + AND per.reference_doctype='Sales Invoice' + {join_type} JOIN `tabPayment Entry` pe + ON pe.name = per.parent + + + LEFT JOIN `tabSales Invoice Payment` sip + ON sip.parent = si.name + WHERE si.docstatus IN (0,1) + AND si.is_return=0 + {date_condition} + AND ( %(company)s IS NULL OR %(company)s = '' OR si.company = %(company)s ) + AND ( %(cost_center)s IS NULL OR %(cost_center)s = '' OR si.cost_center = %(cost_center)s ) + GROUP BY si.name + """ + + return frappe.db.sql(query, {"date": date, "company": company, "cost_center": cost_center}, as_dict=True) + + +def fetch_purchase_invoices(t, date,company, cost_center): + """Fetch Purchase Invoice rows per type & MoP""" + + date_condition = f"AND pi.posting_date = '{date}'" if date else "" + + if t == "Cash Purchases": + amount_field = "IFNULL(SUM(per.allocated_amount),0)" + date_condition = """ + AND pi.posting_date = %(date)s + AND pe.posting_date = %(date)s + AND pe.mode_of_payment IN ( + SELECT name FROM `tabMode of Payment` WHERE type='Cash' + ) + """ + + elif t == "Card/Bank Purchases": + amount_field = "IFNULL(SUM(per.allocated_amount),0)" + date_condition = """ + AND pi.posting_date = %(date)s + AND pe.posting_date = %(date)s + AND pe.mode_of_payment IN ( + SELECT name FROM `tabMode of Payment` WHERE type IN ('Bank','Card') + ) + """ + + else: # Credit Purchases + amount_field = "pi.grand_total" + date_condition = """ + AND pi.posting_date = %(date)s + AND NOT EXISTS ( + SELECT 1 + FROM `tabPayment Entry Reference` per2 + INNER JOIN `tabPayment Entry` pe2 + ON pe2.name = per2.parent + WHERE per2.reference_name = pi.name + AND per2.reference_doctype = 'Purchase Invoice' + AND pe2.docstatus = 1 + AND pe2.posting_date = %(date)s + ) + """ + + query = f""" + SELECT pi.name AS voucher_no, + {amount_field} AS amount, + 'Purchase Invoice' AS voucher_type + FROM `tabPurchase Invoice` pi + LEFT JOIN `tabPayment Entry Reference` per + ON per.reference_name = pi.name AND per.reference_doctype='Purchase Invoice' + LEFT JOIN `tabPayment Entry` pe + ON pe.name = per.parent + WHERE pi.docstatus IN (0,1) + AND pi.is_return=0 + {date_condition} + AND ( %(company)s IS NULL OR %(company)s = '' OR pi.company = %(company)s ) + AND ( %(cost_center)s IS NULL OR %(cost_center)s = '' OR pi.cost_center = %(cost_center)s ) + GROUP BY pi.name + """ + + return frappe.db.sql(query, {"date": date, "company": company,"cost_center": cost_center}, as_dict=True) + + +def get_sales_returns(date,company,cost_center): + # Fetch sales returns for the exact filter date only + data = frappe.db.sql(""" + SELECT + 'Sales Invoice' AS voucher_type, + si.name AS voucher_no, + CASE + WHEN si.grand_total - COALESCE(SUM( + CASE WHEN pe.posting_date = %(date)s AND pe.docstatus=1 + THEN per.allocated_amount ELSE 0 END + ),0) = 0 THEN 'Paid' + WHEN COALESCE(SUM( + CASE WHEN pe.posting_date = %(date)s AND pe.docstatus=1 + THEN per.allocated_amount ELSE 0 END + ),0) = 0 THEN 'Unpaid' + ELSE 'Partially Paid' + END AS status, + si.grand_total AS invoice_total, + CASE + WHEN COALESCE(SUM( + CASE WHEN pe.posting_date = %(date)s AND pe.docstatus=1 + THEN per.allocated_amount ELSE 0 END + ),0) > 0 + THEN COALESCE(SUM( + CASE WHEN pe.posting_date = %(date)s AND pe.docstatus=1 + THEN per.allocated_amount ELSE 0 END + ),0) + ELSE si.grand_total + END AS amount, + COUNT(si.name) OVER () AS total_count + FROM `tabSales Invoice` si + LEFT JOIN `tabPayment Entry Reference` per + ON per.reference_name=si.name AND per.reference_doctype='Sales Invoice' + LEFT JOIN `tabPayment Entry` pe + ON pe.name = per.parent + WHERE si.docstatus IN (0,1) + AND si.is_return=1 + AND si.posting_date = %(date)s + AND ( %(company)s IS NULL OR %(company)s = '' OR si.company = %(company)s ) + AND ( %(cost_center)s IS NULL OR %(cost_center)s = '' OR si.cost_center = %(cost_center)s ) + GROUP BY si.name, si.grand_total + ORDER BY si.posting_date ASC + """, {"date": date, "company": company,"cost_center": cost_center}, as_dict=True) + + return data + + +def get_purchase_returns(date,company,cost_center): + # Fetch purchase returns for the exact invoice posting date only + return frappe.db.sql(""" + SELECT + 'Purchase Return' AS document, + pi.name AS id, + CASE + WHEN pi.outstanding_amount = 0 THEN 'Paid' + WHEN pi.outstanding_amount = pi.grand_total THEN 'Unpaid' + ELSE 'Partially Paid' + END AS status, + pi.grand_total AS invoice_total, + IFNULL(SUM( + CASE WHEN pe.docstatus=1 THEN per.allocated_amount ELSE 0 END + ),0) AS amount + FROM `tabPurchase Invoice` pi + LEFT JOIN `tabPayment Entry Reference` per + ON per.reference_name=pi.name AND per.reference_doctype='Purchase Invoice' + LEFT JOIN `tabPayment Entry` pe + ON pe.name = per.parent + WHERE pi.docstatus IN (0,1) AND pi.is_return=1 + AND pi.posting_date = %(date)s + AND ( %(company)s IS NULL OR %(company)s = '' OR pi.company = %(company)s ) + AND ( %(cost_center)s IS NULL OR %(cost_center)s = '' OR pi.cost_center = %(cost_center)s ) + GROUP BY pi.name + ORDER BY pi.posting_date ASC + """, {"date": date,"company": company, "cost_center": cost_center}, as_dict=True) + + +def get_customer_receipts(date, company=None, cost_center=None): + return frappe.db.sql(""" + SELECT + 'Payment Entry' AS document, + pe.name AS id, + 'Paid' AS status, + pe.paid_amount AS invoice_total, + pe.paid_amount AS amount + FROM `tabPayment Entry` pe + INNER JOIN `tabPayment Entry Reference` per + ON per.parent = pe.name + AND per.reference_doctype = 'Sales Invoice' + INNER JOIN `tabSales Invoice` si + ON si.name = per.reference_name + WHERE pe.docstatus IN (0,1) + AND pe.posting_date = %(date)s + AND pe.party_type = 'Customer' + AND pe.posting_date != si.posting_date + AND ( %(company)s IS NULL OR %(company)s = '' OR pe.company = %(company)s ) + AND ( %(cost_center)s IS NULL OR %(cost_center)s = '' OR pe.cost_center = %(cost_center)s ) + """, { + "date": date, + "company": company, + "cost_center": cost_center + }, as_dict=True) + + +def get_supplier_payments(date, company, cost_center): + return frappe.db.sql(""" + SELECT + 'Payment Entry' AS document, + pe.name AS id, + 'Paid' AS status, + pe.paid_amount AS invoice_total, + pe.paid_amount AS amount + FROM `tabPayment Entry` pe + LEFT JOIN `tabPayment Entry Reference` per + ON per.parent = pe.name + AND per.reference_doctype = 'Purchase Invoice' + LEFT JOIN `tabPurchase Invoice` pi + ON pi.name = per.reference_name + WHERE pe.docstatus IN (0,1) + AND pe.posting_date = %(date)s + AND pe.party_type = 'Supplier' + AND ( %(company)s IS NULL OR %(company)s = '' OR pe.company = %(company)s ) + AND ( + per.name IS NULL + OR pi.posting_date < pe.posting_date + ) + AND ( %(cost_center)s IS NULL OR %(cost_center)s = '' OR pe.cost_center = %(cost_center)s ) + GROUP BY pe.name, pe.paid_amount + ORDER BY pe.posting_date ASC + """, { + "date": date, + "company": company, + "cost_center": cost_center + }, as_dict=True) + +def get_journal_entries(date, report_type=None, company=None, cost_center=None): + if report_type in ("Bank Receipts", "Bank Payments", "Cash Receipts", "Cash Payments"): + if report_type == "Bank Receipts": + conditions = "acc.account_type='Bank' AND jea.debit>0" + elif report_type == "Bank Payments": + conditions = "acc.account_type='Bank' AND jea.credit>0" + elif report_type == "Cash Receipts": + conditions = "acc.account_type='Cash' AND jea.debit>0" + elif report_type == "Cash Payments": + conditions = "acc.account_type='Cash' AND jea.credit>0" + + return frappe.db.sql(f""" + SELECT + 'Journal Entry' AS document, + je.name AS id, + 'Posted' AS status, + (jea.debit + jea.credit) AS invoice_total, + CASE WHEN jea.debit>0 THEN jea.debit ELSE jea.credit END AS amount + FROM `tabJournal Entry` je + INNER JOIN `tabJournal Entry Account` jea + ON jea.parent=je.name + INNER JOIN `tabAccount` acc + ON acc.name=jea.account + WHERE je.docstatus IN (0,1) + AND je.posting_date=%(date)s + AND {conditions} + AND (%(company)s IS NULL OR je.company = %(company)s) + AND (%(cost_center)s IS NULL OR jea.cost_center = %(cost_center)s) + """, {"date": date,"company": company,"cost_center": cost_center}, as_dict=True) + + else: + # Other Journal Entries → Only non-Bank/non-Cash entries entirely + return frappe.db.sql(""" + SELECT + 'Journal Entry' AS document, + je.name AS id, + 'Posted' AS status, + SUM(jea.debit + jea.credit) AS invoice_total, + SUM(CASE WHEN jea.debit>0 THEN jea.debit ELSE jea.credit END) AS amount + FROM `tabJournal Entry` je + INNER JOIN `tabJournal Entry Account` jea + ON jea.parent = je.name + INNER JOIN `tabAccount` acc + ON acc.name = jea.account + WHERE je.docstatus IN (0,1) + AND je.posting_date = %(date)s + AND (%(company)s IS NULL OR je.company = %(company)s) + AND (%(cost_center)s IS NULL OR jea.cost_center = %(cost_center)s) + GROUP BY je.name + HAVING SUM(CASE WHEN acc.account_type IN ('Bank','Cash') THEN 1 ELSE 0 END) = 0 + """, {"date": date, "company": company,"cost_center": cost_center}, as_dict=True) + + +def get_internal_transfers(date, company=None, cost_center=None): + + return frappe.db.sql(""" + SELECT + 'Payment Entry' AS voucher_type, + pe.name AS voucher_no, + 'Internal Transfer' AS status, + pe.paid_amount AS amount, + pe.paid_amount AS invoice_total + FROM `tabPayment Entry` pe + WHERE pe.docstatus IN (0,1) + AND pe.payment_type = 'Internal Transfer' + AND pe.posting_date = %(date)s + AND ( %(company)s IS NULL OR pe.company = %(company)s ) + AND ( %(cost_center)s IS NULL OR pe.cost_center = %(cost_center)s ) + """, { + "date": date, + "company": company, + "cost_center": cost_center + }, as_dict=True) \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/report/work_flow_approval/__init__.py b/aqrar_ext/aqrar_ext/report/work_flow_approval/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.js b/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.js new file mode 100644 index 0000000..61b3fc8 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.js @@ -0,0 +1,109 @@ +let wf_action = ""; + +frappe.query_reports["Work Flow Approval"] = { + filters: [ + { fieldname: "user", label: "User", fieldtype: "Link", options: "User", default: frappe.session.user }, + { fieldname: "company", label: "Company", fieldtype: "Link", options: "Company" }, + { + fieldname: "doctype", label: "Document Type", fieldtype: "Link", options: "DocType", + get_query: function() { + return { + query: "aqrar_ext.aqrar_ext.report.work_flow_approval.work_flow_approval.get_workflow_doctypes" + }; + }, + on_change: function() { + wf_action = ""; + $("#wf-action-select").val(""); + load_actions(); + frappe.query_report.refresh(); + } + }, + { fieldname: "from_date", label: "From Date", fieldtype: "Date" }, + { fieldname: "to_date", label: "To Date", fieldtype: "Date" } + ], + + after_datatable_render: function(dt) { + $(dt.wrapper).find(".dt-cell--col-0").each(function(i) { + if (i === 0) return; + let $c = $(this).css({ "text-align": "center", "cursor": "pointer" }); + if (!$c.find('input[type="checkbox"]').length) + $c.html(''); + $c.off("click").on("click", function(e) { + if (!$(e.target).is("input")) + $c.find('input[type="checkbox"]').prop("checked", v => !v); + }); + }); + }, + + onload: function(report) { + setTimeout(function() { + if (report.page.page_form.length && !$("#wf-action-select").length) { + report.page.page_form.append(` +
+ +
`); + $("#wf-action-select").on("change", function() { wf_action = $(this).val(); }); + } + load_actions(); + }, 800); + + report.page.add_inner_button("Apply Workflow Action", function() { + let docs = []; + + $(report.datatable.wrapper).find("input.row-check:checked").each(function() { + let idx = parseInt($(this).closest(".dt-row").attr("data-row-index")); + if (!isNaN(idx) && report.data[idx]) + docs.push({ doctype: report.data[idx].doctype, name: report.data[idx].name }); + }); + + if (!docs.length) { + $(report.datatable.wrapper).find(".dt-row").each(function(i) { + if ($(this).find("input.row-check").prop("checked") && report.data[i]) + docs.push({ doctype: report.data[i].doctype, name: report.data[i].name }); + }); + } + + if (!docs.length) return frappe.msgprint(__("Please select at least one document.")); + + let action = $("#wf-action-select").val() || wf_action; + if (!action) return frappe.msgprint(__("Please select a Workflow Action.")); + + frappe.confirm( + __("Apply {0} to {1} document(s)?", [action, docs.length]), + () => frappe.call({ + method: "aqrar_ext.aqrar_ext.report.work_flow_approval.work_flow_approval.apply_bulk_workflow", + args: { docs: JSON.stringify(docs), action }, + freeze: true, + freeze_message: __("Applying..."), + callback: r => { + if (!r.exc) { + frappe.msgprint({ title: __("Result"), message: r.message, indicator: "green" }); + frappe.query_report.refresh(); + } + } + }) + ); + }); + } +}; + +function load_actions() { + let doctype = frappe.query_report.get_filter_value("doctype"); + let method = doctype + ? "aqrar_ext.aqrar_ext.report.work_flow_approval.work_flow_approval.get_workflow_actions" + : "aqrar_ext.aqrar_ext.report.work_flow_approval.work_flow_approval.get_all_workflow_actions"; + let args = doctype ? { doctype } : {}; + + frappe.call({ + method: method, + args: args, + callback: r => { + let $s = $("#wf-action-select").empty().append(''); + (r.message || []).forEach(a => $s.append(``)); + } + }); +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.json b/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.json new file mode 100644 index 0000000..69ab969 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.json @@ -0,0 +1,28 @@ +{ + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2026-05-16 19:26:30.257054", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letter_head": null, + "modified": "2026-05-16 19:26:30.257054", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Work Flow Approval", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Workflow", + "report_name": "Work Flow Approval", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + } + ], + "timeout": 0 +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.py b/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.py new file mode 100644 index 0000000..a38efe8 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/work_flow_approval/work_flow_approval.py @@ -0,0 +1,215 @@ +# Copyright (c) 2026, enfono and contributors +# For license information, please see license.txt + +import frappe + + +def execute(filters=None): + return get_columns(), get_data(filters) + + +def get_columns(): + return [ + { "label": "User", "fieldname": "owner", "fieldtype": "Link", "options": "User", "width": 180 }, + { "label": "Company", "fieldname": "company", "fieldtype": "Link", "options": "Company", "width": 150 }, + { "label": "DocType", "fieldname": "doctype", "fieldtype": "Data", "width": 150 }, + { "label": "Document", "fieldname": "name", "fieldtype": "Dynamic Link", "options": "doctype", "width": 200 }, + { "label": "Status", "fieldname": "workflow_state", "fieldtype": "Data", "width": 150 }, + { "label": "Created Date", "fieldname": "creation", "fieldtype": "Datetime", "width": 180 } + ] + + +def get_data(filters): + filters = filters or {} + + if filters.get("doctype"): + return get_doctype_data(filters.get("doctype"), filters) + + workflows = frappe.db.get_all("Workflow", + filters={"is_active": 1}, + fields=["document_type"] + ) + + all_data = [] + seen = set() + for w in workflows: + dt = w.document_type + if not dt or dt in seen: + continue + seen.add(dt) + try: + all_data.extend(get_doctype_data(dt, filters)) + except Exception: + continue + + all_data.sort(key=lambda x: x.get("creation") or "", reverse=True) + return all_data + + +def get_doctype_data(doctype, filters): + if not frappe.db.has_column(doctype, "workflow_state"): + return [] + + has_company = frappe.db.has_column(doctype, "company") + conditions = "workflow_state = 'Pending'" + values = {} + + if filters.get("company") and has_company: + conditions += " AND company = %(company)s" + values["company"] = filters["company"] + + if filters.get("from_date"): + conditions += " AND creation >= %(from_date)s" + values["from_date"] = filters["from_date"] + + if filters.get("to_date"): + conditions += " AND creation <= %(to_date)s" + values["to_date"] = filters["to_date"] + + fields = ("company, " if has_company else "") + "name, workflow_state, owner, creation" + data = frappe.db.sql(f""" + SELECT {fields} FROM `tab{doctype}` + WHERE {conditions} ORDER BY creation DESC + """, values, as_dict=True) + + for d in data: + d["doctype"] = doctype + if not has_company: + d["company"] = "" + + return data + + +@frappe.whitelist() +def get_workflow_actions(doctype): + wf = frappe.db.get_all("Workflow", + filters={"document_type": doctype, "is_active": 1}, + fields=["name"], limit=1) + + if not wf: + return [] + + seen, actions = set(), [] + for t in frappe.get_doc("Workflow", wf[0].name).transitions: + if t.action and t.action not in seen: + seen.add(t.action) + actions.append(t.action) + return actions + + +@frappe.whitelist() +def get_all_workflow_actions(): + workflows = frappe.db.get_all("Workflow", + filters={"is_active": 1}, + fields=["name"] + ) + + seen, actions = set(), [] + for w in workflows: + try: + for t in frappe.get_doc("Workflow", w.name).transitions: + if t.action and t.action not in seen: + seen.add(t.action) + actions.append(t.action) + except Exception: + continue + return actions + + +@frappe.whitelist() +def apply_bulk_workflow(docs, action): + import json + from frappe.model.workflow import apply_workflow + + results = [] + for d in json.loads(docs): + try: + apply_workflow(frappe.get_doc(d["doctype"], d["name"]), action) + results.append(f" {d['name']} — Success") + except Exception as e: + results.append(f" {d['name']} — {str(e)}") + + frappe.db.commit() + return "
".join(results) + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_workflow_doctypes(doctype, txt, searchfield, start, page_len, filters): + workflows = frappe.db.get_all("Workflow", + filters={"is_active": 1}, + fields=["document_type"], + limit=100 + ) + + doctypes = [[w.document_type, w.document_type] for w in workflows + if w.document_type and txt.lower() in w.document_type.lower()] + + return doctypes + + +@frappe.whitelist() +def get_pending_approval_count(user=None): + user = user or frappe.session.user + + if user == "Administrator": + return _count_all_pending() + + user_roles = set(frappe.get_roles(user)) + + workflows = frappe.db.get_all("Workflow", + filters={"is_active": 1}, + fields=["name", "document_type"] + ) + + total = 0 + seen = set() + for w in workflows: + dt = w.document_type + if not dt or dt in seen: + continue + seen.add(dt) + + if not frappe.db.has_column(dt, "workflow_state"): + continue + + try: + wf_doc = frappe.get_cached_doc("Workflow", w.name) + except Exception: + continue + + approver_roles = set() + for state in wf_doc.states: + if state.state == "Pending" and state.allow_edit: + approver_roles.add(state.allow_edit) + + if approver_roles and not (user_roles & approver_roles): + continue + + try: + total += frappe.db.count(dt, filters={"workflow_state": "Pending"}) or 0 + except Exception: + continue + + return total + + +def _count_all_pending(): + workflows = frappe.db.get_all("Workflow", + filters={"is_active": 1}, + fields=["document_type"] + ) + total = 0 + seen = set() + for w in workflows: + dt = w.document_type + if not dt or dt in seen: + continue + seen.add(dt) + if not frappe.db.has_column(dt, "workflow_state"): + continue + try: + total += frappe.db.count(dt, filters={"workflow_state": "Pending"}) or 0 + except Exception: + continue + return total \ No newline at end of file diff --git a/aqrar_ext/config/__init__.py b/aqrar_ext/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/events/__init__.py b/aqrar_ext/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/events/material_request.py b/aqrar_ext/events/material_request.py new file mode 100644 index 0000000..97c2582 --- /dev/null +++ b/aqrar_ext/events/material_request.py @@ -0,0 +1,59 @@ +# aqrar_ext: Material Request — branch-user approval enforcement +import frappe +from frappe import _ + + +def validate_branch_user(doc, method): + """Ensure Branch Users can only approve MRs for their own warehouse.""" + user = frappe.session.user + roles = frappe.get_roles(user) + + # Only enforce for Branch Users, not central admins/managers + if "Branch User" not in roles or "Stock Manager" in roles or "System Manager" in roles: + return + + # Get warehouses from MR + warehouses = [] + if doc.set_warehouse: + warehouses.append(doc.set_warehouse) + if doc.set_from_warehouse: + warehouses.append(doc.set_from_warehouse) + for item in doc.items: + if item.warehouse: + warehouses.append(item.warehouse) + + if not warehouses: + return + + # Check user has permission for at least one of the MR's warehouses + user_warehouses = frappe.get_all("User Permission", filters={ + "user": user, + "allow": "Warehouse", + "for_value": ["in", list(set(warehouses))], + }, pluck="for_value") + + if not user_warehouses: + frappe.throw( + _("You do not have permission to approve Material Requests for this warehouse. " + "Please contact your branch administrator.") + ) + + +@frappe.whitelist() +def close_material_request(mr_name, reason): + """Close an MR with a reason, bypassing form-level status restrictions.""" + frappe.db.set_value("Material Request", mr_name, "custom_close_reason", reason) + frappe.db.set_value("Material Request", mr_name, "status", "Stopped", update_modified=True) + frappe.db.commit() + + +@frappe.whitelist() +def reopen_material_request(mr_name): + """Reopen a closed MR.""" + status = "Pending" + per_ordered = frappe.db.get_value("Material Request", mr_name, "per_ordered") or 0 + if per_ordered > 0: + status = "Partially Ordered" + frappe.db.set_value("Material Request", mr_name, "custom_close_reason", "") + frappe.db.set_value("Material Request", mr_name, "status", status, update_modified=True) + frappe.db.commit() diff --git a/aqrar_ext/events/purchase_receipt.py b/aqrar_ext/events/purchase_receipt.py new file mode 100644 index 0000000..9df984f --- /dev/null +++ b/aqrar_ext/events/purchase_receipt.py @@ -0,0 +1,29 @@ +# aqrar_ext: Block PR cancellation when stock already consumed +import frappe +from frappe import _ + + +def block_cancel_if_consumed(doc, method): + """Prevent cancelling PR if items have been sold via Sales Invoice or Delivery Note.""" + for item in doc.items: + # Check if any of this item's stock was consumed via Sales Invoice + consumed = frappe.db.sql(""" + SELECT sle.name + FROM `tabStock Ledger Entry` sle + INNER JOIN `tabSales Invoice Item` sii ON sle.voucher_no = sii.parent + AND sle.voucher_type = 'Sales Invoice' + WHERE sle.item_code = %s + AND sle.warehouse = %s + AND sle.actual_qty < 0 + AND sle.posting_date >= %s + AND sii.docstatus = 1 + LIMIT 1 + """, (item.item_code, item.warehouse or doc.set_warehouse, doc.posting_date)) + + if consumed: + frappe.throw( + _("Cannot cancel {0}. Item {1} has already been sold. " + "Please use Final GRN to update the rate instead.") + .format(doc.name, item.item_code), + title=_("Stock Already Consumed"), + ) diff --git a/aqrar_ext/fixtures/custom_field.json b/aqrar_ext/fixtures/custom_field.json new file mode 100644 index 0000000..a8c8c26 --- /dev/null +++ b/aqrar_ext/fixtures/custom_field.json @@ -0,0 +1,686 @@ +[ + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": "0", + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Material Request", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_urgent", + "fieldtype": "Check", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 0, + "in_standard_filter": 1, + "insert_after": "schedule_date", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Urgent", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-05-17 00:00:00", + "module": null, + "name": "Material Request-custom_urgent", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 1, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Material Request", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_close_reason", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_urgent", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Close Reason", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-05-17 00:00:00", + "module": null, + "name": "Material Request-custom_close_reason", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_payment_mode", + "fieldtype": "Select", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "due_date", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Payment Mode", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-04-22 13:02:16.808435", + "module": null, + "name": "Sales Invoice-custom_payment_mode", + "no_copy": 0, + "non_negative": 0, + "options": "Cash\nCredit\nCard", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 1, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 1, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": "eval:doc.custom_payment_mode === 'Card' || doc.custom_payment_mode === 'Credit'", + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_partial_payment_amount", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "custom_payment_mode", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Partial Payment Amount", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-05-19 16:14:34.276535", + "module": null, + "name": "Sales Invoice-custom_partial_payment_amount", + "no_copy": 0, + "non_negative": 1, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 1, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 1, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Material Request", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "workflow_state", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 0, + "in_standard_filter": 1, + "insert_after": "status", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Workflow State", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-05-17 00:00:00", + "module": null, + "name": "Material Request-workflow_state", + "no_copy": 0, + "non_negative": 0, + "options": "Workflow State", + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 1, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Sales Invoice Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_last_price", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "rate", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Last Price", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-05-19 16:12:16.316135", + "module": null, + "name": "Sales Invoice Item-custom_last_price", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Purchase Invoice Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_last_price", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "rate", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Last Price", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-05-19 16:13:25.722972", + "module": null, + "name": "Purchase Invoice Item-custom_last_price", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Delivery Note Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_last_price", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "rate", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Last Price", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-05-19 16:12:31.457961", + "module": null, + "name": "Delivery Note Item-custom_last_price", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Sales Order Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_last_price", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "rate", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Last Price", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-05-19 16:12:46.012588", + "module": null, + "name": "Sales Order Item-custom_last_price", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Quotation Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_last_price", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "rate", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Last Price", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-05-19 16:13:13.434131", + "module": null, + "name": "Quotation Item-custom_last_price", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Purchase Order Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_last_price", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "rate", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Last Price", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-05-19 16:13:42.476090", + "module": null, + "name": "Purchase Order Item-custom_last_price", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Purchase Receipt Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "custom_last_price", + "fieldtype": "Currency", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "rate", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Last Price", + "length": 0, + "link_filters": null, + "mandatory_depends_on": null, + "modified": "2026-05-19 16:14:07.152340", + "module": null, + "name": "Purchase Receipt Item-custom_last_price", + "no_copy": 0, + "non_negative": 0, + "options": null, + "permlevel": 0, + "placeholder": null, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 1, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + } +] \ No newline at end of file diff --git a/aqrar_ext/fixtures/mode_of_payment.json b/aqrar_ext/fixtures/mode_of_payment.json new file mode 100644 index 0000000..d3974ad --- /dev/null +++ b/aqrar_ext/fixtures/mode_of_payment.json @@ -0,0 +1,56 @@ +[ + { + "accounts": [ + { + "company": "enfono", + "default_account": "Cash - E", + "parent": "Cash", + "parentfield": "accounts", + "parenttype": "Mode of Payment" + } + ], + "docstatus": 0, + "doctype": "Mode of Payment", + "enabled": 1, + "mode_of_payment": "Cash", + "modified": "2026-05-11 12:00:13.777907", + "name": "Cash", + "type": "Cash" + }, + { + "accounts": [ + { + "company": "enfono", + "default_account": "Cash - E", + "parent": "Card", + "parentfield": "accounts", + "parenttype": "Mode of Payment" + } + ], + "docstatus": 0, + "doctype": "Mode of Payment", + "enabled": 1, + "mode_of_payment": "Card", + "modified": "2026-05-19 15:53:16.525640", + "name": "Card", + "type": "Bank" + }, + { + "accounts": [ + { + "company": "enfono", + "default_account": "Cash - E", + "parent": "Credit", + "parentfield": "accounts", + "parenttype": "Mode of Payment" + } + ], + "docstatus": 0, + "doctype": "Mode of Payment", + "enabled": 1, + "mode_of_payment": "Credit", + "modified": "2026-05-19 15:53:16.566542", + "name": "Credit", + "type": "Bank" + } +] \ No newline at end of file diff --git a/aqrar_ext/fixtures/print_format.json b/aqrar_ext/fixtures/print_format.json new file mode 100644 index 0000000..bdf3e65 --- /dev/null +++ b/aqrar_ext/fixtures/print_format.json @@ -0,0 +1,36 @@ +[ + { + "absolute_value": 0, + "align_labels_right": 0, + "css": null, + "custom_format": 1, + "default_print_language": "en", + "disabled": 0, + "doc_type": "Delivery Note", + "docstatus": 0, + "doctype": "Print Format", + "font": null, + "font_size": 14, + "format_data": null, + "html": "\n\n
\n\n \n
\n

Delivery Note

\n

{{ doc.name }}

\n {% if doc.docstatus == 0 %}\n

DRAFT

\n {% endif %}\n
\n\n \n
\n
\n Customer Name:
\n {{ doc.customer_name or doc.customer }}\n
\n
\n Date: {{ doc.posting_date }}
\n Company: {{ doc.company }}\n
\n
\n\n \n \n \n \n \n \n \n \n \n \n \n \n {% for item in doc.items %}\n \n \n \n \n \n \n \n {% endfor %}\n \n \n \n \n \n \n \n \n
SrItem CodeDescriptionQuantityUOM
{{ loop.index }}{{ item.item_code }}
\n {{ item.item_name }}\n
{{ item.description or \"\" }}{{ item.qty }}{{ item.uom }}
\n Total Quantity:\n \n {{ doc.total_qty }}\n
\n\n \n {% if doc.terms %}\n
\n Terms & Conditions:
{{ doc.terms }}\n
\n {% endif %}\n\n \n
\n
\n
\n Prepared By\n
\n
\n
\n Authorized Signatory\n
\n
\n
\n Received By\n
\n
\n\n
", + "line_breaks": 0, + "margin_bottom": 15.0, + "margin_left": 15.0, + "margin_right": 15.0, + "margin_top": 15.0, + "modified": "2026-05-16 22:57:41.237619", + "module": "Stock", + "name": "Aqrar Delivery Note", + "page_number": "Hide", + "pdf_generator": "wkhtmltopdf", + "print_format_builder": 0, + "print_format_builder_beta": 0, + "print_format_for": "DocType", + "print_format_type": "Jinja", + "raw_commands": null, + "raw_printing": 0, + "report": null, + "show_section_headings": 0, + "standard": "Yes" + } +] \ No newline at end of file diff --git a/aqrar_ext/fixtures/workflow.json b/aqrar_ext/fixtures/workflow.json new file mode 100644 index 0000000..5547090 --- /dev/null +++ b/aqrar_ext/fixtures/workflow.json @@ -0,0 +1,135 @@ +[ + { + "docstatus": 0, + "doctype": "Workflow", + "document_type": "Material Request", + "is_active": 1, + "modified": "2026-05-19 16:15:59.237356", + "name": "Material Request Approval", + "override_status": 0, + "send_email_alert": 0, + "states": [ + { + "allow_edit": "Branch User", + "avoid_status_override": 0, + "doc_status": "1", + "is_optional_state": 0, + "message": "Waiting for Branch User approval", + "next_action_email_template": null, + "parent": "Material Request Approval", + "parentfield": "states", + "parenttype": "Workflow", + "send_email": 0, + "state": "Pending Approval", + "update_field": "workflow_state", + "update_value": null, + "workflow_builder_id": null + }, + { + "allow_edit": "Stock Manager", + "avoid_status_override": 0, + "doc_status": "1", + "is_optional_state": 0, + "message": "Approved - Ready for Transfer", + "next_action_email_template": null, + "parent": "Material Request Approval", + "parentfield": "states", + "parenttype": "Workflow", + "send_email": 0, + "state": "Approved", + "update_field": "workflow_state", + "update_value": null, + "workflow_builder_id": null + }, + { + "allow_edit": "Stock Manager", + "avoid_status_override": 0, + "doc_status": "1", + "is_optional_state": 0, + "message": "Fully Transferred", + "next_action_email_template": null, + "parent": "Material Request Approval", + "parentfield": "states", + "parenttype": "Workflow", + "send_email": 0, + "state": "Transferred", + "update_field": "workflow_state", + "update_value": null, + "workflow_builder_id": null + }, + { + "allow_edit": "Branch User", + "avoid_status_override": 0, + "doc_status": "1", + "is_optional_state": 0, + "message": "Closed - Not Fully Fulfilled", + "next_action_email_template": null, + "parent": "Material Request Approval", + "parentfield": "states", + "parenttype": "Workflow", + "send_email": 0, + "state": "Closed", + "update_field": "workflow_state", + "update_value": null, + "workflow_builder_id": null + } + ], + "transitions": [ + { + "action": "Approve", + "allow_self_approval": 0, + "allowed": "Branch User", + "condition": "frappe.session.user != doc.owner", + "next_state": "Approved", + "parent": "Material Request Approval", + "parentfield": "transitions", + "parenttype": "Workflow", + "send_email_to_creator": 0, + "state": "Pending Approval", + "workflow_builder_id": null + }, + { + "action": "Reject", + "allow_self_approval": 0, + "allowed": "Branch User", + "condition": null, + "next_state": "Closed", + "parent": "Material Request Approval", + "parentfield": "transitions", + "parenttype": "Workflow", + "send_email_to_creator": 0, + "state": "Pending Approval", + "workflow_builder_id": null + }, + { + "action": "Cancel", + "allow_self_approval": 0, + "allowed": "Branch User", + "condition": null, + "next_state": "Closed", + "parent": "Material Request Approval", + "parentfield": "transitions", + "parenttype": "Workflow", + "send_email_to_creator": 0, + "state": "Approved", + "workflow_builder_id": null + }, + { + "action": "Transfer", + "allow_self_approval": 1, + "allowed": "Stock Manager", + "condition": "frappe.db.get_value('Material Request', doc.name, 'per_ordered') == 100", + "next_state": "Transferred", + "parent": "Material Request Approval", + "parentfield": "transitions", + "parenttype": "Workflow", + "send_email_to_creator": 0, + "state": "Approved", + "workflow_builder_id": null + } + ], + "workflow_data": null, + "workflow_name": "Material Request Approval", + "workflow_state_field": "workflow_state" + } +] \ No newline at end of file diff --git a/aqrar_ext/hooks.py b/aqrar_ext/hooks.py new file mode 100644 index 0000000..6dc1cc0 --- /dev/null +++ b/aqrar_ext/hooks.py @@ -0,0 +1,90 @@ +app_name = "aqrar_ext" +app_title = "Aqrar Ext" +app_publisher = "Enfono" +app_description = "Customizations and Extensions for Aqrar" +app_email = "nah@enfono.com" +app_license = "mit" + +import aqrar_ext.aqrar_ext.overrides.stock_ledger # noqa + +app_include_js = [ + "/assets/aqrar_ext/js/sales_invoice_pos_total_popup.js", + "/assets/aqrar_ext/js/item_selector.js", + "/assets/aqrar_ext/js/item_selector_hook.js", + "/assets/aqrar_ext/js/customer_price_history.js", + "/assets/aqrar_ext/js/customer_statement.js", + "/assets/aqrar_ext/js/stock_ledger_override.js", + "/assets/aqrar_ext/js/material_request_custom.js", + "/assets/aqrar_ext/js/purchase_receipt_final_grn.js", +] + +doctype_js = { + "Sales Invoice": "public/js/customer_price_history.js", + "Sales Order": "public/js/customer_price_history.js", + "Quotation": "public/js/customer_price_history.js", + "Delivery Note": "public/js/customer_price_history.js", + "Purchase Invoice": "public/js/customer_price_history.js", + "Purchase Order": "public/js/customer_price_history.js", + "Purchase Receipt": "public/js/customer_price_history.js", + "Customer": "public/js/customer_statement.js", + "Material Request": "public/js/material_request_custom.js", + "Purchase Receipt": "public/js/purchase_receipt_final_grn.js", +} + +doc_events = { + "Sales Invoice": { + "on_submit": "aqrar_ext.api.sales_invoice.auto_create_payment_entry_on_submit" + }, + "Material Request": { + "before_submit": "aqrar_ext.events.material_request.validate_branch_user" + }, + "Purchase Receipt": { + "before_cancel": "aqrar_ext.events.purchase_receipt.block_cancel_if_consumed" + } +} + +fixtures = [ + { + "dt": "Mode of Payment", + "filters": [ + ["name", "in", ["Cash", "Card", "Credit"]] + ] + }, + { + "dt": "Print Format", + "filters": [ + ["name", "in", ["Aqrar Delivery Note"]] + ] + }, + { + "dt": "Workflow", + "filters": [ + ["name", "in", ["Material Request Approval"]] + ] + }, + { + "dt": "Custom Field", + "filters": [ + [ + "name", + "in", + [ + # Sales Invoice + "Sales Invoice-custom_payment_mode", + # custom_last_price on child doctypes + "Sales Invoice Item-custom_last_price", + "Delivery Note Item-custom_last_price", + "Sales Order Item-custom_last_price", + "Quotation Item-custom_last_price", + "Purchase Invoice Item-custom_last_price", + "Purchase Order Item-custom_last_price", + "Purchase Receipt Item-custom_last_price", + "Sales Invoice-custom_partial_payment_amount", + "Material Request-custom_urgent", + "Material Request-custom_close_reason", + "Material Request-workflow_state", + ], + ] + ] + }, +] diff --git a/aqrar_ext/modules.txt b/aqrar_ext/modules.txt new file mode 100644 index 0000000..5fcec88 --- /dev/null +++ b/aqrar_ext/modules.txt @@ -0,0 +1 @@ +Aqrar Ext diff --git a/aqrar_ext/overrides/__init__.py b/aqrar_ext/overrides/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/overrides/item.py b/aqrar_ext/overrides/item.py new file mode 100644 index 0000000..67ade9b --- /dev/null +++ b/aqrar_ext/overrides/item.py @@ -0,0 +1,65 @@ +import frappe +from erpnext.stock.doctype.item.item import Item + + +class CustomItem(Item): + + def validate(self): + self._skip_uom_validation = True + super().validate() + + def validate_uom(self): + if self.is_new(): + return + + old_uom = frappe.db.get_value("Item", self.name, "stock_uom") + + if not old_uom or old_uom == self.stock_uom: + return + + sle_count = frappe.db.count( + "Stock Ledger Entry", + filters={ + "item_code": self.name, + "is_cancelled": 0 + } + ) + + if sle_count == 0: + return + + # Check admin using session user directly + user = frappe.session.user + is_admin = user == "Administrator" + + if not is_admin: + # Check System Manager role via DB + has_role = frappe.db.exists( + "Has Role", + {"parent": user, "role": "System Manager"} + ) + is_admin = bool(has_role) + + if not is_admin: + frappe.throw( + "Default UOM cannot be changed after stock transactions exist. " + "This item has " + str(sle_count) + " stock ledger entries. " + "Contact your administrator to override.", + title="UOM Locked" + ) + + elif not self.custom_uom_override_reason: + frappe.throw( + "Please use the Override UOM (Admin) button and provide " + "a reason before changing the Default UOM.", + title="Override Reason Required" + ) + + else: + comment_text = ( + "UOM Override by " + str(user) + + " | Old UOM: " + str(old_uom) + + " | New UOM: " + str(self.stock_uom) + + " | Reason: " + str(self.custom_uom_override_reason) + ) + self.add_comment("Info", comment_text) diff --git a/aqrar_ext/overrides/payment_entry.py b/aqrar_ext/overrides/payment_entry.py new file mode 100644 index 0000000..52bed1c --- /dev/null +++ b/aqrar_ext/overrides/payment_entry.py @@ -0,0 +1,54 @@ +import frappe +from frappe import _ +from erpnext.accounts.doctype.payment_entry.payment_entry import PaymentEntry + + +class CustomPaymentEntry(PaymentEntry): + + def validate(self): + super().validate() + self.validate_bank_reference() + + def before_submit(self): + if hasattr(super(), 'before_submit'): + super().before_submit() + self.validate_bank_reference_uniqueness() + + def validate_bank_reference(self): + """Req 63: Mandatory for Bank Transfer / Cheque""" + bank_modes = ["Bank Transfer", "Cheque"] + if self.mode_of_payment in bank_modes: + if not self.custom__bank_reference_no: + frappe.throw( + _("Bank Reference No is mandatory for {0} payments.").format( + self.mode_of_payment + ), + title=_("Missing Bank Reference No") + ) + + def validate_bank_reference_uniqueness(self): + """Req 64: Unique reference per bank account""" + bank_modes = ["Bank Transfer", "Cheque"] + if self.mode_of_payment in bank_modes and self.custom__bank_reference_no: + duplicate = frappe.db.get_value( + "Payment Entry", + { + "custom__bank_reference_no": self.custom__bank_reference_no, + "bank_account": self.bank_account, + "docstatus": 1, + "name": ("!=", self.name) + }, + "name" + ) + if duplicate: + frappe.throw( + _( + "Bank Reference No {0} already exists for " + "bank account {1} in Payment Entry {2}." + ).format( + self.custom__bank_reference_no, + self.bank_account, + duplicate + ), + title=_("Duplicate Bank Reference No") + ) diff --git a/aqrar_ext/overrides/quotation.py b/aqrar_ext/overrides/quotation.py new file mode 100644 index 0000000..c63d6f6 --- /dev/null +++ b/aqrar_ext/overrides/quotation.py @@ -0,0 +1,32 @@ +import frappe +from erpnext.selling.doctype.quotation.quotation import Quotation + + +class CustomQuotation(Quotation): + + def validate(self): + super().validate() + self.validate_customer_specific_items() + + def validate_customer_specific_items(self): + """Req 95: Warn if TM items are added to wrong customer""" + customer = self.party_name + + for item in self.items: + if not item.item_code: + continue + if not str(item.item_code).startswith("TM-"): + continue + + visibility = frappe.db.get_value( + "Item", item.item_code, "custom_item_visibility" + ) + + if visibility == "Customer-Specific": + frappe.msgprint( + "Item " + str(item.item_code) + " is a " + "Customer-Specific item. Ensure this Quotation " + "is for the correct customer: " + str(customer) + ".", + title="Customer-Specific Item", + indicator="blue" + ) diff --git a/aqrar_ext/patches.txt b/aqrar_ext/patches.txt new file mode 100644 index 0000000..f15c3a9 --- /dev/null +++ b/aqrar_ext/patches.txt @@ -0,0 +1,6 @@ +[pre_model_sync] +# Patches added in this section will be executed before doctypes are migrated +# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations + +[post_model_sync] +# Patches added in this section will be executed after doctypes are migrated \ No newline at end of file diff --git a/aqrar_ext/patches/__init__.py b/aqrar_ext/patches/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/patches/fix_stock_entry_naming_series.py b/aqrar_ext/patches/fix_stock_entry_naming_series.py new file mode 100644 index 0000000..3efe5c9 --- /dev/null +++ b/aqrar_ext/patches/fix_stock_entry_naming_series.py @@ -0,0 +1,31 @@ +# Fix duplicate Stock Transfer naming by syncing naming series counter +import frappe +from datetime import date + + +def execute(): + current_year = date.today().year + series_name = f"MAT-STE-{current_year}-" + + current = frappe.db.sql( + "SELECT current FROM `tabSeries` WHERE name = %s", + (series_name,) + ) + current_val = current[0][0] if current else 0 + + max_entry = frappe.db.sql( + "SELECT name FROM `tabStock Entry` WHERE name LIKE %s ORDER BY name DESC LIMIT 1", + (f"MAT-STE-{current_year}-%",) + ) + + if not max_entry: + return + + actual_max = int(max_entry[0][0].split("-")[-1]) + + if actual_max >= (current_val or 0): + frappe.db.sql( + "UPDATE `tabSeries` SET current = %s WHERE name = %s", + (actual_max + 1, series_name) + ) + frappe.db.commit() diff --git a/aqrar_ext/public/.gitkeep b/aqrar_ext/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/public/js/customer_price_history.js b/aqrar_ext/public/js/customer_price_history.js new file mode 100644 index 0000000..af04307 --- /dev/null +++ b/aqrar_ext/public/js/customer_price_history.js @@ -0,0 +1,720 @@ +frappe.provide("aqrar_ext.price_assist"); + +const DOCTYPE_CONFIG = { + "Sales Invoice": { + child_doctype: "Sales Invoice Item", + customer_field: "customer", + source: "sales" + }, + "Delivery Note": { + child_doctype: "Delivery Note Item", + customer_field: "customer", + source: "sales" + }, + "Sales Order": { + child_doctype: "Sales Order Item", + customer_field: "customer", + source: "sales" + }, + "Quotation": { + child_doctype: "Quotation Item", + customer_field: "party_name", + source: "sales" + }, + "Purchase Invoice": { + child_doctype: "Purchase Invoice Item", + customer_field: "supplier", + source: "purchase" + }, + "Purchase Order": { + child_doctype: "Purchase Order Item", + customer_field: "supplier", + source: "purchase" + }, + "Purchase Receipt": { + child_doctype: "Purchase Receipt Item", + customer_field: "supplier", + source: "purchase" + }, +}; + +for (const [doctype, config] of Object.entries(DOCTYPE_CONFIG)) { + + frappe.ui.form.on(doctype, { + + refresh(frm) { + + bind_row_click(frm, config); + + add_price_assist_button(frm, config); + + add_price_history_button(frm, config); + + update_all_last_prices(frm, config); + }, + + [config.customer_field](frm) { + update_all_last_prices(frm, config); + } + }); + + frappe.ui.form.on(config.child_doctype, { + + item_code(frm, cdt, cdn) { + + const row = locals[cdt][cdn]; + + if (!row.item_code) return; + + update_row_last_price(frm, row, config); + }, + + rate(frm, cdt, cdn) { + + const row = locals[cdt][cdn]; + + if (row._popup_opened) { + show_price_popup(frm, row, config); + } + } + }); +} + +function bind_row_click(frm, config) { + + if (frm.__price_row_bound) return; + + frm.fields_dict.items.grid.wrapper.on( + "click", + ".grid-row", + function () { + + const row_name = $(this).attr("data-name"); + + if (!row_name) return; + + const row = locals[config.child_doctype][row_name]; + + frm.__selected_price_row = row; + + // Auto-show popup on row click when item_code is set + if (row && row.item_code) { + show_price_popup(frm, row, config); + } + } + ); + + frm.__price_row_bound = true; +} + +function add_price_assist_button(frm, config) { + + if (frm.__price_btn_added) return; + + const btn = frm.fields_dict.items.grid.add_custom_button( + __("Price Assist"), + () => { + + const row = frm.__selected_price_row; + + if (!row) { + frappe.msgprint("Please select an item row"); + return; + } + + if (!row.item_code) { + frappe.msgprint("Please select item code"); + return; + } + + show_price_popup(frm, row, config); + } + ); + + frm.__price_btn_added = true; + + setTimeout(() => { + + const $toolbar = + frm.fields_dict.items.grid.wrapper.find(".grid-buttons"); + + const $add_multiple = + $toolbar.find("button:contains('Add Multiple')").last(); + + if ($add_multiple.length) { + $(btn).insertAfter($add_multiple); + } + + }, 100); +} + +function add_price_history_button(frm, config) { + + if (frm.__price_history_btn_added) return; + + const btn = frm.fields_dict.items.grid.add_custom_button( + __("Price History"), + () => { + + const row = frm.__selected_price_row; + + if (!row) { + frappe.msgprint("Please select an item row"); + return; + } + + if (!row.item_code) { + frappe.msgprint("Please select item code"); + return; + } + + const party = frm.doc[config.customer_field]; + show_price_history_dialog(row.item_code, config.source, party); + } + ); + + frm.__price_history_btn_added = true; + + setTimeout(() => { + + const $toolbar = + frm.fields_dict.items.grid.wrapper.find(".grid-buttons"); + + const $price_assist = + $toolbar.find("button:contains('Price Assist')").last(); + + if ($price_assist.length) { + $(btn).insertAfter($price_assist); + } + + }, 100); +} + +function show_price_history_dialog(item_code, source, customer) { + + source = source || "sales"; + const party_label = source === "purchase" ? __("Supplier") : __("Customer"); + + const d = new frappe.ui.Dialog({ + title: __("Item Sales & Purchase Price History"), + size: "extra-large", + fields: [ + { + fieldtype: "Link", + fieldname: "item_code", + label: __("Item Code"), + options: "Item", + default: item_code, + }, + { + fieldtype: "HTML", + fieldname: "history_table", + }, + ], + }); + + d.show(); + + function load_table(code) { + if (!code) { + d.fields_dict.history_table.$wrapper.html( + '

' + __("Please select an Item Code") + "

" + ); + return; + } + + d.fields_dict.history_table.$wrapper.html( + '

' + __("Loading...") + "

" + ); + + frappe.call({ + method: "aqrar_ext.api.get_item_price_history", + args: { item_code: code, source: source, customer: customer }, + callback: function (r) { + const data = r.message || {}; + const rows = data.history || []; + const last_pr = data.last_price || 0; + + render_price_history_table( + d.fields_dict.history_table.$wrapper, + rows, + last_pr, + source + ); + }, + }); + } + + d.fields_dict.item_code.$input.on("change", function () { + load_table(d.fields_dict.item_code.get_value()); + }); + + load_table(item_code); +} + +function render_price_history_table($wrapper, rows, last_price, source) { + + source = source || "sales"; + const party_col = source === "purchase" ? __("Supplier") : __("Customer"); + const rate_col = source === "purchase" ? __("Purchase Rate") : __("Sales Rate (Txn)"); + const last_col = source === "purchase" ? __("Last Sales Rate") : __("Last Purchase Rate"); + const empty_msg = source === "purchase" ? __("No purchase history found") : __("No sales history found"); + + if (!rows.length) { + $wrapper.html( + '

' + empty_msg + "

" + ); + return; + } + + let html = ` +
+ + + + + + + + + + + + + + + + + + + + + `; + + function build_row(r) { + return ` + + + + + + + + + `; + } + + rows.forEach(function (r) { + html += build_row(r); + }); + + html += ` + +
${__("Item Code")}${__("Item Name")}${party_col}${rate_col}${__("Qty")}${last_col}
${r.item_code}${r.item_name || ""}${r.party || r.customer || ""}${format_currency(r.rate)}${r.qty}${format_currency(last_price)}
+
+ `; + + $wrapper.html(html); + + // Filter logic + var $table = $wrapper.find(".price-history-table-wrap"); + + $table.on("input", ".ph-filter", function () { + var filters = []; + $table.find(".ph-filter").each(function () { + filters.push($(this).val().toLowerCase().trim()); + }); + + var tbody_html = ""; + var count = 0; + + rows.forEach(function (r) { + var cols = [ + (r.item_code || "").toLowerCase(), + (r.item_name || "").toLowerCase(), + (r.party || r.customer || "").toLowerCase(), + format_currency(r.rate).toLowerCase(), + String(r.qty || 0).toLowerCase(), + format_currency(last_price).toLowerCase(), + ]; + + var match = true; + for (var i = 0; i < 6; i++) { + if (filters[i] && cols[i].indexOf(filters[i]) === -1) { + match = false; + break; + } + } + + if (match) { + tbody_html += build_row(r); + count++; + } + }); + + if (!count) { + tbody_html = '' + __("No matching records") + ""; + } + + $table.find(".ph-tbody").html(tbody_html); + }); +} + +function update_all_last_prices(frm, config) { + + if (frm.doc.docstatus !== 0) return; + + (frm.doc.items || []).forEach(row => { + + if (row.item_code) { + update_row_last_price(frm, row, config); + } + }); +} + +function update_row_last_price(frm, row, config) { + + // Do not modify submitted/cancelled documents + if (frm.doc.docstatus !== 0) return; + + const party = frm.doc[config.customer_field]; + + frappe.call({ + method: "aqrar_ext.api.get_last_sold_price", + args: { + customer: party, + item_code: row.item_code, + source: config.source + }, + callback: function(r) { + + if (!r.message) return; + + frappe.model.set_value( + row.doctype, + row.name, + "custom_last_price", + r.message.last_price || 0 + ); + } + }); +} + +function show_price_popup(frm, row, config) { + + $(".customer-price-popup").remove(); + + const party = frm.doc[config.customer_field]; + + frappe.call({ + method: "aqrar_ext.api.get_item_insights", + args: { + customer: party, + item_code: row.item_code, + company: frm.doc.company, + source: config.source + }, + callback: function(r) { + + render_popup(frm, row, r.message || {}); + } + }); +} + +function render_popup(frm, row, data) { + + $(".customer-price-popup").remove(); + + row._popup_opened = true; + + const stock = data.stock || []; + const history = data.price_history || []; + const purchase_rate = data.last_purchase_rate || 0; + + let html = ` +
+ +
+
+ ${row.item_name || row.item_code} +
+ +
+ ✕ +
+
+ +
+ +
+ Last Purchase Rate +
+ +
+ ${purchase_rate} +
+ +
+ `; + + html += ` +
+ +
+ Stock By Warehouse +
+ `; + + stock.forEach(s => { + + html += ` +
+ ${s.warehouse} + ${s.projected_qty} +
+ `; + }); + + html += `
`; + + html += ` +
+ +
+ Customer Price History +
+ `; + + history.forEach(h => { + + html += ` +
+ +
+ ${format_currency(h.rate)} +
+ +
+ ${h.customer} +
+ +
+ ${frappe.datetime.str_to_user(h.posting_date)} +
+ +
+ `; + }); + + html += `
`; + + const $popup = $(html).appendTo("body"); + + const $row = $(`.grid-row[data-name="${row.name}"]`); + + if ($row.length) { + + const pos = $row.offset(); + const popup_h = 450; // approximate popup height + const popup_w = 430; + const win_h = $(window).height(); + const win_w = $(window).width(); + + let top = pos.top + 40; + let left = pos.left + 250; + + // Clamp to viewport — flip above row if near bottom + if (top + popup_h > win_h + $(window).scrollTop()) { + top = pos.top - popup_h - 10; + } + if (left + popup_w > win_w) { + left = win_w - popup_w - 20; + } + + $popup.css({ + top: top, + left: left + }); + } + + $popup.find(".cpp-close").on("click", function () { + + $(".customer-price-popup").remove(); + + row._popup_opened = false; + }); +} + +// aqrar_ext: Simplified Sales Invoice for Branch Users +frappe.ui.form.on("Sales Invoice", { + refresh(frm) { + if (!frappe.user.has_role("Branch User") || frappe.user.has_role("System Manager") || frappe.user.has_role("Stock Manager") || frm._branch_setup_done) return; + frm._branch_setup_done = true; + + // Hide unnecessary fields + [ + "posting_time", "set_posting_time", "due_date", + "is_pos", "pos_profile", "is_return", "is_debit_note", + "return_against", "amended_from", "scan_barcode", + "currency", "conversion_rate", "selling_price_list", "price_list_currency", + "plc_conversion_rate", "ignore_pricing_rule", + "apply_discount_on", "additional_discount_percentage", "discount_amount", + "additional_discount_account", "base_discount_amount", + "tax_category", "taxes_and_charges", "shipping_rule", "incoterm", "named_place", + "taxes", "total_taxes_and_charges", "base_total_taxes_and_charges", + "update_stock", "set_warehouse", "set_target_warehouse", + "po_no", "po_date", "commission_rate", "total_commission", "sales_partner", + "amount_eligible_for_commission", + "is_cash_or_non_trade_discount", + ].forEach(function (f) { frm.set_df_property(f, "hidden", 1); }); + + // Hide sections + [ + "accounting_dimensions_section", "currency_and_price_list", + "section_break_49", "taxes_section", "customer_po_details", + "more_info", "sales_team_section_break", "section_break2", + "edit_printing_settings", "more_information", "subscription_section", + ].forEach(function (s) { frm.set_df_property(s, "hidden", 1); }); + + // Hide tabs + ["payments_tab", "contact_and_address_tab", "terms_tab", "more_info_tab"] + .forEach(function (t) { frm.set_df_property(t, "hidden", 1); }); + + // naming_series — force hide via DOM (set_only_once blocks set_df_property) + frm.set_df_property("naming_series", "reqd", 0); + frm.set_df_property("naming_series", "hidden", 1); + $(frm.fields_dict.naming_series.wrapper).hide(); + + // Company read-only + frm.set_df_property("company", "read_only", 1); + + // Payment Mode required + frm.set_df_property("custom_payment_mode", "reqd", 1); + + // Auto-fill cost_center from Branch Configuration + if (!frm.doc.cost_center) { + frappe.call({ + method: "aqrar_ext.api.branch_config.get_user_branch_defaults", + callback: function (r) { + if (r.message && r.message.cost_center) { + frm.set_value("cost_center", r.message.cost_center); + } + if (r.message && r.message.warehouse && !frm.doc.set_warehouse) { + frm.set_value("set_warehouse", r.message.warehouse); + } + }, + }); + } + }, +}); + +$(document).on("click", function(e) { + + if ($(e.target).closest(".customer-price-popup").length) return; + + if ($(e.target).closest(".grid-row").length) return; + + $(".customer-price-popup").remove(); +}); + +$(` + + + +`).appendTo("head"); \ No newline at end of file diff --git a/aqrar_ext/public/js/customer_statement.js b/aqrar_ext/public/js/customer_statement.js new file mode 100644 index 0000000..4eaa187 --- /dev/null +++ b/aqrar_ext/public/js/customer_statement.js @@ -0,0 +1,71 @@ +// aqrar_ext: "Send Statement" button on Customer form +frappe.ui.form.on("Customer", { + refresh(frm) { + if (frm.doc.__islocal) return; + + frm.add_custom_button( + __("Send Statement"), + function () { + show_statement_dialog(frm); + }, + __("View") + ); + }, +}); + +function show_statement_dialog(frm) { + const d = new frappe.ui.Dialog({ + title: __("Customer Statement"), + size: "small", + fields: [ + { + fieldtype: "Date", + fieldname: "from_date", + label: __("From Date"), + reqd: 1, + default: frappe.datetime.month_start(), + }, + { + fieldtype: "Date", + fieldname: "to_date", + label: __("To Date"), + reqd: 1, + default: frappe.datetime.month_end(), + }, + ], + primary_action_label: __("Download PDF"), + primary_action(values) { + const { from_date, to_date } = values; + if (from_date > to_date) { + frappe.msgprint(__("From Date cannot be after To Date")); + return; + } + d.hide(); + const url = + "/api/method/aqrar_ext.aqrar_ext.report.customer_statement.customer_statement.get_pdf" + + "?customer=" + + encodeURIComponent(frm.doc.name) + + "&from_date=" + + encodeURIComponent(from_date) + + "&to_date=" + + encodeURIComponent(to_date); + window.open(url); + }, + secondary_action_label: __("View Report"), + secondary_action() { + const vals = d.get_values(); + if (!vals) return; + if (vals.from_date > vals.to_date) { + frappe.msgprint(__("From Date cannot be after To Date")); + return; + } + d.hide(); + frappe.set_route("query-report", "Customer Statement", { + customer: frm.doc.name, + from_date: vals.from_date, + to_date: vals.to_date, + }); + }, + }); + d.show(); +} diff --git a/aqrar_ext/public/js/item_selector.js b/aqrar_ext/public/js/item_selector.js new file mode 100644 index 0000000..72c206f --- /dev/null +++ b/aqrar_ext/public/js/item_selector.js @@ -0,0 +1,305 @@ +// aqrar_ext: Multi-select item picker with running search and quantity +// Replaces the stock "Add Multiple" LinkSelector for item grids. + +frappe.ui.form.ItemMultiSelector = class ItemMultiSelector { + constructor(opts) { + this.target = opts.target; // the grid object + this.item_field = opts.fieldname; // typically "item_code" + this.qty_field = opts.qty_fieldname; // typically "qty" + this.get_query = opts.get_query; + this.start = 0; + this.page_length = 20; + this.selected = {}; // { item_code: qty } + this.make(); + } + + make() { + var me = this; + + this.dialog = new frappe.ui.Dialog({ + title: __("Select Items"), + fields: [ + { + fieldtype: "Data", + fieldname: "search_txt", + label: __("Search Items"), + placeholder: __("Type to search..."), + onchange: function () { + me.debounced_search(); + }, + }, + { + fieldtype: "HTML", + fieldname: "results_area", + }, + ], + primary_action_label: __("Add Selected Items"), + primary_action: function () { + me.add_selected_to_grid(); + }, + }); + + this.dialog.show(); + this.search(); + } + + debounced_search() { + if (this._search_timeout) clearTimeout(this._search_timeout); + var me = this; + this._search_timeout = setTimeout(function () { + me.start = 0; + me.search(); + }, 300); + } + + search() { + var me = this; + var txt = this.dialog.fields_dict.search_txt.get_value() || ""; + + var args = { + txt: txt, + searchfield: "name", + start: this.start, + page_length: this.page_length, + }; + + // Apply custom query filters from the grid field + if ( + this.target.is_grid && + this.target.fieldinfo && + this.target.fieldinfo[this.item_field] && + this.target.fieldinfo[this.item_field].get_query + ) { + $.extend(args, this.target.fieldinfo[this.item_field].get_query(cur_frm.doc)); + } + + frappe.link_search("Item", args, function (results) { + me.render_results(results, args.start > 0); + }); + } + + render_results(results, append) { + var parent = this.dialog.fields_dict.results_area.$wrapper; + + if (!append) { + parent.empty(); + } + + if (!results.length && !append) { + parent.html( + '

' + __("No items found") + "

" + ); + return; + } + + // Remove old Load More button before adding new rows + parent.find(".load-more").remove(); + + if (!append) { + // Build table header + var header = $( + '
' + + '' + + '' + __("Item") + '' + + '' + __("Description") + '' + + '' + __("Stock") + '' + + '' + __("Qty") + '' + + '
' + ).appendTo(parent); + var list = $('
').appendTo(parent); + } else { + // Append to existing row container + var list = parent.find(".item-selector-rows"); + } + var me = this; + + // Collect item codes for batch stock lookup + var item_codes = results.map(function (r) { return r[0]; }); + + // Render each row + results.forEach(function (r) { + var item_code = r[0]; + var item_name = r[1] || ""; + var checked_attr = me.selected[item_code] !== undefined ? "checked" : ""; + var qty_val = me.selected[item_code] || 1; + + var row = $( + '
' + + '' + + '' + + '' + + '' + item_code + '' + + '' + item_name + '' + + '' + + '...' + + '' + + '' + + '' + + '' + + '
' + ).appendTo(list); + + // Checkbox click + row.find(".item-check").on("change", function () { + var code = $(this).attr("data-item"); + if (this.checked) { + var qty = parseFloat(row.find(".item-qty").val()) || 1; + me.selected[code] = qty; + } else { + delete me.selected[code]; + } + }); + + // Qty change + row.find(".item-qty").on("change input", function () { + var code = $(this).attr("data-item"); + var val = parseFloat($(this).val()) || 0; + if (row.find(".item-check").is(":checked")) { + me.selected[code] = val; + } + }); + }); + + // Load stock info for all items + this.load_stock_info(item_codes); + + // Load More button + if (results.length >= this.page_length) { + var me = this; + $( + '" + ) + .appendTo(parent) + .on("click", function () { + me.start += me.page_length; + me.search(); + }); + } + } + + load_stock_info(item_codes) { + if (!item_codes.length) return; + var me = this; + + var warehouse = ( + cur_frm.doc.set_warehouse || + cur_frm.doc.set_source_warehouse || + "" + ); + + var filters = { item_code: ["in", item_codes] }; + if (warehouse) { + filters.warehouse = warehouse; + } + + frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "Bin", + fields: ["item_code", "actual_qty"], + filters: filters, + limit_page_length: 500, + }, + callback: function (r) { + if (!r.message) { + // API failed — show "--" for all items + item_codes.forEach(function (code) { + var badge = me.dialog.$wrapper.find('.stock-badge[data-item="' + code + '"]'); + badge.text("--").removeClass().addClass("stock-badge badge"); + }); + return; + } + // Sum actual_qty per item_code (across warehouses when no filter) + var stock_map = {}; + r.message.forEach(function (b) { + var code = b.item_code; + stock_map[code] = (stock_map[code] || 0) + (b.actual_qty || 0); + }); + item_codes.forEach(function (code) { + var badge = me.dialog.$wrapper.find('.stock-badge[data-item="' + code + '"]'); + if (stock_map[code] === undefined) { + badge.text("0").removeClass().addClass("stock-badge badge"); + } else if (stock_map[code] <= 0) { + badge.text("0").removeClass().addClass("stock-badge badge"); + } else { + badge.text(stock_map[code]).removeClass().addClass("stock-badge badge"); + } + }); + }, + }); + } + + add_selected_to_grid() { + var me = this; + var items = Object.keys(this.selected); + + if (!items.length) { + frappe.msgprint(__("Please select at least one item.")); + return; + } + + // Build list of rows to add + var to_add = items + .filter(function (code) { + return (me.selected[code] || 0) > 0; + }) + .map(function (code) { + return { item_code: code, qty: me.selected[code] }; + }); + + if (!to_add.length) { + frappe.msgprint(__("All selected items have qty 0.")); + return; + } + + // Add rows sequentially + var chain = Promise.resolve(); + to_add.forEach(function (row) { + chain = chain.then(function () { + return me.add_row_to_grid(row.item_code, row.qty); + }); + }); + + chain.then(function () { + me.dialog.hide(); + frappe.show_alert( + __("Added {0} items", [to_add.length]), + "green" + ); + }); + } + + add_row_to_grid(item_code, qty) { + var me = this; + return new Promise(function (resolve) { + var existing = (me.target.frm.doc[me.target.df.fieldname] || []).find(function (d) { + return d[me.item_field] === item_code; + }); + + if (existing) { + frappe.model + .set_value(existing.doctype, existing.name, me.qty_field, qty) + .then(function () { resolve(); }); + } else { + var d = me.target.add_new_row(); + // Set item_code first so item details are fetched, + // then set qty to prevent it being overwritten by item defaults + frappe.timeout(0.1).then(function () { + var item_args = {}; + item_args[me.item_field] = item_code; + frappe.model.set_value(d.doctype, d.name, item_args).then(function () { + frappe.model.set_value(d.doctype, d.name, me.qty_field, qty).then(function () { + resolve(); + }); + }); + }); + } + }); + } +}; diff --git a/aqrar_ext/public/js/item_selector_hook.js b/aqrar_ext/public/js/item_selector_hook.js new file mode 100644 index 0000000..b2ffa72 --- /dev/null +++ b/aqrar_ext/public/js/item_selector_hook.js @@ -0,0 +1,43 @@ +// aqrar_ext: Wire ItemMultiSelector into Sales Invoice, Quotation, Custom Quote + +var doctypes_with_items = ["Sales Invoice", "Quotation", "Custom Quote"]; + +doctypes_with_items.forEach(function (doctype) { + frappe.ui.form.on(doctype, { + refresh: function (frm) { + if (!frm.fields_dict.items || !frm.fields_dict.items.grid) return; + + var grid = frm.fields_dict.items.grid; + if (grid._aqrar_multisel_hooked) return; + grid._aqrar_multisel_hooked = true; + + // Detect which field is the item link + var child_doctype = grid.df.options; + var item_field = frappe.meta.get_docfield(child_doctype, "item_code") + ? "item_code" + : frappe.meta.get_docfield(child_doctype, "item") + ? "item" + : null; + + if (!item_field) return; + + // Only wire up if the item field links to Item doctype + var df = frappe.meta.get_docfield(child_doctype, item_field); + if (!df || df.fieldtype !== "Link" || df.options !== "Item") return; + + var qty_field = frappe.meta.get_docfield(child_doctype, "qty") ? "qty" : null; + + var btn = $(grid.wrapper).find(".grid-add-multiple-rows"); + btn.removeClass("hidden"); + btn.off("click.aqrar").on("click.aqrar", function (e) { + e.stopImmediatePropagation(); + e.preventDefault(); + new frappe.ui.form.ItemMultiSelector({ + target: grid, + fieldname: item_field, + qty_fieldname: qty_field, + }); + }); + }, + }); +}); diff --git a/aqrar_ext/public/js/material_request_custom.js b/aqrar_ext/public/js/material_request_custom.js new file mode 100644 index 0000000..254783b --- /dev/null +++ b/aqrar_ext/public/js/material_request_custom.js @@ -0,0 +1,91 @@ +// aqrar_ext: Material Request — running counter + close button + urgent +frappe.ui.form.on("Material Request", { + refresh(frm) { + show_fulfillment(frm); + + if (frm.doc.docstatus === 0) return; + + // Close button (submitted, not closed, not 100% done) + if (frm.doc.docstatus === 1 && !frm.doc.custom_close_reason + && frm.doc.per_ordered < 100) { + frm.add_custom_button(__("Close MR"), function () { + show_close_dialog(frm); + }, __("Actions")); + } + + // Reopen button + if (frm.doc.docstatus === 1 && frm.doc.custom_close_reason) { + frm.add_custom_button(__("Reopen MR"), function () { + frappe.confirm(__("Reopen this Material Request?"), function () { + frappe.call({ + method: "aqrar_ext.events.material_request.reopen_material_request", + args: { mr_name: frm.doc.name }, + freeze: true, + callback: function () { frm.reload_doc(); }, + }); + }); + }, __("Actions")); + } + }, + + before_save(frm) { + if (frm.doc.custom_close_reason && frm.doc.docstatus === 1) { + frm.set_value("status", "Stopped"); + } + }, +}); + +function show_fulfillment(frm) { + // Remove previous + $(".aqrar-fulfillment").remove(); + + var total_req = 0, total_done = 0; + (frm.doc.items || []).forEach(function (item) { + total_req += flt(item.stock_qty || item.qty || 0); + total_done += flt(item.ordered_qty || 0); + }); + + var pct = total_req > 0 ? Math.round((total_done / total_req) * 100) : 0; + var color = pct >= 100 ? "green" : pct > 0 ? "orange" : "gray"; + var bar = ""; + for (var i = 0; i < 10; i++) bar += i < Math.round(pct / 10) ? "█" : "░"; + + var html = '
' + + '' + __("Fulfillment") + ': ' + + '' + + pct + '% ' + + '(' + total_done + ' / ' + total_req + ' ' + __("transferred") + ') ' + + '' + bar + '
'; + + var $ctrl = $(frm.fields_dict.items.wrapper); + $ctrl.prepend(html); +} + +function show_close_dialog(frm) { + var d = new frappe.ui.Dialog({ + title: __("Close Material Request"), + fields: [ + { + fieldtype: "Small Text", + fieldname: "reason", + label: __("Reason for Closing"), + reqd: 1, + }, + ], + primary_action_label: __("Close MR"), + primary_action(values) { + d.hide(); + frappe.call({ + method: "aqrar_ext.events.material_request.close_material_request", + args: { + mr_name: frm.doc.name, + reason: values.reason, + }, + freeze: true, + callback: function () { frm.reload_doc(); }, + }); + }, + }); + d.show(); +} diff --git a/aqrar_ext/public/js/purchase_receipt_final_grn.js b/aqrar_ext/public/js/purchase_receipt_final_grn.js new file mode 100644 index 0000000..56678b6 --- /dev/null +++ b/aqrar_ext/public/js/purchase_receipt_final_grn.js @@ -0,0 +1,198 @@ +frappe.ui.form.on('Purchase Receipt', { + refresh: function(frm) { + if (frm.doc.docstatus === 1 || frm.doc.docstatus === 0) { + frm.add_custom_button(__('Final GRN'), function() { + new_purchase_receipt_with_data(frm); + }, __('Create')); + } + } +}); + +function new_purchase_receipt_with_data(frm) { + + if (!frm.doc.items || frm.doc.items.length === 0) { + frappe.msgprint(__('No items found to carry forward.')); + return; + } + + // Store source data BEFORE navigating away + let source = JSON.parse(JSON.stringify(frm.doc)); + let source_name = frm.doc.name; + let source_docstatus = frm.doc.docstatus; + + // Navigate to new Purchase Receipt + frappe.new_doc('Purchase Receipt'); + + // Wait for form to fully render + setTimeout(function() { + populate_new_form(source, source_name, source_docstatus); + }, 1500); +} + +function populate_new_form(source, source_name, source_docstatus) { + let new_frm = cur_frm; + + if (!new_frm || new_frm.doctype !== 'Purchase Receipt') { + setTimeout(function() { populate_new_form(source, source_name, source_docstatus); }, 500); + return; + } + + //Helper: safe set value + function s(field, value) { + try { + if (new_frm.fields_dict[field] !== undefined && value) { + new_frm.doc[field] = value; + } + } catch(e) {} + } + + //Header + s('company', source.company); + s('supplier', source.supplier); + s('supplier_name', source.supplier_name); + s('currency', source.currency); + s('conversion_rate', source.conversion_rate); + s('buying_price_list', source.buying_price_list); + s('price_list_currency', source.price_list_currency); + s('plc_conversion_rate', source.plc_conversion_rate); + s('set_warehouse', source.set_warehouse); + s('cost_center', source.cost_center); + s('project', source.project); + s('is_subcontracted', source.is_subcontracted); + s('apply_putaway_rule', source.apply_putaway_rule); + s('ignore_pricing_rule', source.ignore_pricing_rule); + s('letter_head', source.letter_head); + s('tc_name', source.tc_name); + s('terms', source.terms); + s('remarks', source.remarks); + s('taxes_and_charges', source.taxes_and_charges); + s('lr_no', source.lr_no); + s('lr_date', source.lr_date); + s('supplier_delivery_note', source.supplier_delivery_note); + + //Address & Contact + s('supplier_address', source.supplier_address); + s('contact_person', source.contact_person); + s('contact_email', source.contact_email); + s('shipping_address', source.shipping_address); + s('billing_address', source.billing_address); + + // Store source info for after_save + new_frm._source_name = source_name; + new_frm._source_docstatus = source_docstatus; + + //ITEMS + new_frm.doc.items = []; + + (source.items || []).forEach(function(item, idx) { + let row = frappe.model.add_child(new_frm.doc, 'Purchase Receipt Item', 'items'); + row.idx = idx + 1; + row.item_code = item.item_code; + row.item_name = item.item_name; + row.description = item.description; + row.item_group = item.item_group; + row.brand = item.brand; + row.qty = item.qty; + row.received_qty = item.qty; + row.rejected_qty = 0; + row.uom = item.uom; + row.stock_uom = item.stock_uom; + row.conversion_factor = item.conversion_factor; + row.stock_qty = item.stock_qty; + row.rate = item.rate; + row.amount = item.amount; + row.base_rate = item.base_rate; + row.base_amount = item.base_amount; + row.price_list_rate = item.price_list_rate; + row.discount_percentage = item.discount_percentage; + row.discount_amount = item.discount_amount; + row.warehouse = item.warehouse || source.set_warehouse; + row.rejected_warehouse = item.rejected_warehouse; + row.expense_account = item.expense_account; + row.cost_center = item.cost_center; + row.project = item.project; + row.purchase_order = item.purchase_order; + row.purchase_order_item = item.purchase_order_item; + row.is_free_item = item.is_free_item; + row.batch_no = item.batch_no; + row.serial_no = item.serial_no; + row.weight_per_unit = item.weight_per_unit; + row.weight_uom = item.weight_uom; + row.total_weight = item.total_weight; + row.valuation_rate = item.valuation_rate; + row.allow_zero_valuation_rate = item.allow_zero_valuation_rate; + }); + + //TAXES + new_frm.doc.taxes = []; + + (source.taxes || []).forEach(function(tax, idx) { + let row = frappe.model.add_child(new_frm.doc, 'Purchase Taxes and Charges', 'taxes'); + row.idx = idx + 1; + row.charge_type = tax.charge_type; + row.account_head = tax.account_head; + row.description = tax.description; + row.cost_center = tax.cost_center; + row.rate = tax.rate; + row.tax_amount = tax.tax_amount; + row.total = tax.total; + row.included_in_print_rate = tax.included_in_print_rate; + row.row_id = tax.row_id; + }); + + //Refresh all fields + new_frm.refresh_fields(); + new_frm.refresh_field('items'); + new_frm.refresh_field('taxes'); + + try { new_frm.script_manager.trigger('calculate_taxes_and_totals'); } catch(e) {} + + frappe.show_alert({ + message: __('Data carried forward from ' + source_name + '. Save to auto-cancel original.'), + indicator: 'blue' + }, 5); +} + +//Auto-cancel original when new receipt is SAVED +frappe.ui.form.on('Purchase Receipt', { + after_save: function(frm) { + let source_name = frm._source_name; + let source_docstatus = frm._source_docstatus; + + if (!source_name) return; + + if (source_docstatus === 1) { + frappe.call({ + method: 'frappe.client.cancel', + args: { doctype: 'Purchase Receipt', name: source_name }, + callback: function(r) { + if (!r.exc) { + frappe.show_alert({ + message: __(source_name + ' automatically cancelled.'), + indicator: 'green' + }, 6); + frm._source_name = null; + } else { + frappe.msgprint(__('Could not auto-cancel ' + source_name + '. Please cancel manually.')); + } + } + }); + } else if (source_docstatus === 0) { + frappe.call({ + method: 'frappe.client.delete', + args: { doctype: 'Purchase Receipt', name: source_name }, + callback: function(r) { + if (!r.exc) { + frappe.show_alert({ + message: __('Draft' + source_name + ' automatically deleted.'), + indicator: 'green' + }, 6); + frm._source_name = null; + } else { + frappe.msgprint(__('Could not delete draft ' + source_name + '. Please delete manually.')); + } + } + }); + } + } +}); diff --git a/aqrar_ext/public/js/sales_invoice_pos_total_popup.js b/aqrar_ext/public/js/sales_invoice_pos_total_popup.js new file mode 100644 index 0000000..b66a221 --- /dev/null +++ b/aqrar_ext/public/js/sales_invoice_pos_total_popup.js @@ -0,0 +1,620 @@ +// aqrar_ext: POS payment popup + create Payment Entry on submit + +frappe.ui.form.on("Sales Invoice", { + refresh: function (frm) { + const should_open_popup_before_submit = function () { + return ( + !frappe.flags.aqrar_skip_payment_popup && + !frappe.flags.aqrar_payment_popup_showing && + frm.doc && + frm.doc.docstatus === 0 && + frm.doc.custom_payment_mode === "Cash" && + flt(frm.doc.grand_total) > 0 && + frm.doc.name && + !String(frm.doc.name).startsWith("new-") + ); + }; + if (frm._aqrar_save_wrapped) return; + frm._aqrar_save_wrapped = true; + const orig = frm.save.bind(frm); + frm.save = function (save_action, callback, btn, on_error) { + frappe.flags._aqrar_save_action = save_action || "Save"; + if (save_action === "Submit" && should_open_popup_before_submit()) { + aqrar_show_pos_total_popup(frm); + return Promise.resolve(); + } + + return orig(save_action, callback, btn, on_error).finally(function () { + delete frappe.flags._aqrar_save_action; + }); + }; + + // Frappe toolbar submit can call savesubmit() directly. + // Intercept it too so popup always appears before submit. + if (!frm._aqrar_savesubmit_wrapped && frm.savesubmit) { + frm._aqrar_savesubmit_wrapped = true; + const orig_savesubmit = frm.savesubmit.bind(frm); + frm.savesubmit = function (btn, callback, on_error) { + if (should_open_popup_before_submit()) { + aqrar_show_pos_total_popup(frm); + return Promise.resolve(); + } + // Credit: direct submit (no payment popup) + return orig_savesubmit(btn, callback, on_error); + }; + } + }, + after_save: function (frm) { + // Popup on Save also (but never when the save is triggered from inside this popup) + if (frappe.flags.aqrar_skip_payment_popup) return; + + // Prevent popup if already showing + if (frappe.flags.aqrar_payment_popup_showing) return; + + // Only for draft Sales Invoice + if (frm.doc.docstatus !== 0) return; + if (frm.doc.custom_payment_mode !== "Cash") return; + + // Validate required fields + if (!frm.doc.grand_total || frm.doc.grand_total <= 0) return; + if (!frm.doc.name || String(frm.doc.name).startsWith("new-")) return; + + // If POS Profile exists, respect its disable flag; else show popup anyway + if (frm.doc.pos_profile) { + frappe.db.get_value( + "POS Profile", + frm.doc.pos_profile, + "disable_grand_total_to_default_mop", + function (r) { + if (r && r.message === 1) return; + aqrar_show_pos_total_popup(frm); + } + ); + } else { + aqrar_show_pos_total_popup(frm); + } + }, + // Credit — ask submit vs save only when user clicks Save (not Submit) + before_save: function (frm) { + if (!frm.doc.custom_payment_mode || frm.doc.custom_payment_mode !== "Credit") return; + if (frm.doc.docstatus !== 0) return; + if (frappe.flags._aqrar_save_action === "Submit") return; + if (frm._aqrar_asked_to_submit) return; + + frm._aqrar_asked_to_submit = true; + frappe.validated = false; + + frappe.confirm( + __("Do you want to Submit this Sales Invoice now?"), + function () { + frm.save("Submit").then(function () { + frm._aqrar_asked_to_submit = false; + frm.reload_doc(); + }); + }, + function () { + frm.save().then(function () { + frm._aqrar_asked_to_submit = false; + }); + } + ); + }, +}); + +function aqrar_show_pos_total_popup(frm) { + // Prevent multiple popups + if (frappe.flags.aqrar_payment_popup_showing) return; + + // Validate form state + if (!frm || !frm.doc) { + return; + } + + frappe.flags.aqrar_payment_popup_showing = true; + + function do_show_popup() { + if (frm.doc.pos_profile || !frm.doc.payments || frm.doc.payments.length === 0) { + if (!frm.doc.pos_profile) { + frappe.call({ + method: "aqrar_ext.api.sales_invoice.get_payment_modes_with_account", + args: { company: frm.doc.company }, + callback: function (res) { + const modes = res.message || []; + if (!modes.length) { + frappe.flags.aqrar_payment_popup_showing = false; + frappe.msgprint( + __("No enabled payment modes with a default account for this company.") + ); + return; + } + + // Only show Cash mode in the payment popup + const cash_mode = modes.filter(function (m) { return m === "Cash"; }); + frm.clear_table("payments"); + cash_mode.forEach(function (mode) { + const row = frm.add_child("payments"); + row.mode_of_payment = mode; + }); + frm.refresh_field("payments"); + + frappe.call({ + doc: frm.doc, + method: "set_account_for_mode_of_payment", + callback: function () { + frm.refresh_field("payments"); + aqrar_render_dialog(frm); + }, + error: function () { + frappe.flags.aqrar_payment_popup_showing = false; + frappe.msgprint(__("Error loading payment accounts. Please try again.")); + }, + }); + }, + error: function () { + frappe.flags.aqrar_payment_popup_showing = false; + frappe.msgprint(__("Error loading payment modes. Please try again.")); + }, + }); + return; + } + frappe.call({ + method: "frappe.client.get", + args: { doctype: "POS Profile", name: frm.doc.pos_profile }, + callback: function (r) { + if (r.message && r.message.payments && r.message.payments.length > 0) { + const profile_payments = r.message.payments; + const mode_list = profile_payments.map((p) => p.mode_of_payment); + const default_by_mode = {}; + profile_payments.forEach((p) => (default_by_mode[p.mode_of_payment] = p.default)); + + frappe.call({ + method: "aqrar_ext.api.sales_invoice.get_payment_modes_with_account", + args: { company: frm.doc.company, mode_list: mode_list }, + callback: function (res) { + const valid_modes = res.message || []; + if (!valid_modes.length) { + frappe.flags.aqrar_payment_popup_showing = false; + frappe.msgprint( + __("No enabled payment modes with a default account for this company.") + ); + return; + } + + // Only show Cash mode in the payment popup + const cash_modes = valid_modes.filter(function (m) { return m === "Cash"; }); + frm.clear_table("payments"); + cash_modes.forEach(function (mode) { + const row = frm.add_child("payments"); + row.mode_of_payment = mode; + row.default = default_by_mode[mode] || 0; + }); + frm.refresh_field("payments"); + + frappe.call({ + doc: frm.doc, + method: "set_account_for_mode_of_payment", + callback: function () { + frm.refresh_field("payments"); + aqrar_render_dialog(frm); + }, + error: function () { + frappe.flags.aqrar_payment_popup_showing = false; + frappe.msgprint(__("Error loading payment accounts. Please try again.")); + }, + }); + }, + error: function () { + frappe.flags.aqrar_payment_popup_showing = false; + frappe.msgprint(__("Error loading payment modes. Please try again.")); + }, + }); + } else { + frappe.flags.aqrar_payment_popup_showing = false; + frappe.msgprint(__("Add payment modes in POS Profile first")); + } + }, + error: function () { + frappe.flags.aqrar_payment_popup_showing = false; + frappe.msgprint(__("Error loading POS Profile. Please try again.")); + }, + }); + } else { + aqrar_render_dialog(frm); + } + } + + do_show_popup(); +} + +function aqrar_render_dialog(frm) { + // Validate form state + if (!frm || !frm.doc) { + frappe.flags.aqrar_payment_popup_showing = false; + return; + } + + const payments = frm.doc.payments || []; + if (payments.length === 0) { + frappe.flags.aqrar_payment_popup_showing = false; + return; + } + + const invoice_total = flt(frm.doc.rounded_total || frm.doc.grand_total || 0); + const currency = frm.doc.currency || ""; + + // Validate invoice total + if (invoice_total <= 0) { + frappe.flags.aqrar_payment_popup_showing = false; + frappe.msgprint(__("Invoice total must be greater than zero.")); + return; + } + + const fields = [ + { + fieldname: "invoice_total", + fieldtype: "Currency", + label: __("Invoice Total"), + default: invoice_total, + read_only: 1, + options: currency, + }, + { fieldtype: "Section Break", label: __("Enter Payment Amounts") }, + ]; + + payments.forEach(function (payment, idx) { + const mode = payment.mode_of_payment || "Payment " + (idx + 1); + fields.push( + { + fieldtype: "Section Break", + fieldname: "row_" + idx, + label: "", + hide_border: 1, + collapsible: 0, + }, + { + fieldname: "pay_" + idx, + fieldtype: "Currency", + label: mode, + default: payment.amount || 0, + options: currency, + }, + { fieldtype: "Column Break", fieldname: "cb_" + idx }, + { + fieldtype: "Button", + fieldname: "fill_" + idx, + label: mode, + click: function () { + payments.forEach(function (_, i) { + d.set_value("pay_" + i, i === idx ? invoice_total : 0); + }); + }, + } + ); + }); + + function apply_payments_and_close(vals, submit) { + // Prevent multiple simultaneous saves + if (frappe.flags.aqrar_payment_popup_saving) { + frappe.msgprint({ + title: __("Please Wait"), + message: __("Saving in progress. Please wait..."), + indicator: "orange", + }); + return; + } + + // Validate form state + if (!frm || !frm.doc || frm.doc.docstatus !== 0) { + frappe.msgprint({ + title: __("Error"), + message: __("Cannot update payments. Form is not in draft state."), + indicator: "red", + }); + return; + } + + // Validate inputs + if (!vals) { + frappe.msgprint({ + title: __("Error"), + message: __("Please enter payment amounts."), + indicator: "red", + }); + return; + } + + let total = 0; + // First validate total + collect payload for Payment Entry creation + const payments_payload = []; + payments.forEach(function (p, i) { + const amt = flt(vals["pay_" + i]) || 0; + total += amt; + if (amt > 0) { + payments_payload.push({ mode_of_payment: p.mode_of_payment, amount: amt }); + } + }); + + if (total < invoice_total) { + frappe.msgprint({ + title: __("Incomplete"), + message: __("{0} still to be allocated", [format_currency(invoice_total - total, currency)]), + indicator: "red", + }); + return; + } + + if (total - invoice_total > 0.5) { + frappe.msgprint({ + title: __("Error"), + message: __( + "Total payment amount {0} cannot be greater than invoice total {1}.", + [format_currency(total, currency), format_currency(invoice_total, currency)] + ), + indicator: "red", + }); + return; + } + + // Ensure form payments exist and match + const form_payments = frm.doc.payments || []; + if (form_payments.length === 0) { + frappe.msgprint({ + title: __("Error"), + message: __("No payment methods found. Please refresh the form."), + indicator: "red", + }); + return; + } + + // Ensure conversion_rate is valid + const conversion_rate = flt(frm.doc.conversion_rate) || 1; + + // Helper function for precision + const get_precision = function (fieldname, doc) { + try { + return precision(fieldname, doc) || 2; + } catch (e) { + return 2; + } + }; + + // Update payments with robust matching - update ALL payments (including zero amounts) + let update_count = 0; + payments.forEach(function (p, i) { + const amt = flt(vals["pay_" + i]) || 0; + const base_amt = flt(amt * conversion_rate, get_precision("base_amount", p)); + + // Try multiple matching strategies for reliability + let form_payment = null; + + // Strategy 1: Match by mode_of_payment + if (p.mode_of_payment) { + form_payment = form_payments.find((fp) => fp.mode_of_payment === p.mode_of_payment); + } + + // Strategy 2: Match by index if same length + if (!form_payment && i < form_payments.length && payments.length === form_payments.length) { + form_payment = form_payments[i]; + } + + // Strategy 3: Match by idx if available + if (!form_payment && p.idx) { + form_payment = form_payments.find((fp) => fp.idx === p.idx); + } + + // Strategy 4: Match by name if available + if (!form_payment && p.name) { + form_payment = form_payments.find((fp) => fp.name === p.name); + } + + // Update if match found - update ALL payments including zero amounts + if (form_payment) { + form_payment.amount = amt; + form_payment.base_amount = base_amt; + update_count++; + } + }); + + // Validate that we updated at least one payment + if (update_count === 0) { + frappe.msgprint({ + title: __("Error"), + message: __("Could not match payments. Please refresh the form and try again."), + indicator: "red", + }); + return; + } + + // Verify payments were updated + const updated_payments = frm.doc.payments.filter((p) => flt(p.amount) > 0); + if (updated_payments.length === 0) { + frappe.msgprint({ + title: __("Error"), + message: __("No payment amounts were set. Please try again."), + indicator: "red", + }); + return; + } + + // Ensure form recognizes payments as changed + if (frm.local_doclist && frm.local_doclist["Sales Invoice Payment"]) { + frm.doc.payments.forEach(function (payment) { + const doclist_item = frm.local_doclist["Sales Invoice Payment"].find( + (item) => item.name === payment.name || item.idx === payment.idx + ); + if (doclist_item) { + doclist_item.amount = payment.amount; + doclist_item.base_amount = payment.base_amount; + } + }); + } + + // Mark form as dirty to ensure changes are saved + frm.dirty(); + + // Refresh payments field to update UI before saving + frm.refresh_field("payments"); + + // Close dialog before saving + d.hide(); + frappe.flags.aqrar_skip_payment_popup = true; + frappe.flags.aqrar_payment_popup_showing = false; + frappe.flags.aqrar_payment_popup_saving = true; + + // Use save with "Submit" action instead of savesubmit + const save_action = submit ? "Submit" : "Save"; + + // Delay to ensure refresh_field completes and form processes updates + setTimeout(function () { + // Double-check payments are in form doc before saving + if (!frm.doc.payments || frm.doc.payments.length === 0) { + frappe.msgprint({ + title: __("Error"), + message: __("Payments were not updated. Please try again."), + indicator: "red", + }); + delete frappe.flags.aqrar_skip_payment_popup; + delete frappe.flags.aqrar_payment_popup_saving; + return; + } + + // Verify payments have amounts + const total_payment = frm.doc.payments.reduce((sum, p) => sum + flt(p.amount), 0); + if (total_payment <= 0) { + frappe.msgprint({ + title: __("Error"), + message: __("Total payment amount must be greater than zero."), + indicator: "red", + }); + delete frappe.flags.aqrar_skip_payment_popup; + delete frappe.flags.aqrar_payment_popup_saving; + return; + } + + // Submit as non-POS so the payments child table does NOT auto-settle the + // invoice at submit. Outstanding stays at grand_total so the backend + // Payment Entries created next actually close the invoice. + if (submit) { + frm.doc.is_pos = 0; + frm.clear_table("payments"); + frm.refresh_field("is_pos"); + frm.refresh_field("payments"); + } + + // Save - payments are already updated in frm.doc.payments + frm.save(save_action) + .then(function (r) { + // If user submitted, create Payment Entries from the popup amounts + if (submit && frm.doc.docstatus === 1) { + frappe.call({ + method: "aqrar_ext.api.sales_invoice.create_pos_payments_for_invoice", + args: { + sales_invoice: frm.doc.name, + payments: JSON.stringify(payments_payload), + }, + freeze: true, + freeze_message: __("Creating Payment Entries..."), + callback: function (res) { + const created = (res && res.message) || []; + if (created.length) { + frappe.show_alert( + { + message: __("Created {0} Payment Entries", [created.length]), + indicator: "green", + }, + 5 + ); + } + frm.reload_doc(); + }, + error: function () { + frappe.msgprint({ + title: __("Error"), + message: __("Could not create Payment Entries. Please check Mode of Payment accounts."), + indicator: "red", + }); + frm.reload_doc(); + }, + }); + return; + } + + // Submit was requested but docstatus is still 0 — submit failed + if (submit && frm.doc.docstatus !== 1) { + frappe.msgprint({ + title: __("Submit Failed"), + message: __("The invoice could not be submitted. Please check for validation errors and try again."), + indicator: "orange", + }); + frm.reload_doc(); + return; + } + + // After save (without submit), refresh payments field to show updated values + setTimeout(function () { + frm.refresh_field("payments"); + + if (submit) { + setTimeout(function () { + frm.reload_doc(); + }, 200); + } + }, 100); + }) + .catch(function (err) { + frappe.msgprint({ + title: __("Error"), + message: __("Failed to save invoice: {0}", [err.message || err]), + indicator: "red", + }); + }) + .finally(function () { + setTimeout(function () { + delete frappe.flags.aqrar_skip_payment_popup; + delete frappe.flags.aqrar_payment_popup_saving; + }, 500); + }); + }, 300); + } + + const d = new frappe.ui.Dialog({ + title: __("Enter Payment Amounts"), + fields: fields, + primary_action_label: __("Save & Submit"), + primary_action: function (vals) { + apply_payments_and_close(vals, true); + }, + secondary_action_label: __("Save"), + secondary_action: function () { + const vals = d.get_values(); + if (vals) apply_payments_and_close(vals, false); + }, + onhide: function () { + frappe.flags.aqrar_payment_popup_showing = false; + }, + }); + + d.show(); + + // Align button with input (same level) and field click handler + frappe.utils.sleep(100).then(function () { + d.$wrapper.find(".section-body").css({ + display: "flex", + alignItems: "flex-end", + }); + + // Field click: fill with balance only (invoice_total - sum of others) + payments.forEach(function (_, idx) { + const field = d.fields_dict["pay_" + idx]; + if (!field || !field.$wrapper) return; + const $input = field.$wrapper.find("input"); + $input.off("click.aqrar_fill_balance").on("click.aqrar_fill_balance", function () { + let other = 0; + payments.forEach(function (__, i) { + if (i !== idx) other += flt(d.get_value("pay_" + i)) || 0; + }); + d.set_value("pay_" + idx, Math.max(0, flt(invoice_total - other, 2))); + }); + }); + }); +} diff --git a/aqrar_ext/public/js/stock_ledger_override.js b/aqrar_ext/public/js/stock_ledger_override.js new file mode 100644 index 0000000..b845ff7 --- /dev/null +++ b/aqrar_ext/public/js/stock_ledger_override.js @@ -0,0 +1,37 @@ +// aqrar_ext: Enhance Stock Ledger with Transaction Type + Item filter +(function () { + var _report; + + // Delete any existing property so defineProperty works + delete frappe.query_reports["Stock Ledger"]; + + Object.defineProperty(frappe.query_reports, "Stock Ledger", { + get: function () { return _report; }, + set: function (val) { + if (val && val.filters && !val._aqrar) { + val.filters.splice(1, 0, { + fieldname: "voucher_type", + label: __("Transaction Type"), + fieldtype: "Select", + options: ["All", "Purchase Only", "Sale Only", "Transfer Only", "Stock Entry Only"], + default: "All", + }); + for (var i = 0; i < val.filters.length; i++) { + if (val.filters[i].fieldname === "item_code" && val.filters[i].fieldtype === "MultiSelectList") { + val.filters[i] = { + fieldname: "item_code", + label: __("Item"), + fieldtype: "Link", + options: "Item", + }; + break; + } + } + val._aqrar = true; + } + _report = val; + }, + configurable: true, + enumerable: true, + }); +})(); diff --git a/aqrar_ext/public/js/workflowapproval.js b/aqrar_ext/public/js/workflowapproval.js new file mode 100644 index 0000000..ca0e372 --- /dev/null +++ b/aqrar_ext/public/js/workflowapproval.js @@ -0,0 +1,62 @@ + +// Adds a pending count badge next to the "Work Flow Approval" shortcut +// on any workspace, matching the native ERPNext shortcut count style. + +frappe.provide("aqrar_ext.wf_shortcut"); + +aqrar_ext.wf_shortcut.update_count = function() { + let $links = $('.shortcut-widget-box a, .widget.shortcut-widget-box .widget-title') + .filter(function() { + return $(this).text().trim().toLowerCase().includes("work flow approval") + || $(this).text().trim().toLowerCase().includes("workflow approval"); + }); + + if (!$links.length) return; + + frappe.call({ + method: "aqrar_ext.aqrar_ext.report.work_flow_approval.work_flow_approval.get_pending_approval_count", + callback: function(r) { + let count = r.message || 0; + + $('.shortcut-widget-box').each(function() { + let $box = $(this); + let txt = $box.find('.widget-title, a').first().text().trim().toLowerCase(); + if (!txt.includes("work flow approval") && !txt.includes("workflow approval")) return; + + // Remove any previous injected badge + $box.find('.wf-pending-count').remove(); + + // Build badge in the same style as ERPNext's native shortcut count + let $badge = $(`${count}`); + + // Append to the title/link + let $target = $box.find('.widget-title').first(); + if (!$target.length) $target = $box.find('a').first(); + $target.append($badge); + }); + } + }); +}; + +// Run after route changes (workspace navigation) and on initial load +$(document).on("page-change app_ready", function() { + setTimeout(aqrar_ext.wf_shortcut.update_count, 600); + setTimeout(aqrar_ext.wf_shortcut.update_count, 1500); // retry for slow loads +}); + +frappe.after_ajax(function() { + setTimeout(aqrar_ext.wf_shortcut.update_count, 1000); +}); + +// Refresh every 60 seconds while the page is open +setInterval(aqrar_ext.wf_shortcut.update_count, 60000); \ No newline at end of file diff --git a/aqrar_ext/templates/__init__.py b/aqrar_ext/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aqrar_ext/templates/pages/__init__.py b/aqrar_ext/templates/pages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..8aa2645 --- /dev/null +++ b/license.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8206ff0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "aqrar_ext" +authors = [ + { name = "Enfono", email = "nah@enfono.com"} +] +description = "Customizations and Extensions for Aqrar" +requires-python = ">=3.10" +readme = "README.md" +dynamic = ["version"] +dependencies = [] + +[build-system] +requires = ["flit_core >=3.4,<4"] +build-backend = "flit_core.buildapi" + +[tool.bench.dev-dependencies] + +[tool.ruff] +line-length = 110 +target-version = "py310" + +[tool.ruff.lint] +select = ["F","E","W","I","UP","B","RUF"] +ignore = ["B017","B018","B023","B904","E101","E402","E501","E741","F401","F403","F405","F722","W191"] +typing-modules = ["frappe.types.DF"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "tab" +docstring-code-format = true