diff --git a/aqrar_ext/api/__init__.py b/aqrar_ext/api/__init__.py
index e69de29..f181409 100644
--- a/aqrar_ext/api/__init__.py
+++ b/aqrar_ext/api/__init__.py
@@ -0,0 +1 @@
+from .price_history import get_last_sold_price, get_item_insights, get_item_price_history
diff --git a/aqrar_ext/api/price_history.py b/aqrar_ext/api/price_history.py
new file mode 100644
index 0000000..a2d9b11
--- /dev/null
+++ b/aqrar_ext/api/price_history.py
@@ -0,0 +1,303 @@
+import frappe
+from frappe.utils import flt
+
+
+@frappe.whitelist()
+def get_last_sold_price(customer=None, item_code=None, source="sales"):
+
+ if not item_code:
+ return {}
+
+ if source == "purchase":
+ return get_last_purchase_price(customer, item_code)
+
+ filters = {
+ "item_code": item_code
+ }
+
+ customer_condition = ""
+
+ if customer:
+ customer_condition = "AND si.customer = %(customer)s"
+ filters["customer"] = customer
+
+ row = frappe.db.sql(f"""
+ SELECT
+ sii.rate
+ FROM `tabSales Invoice Item` sii
+ INNER JOIN `tabSales Invoice` si
+ ON si.name = sii.parent
+ WHERE
+ sii.item_code = %(item_code)s
+ AND si.docstatus = 1
+ {customer_condition}
+ ORDER BY
+ si.posting_date DESC,
+ si.creation DESC
+ LIMIT 1
+ """, filters, as_dict=True)
+
+ # fallback general last sold price
+ if not row:
+
+ row = frappe.db.sql("""
+ SELECT
+ sii.rate
+ FROM `tabSales Invoice Item` sii
+ INNER JOIN `tabSales Invoice` si
+ ON si.name = sii.parent
+ WHERE
+ sii.item_code = %(item_code)s
+ AND si.docstatus = 1
+ ORDER BY
+ si.posting_date DESC,
+ si.creation DESC
+ LIMIT 1
+ """, filters, as_dict=True)
+
+ return {
+ "last_price": flt(row[0].rate) if row else 0
+ }
+
+
+def get_last_purchase_price(supplier=None, item_code=None):
+
+ filters = {"item_code": item_code}
+ supplier_condition = ""
+
+ if supplier:
+ supplier_condition = "AND pi.supplier = %(supplier)s"
+ filters["supplier"] = supplier
+
+ row = frappe.db.sql(f"""
+ SELECT
+ pii.rate
+ FROM `tabPurchase Invoice Item` pii
+ INNER JOIN `tabPurchase Invoice` pi
+ ON pi.name = pii.parent
+ WHERE
+ pii.item_code = %(item_code)s
+ AND pi.docstatus = 1
+ {supplier_condition}
+ ORDER BY
+ pi.posting_date DESC,
+ pi.creation DESC
+ LIMIT 1
+ """, filters, as_dict=True)
+
+ if not row:
+
+ row = frappe.db.sql("""
+ SELECT
+ pii.rate
+ FROM `tabPurchase Invoice Item` pii
+ INNER JOIN `tabPurchase Invoice` pi
+ ON pi.name = pii.parent
+ WHERE
+ pii.item_code = %(item_code)s
+ AND pi.docstatus = 1
+ ORDER BY
+ pi.posting_date DESC,
+ pi.creation DESC
+ LIMIT 1
+ """, filters, as_dict=True)
+
+ return {
+ "last_price": flt(row[0].rate) if row else 0
+ }
+
+
+@frappe.whitelist()
+def get_item_insights(customer=None, item_code=None, company=None, source="sales"):
+
+ stock = frappe.db.sql("""
+ SELECT
+ warehouse,
+ projected_qty
+ FROM `tabBin`
+ WHERE item_code = %s
+ ORDER BY warehouse
+ """, item_code, as_dict=True)
+
+ if source == "purchase":
+ price_history = frappe.db.sql("""
+ SELECT
+ pi.supplier AS customer,
+ pii.rate,
+ pi.posting_date
+ FROM `tabPurchase Invoice Item` pii
+ INNER JOIN `tabPurchase Invoice` pi
+ ON pi.name = pii.parent
+ WHERE
+ pii.item_code = %s
+ AND pi.supplier = %s
+ AND pi.docstatus = 1
+ ORDER BY
+ pi.posting_date DESC
+ LIMIT 10
+ """, (item_code, customer), as_dict=True)
+
+ last_purchase = frappe.db.sql("""
+ SELECT
+ pii.rate
+ FROM `tabPurchase Invoice Item` pii
+ INNER JOIN `tabPurchase Invoice` pi
+ ON pi.name = pii.parent
+ WHERE
+ pii.item_code = %s
+ AND pi.docstatus = 1
+ ORDER BY
+ pi.posting_date DESC
+ LIMIT 1
+ """, item_code, as_dict=True)
+
+ return {
+ "stock": stock,
+ "price_history": price_history,
+ "last_purchase_rate":
+ last_purchase[0].rate if last_purchase else 0
+ }
+
+ price_history = frappe.db.sql("""
+ SELECT
+ si.customer,
+ sii.rate,
+ si.posting_date
+ FROM `tabSales Invoice Item` sii
+ INNER JOIN `tabSales Invoice` si
+ ON si.name = sii.parent
+ WHERE
+ sii.item_code = %s
+ AND si.customer = %s
+ AND si.docstatus = 1
+ ORDER BY
+ si.posting_date DESC
+ LIMIT 10
+ """, (item_code, customer), as_dict=True)
+
+ purchase = frappe.db.sql("""
+ SELECT
+ pii.rate
+ FROM `tabPurchase Invoice Item` pii
+ INNER JOIN `tabPurchase Invoice` pi
+ ON pi.name = pii.parent
+ WHERE
+ pii.item_code = %s
+ AND pi.docstatus = 1
+ ORDER BY
+ pi.posting_date DESC
+ LIMIT 1
+ """, item_code, as_dict=True)
+
+ return {
+ "stock": stock,
+ "price_history": price_history,
+ "last_purchase_rate":
+ purchase[0].rate if purchase else 0
+ }
+
+
+@frappe.whitelist()
+def get_item_price_history(item_code=None, source="sales", customer=None):
+
+ if not item_code:
+ return {"history": [], "last_price": 0, "source": source}
+
+ if source == "purchase":
+ supplier_cond = ""
+ params = [item_code]
+ if customer:
+ supplier_cond = "AND pi.supplier = %s"
+ params.append(customer)
+
+ rows = frappe.db.sql(f"""
+ SELECT
+ pii.item_code,
+ pii.item_name,
+ pii.parent AS invoice,
+ pi.supplier AS party,
+ pii.rate,
+ pii.qty,
+ pi.posting_date
+ FROM `tabPurchase Invoice Item` pii
+ INNER JOIN `tabPurchase Invoice` pi
+ ON pi.name = pii.parent
+ WHERE
+ pii.item_code = %s
+ AND pi.docstatus = 1
+ {supplier_cond}
+ ORDER BY
+ pi.posting_date DESC
+ LIMIT 100
+ """, params, as_dict=True)
+
+ last_price = frappe.db.sql(f"""
+ SELECT
+ pii.rate
+ FROM `tabPurchase Invoice Item` pii
+ INNER JOIN `tabPurchase Invoice` pi
+ ON pi.name = pii.parent
+ WHERE
+ pii.item_code = %s
+ AND pi.docstatus = 1
+ {supplier_cond}
+ ORDER BY
+ pi.posting_date DESC,
+ pi.creation DESC
+ LIMIT 1
+ """, params, as_dict=True)
+
+ return {
+ "history": rows,
+ "last_price": last_price[0].rate if last_price else 0,
+ "source": "purchase"
+ }
+
+ customer_cond = ""
+ params = [item_code]
+ if customer:
+ customer_cond = "AND si.customer = %s"
+ params.append(customer)
+
+ rows = frappe.db.sql(f"""
+ SELECT
+ sii.item_code,
+ sii.item_name,
+ sii.parent AS invoice,
+ si.customer AS party,
+ sii.rate,
+ sii.qty,
+ si.posting_date
+ FROM `tabSales Invoice Item` sii
+ INNER JOIN `tabSales Invoice` si
+ ON si.name = sii.parent
+ WHERE
+ sii.item_code = %s
+ AND si.docstatus = 1
+ {customer_cond}
+ ORDER BY
+ si.posting_date DESC
+ LIMIT 100
+ """, params, as_dict=True)
+
+ last_price = frappe.db.sql(f"""
+ SELECT
+ sii.rate
+ FROM `tabSales Invoice Item` sii
+ INNER JOIN `tabSales Invoice` si
+ ON si.name = sii.parent
+ WHERE
+ sii.item_code = %s
+ AND si.docstatus = 1
+ {customer_cond}
+ ORDER BY
+ si.posting_date DESC,
+ si.creation DESC
+ LIMIT 1
+ """, params, as_dict=True)
+
+ return {
+ "history": rows,
+ "last_price": last_price[0].rate if last_price else 0,
+ "source": "sales"
+ }
\ No newline at end of file
diff --git a/aqrar_ext/api/sales_invoice_payment.py b/aqrar_ext/api/sales_invoice.py
similarity index 52%
rename from aqrar_ext/api/sales_invoice_payment.py
rename to aqrar_ext/api/sales_invoice.py
index f30ea16..dcca328 100644
--- a/aqrar_ext/api/sales_invoice_payment.py
+++ b/aqrar_ext/api/sales_invoice.py
@@ -2,6 +2,7 @@
import frappe
from frappe import _
+from frappe.utils import flt
@frappe.whitelist()
@@ -149,3 +150,135 @@ def create_pos_payments_for_invoice(sales_invoice: str, payments: str | list):
created.append(pe.name)
return created
+
+
+def auto_create_payment_entry_on_submit(doc, method):
+ if doc.is_pos:
+ return
+
+ if doc.is_return:
+ return
+
+ if flt(doc.outstanding_amount) <= 0:
+ return
+
+ if not doc.custom_payment_mode:
+ return
+
+ if flt(doc.grand_total) <= 0:
+ return
+
+ payment_mode = doc.custom_payment_mode
+
+ # Cash is handled by the frontend popup (sales_invoice_pos_total_popup.js)
+ if payment_mode == "Cash":
+ return
+
+ elif payment_mode == "Card":
+ partial = flt(doc.custom_partial_payment_amount or 0)
+ if partial > 0 and partial <= flt(doc.grand_total):
+ pay_amount = partial
+ else:
+ pay_amount = flt(doc.grand_total)
+ _create_and_submit_pe(doc, "Card", pay_amount)
+
+ elif payment_mode == "Credit":
+ partial = flt(doc.custom_partial_payment_amount or 0)
+ if partial > 0 and partial <= flt(doc.grand_total):
+ _create_and_submit_pe(doc, "Cash", partial)
+
+
+def _create_and_submit_pe(doc, mode_of_payment, amount):
+ amount = flt(amount)
+ if amount <= 0:
+ return
+
+ # Re-fetch outstanding from DB to avoid stale in-memory value
+ outstanding = flt(frappe.db.get_value("Sales Invoice", doc.name, "outstanding_amount"))
+ if outstanding <= 0:
+ return
+ if amount - outstanding > 0.5:
+ amount = outstanding
+
+ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+ from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
+
+ try:
+ bank_cash = get_bank_cash_account(mode_of_payment, doc.company)
+ except Exception:
+ frappe.log_error(
+ title="Auto Payment Entry: Missing Account",
+ message=_(
+ "No default account found for Mode of Payment '{0}' in company '{1}'. "
+ "Invoice {2} submitted without Payment Entry."
+ ).format(mode_of_payment, doc.company, doc.name),
+ )
+ frappe.msgprint(
+ _(
+ "Payment Entry was not created. No default account configured for "
+ "Mode of Payment '{0}' in company '{1}'."
+ ).format(mode_of_payment, doc.company),
+ alert=True,
+ )
+ return
+
+ try:
+ pe = get_payment_entry("Sales Invoice", doc.name)
+ if not pe.references:
+ return
+ pe.mode_of_payment = mode_of_payment
+ pe.paid_to = bank_cash.get("account")
+
+ if pe.paid_to:
+ acc = frappe.get_cached_value(
+ "Account", pe.paid_to, ["account_currency", "account_type"], as_dict=True
+ )
+ if acc:
+ pe.paid_to_account_currency = acc.account_currency
+ pe.paid_to_account_type = acc.account_type
+
+ pe.paid_amount = amount
+ pe.received_amount = amount
+ pe.references[0].allocated_amount = amount
+ pe.reference_no = doc.name
+ pe.reference_date = doc.posting_date
+
+ pe.insert()
+ pe.submit()
+
+ frappe.msgprint(
+ _("Payment Entry {0} created against {1} for {2}").format(
+ pe.name, doc.name, frappe.utils.fmt_money(amount, currency=doc.currency)
+ ),
+ alert=True,
+ )
+
+ except frappe.exceptions.ValidationError:
+ # If outstanding is already 0, invoice was paid by another process — skip silently
+ if flt(frappe.db.get_value("Sales Invoice", doc.name, "outstanding_amount")) <= 0:
+ return
+ # Re-raise other validation errors
+ frappe.log_error(
+ title="Auto Payment Entry Failed",
+ message=frappe.get_traceback(),
+ )
+ frappe.msgprint(
+ _(
+ "Could not create Payment Entry for {0}. "
+ "Please create it manually."
+ ).format(doc.name),
+ alert=True,
+ )
+
+ except Exception:
+ frappe.log_error(
+ title="Auto Payment Entry Failed",
+ message=frappe.get_traceback(),
+ )
+ frappe.msgprint(
+ _(
+ "Could not create Payment Entry for {0}. "
+ "Please create it manually."
+ ).format(doc.name),
+ alert=True,
+ )
diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/__init__.py b/aqrar_ext/aqrar_ext/report/customer_statement/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.html b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.html
new file mode 100644
index 0000000..373be8a
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.html
@@ -0,0 +1,229 @@
+
+
+
+
+
+
+
+
+
+ {{ _("Period") }}:
+ {{ frappe.utils.formatdate(from_date) }} — {{ frappe.utils.formatdate(to_date) }}
+
+
+
+
+
+
+ | {{ _("Opening Balance") }} |
+ {{ frappe.utils.fmt_money(opening, currency=company_currency) }} |
+
+
+ | {{ _("Total Invoiced") }} |
+ {{ frappe.utils.fmt_money(total_invoiced, currency=company_currency) }} |
+
+
+ | {{ _("Total Paid") }} |
+ {{ frappe.utils.fmt_money(total_paid, currency=company_currency) }} |
+
+
+ | {{ _("Total Credit Notes") }} |
+ {{ frappe.utils.fmt_money(total_credit, currency=company_currency) }} |
+
+
+ | {{ _("Closing Balance") }} |
+ {{ frappe.utils.fmt_money(closing, currency=company_currency) }} |
+
+
+
+
+
+
{{ _("Transactions") }}
+
+
+
+ | {{ _("Date") }} |
+ {{ _("Type") }} |
+ {{ _("Reference") }} |
+ {{ _("Due Date") }} |
+ {{ _("Age") }} |
+ {{ _("Invoiced") }} |
+ {{ _("Paid") }} |
+ {{ _("Credit Note") }} |
+ {{ _("Outstanding") }} |
+
+
+
+ {% for row in data %}
+ {% set label = row.voucher_no %}
+ {% if label == _("Opening Balance") %}
+
+ | |
+ {{ label }} |
+ | | | | |
+ {{ frappe.utils.fmt_money(row.outstanding, currency=company_currency) }} |
+
+ {% elif label == _("Totals") %}
+
+ | |
+ {{ label }} |
+ | |
+ {{ frappe.utils.fmt_money(row.invoiced, currency=company_currency) }} |
+ {{ frappe.utils.fmt_money(row.paid, currency=company_currency) }} |
+ {{ frappe.utils.fmt_money(row.credit_note, currency=company_currency) }} |
+ {{ frappe.utils.fmt_money(row.outstanding, currency=company_currency) }} |
+
+ {% elif label == _("Closing Balance") %}
+
+ | |
+ {{ label }} |
+ | | | | |
+ {{ frappe.utils.fmt_money(row.outstanding, currency=company_currency) }} |
+
+ {% elif row.voucher_type == "Sales Invoice" %}
+
+ | {{ frappe.utils.formatdate(row.posting_date) }} |
+ {{ _(row.voucher_type) }} |
+ {{ row.voucher_no }} |
+ {{ frappe.utils.formatdate(row.due_date) }} |
+ {{ row.age }} |
+ {{ frappe.utils.fmt_money(row.invoiced, currency=company_currency) }} |
+ {{ frappe.utils.fmt_money(row.paid, currency=company_currency) }} |
+ |
+ {{ frappe.utils.fmt_money(row.outstanding, currency=company_currency) }} |
+
+ {% elif row.voucher_type == "Credit Note" %}
+
+ | {{ frappe.utils.formatdate(row.posting_date) }} |
+ {{ _(row.voucher_type) }} |
+ {{ row.voucher_no }} |
+ {{ frappe.utils.formatdate(row.due_date) }} |
+ |
+ |
+ |
+ {{ frappe.utils.fmt_money(row.credit_note, currency=company_currency) }} |
+ |
+
+ {% elif row.voucher_type == "Payment Entry" %}
+
+ | {{ frappe.utils.formatdate(row.posting_date) }} |
+ {{ _(row.voucher_type) }} |
+ {{ row.voucher_no }} |
+ {{ frappe.utils.formatdate(row.posting_date) }} |
+ |
+ |
+ {{ frappe.utils.fmt_money(row.paid, currency=company_currency) }} |
+ |
+ |
+
+ {% endif %}
+ {% endfor %}
+
+
+
+
+ {% if vat and vat|length > 0 %}
+
{{ _("VAT Summary") }}
+
+
+
+ | {{ _("VAT Rate") }} |
+ {{ _("Taxable Amount") }} |
+ {{ _("VAT Amount") }} |
+
+
+
+ {% for v in vat %}
+
+ | {{ v.rate }}% |
+ {{ frappe.utils.fmt_money(v.taxable_amount, currency=company_currency) }} |
+ {{ frappe.utils.fmt_money(v.tax_amount, currency=company_currency) }} |
+
+ {% endfor %}
+
+ | {{ _("Total") }} |
+ {{ frappe.utils.fmt_money(vat_total_taxable, currency=company_currency) }} |
+ {{ frappe.utils.fmt_money(vat_total_tax, currency=company_currency) }} |
+
+
+
+ {% endif %}
+
+
+ {% if aging %}
+
{{ _("Outstanding Aging") }}
+
+
+
+ | {{ _("0-30 Days") }} |
+ {{ _("31-60 Days") }} |
+ {{ _("61-90 Days") }} |
+ {{ _("91-120 Days") }} |
+ {{ _("120+ Days") }} |
+ {{ _("Total Outstanding") }} |
+
+
+
+
+ | {{ frappe.utils.fmt_money(aging["0-30"], currency=company_currency) }} |
+ {{ frappe.utils.fmt_money(aging["31-60"], currency=company_currency) }} |
+ {{ frappe.utils.fmt_money(aging["61-90"], currency=company_currency) }} |
+ {{ frappe.utils.fmt_money(aging["91-120"], currency=company_currency) }} |
+ {{ frappe.utils.fmt_money(aging["120+"], currency=company_currency) }} |
+ {{ frappe.utils.fmt_money(closing, currency=company_currency) }} |
+
+
+
+ {% endif %}
+
+
+
+
+
diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.js b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.js
new file mode 100644
index 0000000..5208028
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.js
@@ -0,0 +1,40 @@
+frappe.query_reports["Customer Statement"] = {
+ filters: [
+ {
+ fieldname: "company",
+ label: __("Company"),
+ fieldtype: "Link",
+ options: "Company",
+ reqd: 1,
+ default: frappe.defaults.get_user_default("Company"),
+ },
+ {
+ fieldname: "customer",
+ label: __("Customer"),
+ fieldtype: "Link",
+ options: "Customer",
+ reqd: 1,
+ },
+ {
+ fieldname: "from_date",
+ label: __("From Date"),
+ fieldtype: "Date",
+ reqd: 1,
+ default: frappe.datetime.month_start(),
+ },
+ {
+ fieldname: "to_date",
+ label: __("To Date"),
+ fieldtype: "Date",
+ reqd: 1,
+ default: frappe.datetime.month_end(),
+ },
+ {
+ fieldname: "ageing_based_on",
+ label: __("Ageing Based On"),
+ fieldtype: "Select",
+ options: ["Posting Date", "Due Date"],
+ default: "Posting Date",
+ },
+ ],
+};
diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.json b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.json
new file mode 100644
index 0000000..ade763e
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.json
@@ -0,0 +1,49 @@
+{
+ "add_total_row": 0,
+ "add_translate_data": 0,
+ "columns": [],
+ "creation": "2026-05-16 16:33:10.084273",
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "letter_head": null,
+ "modified": "2026-05-16 16:33:10.084273",
+ "modified_by": "Administrator",
+ "module": "Aqrar Ext",
+ "name": "Customer Statement",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Customer",
+ "report_name": "Customer Statement",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Stock User"
+ },
+ {
+ "role": "Sales Manager"
+ },
+ {
+ "role": "Accounts Manager"
+ },
+ {
+ "role": "Sales User"
+ },
+ {
+ "role": "Stock Manager"
+ },
+ {
+ "role": "Accounts User"
+ },
+ {
+ "role": "Sales Master Manager"
+ },
+ {
+ "role": "Branch User"
+ }
+ ],
+ "timeout": 0
+}
\ No newline at end of file
diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.py b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.py
new file mode 100644
index 0000000..3a5132e
--- /dev/null
+++ b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.py
@@ -0,0 +1,299 @@
+import frappe
+from frappe import _
+from frappe.utils import flt, getdate, formatdate
+
+
+def execute(filters=None):
+ if not filters:
+ filters = {}
+ if not filters.get("customer"):
+ return [], []
+
+ return get_columns(), get_data(filters)
+
+
+def get_columns():
+ return [
+ {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100},
+ {"label": _("Voucher Type"), "fieldname": "voucher_type", "fieldtype": "Data", "width": 120},
+ {"label": _("Voucher No"), "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", "width": 180},
+ {"label": _("Due Date"), "fieldname": "due_date", "fieldtype": "Date", "width": 100},
+ {"label": _("Age (Days)"), "fieldname": "age", "fieldtype": "Int", "width": 80},
+ {"label": _("Invoiced"), "fieldname": "invoiced", "fieldtype": "Currency", "width": 120},
+ {"label": _("Paid"), "fieldname": "paid", "fieldtype": "Currency", "width": 120},
+ {"label": _("Credit Note"), "fieldname": "credit_note", "fieldtype": "Currency", "width": 120},
+ {"label": _("Outstanding"), "fieldname": "outstanding", "fieldtype": "Currency", "width": 120},
+ ]
+
+
+def get_data(filters):
+ customer = filters.get("customer")
+ company = filters.get("company")
+ from_date = filters.get("from_date")
+ to_date = filters.get("to_date")
+ ageing_based_on = filters.get("ageing_based_on", "Posting Date")
+
+ receivable_account = _get_receivable_account(customer, company)
+ opening = _get_opening_balance(customer, company, from_date, receivable_account)
+ rows = []
+
+ # Sales Invoices (non-return)
+ invoices = _get_sales_invoices(customer, company, from_date, to_date, is_return=0)
+ for inv in invoices:
+ paid = _get_paid_amount(inv.name)
+ age_date = inv.due_date if ageing_based_on == "Due Date" else inv.posting_date
+ age = (getdate(to_date) - getdate(age_date)).days if getdate(age_date) <= getdate(to_date) else 0
+ outstanding = flt(inv.grand_total) - flt(paid)
+ rows.append({
+ "posting_date": inv.posting_date,
+ "voucher_type": "Sales Invoice",
+ "voucher_no": inv.name,
+ "due_date": inv.due_date,
+ "age": age,
+ "invoiced": flt(inv.grand_total),
+ "paid": paid,
+ "credit_note": 0,
+ "outstanding": outstanding,
+ })
+
+ # Credit Notes (Sales Returns)
+ credit_notes = _get_sales_invoices(customer, company, from_date, to_date, is_return=1)
+ for cn in credit_notes:
+ rows.append({
+ "posting_date": cn.posting_date,
+ "voucher_type": "Credit Note",
+ "voucher_no": cn.name,
+ "due_date": cn.due_date,
+ "age": 0,
+ "invoiced": 0,
+ "paid": 0,
+ "credit_note": abs(flt(cn.grand_total)),
+ "outstanding": 0,
+ })
+
+ # Payment Entries
+ payments = _get_payment_entries(customer, company, from_date, to_date)
+ for pe in payments:
+ rows.append({
+ "posting_date": pe.posting_date,
+ "voucher_type": "Payment Entry",
+ "voucher_no": pe.name,
+ "due_date": pe.posting_date,
+ "age": 0,
+ "invoiced": 0,
+ "paid": flt(pe.paid_amount),
+ "credit_note": 0,
+ "outstanding": 0,
+ })
+
+ rows.sort(key=lambda r: r["posting_date"])
+
+ # Totals
+ total_invoiced = sum(r["invoiced"] for r in rows)
+ total_paid = sum(r["paid"] for r in rows)
+ total_credit_note = sum(r["credit_note"] for r in rows)
+ closing = flt(opening) + total_invoiced - total_paid - total_credit_note
+
+ # Prepend opening
+ rows.insert(0, {
+ "posting_date": None,
+ "voucher_type": "",
+ "voucher_no": _("Opening Balance"),
+ "due_date": None,
+ "age": 0,
+ "invoiced": 0,
+ "paid": 0,
+ "credit_note": 0,
+ "outstanding": flt(opening),
+ })
+
+ # Totals row
+ rows.append({
+ "posting_date": None,
+ "voucher_type": "",
+ "voucher_no": _("Totals"),
+ "due_date": None,
+ "age": 0,
+ "invoiced": total_invoiced,
+ "paid": total_paid,
+ "credit_note": total_credit_note,
+ "outstanding": closing,
+ })
+
+ # Closing row
+ rows.append({
+ "posting_date": None,
+ "voucher_type": "",
+ "voucher_no": _("Closing Balance"),
+ "due_date": None,
+ "age": 0,
+ "invoiced": 0,
+ "paid": 0,
+ "credit_note": 0,
+ "outstanding": closing,
+ })
+
+ return rows
+
+
+def _get_receivable_account(customer, company):
+ acc = frappe.db.get_value("Party Account", {
+ "parenttype": "Customer", "parent": customer, "company": company
+ }, "account")
+ if not acc:
+ acc = frappe.db.get_value("Company", company, "default_receivable_account")
+ return acc
+
+
+def _get_opening_balance(customer, company, from_date, receivable_account):
+ result = frappe.db.sql("""
+ SELECT SUM(debit) - SUM(credit)
+ FROM `tabGL Entry`
+ WHERE party_type = 'Customer'
+ AND party = %s
+ AND company = %s
+ AND account = %s
+ AND posting_date < %s
+ AND is_cancelled = 0
+ """, (customer, company, receivable_account, from_date))
+ return flt(result[0][0]) if result and result[0][0] else 0.0
+
+
+def _get_sales_invoices(customer, company, from_date, to_date, is_return=0):
+ return frappe.db.get_all("Sales Invoice", filters={
+ "customer": customer,
+ "company": company,
+ "docstatus": 1,
+ "is_return": is_return,
+ "posting_date": ["between", [from_date, to_date]],
+ }, fields=["name", "posting_date", "due_date", "grand_total"], order_by="posting_date")
+
+
+def _get_paid_amount(invoice_name):
+ result = frappe.db.sql("""
+ SELECT SUM(per.allocated_amount)
+ FROM `tabPayment Entry Reference` per
+ INNER JOIN `tabPayment Entry` pe ON pe.name = per.parent
+ WHERE per.reference_name = %s
+ AND per.reference_doctype = 'Sales Invoice'
+ AND pe.docstatus = 1
+ """, (invoice_name,))
+ return flt(result[0][0]) if result and result[0][0] else 0.0
+
+
+def _get_payment_entries(customer, company, from_date, to_date):
+ return frappe.db.get_all("Payment Entry", filters={
+ "party_type": "Customer",
+ "party": customer,
+ "company": company,
+ "docstatus": 1,
+ "posting_date": ["between", [from_date, to_date]],
+ "payment_type": "Receive",
+ }, fields=["name", "posting_date", "paid_amount"], order_by="posting_date")
+
+
+@frappe.whitelist()
+def get_pdf(customer, company=None, from_date=None, to_date=None):
+ """Generate statement PDF for download."""
+ if not company:
+ company = frappe.defaults.get_user_default("Company")
+
+ filters = frappe._dict({
+ "customer": customer,
+ "company": company,
+ "from_date": from_date,
+ "to_date": to_date,
+ })
+ _columns, data = execute(filters)
+ if not data:
+ frappe.throw(_("No transactions found for this customer in the selected period."))
+
+ customer_doc = frappe.get_doc("Customer", customer)
+ company_currency = frappe.db.get_value("Company", company, "default_currency")
+
+ aging = _get_aging(data, to_date, "Posting Date")
+ vat = _get_vat_summary(customer, company, from_date, to_date)
+ vat_total_taxable = sum(flt(v.taxable_amount) for v in vat)
+ vat_total_tax = sum(flt(v.tax_amount) for v in vat)
+
+ opening = flt(data[0]["outstanding"]) if data else 0
+ closing = flt(data[-1]["outstanding"]) if data else 0
+ total_invoiced = total_paid = total_credit = 0
+ for row in data:
+ if row.get("voucher_no") == _("Totals"):
+ total_invoiced = flt(row.get("invoiced", 0))
+ total_paid = flt(row.get("paid", 0))
+ total_credit = flt(row.get("credit_note", 0))
+ break
+
+ template_path = frappe.get_app_path(
+ "aqrar_ext", "aqrar_ext", "report", "customer_statement", "customer_statement.html"
+ )
+ with open(template_path) as f:
+ template_str = f.read()
+
+ html = frappe.get_jenv().from_string(template_str).render({
+ "data": data,
+ "customer_doc": customer_doc,
+ "company": company,
+ "company_currency": company_currency,
+ "from_date": from_date,
+ "to_date": to_date,
+ "opening": opening,
+ "closing": closing,
+ "total_invoiced": total_invoiced,
+ "total_paid": total_paid,
+ "total_credit": total_credit,
+ "aging": aging,
+ "vat": vat,
+ "vat_total_taxable": vat_total_taxable,
+ "vat_total_tax": vat_total_tax,
+ "print_date": formatdate(frappe.utils.today()),
+ })
+
+ from frappe.utils.pdf import get_pdf as _get_pdf
+ pdf = _get_pdf(html, {"orientation": "Portrait"})
+
+ frappe.local.response.filename = f"Customer_Statement_{customer}.pdf"
+ frappe.local.response.filecontent = pdf
+ frappe.local.response.type = "download"
+
+
+def _get_aging(rows, to_date, ageing_based_on):
+ buckets = {"0-30": 0.0, "31-60": 0.0, "61-90": 0.0, "91-120": 0.0, "120+": 0.0}
+ for row in rows:
+ if row.get("voucher_type") != "Sales Invoice":
+ continue
+ outstanding = row.get("outstanding", 0)
+ if outstanding <= 0:
+ continue
+ age = row.get("age", 0)
+ if age <= 30:
+ buckets["0-30"] += outstanding
+ elif age <= 60:
+ buckets["31-60"] += outstanding
+ elif age <= 90:
+ buckets["61-90"] += outstanding
+ elif age <= 120:
+ buckets["91-120"] += outstanding
+ else:
+ buckets["120+"] += outstanding
+ return buckets
+
+
+def _get_vat_summary(customer, company, from_date, to_date):
+ return frappe.db.sql("""
+ SELECT
+ stc.rate,
+ SUM(stc.tax_amount) AS tax_amount,
+ SUM(si.net_total) AS taxable_amount
+ FROM `tabSales Taxes and Charges` stc
+ INNER JOIN `tabSales Invoice` si ON stc.parent = si.name
+ WHERE si.customer = %s
+ AND si.company = %s
+ AND si.posting_date BETWEEN %s AND %s
+ AND si.docstatus = 1
+ AND si.is_return = 0
+ GROUP BY stc.rate
+ ORDER BY stc.rate
+ """, (customer, company, from_date, to_date), as_dict=True)
diff --git a/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.py b/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.py
index 9bc9804..18e0e89 100644
--- a/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.py
+++ b/aqrar_ext/aqrar_ext/report/dcr_report/dcr_report.py
@@ -147,16 +147,25 @@ def fetch_sales_invoices(t, date,company, cost_center):
amount_field = """
IFNULL(
- CASE
+ CASE
WHEN si.is_pos = 1 THEN SUM(sip.amount)
ELSE SUM(per.allocated_amount)
+ END,
+ CASE
+ WHEN si.custom_payment_mode = 'Cash' THEN si.grand_total
+ ELSE 0
END
- ,0)
+ )
"""
date_condition = """
AND si.posting_date = %(date)s
AND (
+ (
+ si.is_pos = 0
+ AND si.custom_payment_mode = 'Cash'
+ )
+ OR
(
si.is_pos = 0
AND pe.posting_date <= si.posting_date
@@ -182,16 +191,25 @@ def fetch_sales_invoices(t, date,company, cost_center):
amount_field = """
IFNULL(
- CASE
+ CASE
WHEN si.is_pos = 1 THEN SUM(sip.amount)
ELSE SUM(per.allocated_amount)
+ END,
+ CASE
+ WHEN si.custom_payment_mode = 'Card' THEN si.grand_total
+ ELSE 0
END
- ,0)
+ )
"""
date_condition = """
AND si.posting_date = %(date)s
AND (
+ (
+ si.is_pos = 0
+ AND si.custom_payment_mode = 'Card'
+ )
+ OR
(
si.is_pos = 0
AND pe.posting_date = %(date)s
@@ -218,6 +236,7 @@ def fetch_sales_invoices(t, date,company, cost_center):
date_condition = """
AND si.posting_date = %(date)s
AND si.is_pos = 0
+ AND si.custom_payment_mode = 'Credit'
AND NOT EXISTS (
SELECT 1
FROM `tabPayment Entry Reference` per2
diff --git a/aqrar_ext/events/__init__.py b/aqrar_ext/events/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/aqrar_ext/events/material_request.py b/aqrar_ext/events/material_request.py
new file mode 100644
index 0000000..97c2582
--- /dev/null
+++ b/aqrar_ext/events/material_request.py
@@ -0,0 +1,59 @@
+# aqrar_ext: Material Request — branch-user approval enforcement
+import frappe
+from frappe import _
+
+
+def validate_branch_user(doc, method):
+ """Ensure Branch Users can only approve MRs for their own warehouse."""
+ user = frappe.session.user
+ roles = frappe.get_roles(user)
+
+ # Only enforce for Branch Users, not central admins/managers
+ if "Branch User" not in roles or "Stock Manager" in roles or "System Manager" in roles:
+ return
+
+ # Get warehouses from MR
+ warehouses = []
+ if doc.set_warehouse:
+ warehouses.append(doc.set_warehouse)
+ if doc.set_from_warehouse:
+ warehouses.append(doc.set_from_warehouse)
+ for item in doc.items:
+ if item.warehouse:
+ warehouses.append(item.warehouse)
+
+ if not warehouses:
+ return
+
+ # Check user has permission for at least one of the MR's warehouses
+ user_warehouses = frappe.get_all("User Permission", filters={
+ "user": user,
+ "allow": "Warehouse",
+ "for_value": ["in", list(set(warehouses))],
+ }, pluck="for_value")
+
+ if not user_warehouses:
+ frappe.throw(
+ _("You do not have permission to approve Material Requests for this warehouse. "
+ "Please contact your branch administrator.")
+ )
+
+
+@frappe.whitelist()
+def close_material_request(mr_name, reason):
+ """Close an MR with a reason, bypassing form-level status restrictions."""
+ frappe.db.set_value("Material Request", mr_name, "custom_close_reason", reason)
+ frappe.db.set_value("Material Request", mr_name, "status", "Stopped", update_modified=True)
+ frappe.db.commit()
+
+
+@frappe.whitelist()
+def reopen_material_request(mr_name):
+ """Reopen a closed MR."""
+ status = "Pending"
+ per_ordered = frappe.db.get_value("Material Request", mr_name, "per_ordered") or 0
+ if per_ordered > 0:
+ status = "Partially Ordered"
+ frappe.db.set_value("Material Request", mr_name, "custom_close_reason", "")
+ frappe.db.set_value("Material Request", mr_name, "status", status, update_modified=True)
+ frappe.db.commit()
diff --git a/aqrar_ext/events/purchase_receipt.py b/aqrar_ext/events/purchase_receipt.py
new file mode 100644
index 0000000..9df984f
--- /dev/null
+++ b/aqrar_ext/events/purchase_receipt.py
@@ -0,0 +1,29 @@
+# aqrar_ext: Block PR cancellation when stock already consumed
+import frappe
+from frappe import _
+
+
+def block_cancel_if_consumed(doc, method):
+ """Prevent cancelling PR if items have been sold via Sales Invoice or Delivery Note."""
+ for item in doc.items:
+ # Check if any of this item's stock was consumed via Sales Invoice
+ consumed = frappe.db.sql("""
+ SELECT sle.name
+ FROM `tabStock Ledger Entry` sle
+ INNER JOIN `tabSales Invoice Item` sii ON sle.voucher_no = sii.parent
+ AND sle.voucher_type = 'Sales Invoice'
+ WHERE sle.item_code = %s
+ AND sle.warehouse = %s
+ AND sle.actual_qty < 0
+ AND sle.posting_date >= %s
+ AND sii.docstatus = 1
+ LIMIT 1
+ """, (item.item_code, item.warehouse or doc.set_warehouse, doc.posting_date))
+
+ if consumed:
+ frappe.throw(
+ _("Cannot cancel {0}. Item {1} has already been sold. "
+ "Please use Final GRN to update the rate instead.")
+ .format(doc.name, item.item_code),
+ title=_("Stock Already Consumed"),
+ )
diff --git a/aqrar_ext/fixtures/custom_field.json b/aqrar_ext/fixtures/custom_field.json
index 75a3bde..a8c8c26 100644
--- a/aqrar_ext/fixtures/custom_field.json
+++ b/aqrar_ext/fixtures/custom_field.json
@@ -1,4 +1,118 @@
[
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": "0",
+ "depends_on": null,
+ "description": null,
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Material Request",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_urgent",
+ "fieldtype": "Check",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_preview": 0,
+ "in_standard_filter": 1,
+ "insert_after": "schedule_date",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Urgent",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-17 00:00:00",
+ "module": null,
+ "name": "Material Request-custom_urgent",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 1,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": null,
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Material Request",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_close_reason",
+ "fieldtype": "Small Text",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "custom_urgent",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Close Reason",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-17 00:00:00",
+ "module": null,
+ "name": "Material Request-custom_close_reason",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
{
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
@@ -38,7 +152,7 @@
"name": "Sales Invoice-custom_payment_mode",
"no_copy": 0,
"non_negative": 0,
- "options": "Cash\nCredit",
+ "options": "Cash\nCredit\nCard",
"permlevel": 0,
"placeholder": null,
"precision": "",
@@ -55,5 +169,518 @@
"translatable": 1,
"unique": 0,
"width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 1,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": "eval:doc.custom_payment_mode === 'Card' || doc.custom_payment_mode === 'Credit'",
+ "description": null,
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Sales Invoice",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_partial_payment_amount",
+ "fieldtype": "Currency",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "custom_payment_mode",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Partial Payment Amount",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-19 16:14:34.276535",
+ "module": null,
+ "name": "Sales Invoice-custom_partial_payment_amount",
+ "no_copy": 0,
+ "non_negative": 1,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 1,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 1,
+ "default": null,
+ "depends_on": null,
+ "description": null,
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Material Request",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "workflow_state",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_preview": 0,
+ "in_standard_filter": 1,
+ "insert_after": "status",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Workflow State",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-17 00:00:00",
+ "module": null,
+ "name": "Material Request-workflow_state",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "Workflow State",
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 1,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 1,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": null,
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Sales Invoice Item",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_last_price",
+ "fieldtype": "Currency",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "rate",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Last Price",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-19 16:12:16.316135",
+ "module": null,
+ "name": "Sales Invoice Item-custom_last_price",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 1,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": null,
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Purchase Invoice Item",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_last_price",
+ "fieldtype": "Currency",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "rate",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Last Price",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-19 16:13:25.722972",
+ "module": null,
+ "name": "Purchase Invoice Item-custom_last_price",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 1,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": null,
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Delivery Note Item",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_last_price",
+ "fieldtype": "Currency",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "rate",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Last Price",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-19 16:12:31.457961",
+ "module": null,
+ "name": "Delivery Note Item-custom_last_price",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 1,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": null,
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Sales Order Item",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_last_price",
+ "fieldtype": "Currency",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "rate",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Last Price",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-19 16:12:46.012588",
+ "module": null,
+ "name": "Sales Order Item-custom_last_price",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 1,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": null,
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Quotation Item",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_last_price",
+ "fieldtype": "Currency",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "rate",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Last Price",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-19 16:13:13.434131",
+ "module": null,
+ "name": "Quotation Item-custom_last_price",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 1,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": null,
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Purchase Order Item",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_last_price",
+ "fieldtype": "Currency",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "rate",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Last Price",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-19 16:13:42.476090",
+ "module": null,
+ "name": "Purchase Order Item-custom_last_price",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 1,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "default": null,
+ "depends_on": null,
+ "description": null,
+ "docstatus": 0,
+ "doctype": "Custom Field",
+ "dt": "Purchase Receipt Item",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "custom_last_price",
+ "fieldtype": "Currency",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 1,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "rate",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Last Price",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-19 16:14:07.152340",
+ "module": null,
+ "name": "Purchase Receipt Item-custom_last_price",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 1,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
}
]
\ No newline at end of file
diff --git a/aqrar_ext/fixtures/mode_of_payment.json b/aqrar_ext/fixtures/mode_of_payment.json
new file mode 100644
index 0000000..d3974ad
--- /dev/null
+++ b/aqrar_ext/fixtures/mode_of_payment.json
@@ -0,0 +1,56 @@
+[
+ {
+ "accounts": [
+ {
+ "company": "enfono",
+ "default_account": "Cash - E",
+ "parent": "Cash",
+ "parentfield": "accounts",
+ "parenttype": "Mode of Payment"
+ }
+ ],
+ "docstatus": 0,
+ "doctype": "Mode of Payment",
+ "enabled": 1,
+ "mode_of_payment": "Cash",
+ "modified": "2026-05-11 12:00:13.777907",
+ "name": "Cash",
+ "type": "Cash"
+ },
+ {
+ "accounts": [
+ {
+ "company": "enfono",
+ "default_account": "Cash - E",
+ "parent": "Card",
+ "parentfield": "accounts",
+ "parenttype": "Mode of Payment"
+ }
+ ],
+ "docstatus": 0,
+ "doctype": "Mode of Payment",
+ "enabled": 1,
+ "mode_of_payment": "Card",
+ "modified": "2026-05-19 15:53:16.525640",
+ "name": "Card",
+ "type": "Bank"
+ },
+ {
+ "accounts": [
+ {
+ "company": "enfono",
+ "default_account": "Cash - E",
+ "parent": "Credit",
+ "parentfield": "accounts",
+ "parenttype": "Mode of Payment"
+ }
+ ],
+ "docstatus": 0,
+ "doctype": "Mode of Payment",
+ "enabled": 1,
+ "mode_of_payment": "Credit",
+ "modified": "2026-05-19 15:53:16.566542",
+ "name": "Credit",
+ "type": "Bank"
+ }
+]
\ No newline at end of file
diff --git a/aqrar_ext/hooks.py b/aqrar_ext/hooks.py
index cde1460..6dc1cc0 100644
--- a/aqrar_ext/hooks.py
+++ b/aqrar_ext/hooks.py
@@ -5,11 +5,63 @@
app_email = "nah@enfono.com"
app_license = "mit"
+import aqrar_ext.aqrar_ext.overrides.stock_ledger # noqa
+
app_include_js = [
"/assets/aqrar_ext/js/sales_invoice_pos_total_popup.js",
+ "/assets/aqrar_ext/js/item_selector.js",
+ "/assets/aqrar_ext/js/item_selector_hook.js",
+ "/assets/aqrar_ext/js/customer_price_history.js",
+ "/assets/aqrar_ext/js/customer_statement.js",
+ "/assets/aqrar_ext/js/stock_ledger_override.js",
+ "/assets/aqrar_ext/js/material_request_custom.js",
+ "/assets/aqrar_ext/js/purchase_receipt_final_grn.js",
]
+doctype_js = {
+ "Sales Invoice": "public/js/customer_price_history.js",
+ "Sales Order": "public/js/customer_price_history.js",
+ "Quotation": "public/js/customer_price_history.js",
+ "Delivery Note": "public/js/customer_price_history.js",
+ "Purchase Invoice": "public/js/customer_price_history.js",
+ "Purchase Order": "public/js/customer_price_history.js",
+ "Purchase Receipt": "public/js/customer_price_history.js",
+ "Customer": "public/js/customer_statement.js",
+ "Material Request": "public/js/material_request_custom.js",
+ "Purchase Receipt": "public/js/purchase_receipt_final_grn.js",
+}
+
+doc_events = {
+ "Sales Invoice": {
+ "on_submit": "aqrar_ext.api.sales_invoice.auto_create_payment_entry_on_submit"
+ },
+ "Material Request": {
+ "before_submit": "aqrar_ext.events.material_request.validate_branch_user"
+ },
+ "Purchase Receipt": {
+ "before_cancel": "aqrar_ext.events.purchase_receipt.block_cancel_if_consumed"
+ }
+}
+
fixtures = [
+ {
+ "dt": "Mode of Payment",
+ "filters": [
+ ["name", "in", ["Cash", "Card", "Credit"]]
+ ]
+ },
+ {
+ "dt": "Print Format",
+ "filters": [
+ ["name", "in", ["Aqrar Delivery Note"]]
+ ]
+ },
+ {
+ "dt": "Workflow",
+ "filters": [
+ ["name", "in", ["Material Request Approval"]]
+ ]
+ },
{
"dt": "Custom Field",
"filters": [
@@ -19,6 +71,18 @@
[
# Sales Invoice
"Sales Invoice-custom_payment_mode",
+ # custom_last_price on child doctypes
+ "Sales Invoice Item-custom_last_price",
+ "Delivery Note Item-custom_last_price",
+ "Sales Order Item-custom_last_price",
+ "Quotation Item-custom_last_price",
+ "Purchase Invoice Item-custom_last_price",
+ "Purchase Order Item-custom_last_price",
+ "Purchase Receipt Item-custom_last_price",
+ "Sales Invoice-custom_partial_payment_amount",
+ "Material Request-custom_urgent",
+ "Material Request-custom_close_reason",
+ "Material Request-workflow_state",
],
]
]
diff --git a/aqrar_ext/public/js/customer_price_history.js b/aqrar_ext/public/js/customer_price_history.js
new file mode 100644
index 0000000..af04307
--- /dev/null
+++ b/aqrar_ext/public/js/customer_price_history.js
@@ -0,0 +1,720 @@
+frappe.provide("aqrar_ext.price_assist");
+
+const DOCTYPE_CONFIG = {
+ "Sales Invoice": {
+ child_doctype: "Sales Invoice Item",
+ customer_field: "customer",
+ source: "sales"
+ },
+ "Delivery Note": {
+ child_doctype: "Delivery Note Item",
+ customer_field: "customer",
+ source: "sales"
+ },
+ "Sales Order": {
+ child_doctype: "Sales Order Item",
+ customer_field: "customer",
+ source: "sales"
+ },
+ "Quotation": {
+ child_doctype: "Quotation Item",
+ customer_field: "party_name",
+ source: "sales"
+ },
+ "Purchase Invoice": {
+ child_doctype: "Purchase Invoice Item",
+ customer_field: "supplier",
+ source: "purchase"
+ },
+ "Purchase Order": {
+ child_doctype: "Purchase Order Item",
+ customer_field: "supplier",
+ source: "purchase"
+ },
+ "Purchase Receipt": {
+ child_doctype: "Purchase Receipt Item",
+ customer_field: "supplier",
+ source: "purchase"
+ },
+};
+
+for (const [doctype, config] of Object.entries(DOCTYPE_CONFIG)) {
+
+ frappe.ui.form.on(doctype, {
+
+ refresh(frm) {
+
+ bind_row_click(frm, config);
+
+ add_price_assist_button(frm, config);
+
+ add_price_history_button(frm, config);
+
+ update_all_last_prices(frm, config);
+ },
+
+ [config.customer_field](frm) {
+ update_all_last_prices(frm, config);
+ }
+ });
+
+ frappe.ui.form.on(config.child_doctype, {
+
+ item_code(frm, cdt, cdn) {
+
+ const row = locals[cdt][cdn];
+
+ if (!row.item_code) return;
+
+ update_row_last_price(frm, row, config);
+ },
+
+ rate(frm, cdt, cdn) {
+
+ const row = locals[cdt][cdn];
+
+ if (row._popup_opened) {
+ show_price_popup(frm, row, config);
+ }
+ }
+ });
+}
+
+function bind_row_click(frm, config) {
+
+ if (frm.__price_row_bound) return;
+
+ frm.fields_dict.items.grid.wrapper.on(
+ "click",
+ ".grid-row",
+ function () {
+
+ const row_name = $(this).attr("data-name");
+
+ if (!row_name) return;
+
+ const row = locals[config.child_doctype][row_name];
+
+ frm.__selected_price_row = row;
+
+ // Auto-show popup on row click when item_code is set
+ if (row && row.item_code) {
+ show_price_popup(frm, row, config);
+ }
+ }
+ );
+
+ frm.__price_row_bound = true;
+}
+
+function add_price_assist_button(frm, config) {
+
+ if (frm.__price_btn_added) return;
+
+ const btn = frm.fields_dict.items.grid.add_custom_button(
+ __("Price Assist"),
+ () => {
+
+ const row = frm.__selected_price_row;
+
+ if (!row) {
+ frappe.msgprint("Please select an item row");
+ return;
+ }
+
+ if (!row.item_code) {
+ frappe.msgprint("Please select item code");
+ return;
+ }
+
+ show_price_popup(frm, row, config);
+ }
+ );
+
+ frm.__price_btn_added = true;
+
+ setTimeout(() => {
+
+ const $toolbar =
+ frm.fields_dict.items.grid.wrapper.find(".grid-buttons");
+
+ const $add_multiple =
+ $toolbar.find("button:contains('Add Multiple')").last();
+
+ if ($add_multiple.length) {
+ $(btn).insertAfter($add_multiple);
+ }
+
+ }, 100);
+}
+
+function add_price_history_button(frm, config) {
+
+ if (frm.__price_history_btn_added) return;
+
+ const btn = frm.fields_dict.items.grid.add_custom_button(
+ __("Price History"),
+ () => {
+
+ const row = frm.__selected_price_row;
+
+ if (!row) {
+ frappe.msgprint("Please select an item row");
+ return;
+ }
+
+ if (!row.item_code) {
+ frappe.msgprint("Please select item code");
+ return;
+ }
+
+ const party = frm.doc[config.customer_field];
+ show_price_history_dialog(row.item_code, config.source, party);
+ }
+ );
+
+ frm.__price_history_btn_added = true;
+
+ setTimeout(() => {
+
+ const $toolbar =
+ frm.fields_dict.items.grid.wrapper.find(".grid-buttons");
+
+ const $price_assist =
+ $toolbar.find("button:contains('Price Assist')").last();
+
+ if ($price_assist.length) {
+ $(btn).insertAfter($price_assist);
+ }
+
+ }, 100);
+}
+
+function show_price_history_dialog(item_code, source, customer) {
+
+ source = source || "sales";
+ const party_label = source === "purchase" ? __("Supplier") : __("Customer");
+
+ const d = new frappe.ui.Dialog({
+ title: __("Item Sales & Purchase Price History"),
+ size: "extra-large",
+ fields: [
+ {
+ fieldtype: "Link",
+ fieldname: "item_code",
+ label: __("Item Code"),
+ options: "Item",
+ default: item_code,
+ },
+ {
+ fieldtype: "HTML",
+ fieldname: "history_table",
+ },
+ ],
+ });
+
+ d.show();
+
+ function load_table(code) {
+ if (!code) {
+ d.fields_dict.history_table.$wrapper.html(
+ '' + __("Please select an Item Code") + "
"
+ );
+ return;
+ }
+
+ d.fields_dict.history_table.$wrapper.html(
+ '' + __("Loading...") + "
"
+ );
+
+ frappe.call({
+ method: "aqrar_ext.api.get_item_price_history",
+ args: { item_code: code, source: source, customer: customer },
+ callback: function (r) {
+ const data = r.message || {};
+ const rows = data.history || [];
+ const last_pr = data.last_price || 0;
+
+ render_price_history_table(
+ d.fields_dict.history_table.$wrapper,
+ rows,
+ last_pr,
+ source
+ );
+ },
+ });
+ }
+
+ d.fields_dict.item_code.$input.on("change", function () {
+ load_table(d.fields_dict.item_code.get_value());
+ });
+
+ load_table(item_code);
+}
+
+function render_price_history_table($wrapper, rows, last_price, source) {
+
+ source = source || "sales";
+ const party_col = source === "purchase" ? __("Supplier") : __("Customer");
+ const rate_col = source === "purchase" ? __("Purchase Rate") : __("Sales Rate (Txn)");
+ const last_col = source === "purchase" ? __("Last Sales Rate") : __("Last Purchase Rate");
+ const empty_msg = source === "purchase" ? __("No purchase history found") : __("No sales history found");
+
+ if (!rows.length) {
+ $wrapper.html(
+ '' + empty_msg + "
"
+ );
+ return;
+ }
+
+ let html = `
+
+ `;
+
+ $wrapper.html(html);
+
+ // Filter logic
+ var $table = $wrapper.find(".price-history-table-wrap");
+
+ $table.on("input", ".ph-filter", function () {
+ var filters = [];
+ $table.find(".ph-filter").each(function () {
+ filters.push($(this).val().toLowerCase().trim());
+ });
+
+ var tbody_html = "";
+ var count = 0;
+
+ rows.forEach(function (r) {
+ var cols = [
+ (r.item_code || "").toLowerCase(),
+ (r.item_name || "").toLowerCase(),
+ (r.party || r.customer || "").toLowerCase(),
+ format_currency(r.rate).toLowerCase(),
+ String(r.qty || 0).toLowerCase(),
+ format_currency(last_price).toLowerCase(),
+ ];
+
+ var match = true;
+ for (var i = 0; i < 6; i++) {
+ if (filters[i] && cols[i].indexOf(filters[i]) === -1) {
+ match = false;
+ break;
+ }
+ }
+
+ if (match) {
+ tbody_html += build_row(r);
+ count++;
+ }
+ });
+
+ if (!count) {
+ tbody_html = '| ' + __("No matching records") + " |
";
+ }
+
+ $table.find(".ph-tbody").html(tbody_html);
+ });
+}
+
+function update_all_last_prices(frm, config) {
+
+ if (frm.doc.docstatus !== 0) return;
+
+ (frm.doc.items || []).forEach(row => {
+
+ if (row.item_code) {
+ update_row_last_price(frm, row, config);
+ }
+ });
+}
+
+function update_row_last_price(frm, row, config) {
+
+ // Do not modify submitted/cancelled documents
+ if (frm.doc.docstatus !== 0) return;
+
+ const party = frm.doc[config.customer_field];
+
+ frappe.call({
+ method: "aqrar_ext.api.get_last_sold_price",
+ args: {
+ customer: party,
+ item_code: row.item_code,
+ source: config.source
+ },
+ callback: function(r) {
+
+ if (!r.message) return;
+
+ frappe.model.set_value(
+ row.doctype,
+ row.name,
+ "custom_last_price",
+ r.message.last_price || 0
+ );
+ }
+ });
+}
+
+function show_price_popup(frm, row, config) {
+
+ $(".customer-price-popup").remove();
+
+ const party = frm.doc[config.customer_field];
+
+ frappe.call({
+ method: "aqrar_ext.api.get_item_insights",
+ args: {
+ customer: party,
+ item_code: row.item_code,
+ company: frm.doc.company,
+ source: config.source
+ },
+ callback: function(r) {
+
+ render_popup(frm, row, r.message || {});
+ }
+ });
+}
+
+function render_popup(frm, row, data) {
+
+ $(".customer-price-popup").remove();
+
+ row._popup_opened = true;
+
+ const stock = data.stock || [];
+ const history = data.price_history || [];
+ const purchase_rate = data.last_purchase_rate || 0;
+
+ let html = `
+ `;
+
+ const $popup = $(html).appendTo("body");
+
+ const $row = $(`.grid-row[data-name="${row.name}"]`);
+
+ if ($row.length) {
+
+ const pos = $row.offset();
+ const popup_h = 450; // approximate popup height
+ const popup_w = 430;
+ const win_h = $(window).height();
+ const win_w = $(window).width();
+
+ let top = pos.top + 40;
+ let left = pos.left + 250;
+
+ // Clamp to viewport — flip above row if near bottom
+ if (top + popup_h > win_h + $(window).scrollTop()) {
+ top = pos.top - popup_h - 10;
+ }
+ if (left + popup_w > win_w) {
+ left = win_w - popup_w - 20;
+ }
+
+ $popup.css({
+ top: top,
+ left: left
+ });
+ }
+
+ $popup.find(".cpp-close").on("click", function () {
+
+ $(".customer-price-popup").remove();
+
+ row._popup_opened = false;
+ });
+}
+
+// aqrar_ext: Simplified Sales Invoice for Branch Users
+frappe.ui.form.on("Sales Invoice", {
+ refresh(frm) {
+ if (!frappe.user.has_role("Branch User") || frappe.user.has_role("System Manager") || frappe.user.has_role("Stock Manager") || frm._branch_setup_done) return;
+ frm._branch_setup_done = true;
+
+ // Hide unnecessary fields
+ [
+ "posting_time", "set_posting_time", "due_date",
+ "is_pos", "pos_profile", "is_return", "is_debit_note",
+ "return_against", "amended_from", "scan_barcode",
+ "currency", "conversion_rate", "selling_price_list", "price_list_currency",
+ "plc_conversion_rate", "ignore_pricing_rule",
+ "apply_discount_on", "additional_discount_percentage", "discount_amount",
+ "additional_discount_account", "base_discount_amount",
+ "tax_category", "taxes_and_charges", "shipping_rule", "incoterm", "named_place",
+ "taxes", "total_taxes_and_charges", "base_total_taxes_and_charges",
+ "update_stock", "set_warehouse", "set_target_warehouse",
+ "po_no", "po_date", "commission_rate", "total_commission", "sales_partner",
+ "amount_eligible_for_commission",
+ "is_cash_or_non_trade_discount",
+ ].forEach(function (f) { frm.set_df_property(f, "hidden", 1); });
+
+ // Hide sections
+ [
+ "accounting_dimensions_section", "currency_and_price_list",
+ "section_break_49", "taxes_section", "customer_po_details",
+ "more_info", "sales_team_section_break", "section_break2",
+ "edit_printing_settings", "more_information", "subscription_section",
+ ].forEach(function (s) { frm.set_df_property(s, "hidden", 1); });
+
+ // Hide tabs
+ ["payments_tab", "contact_and_address_tab", "terms_tab", "more_info_tab"]
+ .forEach(function (t) { frm.set_df_property(t, "hidden", 1); });
+
+ // naming_series — force hide via DOM (set_only_once blocks set_df_property)
+ frm.set_df_property("naming_series", "reqd", 0);
+ frm.set_df_property("naming_series", "hidden", 1);
+ $(frm.fields_dict.naming_series.wrapper).hide();
+
+ // Company read-only
+ frm.set_df_property("company", "read_only", 1);
+
+ // Payment Mode required
+ frm.set_df_property("custom_payment_mode", "reqd", 1);
+
+ // Auto-fill cost_center from Branch Configuration
+ if (!frm.doc.cost_center) {
+ frappe.call({
+ method: "aqrar_ext.api.branch_config.get_user_branch_defaults",
+ callback: function (r) {
+ if (r.message && r.message.cost_center) {
+ frm.set_value("cost_center", r.message.cost_center);
+ }
+ if (r.message && r.message.warehouse && !frm.doc.set_warehouse) {
+ frm.set_value("set_warehouse", r.message.warehouse);
+ }
+ },
+ });
+ }
+ },
+});
+
+$(document).on("click", function(e) {
+
+ if ($(e.target).closest(".customer-price-popup").length) return;
+
+ if ($(e.target).closest(".grid-row").length) return;
+
+ $(".customer-price-popup").remove();
+});
+
+$(`
+
+
+
+`).appendTo("head");
\ No newline at end of file
diff --git a/aqrar_ext/public/js/customer_statement.js b/aqrar_ext/public/js/customer_statement.js
new file mode 100644
index 0000000..4eaa187
--- /dev/null
+++ b/aqrar_ext/public/js/customer_statement.js
@@ -0,0 +1,71 @@
+// aqrar_ext: "Send Statement" button on Customer form
+frappe.ui.form.on("Customer", {
+ refresh(frm) {
+ if (frm.doc.__islocal) return;
+
+ frm.add_custom_button(
+ __("Send Statement"),
+ function () {
+ show_statement_dialog(frm);
+ },
+ __("View")
+ );
+ },
+});
+
+function show_statement_dialog(frm) {
+ const d = new frappe.ui.Dialog({
+ title: __("Customer Statement"),
+ size: "small",
+ fields: [
+ {
+ fieldtype: "Date",
+ fieldname: "from_date",
+ label: __("From Date"),
+ reqd: 1,
+ default: frappe.datetime.month_start(),
+ },
+ {
+ fieldtype: "Date",
+ fieldname: "to_date",
+ label: __("To Date"),
+ reqd: 1,
+ default: frappe.datetime.month_end(),
+ },
+ ],
+ primary_action_label: __("Download PDF"),
+ primary_action(values) {
+ const { from_date, to_date } = values;
+ if (from_date > to_date) {
+ frappe.msgprint(__("From Date cannot be after To Date"));
+ return;
+ }
+ d.hide();
+ const url =
+ "/api/method/aqrar_ext.aqrar_ext.report.customer_statement.customer_statement.get_pdf" +
+ "?customer=" +
+ encodeURIComponent(frm.doc.name) +
+ "&from_date=" +
+ encodeURIComponent(from_date) +
+ "&to_date=" +
+ encodeURIComponent(to_date);
+ window.open(url);
+ },
+ secondary_action_label: __("View Report"),
+ secondary_action() {
+ const vals = d.get_values();
+ if (!vals) return;
+ if (vals.from_date > vals.to_date) {
+ frappe.msgprint(__("From Date cannot be after To Date"));
+ return;
+ }
+ d.hide();
+ frappe.set_route("query-report", "Customer Statement", {
+ customer: frm.doc.name,
+ from_date: vals.from_date,
+ to_date: vals.to_date,
+ });
+ },
+ });
+ d.show();
+}
diff --git a/aqrar_ext/public/js/item_selector.js b/aqrar_ext/public/js/item_selector.js
new file mode 100644
index 0000000..72c206f
--- /dev/null
+++ b/aqrar_ext/public/js/item_selector.js
@@ -0,0 +1,305 @@
+// aqrar_ext: Multi-select item picker with running search and quantity
+// Replaces the stock "Add Multiple" LinkSelector for item grids.
+
+frappe.ui.form.ItemMultiSelector = class ItemMultiSelector {
+ constructor(opts) {
+ this.target = opts.target; // the grid object
+ this.item_field = opts.fieldname; // typically "item_code"
+ this.qty_field = opts.qty_fieldname; // typically "qty"
+ this.get_query = opts.get_query;
+ this.start = 0;
+ this.page_length = 20;
+ this.selected = {}; // { item_code: qty }
+ this.make();
+ }
+
+ make() {
+ var me = this;
+
+ this.dialog = new frappe.ui.Dialog({
+ title: __("Select Items"),
+ fields: [
+ {
+ fieldtype: "Data",
+ fieldname: "search_txt",
+ label: __("Search Items"),
+ placeholder: __("Type to search..."),
+ onchange: function () {
+ me.debounced_search();
+ },
+ },
+ {
+ fieldtype: "HTML",
+ fieldname: "results_area",
+ },
+ ],
+ primary_action_label: __("Add Selected Items"),
+ primary_action: function () {
+ me.add_selected_to_grid();
+ },
+ });
+
+ this.dialog.show();
+ this.search();
+ }
+
+ debounced_search() {
+ if (this._search_timeout) clearTimeout(this._search_timeout);
+ var me = this;
+ this._search_timeout = setTimeout(function () {
+ me.start = 0;
+ me.search();
+ }, 300);
+ }
+
+ search() {
+ var me = this;
+ var txt = this.dialog.fields_dict.search_txt.get_value() || "";
+
+ var args = {
+ txt: txt,
+ searchfield: "name",
+ start: this.start,
+ page_length: this.page_length,
+ };
+
+ // Apply custom query filters from the grid field
+ if (
+ this.target.is_grid &&
+ this.target.fieldinfo &&
+ this.target.fieldinfo[this.item_field] &&
+ this.target.fieldinfo[this.item_field].get_query
+ ) {
+ $.extend(args, this.target.fieldinfo[this.item_field].get_query(cur_frm.doc));
+ }
+
+ frappe.link_search("Item", args, function (results) {
+ me.render_results(results, args.start > 0);
+ });
+ }
+
+ render_results(results, append) {
+ var parent = this.dialog.fields_dict.results_area.$wrapper;
+
+ if (!append) {
+ parent.empty();
+ }
+
+ if (!results.length && !append) {
+ parent.html(
+ '' + __("No items found") + "
"
+ );
+ return;
+ }
+
+ // Remove old Load More button before adding new rows
+ parent.find(".load-more").remove();
+
+ if (!append) {
+ // Build table header
+ var header = $(
+ ''
+ ).appendTo(parent);
+ var list = $('').appendTo(parent);
+ } else {
+ // Append to existing row container
+ var list = parent.find(".item-selector-rows");
+ }
+ var me = this;
+
+ // Collect item codes for batch stock lookup
+ var item_codes = results.map(function (r) { return r[0]; });
+
+ // Render each row
+ results.forEach(function (r) {
+ var item_code = r[0];
+ var item_name = r[1] || "";
+ var checked_attr = me.selected[item_code] !== undefined ? "checked" : "";
+ var qty_val = me.selected[item_code] || 1;
+
+ var row = $(
+ '' +
+ '' +
+ '' +
+ '' +
+ '' + item_code + '' +
+ '' + item_name + '' +
+ '' +
+ '...' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
'
+ ).appendTo(list);
+
+ // Checkbox click
+ row.find(".item-check").on("change", function () {
+ var code = $(this).attr("data-item");
+ if (this.checked) {
+ var qty = parseFloat(row.find(".item-qty").val()) || 1;
+ me.selected[code] = qty;
+ } else {
+ delete me.selected[code];
+ }
+ });
+
+ // Qty change
+ row.find(".item-qty").on("change input", function () {
+ var code = $(this).attr("data-item");
+ var val = parseFloat($(this).val()) || 0;
+ if (row.find(".item-check").is(":checked")) {
+ me.selected[code] = val;
+ }
+ });
+ });
+
+ // Load stock info for all items
+ this.load_stock_info(item_codes);
+
+ // Load More button
+ if (results.length >= this.page_length) {
+ var me = this;
+ $(
+ '"
+ )
+ .appendTo(parent)
+ .on("click", function () {
+ me.start += me.page_length;
+ me.search();
+ });
+ }
+ }
+
+ load_stock_info(item_codes) {
+ if (!item_codes.length) return;
+ var me = this;
+
+ var warehouse = (
+ cur_frm.doc.set_warehouse ||
+ cur_frm.doc.set_source_warehouse ||
+ ""
+ );
+
+ var filters = { item_code: ["in", item_codes] };
+ if (warehouse) {
+ filters.warehouse = warehouse;
+ }
+
+ frappe.call({
+ method: "frappe.client.get_list",
+ args: {
+ doctype: "Bin",
+ fields: ["item_code", "actual_qty"],
+ filters: filters,
+ limit_page_length: 500,
+ },
+ callback: function (r) {
+ if (!r.message) {
+ // API failed — show "--" for all items
+ item_codes.forEach(function (code) {
+ var badge = me.dialog.$wrapper.find('.stock-badge[data-item="' + code + '"]');
+ badge.text("--").removeClass().addClass("stock-badge badge");
+ });
+ return;
+ }
+ // Sum actual_qty per item_code (across warehouses when no filter)
+ var stock_map = {};
+ r.message.forEach(function (b) {
+ var code = b.item_code;
+ stock_map[code] = (stock_map[code] || 0) + (b.actual_qty || 0);
+ });
+ item_codes.forEach(function (code) {
+ var badge = me.dialog.$wrapper.find('.stock-badge[data-item="' + code + '"]');
+ if (stock_map[code] === undefined) {
+ badge.text("0").removeClass().addClass("stock-badge badge");
+ } else if (stock_map[code] <= 0) {
+ badge.text("0").removeClass().addClass("stock-badge badge");
+ } else {
+ badge.text(stock_map[code]).removeClass().addClass("stock-badge badge");
+ }
+ });
+ },
+ });
+ }
+
+ add_selected_to_grid() {
+ var me = this;
+ var items = Object.keys(this.selected);
+
+ if (!items.length) {
+ frappe.msgprint(__("Please select at least one item."));
+ return;
+ }
+
+ // Build list of rows to add
+ var to_add = items
+ .filter(function (code) {
+ return (me.selected[code] || 0) > 0;
+ })
+ .map(function (code) {
+ return { item_code: code, qty: me.selected[code] };
+ });
+
+ if (!to_add.length) {
+ frappe.msgprint(__("All selected items have qty 0."));
+ return;
+ }
+
+ // Add rows sequentially
+ var chain = Promise.resolve();
+ to_add.forEach(function (row) {
+ chain = chain.then(function () {
+ return me.add_row_to_grid(row.item_code, row.qty);
+ });
+ });
+
+ chain.then(function () {
+ me.dialog.hide();
+ frappe.show_alert(
+ __("Added {0} items", [to_add.length]),
+ "green"
+ );
+ });
+ }
+
+ add_row_to_grid(item_code, qty) {
+ var me = this;
+ return new Promise(function (resolve) {
+ var existing = (me.target.frm.doc[me.target.df.fieldname] || []).find(function (d) {
+ return d[me.item_field] === item_code;
+ });
+
+ if (existing) {
+ frappe.model
+ .set_value(existing.doctype, existing.name, me.qty_field, qty)
+ .then(function () { resolve(); });
+ } else {
+ var d = me.target.add_new_row();
+ // Set item_code first so item details are fetched,
+ // then set qty to prevent it being overwritten by item defaults
+ frappe.timeout(0.1).then(function () {
+ var item_args = {};
+ item_args[me.item_field] = item_code;
+ frappe.model.set_value(d.doctype, d.name, item_args).then(function () {
+ frappe.model.set_value(d.doctype, d.name, me.qty_field, qty).then(function () {
+ resolve();
+ });
+ });
+ });
+ }
+ });
+ }
+};
diff --git a/aqrar_ext/public/js/item_selector_hook.js b/aqrar_ext/public/js/item_selector_hook.js
new file mode 100644
index 0000000..b2ffa72
--- /dev/null
+++ b/aqrar_ext/public/js/item_selector_hook.js
@@ -0,0 +1,43 @@
+// aqrar_ext: Wire ItemMultiSelector into Sales Invoice, Quotation, Custom Quote
+
+var doctypes_with_items = ["Sales Invoice", "Quotation", "Custom Quote"];
+
+doctypes_with_items.forEach(function (doctype) {
+ frappe.ui.form.on(doctype, {
+ refresh: function (frm) {
+ if (!frm.fields_dict.items || !frm.fields_dict.items.grid) return;
+
+ var grid = frm.fields_dict.items.grid;
+ if (grid._aqrar_multisel_hooked) return;
+ grid._aqrar_multisel_hooked = true;
+
+ // Detect which field is the item link
+ var child_doctype = grid.df.options;
+ var item_field = frappe.meta.get_docfield(child_doctype, "item_code")
+ ? "item_code"
+ : frappe.meta.get_docfield(child_doctype, "item")
+ ? "item"
+ : null;
+
+ if (!item_field) return;
+
+ // Only wire up if the item field links to Item doctype
+ var df = frappe.meta.get_docfield(child_doctype, item_field);
+ if (!df || df.fieldtype !== "Link" || df.options !== "Item") return;
+
+ var qty_field = frappe.meta.get_docfield(child_doctype, "qty") ? "qty" : null;
+
+ var btn = $(grid.wrapper).find(".grid-add-multiple-rows");
+ btn.removeClass("hidden");
+ btn.off("click.aqrar").on("click.aqrar", function (e) {
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ new frappe.ui.form.ItemMultiSelector({
+ target: grid,
+ fieldname: item_field,
+ qty_fieldname: qty_field,
+ });
+ });
+ },
+ });
+});
diff --git a/aqrar_ext/public/js/sales_invoice_pos_total_popup.js b/aqrar_ext/public/js/sales_invoice_pos_total_popup.js
index 2fb3e44..b66a221 100644
--- a/aqrar_ext/public/js/sales_invoice_pos_total_popup.js
+++ b/aqrar_ext/public/js/sales_invoice_pos_total_popup.js
@@ -116,7 +116,7 @@ function aqrar_show_pos_total_popup(frm) {
if (frm.doc.pos_profile || !frm.doc.payments || frm.doc.payments.length === 0) {
if (!frm.doc.pos_profile) {
frappe.call({
- method: "aqrar_ext.api.sales_invoice_payment.get_payment_modes_with_account",
+ method: "aqrar_ext.api.sales_invoice.get_payment_modes_with_account",
args: { company: frm.doc.company },
callback: function (res) {
const modes = res.message || [];
@@ -128,8 +128,10 @@ function aqrar_show_pos_total_popup(frm) {
return;
}
+ // Only show Cash mode in the payment popup
+ const cash_mode = modes.filter(function (m) { return m === "Cash"; });
frm.clear_table("payments");
- modes.forEach(function (mode) {
+ cash_mode.forEach(function (mode) {
const row = frm.add_child("payments");
row.mode_of_payment = mode;
});
@@ -166,7 +168,7 @@ function aqrar_show_pos_total_popup(frm) {
profile_payments.forEach((p) => (default_by_mode[p.mode_of_payment] = p.default));
frappe.call({
- method: "aqrar_ext.api.sales_invoice_payment.get_payment_modes_with_account",
+ method: "aqrar_ext.api.sales_invoice.get_payment_modes_with_account",
args: { company: frm.doc.company, mode_list: mode_list },
callback: function (res) {
const valid_modes = res.message || [];
@@ -178,8 +180,10 @@ function aqrar_show_pos_total_popup(frm) {
return;
}
+ // Only show Cash mode in the payment popup
+ const cash_modes = valid_modes.filter(function (m) { return m === "Cash"; });
frm.clear_table("payments");
- valid_modes.forEach(function (mode) {
+ cash_modes.forEach(function (mode) {
const row = frm.add_child("payments");
row.mode_of_payment = mode;
row.default = default_by_mode[mode] || 0;
@@ -503,7 +507,7 @@ function aqrar_render_dialog(frm) {
// If user submitted, create Payment Entries from the popup amounts
if (submit && frm.doc.docstatus === 1) {
frappe.call({
- method: "aqrar_ext.api.sales_invoice_payment.create_pos_payments_for_invoice",
+ method: "aqrar_ext.api.sales_invoice.create_pos_payments_for_invoice",
args: {
sales_invoice: frm.doc.name,
payments: JSON.stringify(payments_payload),