diff --git a/aqrar_ext/api/commission.py b/aqrar_ext/api/commission.py
new file mode 100644
index 0000000..cd76b89
--- /dev/null
+++ b/aqrar_ext/api/commission.py
@@ -0,0 +1,97 @@
+import frappe
+from frappe import _
+
+
+@frappe.whitelist()
+def get_commission_je_status(sales_invoice):
+ """Return whether a commission Journal Entry already exists for this invoice."""
+ je_name = frappe.db.exists(
+ "Journal Entry",
+ {"custom_reference_invoice": sales_invoice, "docstatus": ["!=", 2]},
+ )
+ if je_name:
+ je = frappe.db.get_value("Journal Entry", je_name, ["name", "docstatus"], as_dict=True)
+ return {"exists": True, "je_name": je.name, "je_status": "Submitted" if je.docstatus == 1 else "Draft"}
+ return {"exists": False}
+
+
+@frappe.whitelist()
+def create_commission_je(sales_invoice):
+ """Create and return a draft Journal Entry pre-filled with commission data."""
+ si = frappe.get_doc("Sales Invoice", sales_invoice)
+
+ if si.docstatus != 1:
+ frappe.throw(_("Can only book commission for submitted invoices."))
+
+ existing = frappe.db.exists(
+ "Journal Entry",
+ {"custom_reference_invoice": sales_invoice, "docstatus": ["!=", 2]},
+ )
+ if existing:
+ frappe.throw(
+ _("A Journal Entry for this invoice already exists: {0}").format(
+ f'{existing}'
+ )
+ )
+
+ expense_account = _get_commission_expense_account(si.company)
+ payable_account = _get_commission_payable_account(si.company)
+ amount = si.total_commission or 0
+
+ je = frappe.get_doc({
+ "doctype": "Journal Entry",
+ "company": si.company,
+ "posting_date": si.posting_date,
+ "custom_reference_invoice": si.name,
+ "user_remark": f"Commission for {si.name} — {si.customer}",
+ "accounts": [
+ {
+ "account": expense_account,
+ "debit_in_account_currency": amount,
+ "credit_in_account_currency": 0,
+ "cost_center": si.cost_center,
+ },
+ {
+ "account": payable_account,
+ "debit_in_account_currency": 0,
+ "credit_in_account_currency": amount,
+ "cost_center": si.cost_center,
+ },
+ ],
+ })
+ je.flags.ignore_validate = True
+ je.insert(ignore_permissions=True)
+
+ return je.name
+
+
+def _get_commission_expense_account(company):
+ acct = frappe.db.get_value("Company", company, "default_commission_expense_account")
+ if acct:
+ return acct
+ acct = frappe.db.get_value(
+ "Account",
+ {"company": company, "account_name": ("like", "%Commission%Expense%"), "is_group": 0},
+ "name",
+ )
+ if acct:
+ return acct
+ frappe.throw(
+ _("No Commission Expense account found. Please set it in Company settings.")
+ )
+
+
+def _get_commission_payable_account(company):
+ acct = frappe.db.get_value("Company", company, "default_commission_payable_account")
+ if acct:
+ return acct
+ acct = frappe.db.get_value(
+ "Account",
+ {"company": company, "account_name": ("like", "%Commission%Payable%"), "is_group": 0},
+ "name",
+ )
+ if acct:
+ return acct
+ frappe.throw(
+ _("No Commission Payable account found. Please set it in Company settings.")
+ )
diff --git a/aqrar_ext/api/day_close.py b/aqrar_ext/api/day_close.py
new file mode 100644
index 0000000..1ed6867
--- /dev/null
+++ b/aqrar_ext/api/day_close.py
@@ -0,0 +1,126 @@
+import frappe
+from frappe import _
+from frappe.utils import today
+
+
+@frappe.whitelist()
+def run_day_close(date=None, company=None):
+ """Aggregate daily commissions and discounts into summary Journal Entries.
+
+ Creates one commission JE (Dr expense, Cr payable) and one discount JE
+ (Dr expense, Cr payable) for all submitted Sales Invoices on the given date
+ that have not already been booked individually.
+ """
+ date = date or today()
+ company = company or frappe.defaults.get_user_default("company")
+ if not company:
+ frappe.throw(_("No company specified and no default company found."))
+
+ comm_remark = f"Day Close Commission for {date}"
+ disc_remark = f"Day Close Discount for {date}"
+
+ if frappe.db.exists("Journal Entry", {"user_remark": comm_remark, "docstatus": ["!=", 2]}):
+ frappe.throw(_("Day-close commission Journal Entry already exists for {0}").format(date))
+ if frappe.db.exists("Journal Entry", {"user_remark": disc_remark, "docstatus": ["!=", 2]}):
+ frappe.throw(_("Day-close discount Journal Entry already exists for {0}").format(date))
+
+ sis = frappe.db.get_all(
+ "Sales Invoice",
+ filters={"posting_date": date, "company": company, "docstatus": 1},
+ fields=["name", "total_commission", "discount_amount", "cost_center"],
+ )
+
+ if not sis:
+ frappe.throw(_("No submitted Sales Invoices found for {0}").format(date))
+
+ eligible = []
+ skipped = 0
+ for si in sis:
+ if frappe.db.exists("Journal Entry", {"custom_reference_invoice": si.name, "docstatus": ["!=", 2]}):
+ skipped += 1
+ else:
+ eligible.append(si)
+
+ if not eligible:
+ frappe.throw(
+ _("All Sales Invoices for {0} have already been booked individually. Nothing to reconcile.").format(date)
+ )
+
+ total_commission = sum(si.total_commission or 0 for si in eligible)
+ total_discount = sum(si.discount_amount or 0 for si in eligible)
+ cost_center = eligible[0].cost_center
+
+ comm_expense_acct = _get_company_account(company, "default_commission_expense_account")
+ comm_payable_acct = _get_company_account(company, "default_commission_payable_account")
+ disc_expense_acct = _get_company_account(company, "default_discount_expense_account")
+ disc_payable_acct = _get_company_account(company, "default_discount_payable_account")
+
+ result = {}
+
+ if total_commission > 0:
+ je = frappe.get_doc({
+ "doctype": "Journal Entry",
+ "company": company,
+ "posting_date": date,
+ "user_remark": comm_remark,
+ "accounts": [
+ {
+ "account": comm_expense_acct,
+ "debit_in_account_currency": total_commission,
+ "credit_in_account_currency": 0,
+ "cost_center": cost_center,
+ },
+ {
+ "account": comm_payable_acct,
+ "debit_in_account_currency": 0,
+ "credit_in_account_currency": total_commission,
+ "cost_center": cost_center,
+ },
+ ],
+ })
+ je.insert(ignore_permissions=True)
+ result["commission_je"] = je.name
+
+ if total_discount > 0:
+ je = frappe.get_doc({
+ "doctype": "Journal Entry",
+ "company": company,
+ "posting_date": date,
+ "user_remark": disc_remark,
+ "accounts": [
+ {
+ "account": disc_expense_acct,
+ "debit_in_account_currency": total_discount,
+ "credit_in_account_currency": 0,
+ "cost_center": cost_center,
+ },
+ {
+ "account": disc_payable_acct,
+ "debit_in_account_currency": 0,
+ "credit_in_account_currency": total_discount,
+ "cost_center": cost_center,
+ },
+ ],
+ })
+ je.insert(ignore_permissions=True)
+ result["discount_je"] = je.name
+
+ result.update({
+ "total_commission": total_commission,
+ "total_discount": total_discount,
+ "invoices_processed": len(eligible),
+ "invoices_skipped": skipped,
+ })
+
+ return result
+
+
+def _get_company_account(company, fieldname):
+ acct = frappe.db.get_value("Company", company, fieldname)
+ if acct:
+ return acct
+ frappe.throw(
+ _("Please set {0} in Company {1} before running day-close.").format(
+ frappe.get_meta("Company").get_field(fieldname).label, company
+ )
+ )
diff --git a/aqrar_ext/api/item_naming.py b/aqrar_ext/api/item_naming.py
new file mode 100644
index 0000000..d1b59b0
--- /dev/null
+++ b/aqrar_ext/api/item_naming.py
@@ -0,0 +1,8 @@
+import frappe
+from frappe.model.naming import make_autoname
+
+
+@frappe.whitelist()
+def get_next_item_code(naming_series):
+ """Return the next item_code for the given naming series."""
+ return make_autoname(naming_series, "Item")
diff --git a/aqrar_ext/api/navigation.py b/aqrar_ext/api/navigation.py
new file mode 100644
index 0000000..a0dfd33
--- /dev/null
+++ b/aqrar_ext/api/navigation.py
@@ -0,0 +1,65 @@
+import frappe
+import json
+
+
+@frappe.whitelist()
+def get_sibling(doctype, docname, direction, list_filters=None, order_by=None):
+ """Return the next/previous document name respecting list filters and sort."""
+ if isinstance(list_filters, str):
+ list_filters = json.loads(list_filters)
+
+ filters = _build_filters(doctype, docname, direction, list_filters)
+ sort = _resolve_order(doctype, direction, order_by)
+
+ docs = frappe.get_all(
+ doctype,
+ filters=filters,
+ pluck="name",
+ order_by=sort,
+ limit=1,
+ )
+
+ return docs[0] if docs else None
+
+
+def _build_filters(doctype, docname, direction, list_filters):
+ filters = {}
+
+ # Apply list view filters
+ if list_filters:
+ for f in list_filters:
+ if isinstance(f, list) and len(f) == 3:
+ field, op, val = f
+ if op == "=":
+ filters[field] = val
+ elif op in ("like", ">" , "<", ">=", "<="):
+ filters[field] = [op, val]
+ elif op == "in":
+ filters[field] = val if isinstance(val, list) else [val]
+
+ # Cursor: get sibling after/before current doc
+ if direction == "next":
+ filters["name"] = [">", docname]
+ else:
+ filters["name"] = ["<", docname]
+
+ return filters
+
+
+def _resolve_order(doctype, direction, order_by):
+ # Use list's sort field if provided, else fall back to name
+ meta = frappe.get_meta(doctype)
+ field = "name"
+
+ if order_by:
+ field = order_by
+ else:
+ sort_field = meta.sort_field or "modified"
+ sort_order = meta.sort_order or "desc"
+ field = f"{sort_field} {sort_order}, name"
+
+ if direction == "next":
+ return f"{field} asc"
+ else:
+ # Reverse the sort for prev
+ return f"{field} desc"
diff --git a/aqrar_ext/api/sales_invoice_payment.py b/aqrar_ext/api/sales_invoice_payment.py
index f30ea16..5ab79e8 100644
--- a/aqrar_ext/api/sales_invoice_payment.py
+++ b/aqrar_ext/api/sales_invoice_payment.py
@@ -143,7 +143,7 @@ def create_pos_payments_for_invoice(sales_invoice: str, payments: str | list):
pe.flags.ignore_validate = True
if hasattr(pe, "workflow_state"):
- pe.workflow_state = "Pending"
+ pe.workflow_state = "Pending Approval"
pe.submit()
created.append(pe.name)
diff --git a/aqrar_ext/aqrar_ext/doctype/aqrar_settings/aqrar_settings.json b/aqrar_ext/aqrar_ext/doctype/aqrar_settings/aqrar_settings.json
new file mode 100644
index 0000000..9c3d299
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/doctype/aqrar_settings/aqrar_settings.json
@@ -0,0 +1,48 @@
+{
+ "actions": [],
+ "allow_rename": 0,
+ "creation": "2026-05-16 00:00:00.000000",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "item_display_mode"
+ ],
+ "fields": [
+ {
+ "default": "Item Name + Description",
+ "fieldname": "item_display_mode",
+ "fieldtype": "Select",
+ "label": "Default Item Display Mode",
+ "options": "Item Name\nItem Code\nItem Name + Description\nItem Code + Description",
+ "reqd": 1
+ }
+ ],
+ "issingle": 1,
+ "links": [],
+ "modified": "2026-05-16 00:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Aqrar Ext",
+ "name": "Aqrar Settings",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "quick_entry": 0,
+ "read_only": 0,
+ "row_format": "Dynamic",
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 0
+}
diff --git a/aqrar_ext/aqrar_ext/doctype/aqrar_settings/aqrar_settings.py b/aqrar_ext/aqrar_ext/doctype/aqrar_settings/aqrar_settings.py
new file mode 100644
index 0000000..d66ffed
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/doctype/aqrar_settings/aqrar_settings.py
@@ -0,0 +1,6 @@
+import frappe
+from frappe.model.document import Document
+
+
+class AqrarSettings(Document):
+ pass
diff --git a/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.py b/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.py
index 1c80690..810060d 100644
--- a/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.py
+++ b/aqrar_ext/aqrar_ext/doctype/branch_configuration/branch_configuration.py
@@ -7,6 +7,7 @@
from frappe.model.document import Document
BRANCH_USER_ROLE = "Branch User"
+APPROVER_ROLES = {"Branch Approver", "Branch Accountant", "Branch User", "Damage User"}
class BranchConfiguration(Document):
@@ -103,10 +104,14 @@ def create_permissions(self):
_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")
+ _module_profile_map = {
+ "Branch User": "Branch User",
+ "Damage User": "Damage User",
+ "Branch Approver": "Branch User",
+ "Branch Accountant": "Branch User",
+ }
+ profile = _module_profile_map.get(selected_role, "Branch User")
+ _set_module_profile(u.user, profile)
def create_permission(user, allow, value, is_default=0):
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
index 6407f69..f604e07 100644
--- 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
@@ -6,7 +6,8 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
- "user"
+ "user",
+ "role"
],
"fields": [
{
@@ -15,6 +16,14 @@
"in_list_view": 1,
"label": "User",
"options": "User"
+ },
+ {
+ "fieldname": "role",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Role",
+ "options": "Branch User\nBranch Approver\nBranch Accountant\nDamage User",
+ "default": "Branch User"
}
],
"grid_page_length": 50,
diff --git a/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.js b/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.js
index f8821fc..7a25770 100644
--- a/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.js
+++ b/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.js
@@ -1,35 +1,60 @@
// 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()
- );
+ frm.set_value("posting_date", frappe.datetime.now_datetime());
}
if (frm.is_new() && !frm.doc.valid_till) {
set_valid_till(frm);
}
+ if (frm.is_new()) {
+ auto_set_price_list(frm);
+ }
},
posting_date(frm) {
set_valid_till(frm);
},
+ customer(frm) {
+ auto_set_price_list(frm);
+ },
+
refresh(frm) {
calculate_net_total(frm);
}
});
-//Set total vat tax include vat
+// ── Auto-set selling price list from Customer default ─────────────────
+function auto_set_price_list(frm) {
+ if (!frm.doc.customer) return;
+
+ frappe.db.get_value("Customer", frm.doc.customer, "default_price_list", function (r) {
+ var cust_pl = r && r.default_price_list ? r.default_price_list : null;
+ if (cust_pl) {
+ frappe.db.get_value("Price List", cust_pl, "enabled", function (pl) {
+ if (pl && pl.enabled) {
+ frm.set_value("selling_price_list", cust_pl);
+ } else {
+ frm.set_value("selling_price_list", "Standard Selling");
+ }
+ });
+ } else {
+ frm.set_value("selling_price_list", "Standard Selling");
+ }
+ });
+}
+
+// ── Custom Quote Item handlers ────────────────────────────────────────
frappe.ui.form.on("Custom Quote Item", {
+ item_code(frm, cdt, cdn) {
+ fetch_item_details(frm, cdt, cdn);
+ },
+
qty(frm, cdt, cdn) {
calculate_row(frm, cdt, cdn);
},
@@ -47,18 +72,56 @@ frappe.ui.form.on("Custom Quote Item", {
}
});
+// ── Fetch item description, UOM, and rate from Item Price ─────────────
+function fetch_item_details(frm, cdt, cdn) {
+ let row = locals[cdt][cdn];
+ if (!row.item_code) return;
+
+ frappe.db.get_value("Item", row.item_code, ["item_name", "stock_uom"], function (r) {
+ if (r) {
+ if (!row.item) frappe.model.set_value(cdt, cdn, "item", r.item_name);
+ if (!row.unit) frappe.model.set_value(cdt, cdn, "unit", r.stock_uom);
+ }
+ });
+
+ // Fetch rate from Item Price for the current selling_price_list
+ var price_list = frm.doc.selling_price_list;
+ if (!price_list) return;
+
+ frappe.call({
+ method: "frappe.client.get_list",
+ args: {
+ doctype: "Item Price",
+ filters: {
+ item_code: row.item_code,
+ price_list: price_list,
+ selling: 1,
+ },
+ fields: ["price_list_rate", "uom"],
+ limit: 1,
+ },
+ callback: function (res) {
+ if (res.message && res.message.length) {
+ var ip = res.message[0];
+ if (row.rate === undefined || row.rate === 0) {
+ frappe.model.set_value(cdt, cdn, "rate", ip.price_list_rate);
+ }
+ if (!row.unit && ip.uom) {
+ frappe.model.set_value(cdt, cdn, "unit", ip.uom);
+ }
+ }
+ },
+ });
+}
+
+// ── Row calculations ──────────────────────────────────────────────────
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)
- );
+ 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;
@@ -68,11 +131,9 @@ function calculate_row(frm, cdt, cdn) {
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;
diff --git a/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.json b/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.json
index 3adca7b..9ecde4c 100644
--- a/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.json
+++ b/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.json
@@ -19,6 +19,7 @@
"attention",
"mob_no",
"rqf_no",
+ "selling_price_list",
"section_break_dwhc",
"items",
"section_break_iajw",
@@ -154,12 +155,19 @@
"fieldname": "terms_and_conditions",
"fieldtype": "Text Editor",
"label": "Terms and Conditions"
+ },
+ {
+ "fieldname": "selling_price_list",
+ "fieldtype": "Link",
+ "label": "Selling Price List",
+ "options": "Price List",
+ "insert_after": "rqf_no"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2026-02-05 10:42:18.239467",
+ "modified": "2026-05-18 00:00:00.000000",
"modified_by": "Administrator",
"module": "Aqrar Ext",
"name": "Custom Quote",
@@ -184,4 +192,4 @@
"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
index 0ff74c4..78388c5 100644
--- a/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.py
+++ b/aqrar_ext/aqrar_ext/doctype/custom_quote/custom_quote.py
@@ -1,9 +1,54 @@
# Copyright (c) 2026, Enfono and contributors
# For license information, please see license.txt
-# import frappe
+import frappe
from frappe.model.document import Document
+from frappe.utils import flt
+from frappe import _, bold
class CustomQuote(Document):
pass
+
+
+def validate(doc, method=None):
+ """Block Custom Quote submit if any item rate is below its minimum."""
+ price_list = doc.get("selling_price_list")
+ if not price_list:
+ return
+
+ below_min = []
+ for item in doc.items:
+ if not item.item_code:
+ continue
+
+ ip = frappe.db.get_value(
+ "Item Price",
+ {
+ "item_code": item.item_code,
+ "price_list": price_list,
+ "selling": 1,
+ },
+ ["custom_minimum_selling_rate"],
+ as_dict=True,
+ )
+ if not ip:
+ continue
+
+ net = flt(item.rate)
+ if ip.custom_minimum_selling_rate and net < flt(ip.custom_minimum_selling_rate):
+ below_min.append({"idx": item.idx, "item_name": item.item_code, "net_rate": net, "limit": ip.custom_minimum_selling_rate})
+
+ if not below_min:
+ return
+
+ msg = []
+ msg.append(_("The following items are below the minimum selling rate:"))
+ msg.append("")
+ for v in below_min:
+ msg.append(_("Row #{idx}: {item_name} — Rate {net_rate} below Minimum {limit}").format(
+ idx=v["idx"], item_name=bold(v["item_name"]),
+ net_rate=bold(frappe.format_value(v["net_rate"], "Currency")),
+ limit=bold(frappe.format_value(v["limit"], "Currency")),
+ ))
+ frappe.throw("
".join(msg), title=_("Selling Rate Band Violation"))
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
index 252c8c5..19ba269 100644
--- 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
@@ -6,6 +6,7 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
+ "item_code",
"item",
"unit",
"qty",
@@ -65,13 +66,21 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Total Incl. VAT"
+ },
+ {
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Item Code",
+ "options": "Item",
+ "insert_after": null
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2026-02-04 13:00:17.426085",
+ "modified": "2026-05-18 00:00:00.000000",
"modified_by": "Administrator",
"module": "Aqrar Ext",
"name": "Custom Quote Item",
@@ -82,4 +91,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"states": []
-}
\ No newline at end of file
+}
diff --git a/aqrar_ext/aqrar_ext/overrides/sales_invoice.py b/aqrar_ext/aqrar_ext/overrides/sales_invoice.py
new file mode 100644
index 0000000..545f52b
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/overrides/sales_invoice.py
@@ -0,0 +1,109 @@
+import frappe
+from frappe import _, bold
+from frappe.utils import flt
+
+
+def before_save(doc, event=None):
+ """Propagate Customer default payment_terms to Sales Invoice.
+
+ When no template is explicitly set and the customer has a default payment
+ terms template, auto-populate it and regenerate the installment schedule."""
+ if doc.get("ignore_default_payment_terms_template"):
+ return
+ if doc.get("payment_terms_template") or not doc.get("customer"):
+ return
+
+ customer_terms = frappe.db.get_value("Customer", doc.customer, "payment_terms")
+ if not customer_terms:
+ return
+
+ doc.payment_terms_template = customer_terms
+
+ from erpnext.controllers.accounts_controller import get_payment_terms
+
+ grand_total = doc.get("rounded_total") or doc.grand_total
+ base_grand_total = doc.get("base_rounded_total") or doc.base_grand_total
+ data = get_payment_terms(customer_terms, doc.posting_date, grand_total, base_grand_total)
+ if data:
+ doc.payment_schedule = []
+ for item in data:
+ doc.append("payment_schedule", item)
+
+
+def before_print(doc, event=None, print_settings=None):
+ """Apply item_display_mode before print — modifies items in-memory.
+
+ Attaches _item_display_mode to doc so templates can read it without
+ needing any Jinja sandbox ORM calls."""
+ mode = frappe.db.get_value(
+ "Aqrar Settings", "Aqrar Settings", "item_display_mode"
+ ) or "Item Name + Description"
+
+ doc._item_display_mode = mode
+
+ for item in doc.items:
+ if not item.item_code:
+ continue
+
+ if mode in ("Item Code", "Item Name"):
+ item.description = ""
+ elif mode == "Item Code + Description":
+ if item.description == item.item_code:
+ item.description = ""
+ else: # Item Name + Description
+ if item.description == item.item_name:
+ item.description = ""
+
+
+def validate(doc, method=None):
+ """Block Sales Invoice submit if any item rate is below its minimum."""
+ if doc.get("is_return") or doc.get("custom_override_minimum_price"):
+ return
+
+ price_list = doc.get("selling_price_list")
+ if not price_list:
+ return
+
+ below_min = []
+ for item in doc.items:
+ if not item.item_code or item.get("is_free_item"):
+ continue
+
+ ip = frappe.db.get_value(
+ "Item Price",
+ {
+ "item_code": item.item_code,
+ "price_list": price_list,
+ "uom": item.uom,
+ "selling": 1,
+ },
+ ["custom_minimum_selling_rate"],
+ as_dict=True,
+ )
+ if not ip:
+ continue
+
+ net = flt(item.net_rate)
+ if ip.custom_minimum_selling_rate and net < flt(ip.custom_minimum_selling_rate):
+ below_min.append({
+ "idx": item.idx,
+ "item_name": item.item_name,
+ "net_rate": net,
+ "limit": ip.custom_minimum_selling_rate,
+ })
+
+ if not below_min:
+ return
+
+ msg = []
+ msg.append(_("The following items are below the minimum selling rate:"))
+ msg.append("")
+ for v in below_min:
+ msg.append(_("Row #{idx}: {item_name} — Net Rate {net_rate} below Minimum {limit}").format(
+ idx=v["idx"],
+ item_name=bold(v["item_name"]),
+ net_rate=bold(frappe.format_value(v["net_rate"], "Currency")),
+ limit=bold(frappe.format_value(v["limit"], "Currency")),
+ ))
+ msg += ["", _("To override, check Override Minimum Price and try again.")]
+ frappe.throw("
".join(msg), title=_("Selling Rate Band Violation"))
diff --git a/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/__init__.py b/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/__init__.py
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/__init__.py
@@ -0,0 +1 @@
+
diff --git a/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/price_list_bulk_editor.css b/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/price_list_bulk_editor.css
new file mode 100644
index 0000000..41bd04f
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/price_list_bulk_editor.css
@@ -0,0 +1,131 @@
+.ple-card {
+ background: #fff;
+ border: 1px solid #d1d8e0;
+ border-radius: 8px;
+ margin: 14px 14px 0 14px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, .05);
+}
+.ple-card-body { padding: 12px 14px 8px 14px; }
+
+.ple-filters {
+ display: flex;
+ align-items: flex-end;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.ple-field { min-width: 140px; width: 150px; }
+.ple-actions { display: flex; align-items: flex-end; padding-bottom: 1px; }
+
+.ple-status { margin: 8px 14px 0 14px; }
+.ple-status-bar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ padding: 6px 10px;
+ background: #f0f5fb;
+ border: 1px solid #d4e4fc;
+ border-radius: 6px;
+ font-size: 12px;
+}
+.ple-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ padding: 2px 9px;
+ border-radius: 100px;
+ font-size: 11px;
+ font-weight: 500;
+ background: #e8f0fe;
+ color: #1a3a6b;
+}
+.ple-hint { color: #6b7280; font-style: italic; margin-left: auto; font-size: 11px; }
+
+.ple-table-shell {
+ margin: 10px 14px 14px 14px;
+ border: 1px solid #e2e8f0;
+ border-radius: 8px;
+ overflow: hidden;
+ min-height: 200px;
+ background: #fff;
+}
+.ple-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 20px;
+ text-align: center;
+}
+.ple-empty-title { font-size: 15px; color: #374151; margin: 0 0 4px 0; }
+.ple-empty-sub { font-size: 13px; color: #9ca3af; margin: 0; }
+
+.ple-scroll { width: 100%; overflow: auto; max-height: 65vh; }
+
+.ple-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
+.ple-table thead { position: sticky; top: 0; z-index: 3; }
+.ple-table th {
+ padding: 8px 10px;
+ text-align: left;
+ font-weight: 600;
+ font-size: 11px;
+ white-space: nowrap;
+}
+.ple-th-fixed { background: #f4f7fc; color: #374151; border-bottom: 2px solid #d1d5db; }
+.ple-th-price { background: linear-gradient(180deg, #e8f0fe, #d4e4fc); color: #1a3a6b; border-bottom: 2px solid #2490ef; }
+
+.ple-col-sticky {
+ position: sticky;
+ z-index: 1;
+}
+.ple-table thead .ple-col-sticky { z-index: 3; }
+.ple-col-sticky:nth-child(1) { left: 0; }
+.ple-col-sticky:nth-child(2) { left: 140px; }
+.ple-col-sticky:nth-child(3) { left: 340px; }
+
+.ple-table td {
+ padding: 5px 8px;
+ border-bottom: 1px solid #f0f2f5;
+ white-space: nowrap;
+ vertical-align: top;
+}
+.ple-table tr:nth-child(even) td { background: #f9fafc; }
+.ple-table tr:nth-child(even) td.ple-col-sticky { background: #f4f7fc; }
+.ple-table tr:hover td { background: #eef4fc !important; }
+
+.ple-edit { padding: 2px 4px !important; }
+.ple-cell-stack { display: flex; flex-direction: column; gap: 2px; min-width: 100px; }
+
+.ple-inp-min,
+.ple-inp-rate {
+ width: 100%;
+ padding: 2px 6px;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ font-size: 12px;
+ text-align: right;
+ background: transparent;
+ transition: border-color .12s;
+ box-sizing: border-box;
+}
+.ple-inp-min { height: 22px; font-size: 11px; color: #6b7280; }
+.ple-inp-rate { height: 28px; font-weight: 500; }
+
+.ple-inp-min::placeholder { font-size: 10px; color: #cbd5e1; }
+
+.ple-inp-min:hover,
+.ple-inp-rate:hover { border-color: #93c5fd; background: #fafbfc; }
+
+.ple-inp-min:focus,
+.ple-inp-rate:focus {
+ outline: none;
+ border-color: #2490ef;
+ box-shadow: 0 0 0 2px rgba(36, 144, 239, .10);
+ background: #fff;
+}
+
+.ple-saving { background: #fef9e7 !important; }
+.ple-saving .ple-inp-min,
+.ple-saving .ple-inp-rate { border-color: #f59e0b !important; }
+.ple-saved { background: #ecfdf5 !important; transition: background .3s; }
+.ple-error { background: #fef2f2 !important; }
diff --git a/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/price_list_bulk_editor.js b/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/price_list_bulk_editor.js
new file mode 100644
index 0000000..1b3802a
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/price_list_bulk_editor.js
@@ -0,0 +1,263 @@
+frappe.pages["price-list-bulk-editor"].on_page_load = function (wrapper) {
+ var page = frappe.ui.make_app_page({
+ parent: wrapper,
+ title: __("Price List Bulk Editor"),
+ single_column: true,
+ });
+
+ page.main.css("padding", "0");
+
+ var columns = [];
+ var data = [];
+ var current_pls = [];
+
+ var chrome_html = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
${__("Loading…")}
+
${__("Click a cell to edit. Press Enter or Tab to save.")}
+
+
+ `;
+ $(chrome_html).appendTo(page.main);
+
+ var item_code_field = frappe.ui.form.make_control({
+ parent: $("#ple-f-item-code"),
+ df: {
+ fieldname: "item_code", label: __("Item Code"), fieldtype: "Link", options: "Item",
+ change: function () { load_data(); }
+ },
+ render_input: true,
+ });
+
+ var item_group_field = frappe.ui.form.make_control({
+ parent: $("#ple-f-item-group"),
+ df: {
+ fieldname: "item_group", label: __("Item Group"), fieldtype: "Link", options: "Item Group",
+ change: function () { load_data(); }
+ },
+ render_input: true,
+ });
+
+ var price_list_field = frappe.ui.form.make_control({
+ parent: $("#ple-f-plist"),
+ df: {
+ fieldname: "price_list", label: __("Price List"), fieldtype: "Link", options: "Price List",
+ change: function () { load_data(); }
+ },
+ render_input: true,
+ });
+
+ var cost_center_field = frappe.ui.form.make_control({
+ parent: $("#ple-f-cost-center"),
+ df: {
+ fieldname: "cost_center", label: __("Cost Center"), fieldtype: "Link", options: "Cost Center",
+ change: function () { load_price_lists_and_data(); }
+ },
+ render_input: true,
+ });
+
+ $("#ple-btn-refresh").on("click", load_data);
+
+ function load_price_lists_and_data() {
+ var $btn = $("#ple-btn-refresh").prop("disabled", true);
+ frappe.call({
+ method: "aqrar_ext.aqrar_ext.page.price_list_bulk_editor.price_list_bulk_editor.get_selling_price_lists",
+ args: { cost_center: cost_center_field.get_value() || "" },
+ callback: function (r) {
+ current_pls = (r.message || []).map(function (p) { return p.name; });
+ $btn.prop("disabled", false);
+ load_data();
+ },
+ error: function () {
+ $btn.prop("disabled", false);
+ frappe.show_alert({
+ message: __("Failed to load price lists."),
+ indicator: "red",
+ });
+ },
+ });
+ }
+
+ function load_data() {
+ var selected_pl = price_list_field.get_value();
+ var pls = selected_pl ? [selected_pl] : current_pls;
+ if (!pls.length) {
+ if (!current_pls.length) {
+ show_empty(__("Loading price lists…"));
+ } else {
+ show_empty(__("No selling price lists found for this cost center."));
+ }
+ return;
+ }
+ $("#ple-empty").show().find(".ple-empty-title").text(__("Loading…"));
+ $("#ple-table-shell").find(".ple-scroll, table").remove();
+ $("#ple-btn-refresh").prop("disabled", true);
+
+ frappe.call({
+ method: "aqrar_ext.aqrar_ext.page.price_list_bulk_editor.price_list_bulk_editor.get_item_price_matrix",
+ args: {
+ item_group: item_group_field.get_value() || "",
+ item_code: item_code_field.get_value() || "",
+ price_lists: pls,
+ cost_center: cost_center_field.get_value() || "",
+ },
+ callback: function (r) {
+ $("#ple-btn-refresh").prop("disabled", false);
+ var msg = r.message;
+ if (!msg || !msg.data.length) {
+ show_empty(__("No items found."));
+ return;
+ }
+ columns = msg.columns;
+ data = msg.data;
+ build_table(msg.item_count, msg.price_lists.length);
+ },
+ error: function () {
+ $("#ple-btn-refresh").prop("disabled", false);
+ frappe.show_alert({
+ message: __("Failed to load data."),
+ indicator: "red",
+ });
+ },
+ });
+ }
+
+ function build_table(item_count, pl_count) {
+ $("#ple-empty").hide();
+ var $shell = $("#ple-table-shell");
+ $shell.find(".ple-scroll, table").remove();
+
+ var c = columns;
+ var d = data;
+
+ var h = '';
+ $shell.prepend(h);
+
+ $("#ple-status").html(
+ '' +
+ '' + item_count + ' ' + __("items") + '' +
+ '' + pl_count + ' ' + __("price lists") + '' +
+ '' + __("Edit then Enter / Tab to save.") + '' +
+ '
'
+ );
+
+ $shell.find(".ple-inp-min, .ple-inp-rate").off("blur keydown").on("blur", function () {
+ save_cell($(this).closest("td"));
+ }).on("keydown", function (e) {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ $(this).blur();
+ }
+ });
+ }
+
+ function save_cell($td) {
+ if ($td.hasClass("ple-saving")) return;
+
+ var ri = parseInt($td.attr("data-ri"));
+ var ci = parseInt($td.attr("data-ci"));
+ if (isNaN(ri) || isNaN(ci)) return;
+
+ var row = data[ri];
+ var col = columns[ci];
+
+ var $min_inp = $td.find(".ple-inp-min");
+ var $rate_inp = $td.find(".ple-inp-rate");
+
+ var old_info = row[ci] && typeof row[ci] === "object" ? row[ci] : {};
+ var old_rate = parseFloat(old_info.rate) || 0;
+ var old_min = parseFloat(old_info.min_rate) || 0;
+
+ var new_rate = parseFloat($rate_inp.val().trim());
+ var new_min = parseFloat($min_inp.val().trim());
+ if (isNaN(new_rate) || new_rate < 0) { $rate_inp.val(old_rate || ""); return; }
+ if (isNaN(new_min) || new_min < 0) { $min_inp.val(old_min || ""); return; }
+ if (new_rate === old_rate && new_min === old_min) return;
+
+ $td.addClass("ple-saving");
+
+ frappe.call({
+ method: "aqrar_ext.aqrar_ext.page.price_list_bulk_editor.price_list_bulk_editor.save_cell",
+ args: {
+ item_code: row[0],
+ price_list: col.price_list,
+ uom: row[2],
+ rate: new_rate,
+ min_rate: new_min,
+ },
+ callback: function (r) {
+ $td.removeClass("ple-saving");
+ if (r.message) {
+ row[ci] = { rate: new_rate, min_rate: new_min, item_price_name: r.message.name, uom: row[2] };
+ $td.addClass("ple-saved");
+ setTimeout(function () { $td.removeClass("ple-saved"); }, 900);
+ frappe.show_alert({
+ message: r.message.action === "updated"
+ ? __("Updated") + ": " + row[0] + " → " + col.price_list
+ : __("Created") + ": " + row[0] + " → " + col.price_list,
+ indicator: "green",
+ });
+ }
+ },
+ error: function () {
+ $td.removeClass("ple-saving").addClass("ple-error");
+ setTimeout(function () { $td.removeClass("ple-error"); }, 1500);
+ frappe.show_alert({
+ message: __("Failed to save") + ": " + row[0] + " → " + col.price_list,
+ indicator: "red",
+ });
+ },
+ });
+ }
+
+ function show_empty(msg) {
+ $("#ple-status").empty();
+ $("#ple-table-shell").find(".ple-scroll, table").remove();
+ $("#ple-empty").show().find(".ple-empty-title").text(msg || __("No items found."));
+ }
+
+ load_price_lists_and_data();
+};
diff --git a/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/price_list_bulk_editor.json b/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/price_list_bulk_editor.json
new file mode 100644
index 0000000..be010f4
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/price_list_bulk_editor.json
@@ -0,0 +1,13 @@
+{
+ "content": null,
+ "doctype": "Page",
+ "module": "Aqrar Ext",
+ "name": "price-list-bulk-editor",
+ "page_name": "price-list-bulk-editor",
+ "roles": [
+ {"role": "System Manager"},
+ {"role": "Sales Master Manager"}
+ ],
+ "standard": "Yes",
+ "title": "Price List Bulk Editor"
+}
diff --git a/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/price_list_bulk_editor.py b/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/price_list_bulk_editor.py
new file mode 100644
index 0000000..c507fbe
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/page/price_list_bulk_editor/price_list_bulk_editor.py
@@ -0,0 +1,144 @@
+import frappe
+from frappe import _
+from frappe.utils import flt
+
+
+@frappe.whitelist()
+def get_selling_price_lists(cost_center=None):
+ """Return enabled selling price lists, optionally filtered by cost center."""
+ filters = {"enabled": 1, "selling": 1}
+ if cost_center:
+ filters["custom_branch"] = cost_center
+ return frappe.get_all(
+ "Price List",
+ filters=filters,
+ fields=["name", "currency"],
+ order_by="name",
+ )
+
+
+@frappe.whitelist()
+def get_item_price_matrix(item_group=None, price_lists=None, cost_center=None, item_code=None):
+ """Return a pivot grid: rows = items, cols = one per price list with rate/min."""
+ import json
+
+ if isinstance(price_lists, str):
+ price_lists = json.loads(price_lists)
+
+ if not price_lists:
+ price_lists = frappe.get_all(
+ "Price List",
+ filters={"enabled": 1, "selling": 1},
+ pluck="name",
+ order_by="name",
+ )
+
+ item_filters = {"disabled": 0, "is_stock_item": 1}
+ if item_group:
+ item_filters["item_group"] = item_group
+ if item_code:
+ item_filters["item_code"] = ("like", "%{}%".format(item_code))
+
+ items = frappe.get_all(
+ "Item",
+ filters=item_filters,
+ fields=["item_code", "item_name", "stock_uom"],
+ order_by="item_code",
+ limit_page_length=500,
+ )
+
+ if not items or not price_lists:
+ return {"columns": [], "data": [], "price_lists": [], "item_count": 0}
+
+ item_codes = [d.item_code for d in items]
+
+ all_prices = frappe.get_all(
+ "Item Price",
+ filters={
+ "item_code": ("in", item_codes),
+ "price_list": ("in", price_lists),
+ "selling": 1,
+ },
+ fields=[
+ "name", "item_code", "price_list", "uom",
+ "price_list_rate", "custom_minimum_selling_rate",
+ "customer", "supplier",
+ ],
+ )
+
+ price_map = {}
+ for p in all_prices:
+ key = (p.item_code, p.price_list, p.uom)
+ if key not in price_map or (not p.customer and not p.supplier):
+ price_map[key] = {
+ "item_price_name": p.name,
+ "rate": p.price_list_rate,
+ "min_rate": p.custom_minimum_selling_rate,
+ "uom": p.uom,
+ }
+
+ columns = [
+ {"id": "item_code", "name": _("Item Code"), "editable": False, "width": 140},
+ {"id": "item_name", "name": _("Item Name"), "editable": False, "width": 200},
+ {"id": "uom", "name": _("UOM"), "editable": False, "width": 60},
+ ]
+ for pl_name in price_lists:
+ col_id = "pl_" + pl_name.replace(" ", "_").replace("-", "_")
+ columns.append({
+ "id": col_id,
+ "name": pl_name,
+ "editable": True,
+ "width": 130,
+ "price_list": pl_name,
+ })
+
+ data = []
+ for item in items:
+ row = [item.item_code, item.item_name, item.stock_uom]
+ for pl_name in price_lists:
+ info = price_map.get((item.item_code, pl_name, item.stock_uom)) or {}
+ row.append(info if info else {})
+ data.append(row)
+
+ return {
+ "columns": columns,
+ "data": data,
+ "price_lists": price_lists,
+ "item_count": len(items),
+ }
+
+
+@frappe.whitelist()
+def save_cell(item_code, price_list, uom, rate, min_rate=None):
+ """Create or update a single Item Price row."""
+ rate = flt(rate)
+ min_rate = flt(min_rate) if min_rate not in (None, "", 0) else None
+
+ existing = frappe.db.exists(
+ "Item Price",
+ {
+ "item_code": item_code,
+ "price_list": price_list,
+ "uom": uom,
+ "selling": 1,
+ },
+ )
+
+ if existing:
+ update = {"price_list_rate": rate}
+ if min_rate is not None:
+ update["custom_minimum_selling_rate"] = min_rate
+ frappe.db.set_value("Item Price", existing, update)
+ return {"name": existing, "action": "updated"}
+
+ doc = frappe.get_doc({
+ "doctype": "Item Price",
+ "item_code": item_code,
+ "price_list": price_list,
+ "uom": uom,
+ "price_list_rate": rate,
+ "custom_minimum_selling_rate": min_rate,
+ "selling": 1,
+ })
+ doc.save(ignore_permissions=True)
+ return {"name": doc.name, "action": "created"}
diff --git a/aqrar_ext/aqrar_ext/print_format/sales_invoice_aqrar/sales_invoice_aqrar.html b/aqrar_ext/aqrar_ext/print_format/sales_invoice_aqrar/sales_invoice_aqrar.html
new file mode 100644
index 0000000..a8e2e3a
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/print_format/sales_invoice_aqrar/sales_invoice_aqrar.html
@@ -0,0 +1,149 @@
+{%- set mode = doc._item_display_mode or "Item Name + Description" -%}
+
+{%- macro render_item(item) -%}
+{%- if mode == "Item Code" -%}
+ {{ item.item_code }}
+{%- elif mode == "Item Name" -%}
+ {{ item.item_name }}
+{%- elif mode == "Item Code + Description" -%}
+ {{ item.item_code }}
+ {%- if item.description and item.description != item.item_code -%}
{{ item.description }}{%- endif -%}
+{%- else -%}
+ {{ item.item_name }}
+ {%- if item.description and item.description != item.item_name -%}
{{ item.description }}{%- endif -%}
+{%- endif -%}
+{%- endmacro -%}
+
+{%- macro add_header(page_num, max_pages, doc, letter_head, no_letterhead, footer, print_settings=None, print_heading_template=None) -%}
+ {% if letter_head and not no_letterhead %}
+ {{ letter_head }}
+ {% endif %}
+ {% if print_heading_template %}
+ {{ frappe.render_template(print_heading_template, {"doc":doc}) }}
+ {% endif %}
+ {%- if doc.meta.is_submittable and doc.docstatus==2-%}
+
+
{{ _("CANCELLED") }}
+
+ {%- endif -%}
+{%- endmacro -%}
+
+{% for page in layout %}
+
+
+
+ {% if print_settings.repeat_header_footer %}
+
+ {% endif %}
+
+
+
+
{{ doc.customer }}
+
{{ doc.address_display }}
+
{{ _("Contact: ")+doc.contact_display if doc.contact_display else '' }}
+
{{ _("Mobile: ")+doc.contact_mobile if doc.contact_mobile else '' }}
+
+
+
+
+
+
{{ doc.name }}
+
+
+
+
{{ frappe.utils.format_date(doc.posting_date) }}
+
+
+
+
{{ frappe.utils.format_date(doc.due_date) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | {{ _("Sr") }} |
+ {{ _("Details") }} |
+ {{ _("Qty") }} |
+ {{ _("Rate") }} |
+ {{ _("Amount") }} |
+
+
+ {% for item in doc.items %}
+
+ | {{ loop.index }} |
+ {{ render_item(item) }} |
+
+ {{ item.get_formatted("qty", 0) }}
+ {{ item.get_formatted("uom", 0) }}
+ |
+ {{ item.get_formatted("net_rate", doc) }} |
+ {{ item.get_formatted("net_amount", doc) }} |
+
+ {% endfor %}
+
+
+
+
+
+
+ {{ doc.in_words }}
+
+
+
+ {{ doc.status }}
+
+
+
+
+
+
{{ doc.get_formatted("net_total", doc) }}
+
+
+ {% for d in doc.taxes %}
+ {% if d.tax_amount %}
+
+
+
{{ d.get_formatted("tax_amount") }}
+
+ {% endif %}
+ {% endfor %}
+
+
+
+
{{ doc.get_formatted("grand_total", doc) }}
+
+
+
+
+
+
+
+
+
+
{{ doc.terms if doc.terms else '' }}
+
+
+
+
+{% endfor %}
diff --git a/aqrar_ext/aqrar_ext/print_format/sales_invoice_aqrar/sales_invoice_aqrar.json b/aqrar_ext/aqrar_ext/print_format/sales_invoice_aqrar/sales_invoice_aqrar.json
new file mode 100644
index 0000000..c57dfc1
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/print_format/sales_invoice_aqrar/sales_invoice_aqrar.json
@@ -0,0 +1,27 @@
+{
+ "absolute_value": 0,
+ "align_labels_right": 0,
+ "css": "",
+ "custom_format": 1,
+ "default_print_language": "en",
+ "disabled": 0,
+ "doc_type": "Sales Invoice",
+ "doctype": "Print Format",
+ "font": "",
+ "font_size": 14,
+ "idx": 0,
+ "line_breaks": 0,
+ "margin_bottom": 0.0,
+ "margin_left": 0.0,
+ "margin_right": 0.0,
+ "margin_top": 0.0,
+ "module": "Aqrar Ext",
+ "name": "Sales Invoice Aqrar",
+ "page_number": "Hide",
+ "print_format_builder": 0,
+ "print_format_builder_beta": 0,
+ "print_format_type": "Jinja",
+ "raw_printing": 0,
+ "show_section_headings": 0,
+ "standard": "No"
+}
diff --git a/aqrar_ext/aqrar_ext/utils/__init__.py b/aqrar_ext/aqrar_ext/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/aqrar_ext/aqrar_ext/utils/print_helpers.py b/aqrar_ext/aqrar_ext/utils/print_helpers.py
new file mode 100644
index 0000000..18641b1
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/utils/print_helpers.py
@@ -0,0 +1,36 @@
+"""Print format helper functions — exposed to Jinja via jenv hook."""
+import frappe
+
+
+def get_display_mode():
+ """Return the current item display mode from Aqrar Settings."""
+ mode = frappe.db.get_value(
+ "Aqrar Settings", "Aqrar Settings", "item_display_mode"
+ )
+ return mode or "Item Name + Description"
+
+
+def format_item_display(item_code, item_name, description):
+ """Return HTML string for item display based on Aqrar Settings mode.
+
+ Called from print format Jinja templates as: {{ format_item_display(...) }}
+ """
+ mode = get_display_mode()
+
+ if mode == "Item Code":
+ return "{}".format(frappe.utils.escape_html(item_code))
+
+ if mode == "Item Name":
+ return "{}".format(frappe.utils.escape_html(item_name))
+
+ if mode == "Item Code + Description":
+ out = "{}".format(frappe.utils.escape_html(item_code))
+ if description and description != item_code:
+ out += "
{}".format(frappe.utils.escape_html(description))
+ return out
+
+ # Default: Item Name + Description
+ out = "{}".format(frappe.utils.escape_html(item_name))
+ if description and description != item_name:
+ out += "
{}".format(frappe.utils.escape_html(description))
+ return out
diff --git a/aqrar_ext/aqrar_ext/workflow/__init__.py b/aqrar_ext/aqrar_ext/workflow/__init__.py
new file mode 100644
index 0000000..25ce2e8
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/workflow/__init__.py
@@ -0,0 +1 @@
+# Aqrar Workflow module — approval conditions and email-based approval handlers
diff --git a/aqrar_ext/aqrar_ext/workflow/conditions.py b/aqrar_ext/aqrar_ext/workflow/conditions.py
new file mode 100644
index 0000000..2442e98
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/workflow/conditions.py
@@ -0,0 +1,217 @@
+"""
+Server-side validation for Aqrar approval workflows.
+
+Workflow transitions are role-based (Branch Approver, Branch Accountant).
+These functions enforce branch-level granularity via doc_events.
+They are called from validate hooks to ensure only the correct branch
+personnel can approve documents at each workflow state.
+"""
+
+import frappe
+
+
+def validate_stock_entry_approval(doc, method=None):
+ """
+ Enforce that only a Branch Approver for the receiving branch can
+ approve a Stock Entry (Material Transfer). Called via doc_events
+ validate hook.
+ """
+ if not doc.get("workflow_state") or doc.get("workflow_state") not in ("Pending Approval", "Committed"):
+ return
+
+ if doc.get("workflow_state") == "Pending Approval":
+ _ensure_user_is_branch_approver(doc)
+
+
+def validate_journal_entry_approval(doc, method=None):
+ """Enforce branch-accountant approval for Journal Entry."""
+ if not doc.get("workflow_state") or doc.get("workflow_state") not in ("Pending Approval", "Posted"):
+ return
+
+ if doc.get("workflow_state") == "Pending Approval":
+ _ensure_user_is_branch_accountant(doc)
+
+
+def validate_expense_claim_approval(doc, method=None):
+ """Enforce branch-accountant approval for Expense Claim."""
+ if not doc.get("workflow_state") or doc.get("workflow_state") not in ("Pending Approval", "Approved"):
+ return
+
+ if doc.get("workflow_state") == "Pending Approval":
+ _ensure_user_is_branch_accountant(doc)
+
+
+def validate_payment_entry_approval(doc, method=None):
+ """Enforce branch-accountant approval for Payment Entry."""
+ if not doc.get("workflow_state") or doc.get("workflow_state") not in ("Pending Approval", "Approved"):
+ return
+
+ if doc.get("workflow_state") == "Pending Approval":
+ _ensure_user_is_branch_accountant(doc)
+
+
+# ── helpers ────────────────────────────────────────────────────────────
+
+
+def _ensure_user_is_branch_approver(doc):
+ """
+ Raise if the current user is not a Branch Approver for at least one
+ of the target-warehouse branches in this Stock Entry.
+ """
+ user = frappe.session.user
+
+ # Collect target warehouses from items
+ target_warehouses = []
+ if doc.get("items"):
+ for item in doc.items:
+ t_wh = item.get("t_warehouse")
+ if t_wh:
+ target_warehouses.append(t_wh)
+
+ if not target_warehouses:
+ return # no target warehouse to validate against — allow
+
+ # Branches that own those target warehouses
+ wh_parents = frappe.get_all(
+ "Branch Configuration Warehouse",
+ filters={"warehouse": ("in", target_warehouses)},
+ pluck="parent",
+ )
+
+ if not wh_parents:
+ frappe.throw(
+ "No Branch Configuration found for the target warehouse(s). "
+ "Please set up the branch-warehouse mapping first."
+ )
+
+ # Check if user is a Branch Approver for any of those branches
+ user_configs = frappe.get_all(
+ "Branch Configuration User",
+ filters={
+ "user": user,
+ "role": "Branch Approver",
+ "parent": ("in", wh_parents),
+ },
+ pluck="parent",
+ )
+
+ if not user_configs:
+ frappe.throw(
+ f"User {user} is not a Branch Approver for the receiving "
+ "branch of this Stock Transfer. Only the receiving-branch approver "
+ "can approve this document."
+ )
+
+
+def _ensure_user_is_branch_accountant(doc):
+ """
+ Raise if the current user is not a Branch Accountant for the company
+ associated with this document.
+ """
+ user = frappe.session.user
+ company = doc.get("company")
+
+ if not company:
+ return # no company to validate against — allow
+
+ # Find branch configurations for this company
+ branch_configs = frappe.get_all(
+ "Branch Configuration",
+ filters={"company": company},
+ pluck="name",
+ )
+
+ if not branch_configs:
+ frappe.throw(
+ f"No Branch Configuration found for company {company}. "
+ "Please set up branch configurations first."
+ )
+
+ # Check if user is a Branch Accountant for any branch of this company
+ user_configs = frappe.get_all(
+ "Branch Configuration User",
+ filters={
+ "user": user,
+ "role": "Branch Accountant",
+ "parent": ("in", branch_configs),
+ },
+ pluck="parent",
+ )
+
+ if not user_configs:
+ frappe.throw(
+ f"User {user} is not a Branch Accountant for company "
+ f"{company}. Only a branch accountant of this company "
+ "can approve this document."
+ )
+
+
+# ── utility for programmatic checks (can be used from other code) ──────
+
+
+@frappe.whitelist()
+def is_receiving_branch_approver(doc, user=None):
+ """
+ Check if *user* (defaults to current user) is a Branch Approver for
+ the target warehouses in *doc*. Returns True/False.
+ """
+ user = user or frappe.session.user
+
+ target_warehouses = set()
+ if doc.get("items"):
+ for item in doc.items:
+ t_wh = item.get("t_warehouse")
+ if t_wh:
+ target_warehouses.add(t_wh)
+
+ if not target_warehouses:
+ return False
+
+ wh_parents = frappe.get_all(
+ "Branch Configuration Warehouse",
+ filters={"warehouse": ("in", list(target_warehouses))},
+ pluck="parent",
+ )
+
+ if not wh_parents:
+ return False
+
+ return frappe.db.exists(
+ "Branch Configuration User",
+ {
+ "user": user,
+ "role": "Branch Approver",
+ "parent": ("in", wh_parents),
+ },
+ )
+
+
+@frappe.whitelist()
+def is_branch_accountant(doc, user=None):
+ """
+ Check if *user* is a Branch Accountant for *doc*'s company.
+ Returns True/False.
+ """
+ user = user or frappe.session.user
+ company = doc.get("company")
+
+ if not company:
+ return False
+
+ branch_configs = frappe.get_all(
+ "Branch Configuration",
+ filters={"company": company},
+ pluck="name",
+ )
+
+ if not branch_configs:
+ return False
+
+ return frappe.db.exists(
+ "Branch Configuration User",
+ {
+ "user": user,
+ "role": "Branch Accountant",
+ "parent": ("in", branch_configs),
+ },
+ )
diff --git a/aqrar_ext/aqrar_ext/workflow/email_approval.py b/aqrar_ext/aqrar_ext/workflow/email_approval.py
new file mode 100644
index 0000000..e4bb97f
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/workflow/email_approval.py
@@ -0,0 +1,81 @@
+"""
+Email-based approval for Aqrar workflows.
+
+When a workflow sends an email alert to an approver, the email contains
+a link to the document. The approver clicks the link, logs in, and sees
+the Approve/Reject buttons on the form.
+
+This module provides:
+1. A whitelisted endpoint that generates a signed approval link
+2. A utility to auto-apply workflow action via URL token
+
+The signed-URL approach uses Frappe's built-in get_signed_params /
+verify_request for one-click approval without requiring the user to be
+logged in (optional — can be restricted to logged-in users only).
+"""
+
+import frappe
+from frappe.utils.verified_command import get_signed_params, verify_request
+from frappe.utils import get_url
+
+
+@frappe.whitelist(allow_guest=True)
+def approve_via_email(doctype, docname, action, **kwargs):
+ """
+ Approve a document via an email link.
+
+ URL: /api/method/aqrar_ext.aqrar_ext.workflow.email_approval.approve_via_email
+ ?doctype=Stock Entry
+ &docname=MAT-STE-2026-00001
+ &action=Approve
+ &... (signed params)
+
+ When called with valid signed params, applies the workflow action
+ as if the user clicked the button in the UI.
+
+ If the request is not signed, the user must be logged in and have
+ the required role for the action.
+ """
+ if kwargs:
+ # Request came with signed params — verify them
+ verify_request()
+ elif frappe.session.user == "Guest":
+ frappe.throw(
+ "You must be logged in to approve documents. "
+ "Please use the signed link from your email.",
+ frappe.PermissionError,
+ )
+
+ doc = frappe.get_doc(doctype, docname)
+ from frappe.model.workflow import apply_workflow
+
+ apply_workflow(doc, action)
+ frappe.db.commit()
+
+ return {
+ "success": True,
+ "message": f"{doctype} {docname} has been {action.lower()}d.",
+ "doctype": doctype,
+ "docname": docname,
+ "workflow_state": doc.get(doc.meta.workflow_state_field or "workflow_state"),
+ }
+
+
+@frappe.whitelist()
+def get_approval_link(doctype, docname, action):
+ """
+ Generate a signed URL for one-click approval.
+ The link is valid for 7 days.
+
+ Returns a full URL that can be included in email templates.
+ """
+ params = {
+ "doctype": doctype,
+ "docname": docname,
+ "action": action,
+ }
+ signed = get_signed_params(params)
+ return get_url(
+ f"/api/method/aqrar_ext.aqrar_ext.workflow.email_approval.approve_via_email"
+ f"?{signed}"
+ )
diff --git a/aqrar_ext/aqrar_ext/workflow/test_workflows.py b/aqrar_ext/aqrar_ext/workflow/test_workflows.py
new file mode 100644
index 0000000..caba88a
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/workflow/test_workflows.py
@@ -0,0 +1,368 @@
+"""
+Tests for CR-017: Configurable approval workflows.
+
+Covers:
+ - Workflow fixture integrity (4 doctypes)
+ - Branch-level approval enforcement (conditions.py)
+ - Email approval link generation
+ - Whitelisted utility functions
+"""
+
+import frappe
+from frappe.tests.utils import FrappeTestCase, change_settings
+
+
+class TestWorkflowFixtures(FrappeTestCase):
+ """Verify all 4 workflows are defined with correct states + transitions."""
+
+ def setUp(self):
+ self.workflows = frappe.get_all("Workflow", fields=["name", "document_type"])
+
+ def test_four_workflows_exist(self):
+ """CR-017 requires workflows for all 4 doctypes."""
+ doctypes = {w.document_type for w in self.workflows}
+ self.assertIn("Stock Entry", doctypes)
+ self.assertIn("Journal Entry", doctypes)
+ self.assertIn("Expense Claim", doctypes)
+ self.assertIn("Payment Entry", doctypes)
+ self.assertEqual(len(self.workflows), 4)
+
+ def test_workflow_states_and_transitions(self):
+ """Each workflow must have Draft→Pending→Final + Reject states."""
+ expected = {
+ "Stock Transfer Approval": {
+ "states": {"Draft", "Pending Approval", "Committed", "Rejected"},
+ "final_state": "Committed",
+ },
+ "Journal Entry Approval": {
+ "states": {"Draft", "Pending Approval", "Posted", "Rejected"},
+ "final_state": "Posted",
+ },
+ "Expense Claim Approval": {
+ "states": {"Draft", "Pending Approval", "Approved", "Rejected"},
+ "final_state": "Approved",
+ },
+ "Payment Entry Approval": {
+ "states": {"Draft", "Pending Approval", "Approved", "Rejected"},
+ "final_state": "Approved",
+ },
+ }
+
+ for name, spec in expected.items():
+ wf = frappe.get_doc("Workflow", name)
+ actual_states = {s.state for s in wf.states}
+ self.assertEqual(actual_states, spec["states"],
+ f"{name}: expected states {spec['states']}, got {actual_states}")
+
+ # Verify the approve transition lands on the correct final state
+ approve_transitions = [t for t in wf.transitions if t.action == "Approve"]
+ self.assertEqual(len(approve_transitions), 1, f"{name}: expected 1 Approve transition")
+ self.assertEqual(approve_transitions[0].next_state, spec["final_state"],
+ f"{name}: Approve should go to {spec['final_state']}")
+
+ # Verify reject is optional
+ reject_state = next((s for s in wf.states if s.state == "Rejected"), None)
+ self.assertIsNotNone(reject_state, f"{name}: missing Rejected state")
+ self.assertTrue(reject_state.is_optional_state,
+ f"{name}: Rejected should be optional")
+
+ def test_workflow_state_field_custom_fields_exist(self):
+ """Each doctype must have a workflow_state custom field."""
+ for dt in ("Stock Entry", "Journal Entry", "Expense Claim", "Payment Entry"):
+ meta = frappe.get_meta(dt)
+ self.assertIn("workflow_state", [f.fieldname for f in meta.fields],
+ f"{dt} missing workflow_state custom field")
+
+ def test_workflow_actions_exist(self):
+ """Required workflow actions must be defined."""
+ actions = frappe.get_all("Workflow Action Master", pluck="name")
+ for action in ("Submit for Approval", "Approve", "Reject"):
+ self.assertIn(action, actions)
+
+
+class TestBranchApprovalConditions(FrappeTestCase):
+ """Test the server-side enforcement in conditions.py."""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls._create_test_roles()
+ cls._create_test_users()
+ cls._create_test_data()
+
+ @classmethod
+ def _create_test_roles(cls):
+ for role_name in ("Branch Approver", "Branch Accountant", "Accounts User", "Stock User"):
+ if not frappe.db.exists("Role", role_name):
+ frappe.get_doc({"doctype": "Role", "role_name": role_name}).insert(
+ ignore_permissions=True
+ )
+
+ @classmethod
+ def _create_test_users(cls):
+ users = {
+ "test_approver@aqrar.com": ["Branch Approver"],
+ "test_accountant@aqrar.com": ["Branch Accountant"],
+ "test_stranger@aqrar.com": ["Accounts User"],
+ }
+ for email, roles in users.items():
+ if frappe.db.exists("User", email):
+ continue
+ user = frappe.get_doc({
+ "doctype": "User",
+ "email": email,
+ "first_name": email.split("@")[0].replace("_", " ").title(),
+ "send_welcome_email": 0,
+ "roles": [{"role": r} for r in roles],
+ })
+ user.insert(ignore_permissions=True)
+ frappe.db.commit()
+
+ @classmethod
+ def _create_test_data(cls):
+ # Company
+ if not frappe.db.exists("Company", "_Test Company AQR"):
+ company = frappe.get_doc({
+ "doctype": "Company",
+ "company_name": "_Test Company AQR",
+ "abbr": "TCA",
+ "default_currency": "SAR",
+ })
+ company.insert(ignore_permissions=True)
+
+ # Branch
+ if not frappe.db.exists("Branch", "_Test Branch AQR"):
+ frappe.get_doc({
+ "doctype": "Branch", "branch_name": "_Test Branch AQR",
+ }).insert(ignore_permissions=True)
+
+ # Warehouse
+ if not frappe.db.exists("Warehouse", "_Test Warehouse AQR - TCA"):
+ frappe.get_doc({
+ "doctype": "Warehouse",
+ "warehouse_name": "_Test Warehouse AQR",
+ "company": "_Test Company AQR",
+ }).insert(ignore_permissions=True)
+
+ # Branch Configuration (links branch → company, warehouse, users)
+ if not frappe.db.exists("Branch Configuration", "_Test BC AQR"):
+ bc = frappe.get_doc({
+ "doctype": "Branch Configuration",
+ "branch": "_Test Branch AQR",
+ "company": "_Test Company AQR",
+ "warehouse": [{"warehouse": "_Test Warehouse AQR - TCA"}],
+ "user": [
+ {"user": "test_approver@aqrar.com", "role": "Branch Approver"},
+ {"user": "test_accountant@aqrar.com", "role": "Branch Accountant"},
+ ],
+ })
+ bc.insert(ignore_permissions=True)
+
+ frappe.db.commit()
+
+ # ── Stock Entry (Branch Approver) ──────────────────────────────────
+
+ def test_stock_entry_blocks_non_approver(self):
+ """A user who is NOT a Branch Approver for the receiving branch cannot approve."""
+ doc = frappe.new_doc("Stock Entry")
+ doc.stock_entry_type = "Material Transfer"
+ doc.company = "_Test Company AQR"
+ doc.workflow_state = "Pending Approval"
+ doc.append("items", {
+ "item_code": "_Test Item",
+ "qty": 1,
+ "s_warehouse": "_Test Warehouse AQR - TCA",
+ "t_warehouse": "_Test Warehouse AQR - TCA",
+ })
+
+ frappe.set_user("test_stranger@aqrar.com")
+ from aqrar_ext.aqrar_ext.workflow.conditions import validate_stock_entry_approval
+ with self.assertRaises(frappe.ValidationError):
+ validate_stock_entry_approval(doc)
+
+ def test_stock_entry_allows_branch_approver(self):
+ """A Branch Approver for the receiving branch may approve."""
+ doc = frappe.new_doc("Stock Entry")
+ doc.stock_entry_type = "Material Transfer"
+ doc.company = "_Test Company AQR"
+ doc.workflow_state = "Pending Approval"
+ doc.append("items", {
+ "item_code": "_Test Item",
+ "qty": 1,
+ "s_warehouse": "_Test Warehouse AQR - TCA",
+ "t_warehouse": "_Test Warehouse AQR - TCA",
+ })
+
+ frappe.set_user("test_approver@aqrar.com")
+ from aqrar_ext.aqrar_ext.workflow.conditions import validate_stock_entry_approval
+ try:
+ validate_stock_entry_approval(doc)
+ except frappe.ValidationError:
+ self.fail("Branch Approver should be allowed to approve Stock Entry")
+
+ # ── Journal Entry (Branch Accountant) ──────────────────────────────
+
+ def test_journal_entry_blocks_non_accountant(self):
+ """A user who is NOT a Branch Accountant for the company cannot approve."""
+ doc = frappe.new_doc("Journal Entry")
+ doc.company = "_Test Company AQR"
+ doc.workflow_state = "Pending Approval"
+
+ frappe.set_user("test_stranger@aqrar.com")
+ from aqrar_ext.aqrar_ext.workflow.conditions import validate_journal_entry_approval
+ with self.assertRaises(frappe.ValidationError):
+ validate_journal_entry_approval(doc)
+
+ def test_journal_entry_allows_branch_accountant(self):
+ """A Branch Accountant for the company may approve."""
+ doc = frappe.new_doc("Journal Entry")
+ doc.company = "_Test Company AQR"
+ doc.workflow_state = "Pending Approval"
+
+ frappe.set_user("test_accountant@aqrar.com")
+ from aqrar_ext.aqrar_ext.workflow.conditions import validate_journal_entry_approval
+ try:
+ validate_journal_entry_approval(doc)
+ except frappe.ValidationError:
+ self.fail("Branch Accountant should be allowed to approve Journal Entry")
+
+ # ── Expense Claim (Branch Accountant) ──────────────────────────────
+
+ def test_expense_claim_blocks_non_accountant(self):
+ doc = frappe.new_doc("Expense Claim")
+ doc.company = "_Test Company AQR"
+ doc.workflow_state = "Pending Approval"
+
+ frappe.set_user("test_stranger@aqrar.com")
+ from aqrar_ext.aqrar_ext.workflow.conditions import validate_expense_claim_approval
+ with self.assertRaises(frappe.ValidationError):
+ validate_expense_claim_approval(doc)
+
+ def test_expense_claim_allows_branch_accountant(self):
+ doc = frappe.new_doc("Expense Claim")
+ doc.company = "_Test Company AQR"
+ doc.workflow_state = "Pending Approval"
+
+ frappe.set_user("test_accountant@aqrar.com")
+ from aqrar_ext.aqrar_ext.workflow.conditions import validate_expense_claim_approval
+ try:
+ validate_expense_claim_approval(doc)
+ except frappe.ValidationError:
+ self.fail("Branch Accountant should be allowed to approve Expense Claim")
+
+ # ── Payment Entry (Branch Accountant) ──────────────────────────────
+
+ def test_payment_entry_blocks_non_accountant(self):
+ doc = frappe.new_doc("Payment Entry")
+ doc.payment_type = "Receive"
+ doc.company = "_Test Company AQR"
+ doc.workflow_state = "Pending Approval"
+
+ frappe.set_user("test_stranger@aqrar.com")
+ from aqrar_ext.aqrar_ext.workflow.conditions import validate_payment_entry_approval
+ with self.assertRaises(frappe.ValidationError):
+ validate_payment_entry_approval(doc)
+
+ def test_payment_entry_allows_branch_accountant(self):
+ doc = frappe.new_doc("Payment Entry")
+ doc.payment_type = "Receive"
+ doc.company = "_Test Company AQR"
+ doc.workflow_state = "Pending Approval"
+
+ frappe.set_user("test_accountant@aqrar.com")
+ from aqrar_ext.aqrar_ext.workflow.conditions import validate_payment_entry_approval
+ try:
+ validate_payment_entry_approval(doc)
+ except frappe.ValidationError:
+ self.fail("Branch Accountant should be allowed to approve Payment Entry")
+
+ # ── Skip validation for non-workflow states ───────────────────────
+
+ def test_draft_state_skips_validation(self):
+ """Documents in Draft state should NOT trigger approval checks."""
+ for dt, validator in [
+ ("Stock Entry", "validate_stock_entry_approval"),
+ ("Journal Entry", "validate_journal_entry_approval"),
+ ("Expense Claim", "validate_expense_claim_approval"),
+ ("Payment Entry", "validate_payment_entry_approval"),
+ ]:
+ doc = frappe.new_doc(dt)
+ if dt == "Stock Entry":
+ doc.stock_entry_type = "Material Transfer"
+ elif dt == "Payment Entry":
+ doc.payment_type = "Receive"
+ doc.company = "_Test Company AQR"
+ doc.workflow_state = "Draft"
+
+ frappe.set_user("test_stranger@aqrar.com")
+ from aqrar_ext.aqrar_ext.workflow import conditions
+ validate_fn = getattr(conditions, validator)
+ try:
+ validate_fn(doc)
+ except frappe.ValidationError:
+ self.fail(f"{dt} in Draft should not trigger validation")
+
+
+class TestWhitelistedUtilities(FrappeTestCase):
+ """Test the whitelisted helper functions."""
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ # Reuse data from TestBranchApprovalConditions
+ TestBranchApprovalConditions.setUpClass()
+
+ def test_is_receiving_branch_approver_true(self):
+ doc = frappe.new_doc("Stock Entry")
+ doc.stock_entry_type = "Material Transfer"
+ doc.append("items", {
+ "t_warehouse": "_Test Warehouse AQR - TCA",
+ })
+ from aqrar_ext.aqrar_ext.workflow.conditions import is_receiving_branch_approver
+ self.assertTrue(
+ is_receiving_branch_approver(doc, user="test_approver@aqrar.com")
+ )
+
+ def test_is_receiving_branch_approver_false(self):
+ doc = frappe.new_doc("Stock Entry")
+ doc.stock_entry_type = "Material Transfer"
+ doc.append("items", {
+ "t_warehouse": "_Test Warehouse AQR - TCA",
+ })
+ from aqrar_ext.aqrar_ext.workflow.conditions import is_receiving_branch_approver
+ self.assertFalse(
+ is_receiving_branch_approver(doc, user="test_stranger@aqrar.com")
+ )
+
+ def test_is_branch_accountant_true(self):
+ doc = frappe.new_doc("Journal Entry")
+ doc.company = "_Test Company AQR"
+ from aqrar_ext.aqrar_ext.workflow.conditions import is_branch_accountant
+ self.assertTrue(
+ is_branch_accountant(doc, user="test_accountant@aqrar.com")
+ )
+
+ def test_is_branch_accountant_false(self):
+ doc = frappe.new_doc("Journal Entry")
+ doc.company = "_Test Company AQR"
+ from aqrar_ext.aqrar_ext.workflow.conditions import is_branch_accountant
+ self.assertFalse(
+ is_branch_accountant(doc, user="test_stranger@aqrar.com")
+ )
+
+
+class TestEmailApproval(FrappeTestCase):
+ """Test email-based approval link generation."""
+
+ def test_get_approval_link_returns_url(self):
+ from aqrar_ext.aqrar_ext.workflow.email_approval import get_approval_link
+ url = get_approval_link("Stock Entry", "MAT-STE-2026-00001", "Approve")
+ self.assertIn("/api/method/", url)
+ self.assertIn("aqrar_ext.aqrar_ext.workflow.email_approval.approve_via_email", url)
+
+ def test_approve_via_email_rejects_unsigned_guest(self):
+ """Guest without signed params should get PermissionError."""
+ frappe.set_user("Guest")
+ from aqrar_ext.aqrar_ext.workflow.email_approval import approve_via_email
+ with self.assertRaises(frappe.PermissionError):
+ approve_via_email("Stock Entry", "nonexistent", "Approve")
diff --git a/aqrar_ext/fixtures/custom_docperm.json b/aqrar_ext/fixtures/custom_docperm.json
new file mode 100644
index 0000000..51eedb0
--- /dev/null
+++ b/aqrar_ext/fixtures/custom_docperm.json
@@ -0,0 +1,52 @@
+[
+ {
+ "amend": 0,
+ "cancel": 0,
+ "create": 0,
+ "delete": 0,
+ "docstatus": 0,
+ "doctype": "Custom DocPerm",
+ "email": 0,
+ "export": 0,
+ "if_owner": 0,
+ "import": 0,
+ "name": "Project-Branch User",
+ "parent": "Project",
+ "parentfield": "permissions",
+ "parenttype": "DocType",
+ "permlevel": 0,
+ "print": 0,
+ "read": 1,
+ "report": 0,
+ "role": "Branch User",
+ "select": 0,
+ "share": 0,
+ "submit": 0,
+ "write": 1
+ },
+ {
+ "amend": 0,
+ "cancel": 0,
+ "create": 0,
+ "delete": 0,
+ "docstatus": 0,
+ "doctype": "Custom DocPerm",
+ "email": 0,
+ "export": 0,
+ "if_owner": 0,
+ "import": 0,
+ "name": "Project-Sales User",
+ "parent": "Project",
+ "parentfield": "permissions",
+ "parenttype": "DocType",
+ "permlevel": 0,
+ "print": 0,
+ "read": 1,
+ "report": 0,
+ "role": "Sales User",
+ "select": 0,
+ "share": 0,
+ "submit": 0,
+ "write": 1
+ }
+]
diff --git a/aqrar_ext/fixtures/custom_field.json b/aqrar_ext/fixtures/custom_field.json
index a8c8c26..25b782b 100644
--- a/aqrar_ext/fixtures/custom_field.json
+++ b/aqrar_ext/fixtures/custom_field.json
@@ -113,6 +113,174 @@
"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": "Minimum allowed selling rate. Sales Invoice submit blocked if item rate falls below this value.",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Item Price",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_minimum_selling_rate",
+ "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": "price_list_rate",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Minimum Selling Rate",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "module": null,
+ "name": "Item Price-custom_minimum_selling_rate",
+ "no_copy": 0,
+ "non_negative": 1,
+ "options": "currency",
+ "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": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": "Cost center this price list applies to. Used for cost-center-wise price differentiation.",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Price List",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_branch",
+ "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": "selling",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Cost Center",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "module": null,
+ "name": "Price List-custom_branch",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "Cost Center",
+ "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": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": "0",
+ "depends_on": null,
+ "description": "If checked, the minimum selling rate validation is bypassed for this Sales Invoice.",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Sales Invoice",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_override_minimum_price",
+ "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": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "update_stock",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Override Minimum Price",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "module": null,
+ "name": "Sales Invoice-custom_override_minimum_price",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 1,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 1,
+ "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": 0,
@@ -147,7 +315,6 @@
"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,
@@ -229,17 +396,17 @@
},
{
"allow_in_quick_entry": 0,
- "allow_on_submit": 1,
+ "allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
- "columns": 1,
+ "columns": 0,
"default": null,
"depends_on": null,
- "description": null,
+ "description": "Current approval state for the Aqrar Stock Transfer approval workflow.",
"docstatus": 0,
"doctype": "Custom Field",
- "dt": "Material Request",
+ "dt": "Stock Entry",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "workflow_state",
@@ -251,19 +418,18 @@
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
- "in_list_view": 1,
+ "in_list_view": 0,
"in_preview": 0,
- "in_standard_filter": 1,
- "insert_after": "status",
+ "in_standard_filter": 0,
+ "insert_after": "stock_entry_type",
"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",
+ "module": "Aqrar Ext",
+ "name": "Stock Entry-workflow_state",
"no_copy": 0,
"non_negative": 0,
"options": "Workflow State",
@@ -280,7 +446,568 @@
"search_index": 0,
"show_dashboard": 0,
"sort_options": 0,
- "translatable": 0,
+ "translatable": 1,
+ "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": "Current approval state for the Aqrar Journal Entry approval workflow.",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Journal Entry",
+ "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": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "voucher_type",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Workflow State",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "module": "Aqrar Ext",
+ "name": "Journal Entry-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": 1,
+ "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": "Current approval state for the Aqrar Payment Entry approval workflow.",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Payment Entry",
+ "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": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "payment_type",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Workflow State",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "module": "Aqrar Ext",
+ "name": "Payment Entry-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": 1,
+ "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": "Default naming series applied when creating Items under this Item Group.",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Item Group",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_default_item_naming_series",
+ "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": "parent_item_group",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Default Item Naming Series",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "module": "Aqrar Ext",
+ "name": "Item Group-custom_default_item_naming_series",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "\nSTO-ITEM-.YYYY.-\nSTO-ITEM-",
+ "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": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": "0",
+ "depends_on": null,
+ "description": "Play a sound when a desk notification arrives.",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "User",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_enable_sound_alerts",
+ "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": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "user_image",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Enable Sound Alerts",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "module": "Aqrar Ext",
+ "name": "User-custom_enable_sound_alerts",
+ "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": 1,
+ "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": "Link to the source Sales Invoice this commission Journal Entry relates to.",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Journal Entry",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_reference_invoice",
+ "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": "cheque_no",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Reference Invoice",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "module": "Aqrar Ext",
+ "name": "Journal Entry-custom_reference_invoice",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "Sales Invoice",
+ "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": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": "Default expense account for commission journal entries.",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Company",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "default_commission_expense_account",
+ "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": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "default_expense_account",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Default Commission Expense Account",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "module": "Accounts",
+ "name": "Company-default_commission_expense_account",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "Account",
+ "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": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": "Default payable account for commission journal entries.",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Company",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "default_commission_payable_account",
+ "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": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "default_commission_expense_account",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Default Commission Payable Account",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "module": "Accounts",
+ "name": "Company-default_commission_payable_account",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "Account",
+ "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": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": "Default expense account for discount day-close journal entries.",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Company",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "default_discount_expense_account",
+ "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": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "default_commission_payable_account",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Default Discount Expense Account",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "module": "Accounts",
+ "name": "Company-default_discount_expense_account",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "Account",
+ "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": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": "Default payable/clearing account for discount day-close journal entries.",
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Company",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "default_discount_payable_account",
+ "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": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "default_discount_expense_account",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Default Discount Payable Account",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "module": "Accounts",
+ "name": "Company-default_discount_payable_account",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "Account",
+ "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
},
@@ -683,4 +1410,4 @@
"unique": 0,
"width": null
}
-]
\ No newline at end of file
+]
diff --git a/aqrar_ext/fixtures/notification.json b/aqrar_ext/fixtures/notification.json
new file mode 100644
index 0000000..8ffec69
--- /dev/null
+++ b/aqrar_ext/fixtures/notification.json
@@ -0,0 +1,110 @@
+[
+ {
+ "channel": "System Notification",
+ "condition": null,
+ "days_in_advance": 0,
+ "docstatus": 0,
+ "doctype": "Notification",
+ "document_type": "Item",
+ "enabled": 1,
+ "event": "New",
+ "is_standard": 1,
+ "method": null,
+ "module": "Aqrar Ext",
+ "name": "Item Created Alert",
+ "print_format": null,
+ "recipients": [
+ {
+ "cc": null,
+ "bcc": null,
+ "condition": null,
+ "parent": "Item Created Alert",
+ "parentfield": "recipients",
+ "parenttype": "Notification",
+ "receiver_by_document_field": null,
+ "receiver_by_role": "Item Manager"
+ },
+ {
+ "cc": null,
+ "bcc": null,
+ "condition": null,
+ "parent": "Item Created Alert",
+ "parentfield": "recipients",
+ "parenttype": "Notification",
+ "receiver_by_document_field": null,
+ "receiver_by_role": "Stock Manager"
+ },
+ {
+ "cc": null,
+ "bcc": null,
+ "condition": null,
+ "parent": "Item Created Alert",
+ "parentfield": "recipients",
+ "parenttype": "Notification",
+ "receiver_by_document_field": null,
+ "receiver_by_role": "Stock User"
+ }
+ ],
+ "send_to_all_assignees": 0,
+ "sender": null,
+ "sender_email": null,
+ "set_property_after_alert": null,
+ "slack_webhook_url": null,
+ "subject": "New Item Created: {{ doc.item_code }} - {{ doc.item_name }}",
+ "value_changed": null
+ },
+ {
+ "channel": "System Notification",
+ "condition": null,
+ "days_in_advance": 0,
+ "docstatus": 0,
+ "doctype": "Notification",
+ "document_type": "Item Price",
+ "enabled": 1,
+ "event": "Value Change",
+ "is_standard": 1,
+ "method": null,
+ "module": "Aqrar Ext",
+ "name": "Item Price Updated Alert",
+ "print_format": null,
+ "recipients": [
+ {
+ "cc": null,
+ "bcc": null,
+ "condition": null,
+ "parent": "Item Price Updated Alert",
+ "parentfield": "recipients",
+ "parenttype": "Notification",
+ "receiver_by_document_field": null,
+ "receiver_by_role": "Item Manager"
+ },
+ {
+ "cc": null,
+ "bcc": null,
+ "condition": null,
+ "parent": "Item Price Updated Alert",
+ "parentfield": "recipients",
+ "parenttype": "Notification",
+ "receiver_by_document_field": null,
+ "receiver_by_role": "Stock Manager"
+ },
+ {
+ "cc": null,
+ "bcc": null,
+ "condition": null,
+ "parent": "Item Price Updated Alert",
+ "parentfield": "recipients",
+ "parenttype": "Notification",
+ "receiver_by_document_field": null,
+ "receiver_by_role": "Sales Master Manager"
+ }
+ ],
+ "send_to_all_assignees": 0,
+ "sender": null,
+ "sender_email": null,
+ "set_property_after_alert": null,
+ "slack_webhook_url": null,
+ "subject": "Item Price Updated: {{ doc.item_code }} — {{ doc.price_list }} rate changed to {{ doc.price_list_rate }}",
+ "value_changed": "price_list_rate"
+ }
+]
diff --git a/aqrar_ext/fixtures/workflow.json b/aqrar_ext/fixtures/workflow.json
index 5547090..cf4f294 100644
--- a/aqrar_ext/fixtures/workflow.json
+++ b/aqrar_ext/fixtures/workflow.json
@@ -131,5 +131,362 @@
"workflow_data": null,
"workflow_name": "Material Request Approval",
"workflow_state_field": "workflow_state"
+ },
+ {
+ "docstatus": 0,
+ "doctype": "Workflow",
+ "document_type": "Stock Entry",
+ "is_active": 1,
+ "name": "Stock Transfer Approval",
+ "override_status": 0,
+ "send_email_alert": 1,
+ "states": [
+ {
+ "allow_edit": "Stock User",
+ "avoid_status_override": 0,
+ "doc_status": "0",
+ "is_optional_state": 0,
+ "message": "",
+ "next_action_email_template": "",
+ "parent": "Stock Transfer Approval",
+ "parentfield": "states",
+ "parenttype": "Workflow",
+ "send_email": 0,
+ "state": "Draft",
+ "update_field": "workflow_state",
+ "update_value": "Draft",
+ "workflow_builder_id": null
+ },
+ {
+ "allow_edit": "Branch Approver",
+ "avoid_status_override": 0,
+ "doc_status": "0",
+ "is_optional_state": 0,
+ "message": "Stock Transfer requires your approval. Please review and approve or reject.",
+ "next_action_email_template": "",
+ "parent": "Stock Transfer Approval",
+ "parentfield": "states",
+ "parenttype": "Workflow",
+ "send_email": 0,
+ "state": "Pending Approval",
+ "update_field": "workflow_state",
+ "update_value": "Pending Approval",
+ "workflow_builder_id": null
+ },
+ {
+ "allow_edit": "Stock Manager",
+ "avoid_status_override": 0,
+ "doc_status": "1",
+ "is_optional_state": 0,
+ "message": "Stock Transfer has been approved and committed.",
+ "next_action_email_template": "",
+ "parent": "Stock Transfer Approval",
+ "parentfield": "states",
+ "parenttype": "Workflow",
+ "send_email": 0,
+ "state": "Committed",
+ "update_field": "workflow_state",
+ "update_value": "Committed",
+ "workflow_builder_id": null
+ },
+ {
+ "allow_edit": "Stock User",
+ "avoid_status_override": 0,
+ "doc_status": "1",
+ "is_optional_state": 1,
+ "message": "Stock Transfer has been rejected.",
+ "next_action_email_template": "",
+ "parent": "Stock Transfer Approval",
+ "parentfield": "states",
+ "parenttype": "Workflow",
+ "send_email": 0,
+ "state": "Rejected",
+ "update_field": "workflow_state",
+ "update_value": "Rejected",
+ "workflow_builder_id": null
+ }
+ ],
+ "transitions": [
+ {
+ "action": "Submit for Approval",
+ "allow_self_approval": 0,
+ "allowed": "Stock User",
+ "condition": null,
+ "next_state": "Pending Approval",
+ "parent": "Stock Transfer Approval",
+ "parentfield": "transitions",
+ "parenttype": "Workflow",
+ "send_email_to_creator": 0,
+ "state": "Draft",
+ "workflow_builder_id": null
+ },
+ {
+ "action": "Approve",
+ "allow_self_approval": 0,
+ "allowed": "Branch Approver",
+ "condition": null,
+ "next_state": "Committed",
+ "parent": "Stock Transfer 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 Approver",
+ "condition": null,
+ "next_state": "Rejected",
+ "parent": "Stock Transfer Approval",
+ "parentfield": "transitions",
+ "parenttype": "Workflow",
+ "send_email_to_creator": 0,
+ "state": "Pending Approval",
+ "workflow_builder_id": null
+ }
+ ],
+ "workflow_data": null,
+ "workflow_name": "Stock Transfer Approval",
+ "workflow_state_field": "workflow_state"
+ },
+ {
+ "docstatus": 0,
+ "doctype": "Workflow",
+ "document_type": "Journal Entry",
+ "is_active": 1,
+ "name": "Journal Entry Approval",
+ "override_status": 0,
+ "send_email_alert": 1,
+ "states": [
+ {
+ "allow_edit": "Accounts User",
+ "avoid_status_override": 0,
+ "doc_status": "0",
+ "is_optional_state": 0,
+ "message": "",
+ "next_action_email_template": "",
+ "parent": "Journal Entry Approval",
+ "parentfield": "states",
+ "parenttype": "Workflow",
+ "send_email": 0,
+ "state": "Draft",
+ "update_field": "workflow_state",
+ "update_value": "Draft",
+ "workflow_builder_id": null
+ },
+ {
+ "allow_edit": "Branch Accountant",
+ "avoid_status_override": 0,
+ "doc_status": "0",
+ "is_optional_state": 0,
+ "message": "Journal Entry requires your approval. Please review and approve or reject.",
+ "next_action_email_template": "",
+ "parent": "Journal Entry Approval",
+ "parentfield": "states",
+ "parenttype": "Workflow",
+ "send_email": 0,
+ "state": "Pending Approval",
+ "update_field": "workflow_state",
+ "update_value": "Pending Approval",
+ "workflow_builder_id": null
+ },
+ {
+ "allow_edit": "Accounts Manager",
+ "avoid_status_override": 0,
+ "doc_status": "1",
+ "is_optional_state": 0,
+ "message": "Journal Entry has been approved and posted.",
+ "next_action_email_template": "",
+ "parent": "Journal Entry Approval",
+ "parentfield": "states",
+ "parenttype": "Workflow",
+ "send_email": 0,
+ "state": "Posted",
+ "update_field": "workflow_state",
+ "update_value": "Posted",
+ "workflow_builder_id": null
+ },
+ {
+ "allow_edit": "Accounts User",
+ "avoid_status_override": 0,
+ "doc_status": "1",
+ "is_optional_state": 1,
+ "message": "Journal Entry has been rejected.",
+ "next_action_email_template": "",
+ "parent": "Journal Entry Approval",
+ "parentfield": "states",
+ "parenttype": "Workflow",
+ "send_email": 0,
+ "state": "Rejected",
+ "update_field": "workflow_state",
+ "update_value": "Rejected",
+ "workflow_builder_id": null
+ }
+ ],
+ "transitions": [
+ {
+ "action": "Submit for Approval",
+ "allow_self_approval": 0,
+ "allowed": "Accounts User",
+ "condition": null,
+ "next_state": "Pending Approval",
+ "parent": "Journal Entry Approval",
+ "parentfield": "transitions",
+ "parenttype": "Workflow",
+ "send_email_to_creator": 0,
+ "state": "Draft",
+ "workflow_builder_id": null
+ },
+ {
+ "action": "Approve",
+ "allow_self_approval": 0,
+ "allowed": "Branch Accountant",
+ "condition": null,
+ "next_state": "Posted",
+ "parent": "Journal Entry 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 Accountant",
+ "condition": null,
+ "next_state": "Rejected",
+ "parent": "Journal Entry Approval",
+ "parentfield": "transitions",
+ "parenttype": "Workflow",
+ "send_email_to_creator": 0,
+ "state": "Pending Approval",
+ "workflow_builder_id": null
+ }
+ ],
+ "workflow_data": null,
+ "workflow_name": "Journal Entry Approval",
+ "workflow_state_field": "workflow_state"
+ },
+ {
+ "docstatus": 0,
+ "doctype": "Workflow",
+ "document_type": "Payment Entry",
+ "is_active": 1,
+ "name": "Payment Entry Approval",
+ "override_status": 0,
+ "send_email_alert": 1,
+ "states": [
+ {
+ "allow_edit": "Accounts User",
+ "avoid_status_override": 0,
+ "doc_status": "0",
+ "is_optional_state": 0,
+ "message": "",
+ "next_action_email_template": "",
+ "parent": "Payment Entry Approval",
+ "parentfield": "states",
+ "parenttype": "Workflow",
+ "send_email": 0,
+ "state": "Draft",
+ "update_field": "workflow_state",
+ "update_value": "Draft",
+ "workflow_builder_id": null
+ },
+ {
+ "allow_edit": "Branch Accountant",
+ "avoid_status_override": 0,
+ "doc_status": "0",
+ "is_optional_state": 0,
+ "message": "Payment Entry requires your approval. Please verify the payment details.",
+ "next_action_email_template": "",
+ "parent": "Payment Entry Approval",
+ "parentfield": "states",
+ "parenttype": "Workflow",
+ "send_email": 0,
+ "state": "Pending Approval",
+ "update_field": "workflow_state",
+ "update_value": "Pending Approval",
+ "workflow_builder_id": null
+ },
+ {
+ "allow_edit": "Accounts Manager",
+ "avoid_status_override": 0,
+ "doc_status": "1",
+ "is_optional_state": 0,
+ "message": "Payment Entry has been approved.",
+ "next_action_email_template": "",
+ "parent": "Payment Entry Approval",
+ "parentfield": "states",
+ "parenttype": "Workflow",
+ "send_email": 0,
+ "state": "Approved",
+ "update_field": "workflow_state",
+ "update_value": "Approved",
+ "workflow_builder_id": null
+ },
+ {
+ "allow_edit": "Accounts User",
+ "avoid_status_override": 0,
+ "doc_status": "1",
+ "is_optional_state": 1,
+ "message": "Payment Entry has been rejected.",
+ "next_action_email_template": "",
+ "parent": "Payment Entry Approval",
+ "parentfield": "states",
+ "parenttype": "Workflow",
+ "send_email": 0,
+ "state": "Rejected",
+ "update_field": "workflow_state",
+ "update_value": "Rejected",
+ "workflow_builder_id": null
+ }
+ ],
+ "transitions": [
+ {
+ "action": "Submit for Approval",
+ "allow_self_approval": 0,
+ "allowed": "Accounts User",
+ "condition": null,
+ "next_state": "Pending Approval",
+ "parent": "Payment Entry Approval",
+ "parentfield": "transitions",
+ "parenttype": "Workflow",
+ "send_email_to_creator": 0,
+ "state": "Draft",
+ "workflow_builder_id": null
+ },
+ {
+ "action": "Approve",
+ "allow_self_approval": 0,
+ "allowed": "Branch Accountant",
+ "condition": null,
+ "next_state": "Approved",
+ "parent": "Payment Entry 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 Accountant",
+ "condition": null,
+ "next_state": "Rejected",
+ "parent": "Payment Entry Approval",
+ "parentfield": "transitions",
+ "parenttype": "Workflow",
+ "send_email_to_creator": 0,
+ "state": "Pending Approval",
+ "workflow_builder_id": null
+ }
+ ],
+ "workflow_data": null,
+ "workflow_name": "Payment Entry Approval",
+ "workflow_state_field": "workflow_state"
}
-]
\ No newline at end of file
+]
diff --git a/aqrar_ext/fixtures/workflow_action_master.json b/aqrar_ext/fixtures/workflow_action_master.json
new file mode 100644
index 0000000..d96bb2e
--- /dev/null
+++ b/aqrar_ext/fixtures/workflow_action_master.json
@@ -0,0 +1,20 @@
+[
+ {
+ "docstatus": 0,
+ "doctype": "Workflow Action Master",
+ "name": "Submit for Approval",
+ "workflow_action_name": "Submit for Approval"
+ },
+ {
+ "docstatus": 0,
+ "doctype": "Workflow Action Master",
+ "name": "Approve",
+ "workflow_action_name": "Approve"
+ },
+ {
+ "docstatus": 0,
+ "doctype": "Workflow Action Master",
+ "name": "Reject",
+ "workflow_action_name": "Reject"
+ }
+]
diff --git a/aqrar_ext/fixtures/workflow_state.json b/aqrar_ext/fixtures/workflow_state.json
new file mode 100644
index 0000000..a8e99a1
--- /dev/null
+++ b/aqrar_ext/fixtures/workflow_state.json
@@ -0,0 +1,50 @@
+[
+ {
+ "docstatus": 0,
+ "doctype": "Workflow State",
+ "icon": "question-sign",
+ "name": "Draft",
+ "style": "",
+ "workflow_state_name": "Draft"
+ },
+ {
+ "docstatus": 0,
+ "doctype": "Workflow State",
+ "icon": "question-sign",
+ "name": "Pending Approval",
+ "style": "Warning",
+ "workflow_state_name": "Pending Approval"
+ },
+ {
+ "docstatus": 0,
+ "doctype": "Workflow State",
+ "icon": "ok-sign",
+ "name": "Approved",
+ "style": "Success",
+ "workflow_state_name": "Approved"
+ },
+ {
+ "docstatus": 0,
+ "doctype": "Workflow State",
+ "icon": "remove",
+ "name": "Rejected",
+ "style": "Danger",
+ "workflow_state_name": "Rejected"
+ },
+ {
+ "docstatus": 0,
+ "doctype": "Workflow State",
+ "icon": "ok-sign",
+ "name": "Committed",
+ "style": "Success",
+ "workflow_state_name": "Committed"
+ },
+ {
+ "docstatus": 0,
+ "doctype": "Workflow State",
+ "icon": "ok-sign",
+ "name": "Posted",
+ "style": "Success",
+ "workflow_state_name": "Posted"
+ }
+]
diff --git a/aqrar_ext/hooks.py b/aqrar_ext/hooks.py
index 5b66895..063a528 100644
--- a/aqrar_ext/hooks.py
+++ b/aqrar_ext/hooks.py
@@ -12,9 +12,21 @@
# include js, css files in header of desk.html
# app_include_css = "/assets/develop/css/develop.css"
+doctype_js = {
+ "Journal Entry": "public/js/journal_entry_commission.js",
+}
+
app_include_js = [
"/assets/aqrar_ext/js/sales_invoice_pos_total_popup.js",
"/assets/sf_trading/js/workflowapproval.js",
+ "/assets/aqrar_ext/js/sales_invoice_return.js",
+ "/assets/aqrar_ext/js/sales_invoice_branch_price_list.js",
+ "/assets/aqrar_ext/js/auto_print_preview.js",
+ "/assets/aqrar_ext/js/item_naming_from_group.js",
+ "/assets/aqrar_ext/js/notification_sound.js",
+ "/assets/aqrar_ext/js/sales_invoice_navigation.js",
+ "/assets/aqrar_ext/js/sales_invoice_book_commission.js",
+ "/assets/aqrar_ext/js/sales_invoice_payment_terms.js",
]
# include js, css files in header of web template
@@ -302,8 +314,73 @@
[
# Sales Invoice
"Sales Invoice-custom_payment_mode",
- ],
+ # CR-015: Price List Bulk Editor & Min Price
+ "Item Price-custom_minimum_selling_rate",
+ "Price List-custom_branch",
+ "Sales Invoice-custom_override_minimum_price",
+ # CR-023: Commission JE reference
+ "Journal Entry-custom_reference_invoice",
+ "Company-default_commission_expense_account",
+ "Company-default_commission_payable_account",
+ "Company-default_discount_expense_account",
+ "Company-default_discount_payable_account",
+ # CR-021: Sound alert toggle
+ "User-custom_enable_sound_alerts",
+ # CR-020: Item naming series per item group
+ "Item Group-custom_default_item_naming_series",
+ # CR-017: Approval Workflows — workflow_state fields
+ "Stock Entry-workflow_state",
+ "Journal Entry-workflow_state",
+ "Payment Entry-workflow_state",
+ ],
]
]
},
-]
\ No newline at end of file
+ "Workflow State",
+ "Workflow Action Master",
+ "Workflow",
+ "Custom DocPerm",
+ "Notification",
+]
+
+scheduler_events = {
+ "daily": [
+ "aqrar_ext.api.day_close.run_day_close",
+ ],
+}
+
+doc_events = {
+ "Sales Invoice": {
+ "validate": "aqrar_ext.aqrar_ext.overrides.sales_invoice.validate",
+ "before_save": "aqrar_ext.aqrar_ext.overrides.sales_invoice.before_save",
+ "before_print": "aqrar_ext.aqrar_ext.overrides.sales_invoice.before_print",
+ },
+ # CR-017: Branch-level approval validation
+ "Stock Entry": {
+ "validate": "aqrar_ext.aqrar_ext.workflow.conditions.validate_stock_entry_approval",
+ },
+ "Journal Entry": {
+ "validate": "aqrar_ext.aqrar_ext.workflow.conditions.validate_journal_entry_approval",
+ },
+ "Payment Entry": {
+ "validate": "aqrar_ext.aqrar_ext.workflow.conditions.validate_payment_entry_approval",
+ },
+ "Expense Claim": {
+ "validate": "aqrar_ext.aqrar_ext.workflow.conditions.validate_expense_claim_approval",
+ },
+ "Custom Quote": {
+ "validate": "aqrar_ext.aqrar_ext.doctype.custom_quote.custom_quote.validate",
+ },
+}
+
+after_migrate = [
+ "aqrar_ext.setup_data.create",
+]
+
+# CR-024: Expose print helper functions to Jinja templates
+jenv = {
+ "methods": [
+ "aqrar_ext.aqrar_ext.utils.print_helpers.format_item_display",
+ "aqrar_ext.aqrar_ext.utils.print_helpers.get_display_mode",
+ ]
+}
diff --git a/aqrar_ext/public/js/auto_print_preview.js b/aqrar_ext/public/js/auto_print_preview.js
new file mode 100644
index 0000000..1a512de
--- /dev/null
+++ b/aqrar_ext/public/js/auto_print_preview.js
@@ -0,0 +1,151 @@
+frappe.ui.form.on("Sales Invoice", {
+ refresh: function (frm) {
+ if (!frm.doc.__islocal) init_or_refresh_preview(frm);
+ },
+ after_save: function (frm) {
+ init_or_refresh_preview(frm);
+ },
+});
+
+frappe.ui.form.on("Quotation", {
+ refresh: function (frm) {
+ if (!frm.doc.__islocal) init_or_refresh_preview(frm);
+ },
+ after_save: function (frm) {
+ init_or_refresh_preview(frm);
+ },
+});
+
+frappe.ui.form.on("Custom Quote", {
+ refresh: function (frm) {
+ if (!frm.doc.__islocal) init_or_refresh_preview(frm);
+ },
+ after_save: function (frm) {
+ init_or_refresh_preview(frm);
+ },
+});
+
+// Cache print formats per doctype so we don't re-fetch on every save
+var print_format_cache = {};
+
+function get_print_formats(doctype, callback) {
+ if (print_format_cache[doctype]) {
+ callback(print_format_cache[doctype]);
+ return;
+ }
+ frappe.call({
+ method: "frappe.client.get_list",
+ args: {
+ doctype: "Print Format",
+ filters: { doc_type: doctype, disabled: 0 },
+ fields: ["name"],
+ order_by: "name",
+ },
+ callback: function (r) {
+ var formats = (r.message || []).map(function (f) { return f.name; });
+ if (formats.indexOf("Standard") === -1) formats.unshift("Standard");
+ // Prefer Aqrar print format for Sales Invoice
+ if (doctype === "Sales Invoice") {
+ var aqrar_idx = formats.indexOf("Sales Invoice Aqrar");
+ if (aqrar_idx > 0) {
+ formats.splice(aqrar_idx, 1);
+ formats.splice(1, 0, "Sales Invoice Aqrar");
+ }
+ }
+ print_format_cache[doctype] = formats;
+ callback(formats);
+ },
+ });
+}
+
+function init_or_refresh_preview(frm) {
+ if (!frm.doc.name) return;
+ if (frm.in_form === false) return;
+
+ var footer = frm.page.footer;
+ footer.removeClass("hide");
+
+ var existing = footer.find(".auto-print-preview");
+ if (existing.length) {
+ refresh_iframe(frm, existing);
+ return;
+ }
+
+ get_print_formats(frm.doc.doctype, function (formats) {
+ var panel = $(
+ '' +
+ '' +
+ '
' +
+ '' +
+ '
' +
+ '
'
+ );
+
+ footer.append(panel);
+
+ // Default to Sales Invoice Aqrar when available
+ if (frm.doc.doctype === "Sales Invoice" && formats.indexOf("Sales Invoice Aqrar") !== -1) {
+ panel.find(".preview-format-select").val("Sales Invoice Aqrar");
+ }
+
+ // Format selector change
+ panel.find(".preview-format-select").on("change", function () {
+ refresh_iframe(frm, panel);
+ });
+
+ // Toggle show/hide
+ panel.find(".btn-toggle-preview").on("click", function () {
+ var body = panel.find(".preview-body");
+ var btn = $(this);
+ if (body.is(":visible")) {
+ body.hide();
+ btn.text(__("Show"));
+ } else {
+ body.show();
+ btn.text(__("Hide"));
+ }
+ });
+
+ // Close — collapse to header bar so preview can be reopened
+ panel.find(".btn-close-preview").on("click", function () {
+ var body = panel.find(".preview-body");
+ var toggle_btn = panel.find(".btn-toggle-preview");
+ body.hide();
+ toggle_btn.text(__("Show"));
+ });
+
+ refresh_iframe(frm, panel);
+ });
+}
+
+function refresh_iframe(frm, panel) {
+ var doctype = encodeURIComponent(frm.doc.doctype);
+ var docname = encodeURIComponent(frm.doc.name);
+ var format = encodeURIComponent(panel.find(".preview-format-select").val() || "Standard");
+ var url = "/printview?doctype=" + doctype + "&name=" + docname + "&format=" + format + "&_ts=" + Date.now();
+
+ var iframe = panel.find(".preview-iframe");
+ iframe.attr("src", url);
+
+ iframe.off("load").on("load", function () {
+ try {
+ var h = iframe[0].contentWindow.document.body.scrollHeight;
+ if (h > 600) iframe.css("min-height", h + "px");
+ } catch (e) {
+ // cross-origin or empty — keep default height
+ }
+ });
+}
diff --git a/aqrar_ext/public/js/item_naming_from_group.js b/aqrar_ext/public/js/item_naming_from_group.js
new file mode 100644
index 0000000..748d516
--- /dev/null
+++ b/aqrar_ext/public/js/item_naming_from_group.js
@@ -0,0 +1,31 @@
+frappe.ui.form.on("Item", {
+ setup: function (frm) {
+ frm.toggle_display("naming_series", false);
+ },
+ refresh: function (frm) {
+ if (frm.doc.__islocal && frm.doc.item_group && !frm.doc._naming_applied) {
+ set_naming_from_group(frm);
+ }
+ },
+ item_group: function (frm) {
+ if (!frm.doc.__islocal) return;
+ if (!frm.doc.item_group) {
+ frm.toggle_display("naming_series", false);
+ return;
+ }
+ set_naming_from_group(frm);
+ },
+});
+
+function set_naming_from_group(frm) {
+ frappe.db.get_value("Item Group", frm.doc.item_group, "custom_default_item_naming_series", function (r) {
+ if (!r || !r.custom_default_item_naming_series) {
+ frm.toggle_display("naming_series", false);
+ return;
+ }
+
+ frm.doc._naming_applied = true;
+ frm.toggle_display("naming_series", true);
+ frm.set_value("naming_series", r.custom_default_item_naming_series);
+ });
+}
diff --git a/aqrar_ext/public/js/journal_entry_commission.js b/aqrar_ext/public/js/journal_entry_commission.js
new file mode 100644
index 0000000..bb64444
--- /dev/null
+++ b/aqrar_ext/public/js/journal_entry_commission.js
@@ -0,0 +1,5 @@
+frappe.ui.form.on("Journal Entry", {
+ refresh: function (frm) {
+ frm.set_df_property("custom_reference_invoice", "hidden", 0);
+ },
+});
diff --git a/aqrar_ext/public/js/notification_sound.js b/aqrar_ext/public/js/notification_sound.js
new file mode 100644
index 0000000..dcc2b70
--- /dev/null
+++ b/aqrar_ext/public/js/notification_sound.js
@@ -0,0 +1,8 @@
+frappe.realtime.on("notification", function () {
+ frappe.db.get_value("User", frappe.session.user, "custom_enable_sound_alerts", function (r) {
+ if (r && r.custom_enable_sound_alerts) {
+ var audio = new Audio("/assets/frappe/sounds/alert.mp3");
+ audio.play().catch(function () { /* browser may block autoplay */ });
+ }
+ });
+});
diff --git a/aqrar_ext/public/js/sales_invoice_book_commission.js b/aqrar_ext/public/js/sales_invoice_book_commission.js
new file mode 100644
index 0000000..8f73ef3
--- /dev/null
+++ b/aqrar_ext/public/js/sales_invoice_book_commission.js
@@ -0,0 +1,61 @@
+frappe.ui.form.on("Sales Invoice", {
+ refresh: function (frm) {
+ if (frm.doc.__islocal || frm.doc.docstatus !== 1) return;
+
+ frappe.call({
+ method: "aqrar_ext.api.commission.get_commission_je_status",
+ args: { sales_invoice: frm.doc.name },
+ callback: function (r) {
+ if (r.message && r.message.exists) {
+ frm.add_custom_button(__("View Commission JE"), function () {
+ frappe.set_route("Form", "Journal Entry", r.message.je_name);
+ });
+ return;
+ }
+
+ frm.add_custom_button(__("Book Commission"), function () {
+ frappe.call({
+ method: "aqrar_ext.api.commission.create_commission_je",
+ args: { sales_invoice: frm.doc.name },
+ freeze: true,
+ freeze_message: __("Creating Commission Journal Entry..."),
+ callback: function (res) {
+ if (res.message) {
+ frappe.set_route("Form", "Journal Entry", res.message);
+ }
+ },
+ error: function () {
+ frappe.msgprint({
+ title: __("Error"),
+ message: __("Failed to create Commission Journal Entry."),
+ indicator: "red",
+ });
+ },
+ });
+ });
+ },
+ error: function () {
+ frm.add_custom_button(__("Book Commission"), function () {
+ frappe.call({
+ method: "aqrar_ext.api.commission.create_commission_je",
+ args: { sales_invoice: frm.doc.name },
+ freeze: true,
+ freeze_message: __("Creating Commission Journal Entry..."),
+ callback: function (res) {
+ if (res.message) {
+ frappe.set_route("Form", "Journal Entry", res.message);
+ }
+ },
+ error: function () {
+ frappe.msgprint({
+ title: __("Error"),
+ message: __("Failed to create Commission Journal Entry."),
+ indicator: "red",
+ });
+ },
+ });
+ });
+ },
+ });
+ },
+});
diff --git a/aqrar_ext/public/js/sales_invoice_branch_price_list.js b/aqrar_ext/public/js/sales_invoice_branch_price_list.js
new file mode 100644
index 0000000..434f996
--- /dev/null
+++ b/aqrar_ext/public/js/sales_invoice_branch_price_list.js
@@ -0,0 +1,128 @@
+frappe.ui.form.on("Sales Invoice", {
+ onload: function (frm) {
+ auto_set_cost_center_price_list(frm);
+ },
+ customer: function (frm) {
+ auto_set_cost_center_price_list(frm);
+ },
+ cost_center: function (frm) {
+ auto_set_cost_center_price_list(frm);
+ },
+});
+
+frappe.ui.form.on("Quotation", {
+ onload: function (frm) {
+ auto_set_cost_center_price_list(frm);
+ },
+ customer: function (frm) {
+ auto_set_cost_center_price_list(frm);
+ },
+ cost_center: function (frm) {
+ auto_set_cost_center_price_list(frm);
+ },
+});
+
+/**
+ * Find an enabled, selling Price List tagged with this cost center.
+ * Uses the custom_branch field on Price List (which links to Cost Center).
+ */
+function get_cost_center_price_list(cost_center) {
+ return new Promise(function (resolve) {
+ frappe.call({
+ method: "frappe.client.get_list",
+ args: {
+ doctype: "Price List",
+ filters: {
+ custom_branch: cost_center,
+ enabled: 1,
+ selling: 1,
+ },
+ fields: ["name"],
+ limit: 1,
+ },
+ callback: function (r) {
+ var pl = (r.message && r.message.length) ? r.message[0].name : null;
+ resolve(pl);
+ },
+ });
+ });
+}
+
+/**
+ * Check a price list name exists and is enabled.
+ */
+function is_price_list_enabled(pl_name) {
+ return new Promise(function (resolve) {
+ if (!pl_name) return resolve(false);
+ frappe.db.get_value("Price List", pl_name, "enabled", function (r) {
+ resolve(!!(r && r.enabled));
+ });
+ });
+}
+
+/**
+ * Auto-select the selling price list.
+ *
+ * Priority:
+ * 1. Cost-center-specific Price List (if cost_center is set on the invoice)
+ * 2. Customer default_price_list (if set and enabled)
+ * 3. Standard Selling (global fallback)
+ */
+function auto_set_cost_center_price_list(frm) {
+ if (!frm.doc.customer) return;
+
+ resolve_cost_center(frm).then(function (cost_center) {
+ if (cost_center) {
+ get_cost_center_price_list(cost_center).then(function (cc_pl) {
+ if (cc_pl) {
+ frm.set_value("selling_price_list", cc_pl);
+ } else {
+ set_from_customer_or_fallback(frm);
+ }
+ });
+ } else {
+ set_from_customer_or_fallback(frm);
+ }
+ });
+}
+
+function set_from_customer_or_fallback(frm) {
+ frappe.db.get_value("Customer", frm.doc.customer, "default_price_list", function (r) {
+ var cust_pl = r && r.default_price_list ? r.default_price_list : null;
+ is_price_list_enabled(cust_pl).then(function (ok) {
+ if (ok) {
+ frm.set_value("selling_price_list", cust_pl);
+ } else {
+ is_price_list_enabled("Standard Selling").then(function (std_ok) {
+ if (std_ok) frm.set_value("selling_price_list", "Standard Selling");
+ });
+ }
+ });
+ });
+}
+
+function resolve_cost_center(frm) {
+ if (frm.doc.cost_center) {
+ return Promise.resolve(frm.doc.cost_center);
+ }
+ // Fallback: user's default cost center from User Permission
+ return new Promise(function (resolve) {
+ frappe.call({
+ method: "frappe.client.get_list",
+ args: {
+ doctype: "User Permission",
+ filters: {
+ user: frappe.session.user,
+ allow: "Cost Center",
+ is_default: 1,
+ },
+ fields: ["for_value"],
+ limit: 1,
+ },
+ callback: function (r) {
+ var val = (r.message && r.message.length) ? r.message[0].for_value : null;
+ resolve(val);
+ },
+ });
+ });
+}
diff --git a/aqrar_ext/public/js/sales_invoice_item_display.js b/aqrar_ext/public/js/sales_invoice_item_display.js
new file mode 100644
index 0000000..7bbb7bf
--- /dev/null
+++ b/aqrar_ext/public/js/sales_invoice_item_display.js
@@ -0,0 +1,40 @@
+frappe.ui.form.on("Sales Invoice", {
+ refresh: function (frm) {
+ frm.trigger("apply_item_display_mode");
+ },
+ apply_item_display_mode: function (frm) {
+ frappe.call({
+ method: "frappe.client.get_value",
+ args: {
+ doctype: "Aqrar Settings",
+ fieldname: "item_display_mode",
+ },
+ callback: function (r) {
+ var mode = (r && r.message) ? r.message.item_display_mode : "Item Name + Description";
+ update_grid_columns(frm, mode);
+ },
+ });
+ },
+});
+
+function update_grid_columns(frm, mode) {
+ var grid = frm.fields_dict.items.grid;
+
+ if (mode === "Item Code") {
+ grid.set_column_disp("item_code", true);
+ grid.set_column_disp("item_name", false);
+ grid.set_column_disp("description", false);
+ } else if (mode === "Item Name") {
+ grid.set_column_disp("item_code", false);
+ grid.set_column_disp("item_name", true);
+ grid.set_column_disp("description", false);
+ } else if (mode === "Item Code + Description") {
+ grid.set_column_disp("item_code", true);
+ grid.set_column_disp("item_name", false);
+ grid.set_column_disp("description", true);
+ } else {
+ grid.set_column_disp("item_code", false);
+ grid.set_column_disp("item_name", true);
+ grid.set_column_disp("description", true);
+ }
+}
diff --git a/aqrar_ext/public/js/sales_invoice_navigation.js b/aqrar_ext/public/js/sales_invoice_navigation.js
new file mode 100644
index 0000000..bc6e63a
--- /dev/null
+++ b/aqrar_ext/public/js/sales_invoice_navigation.js
@@ -0,0 +1,66 @@
+frappe.ui.form.on("Sales Invoice", {
+ refresh: function (frm) {
+ if (frm.doc.__islocal) return;
+
+ frm.page.add_inner_button(__("Prev"), function () {
+ _navigate(frm, "prev");
+ });
+
+ frm.page.add_inner_button(__("Next"), function () {
+ _navigate(frm, "next");
+ });
+
+ _bind_keys(frm);
+ },
+});
+
+function _bind_keys(frm) {
+ $(document).off("keydown.si_nav").on("keydown.si_nav", function (e) {
+ if (frappe.get_route_str() !== "Form/Sales Invoice/" + frm.doc.name) return;
+ if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.tagName === "SELECT") return;
+ if (e.key === "ArrowLeft" && !e.altKey && !e.ctrlKey) {
+ e.preventDefault();
+ _navigate(frm, "prev");
+ }
+ if (e.key === "ArrowRight" && !e.altKey && !e.ctrlKey) {
+ e.preventDefault();
+ _navigate(frm, "next");
+ }
+ });
+}
+
+function _navigate(frm, direction) {
+ var ctx = _get_list_context();
+
+ frappe.call({
+ method: "aqrar_ext.api.navigation.get_sibling",
+ args: {
+ doctype: frm.doc.doctype,
+ docname: frm.doc.name,
+ direction: direction,
+ list_filters: ctx.filters,
+ order_by: ctx.order_by,
+ },
+ callback: function (r) {
+ if (r.message) {
+ frappe.set_route("Form", frm.doc.doctype, r.message);
+ } else {
+ frappe.show_alert({
+ message: direction === "next" ? __("No next document") : __("No previous document"),
+ indicator: "blue",
+ });
+ }
+ },
+ });
+}
+
+function _get_list_context() {
+ try {
+ var raw = localStorage.getItem("Sales Invoice_list_view");
+ if (raw) {
+ var state = JSON.parse(raw);
+ return { filters: state.filters || [], order_by: state.sort_by || null };
+ }
+ } catch (e) {}
+ return { filters: [], order_by: null };
+}
diff --git a/aqrar_ext/public/js/sales_invoice_payment_terms.js b/aqrar_ext/public/js/sales_invoice_payment_terms.js
new file mode 100644
index 0000000..1b890a0
--- /dev/null
+++ b/aqrar_ext/public/js/sales_invoice_payment_terms.js
@@ -0,0 +1,10 @@
+frappe.ui.form.on("Sales Invoice", {
+ customer: function (frm) {
+ if (!frm.doc.customer || frm.doc.ignore_default_payment_terms_template) return;
+ frappe.db.get_value("Customer", frm.doc.customer, "payment_terms", function (r) {
+ if (r && r.payment_terms) {
+ frm.set_value("payment_terms_template", r.payment_terms);
+ }
+ });
+ },
+});
diff --git a/aqrar_ext/public/js/sales_invoice_return.js b/aqrar_ext/public/js/sales_invoice_return.js
new file mode 100644
index 0000000..2b9255b
--- /dev/null
+++ b/aqrar_ext/public/js/sales_invoice_return.js
@@ -0,0 +1,32 @@
+frappe.ui.form.on("Sales Invoice", {
+ onload(frm) {
+ handle_return_invoice(frm);
+ },
+ refresh(frm) {
+ handle_return_invoice(frm);
+ },
+ validate(frm) {
+ if (!frm.doc.is_return) return;
+ (frm.doc.items || []).forEach(row => {
+ if (row.qty > 0) {
+ row.qty = -Math.abs(row.qty);
+ }
+ });
+ }
+});
+
+
+function handle_return_invoice(frm) {
+ if (!frm.doc.is_return) return;
+ let changed = false;
+ (frm.doc.items || []).forEach(row => {
+ if (row.qty < 0) {
+
+ row.qty = Math.abs(row.qty);
+ changed = true;
+ }
+ });
+ if (changed) {
+ frm.refresh_field("items");
+ }
+}
\ No newline at end of file
diff --git a/aqrar_ext/setup_data.py b/aqrar_ext/setup_data.py
new file mode 100644
index 0000000..bd6f4b1
--- /dev/null
+++ b/aqrar_ext/setup_data.py
@@ -0,0 +1,120 @@
+"""Setup / fixture utilities — called from after_migrate hooks."""
+
+import frappe
+from frappe import _
+from frappe.custom.doctype.custom_field.custom_field import create_custom_field
+
+
+def create():
+ """Called on every migrate. Handles conditional setup."""
+ install_expense_claim_workflow()
+
+
+def install_expense_claim_workflow():
+ """Install Expense Claim workflow + custom field only if HRMS is available.
+
+ Expense Claim doctype lives in the HRMS app, which may not be installed
+ on all sites. This avoids fixture sync errors when HRMS is absent.
+ """
+ if not frappe.db.exists("DocType", "Expense Claim"):
+ return
+
+ # ── Custom field: workflow_state on Expense Claim ──────────────────
+ if not frappe.db.exists("Custom Field", "Expense Claim-workflow_state"):
+ create_custom_field("Expense Claim", {
+ "fieldname": "workflow_state",
+ "fieldtype": "Link",
+ "label": "Workflow State",
+ "options": "Workflow State",
+ "insert_after": "expense_approver",
+ "read_only": 1,
+ "print_hide": 1,
+ "module": "Aqrar Ext",
+ "description": "Current approval state for the Aqrar Expense Claim approval workflow.",
+ })
+ frappe.db.commit()
+
+ # ── Workflow: Expense Claim Approval ───────────────────────────────
+ if frappe.db.exists("Workflow", "Expense Claim Approval"):
+ return
+
+ # Ensure prerequisite states and actions exist
+ for state in ("Draft", "Pending Approval", "Approved", "Rejected"):
+ if not frappe.db.exists("Workflow State", state):
+ frappe.get_doc({"doctype": "Workflow State", "workflow_state_name": state}).insert(
+ ignore_permissions=True
+ )
+
+ for action in ("Submit for Approval", "Approve", "Reject"):
+ if not frappe.db.exists("Workflow Action Master", action):
+ frappe.get_doc({
+ "doctype": "Workflow Action Master",
+ "workflow_action_name": action,
+ }).insert(ignore_permissions=True)
+
+ wf = frappe.get_doc({
+ "doctype": "Workflow",
+ "name": "Expense Claim Approval",
+ "workflow_name": "Expense Claim Approval",
+ "document_type": "Expense Claim",
+ "workflow_state_field": "workflow_state",
+ "is_active": 1,
+ "send_email_alert": 1,
+ "states": [
+ {
+ "state": "Draft",
+ "doc_status": "0",
+ "allow_edit": "Accounts User",
+ "update_field": "workflow_state",
+ "update_value": "Draft",
+ },
+ {
+ "state": "Pending Approval",
+ "doc_status": "0",
+ "allow_edit": "Branch Accountant",
+ "update_field": "workflow_state",
+ "update_value": "Pending Approval",
+ "message": "Expense Claim requires your approval. Please review and approve or reject.",
+ },
+ {
+ "state": "Approved",
+ "doc_status": "1",
+ "allow_edit": "Accounts Manager",
+ "update_field": "workflow_state",
+ "update_value": "Approved",
+ "message": "Expense Claim has been approved.",
+ },
+ {
+ "state": "Rejected",
+ "doc_status": "1",
+ "allow_edit": "Accounts User",
+ "is_optional_state": 1,
+ "update_field": "workflow_state",
+ "update_value": "Rejected",
+ "message": "Expense Claim has been rejected.",
+ },
+ ],
+ "transitions": [
+ {
+ "state": "Draft",
+ "action": "Submit for Approval",
+ "next_state": "Pending Approval",
+ "allowed": "Accounts User",
+ },
+ {
+ "state": "Pending Approval",
+ "action": "Approve",
+ "next_state": "Approved",
+ "allowed": "Branch Accountant",
+ },
+ {
+ "state": "Pending Approval",
+ "action": "Reject",
+ "next_state": "Rejected",
+ "allowed": "Branch Accountant",
+ },
+ ],
+ })
+ wf.insert(ignore_permissions=True)
+ frappe.db.commit()
+ print(f"[aqrar_ext] Installed Expense Claim Approval workflow")