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 @@ + + +
+ + +
+
+

{{ _("Customer Statement") }}

+

{{ customer_doc.customer_name }}

+

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

+
+
+

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

+

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

+

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

+
+
+ + +

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

+ + +
+ + + + + + + + + + + + + + + + + + + + + +
{{ _("Opening Balance") }}{{ frappe.utils.fmt_money(opening, currency=company_currency) }}
{{ _("Total Invoiced") }}{{ frappe.utils.fmt_money(total_invoiced, currency=company_currency) }}
{{ _("Total Paid") }}{{ frappe.utils.fmt_money(total_paid, currency=company_currency) }}
{{ _("Total Credit Notes") }}{{ frappe.utils.fmt_money(total_credit, currency=company_currency) }}
{{ _("Closing Balance") }}{{ frappe.utils.fmt_money(closing, currency=company_currency) }}
+
+ + +
{{ _("Transactions") }}
+ + + + + + + + + + + + + + + + {% for row in data %} + {% set label = row.voucher_no %} + {% if label == _("Opening Balance") %} + + + + + + + {% elif label == _("Totals") %} + + + + + + + + + + {% elif label == _("Closing Balance") %} + + + + + + + {% elif row.voucher_type == "Sales Invoice" %} + + + + + + + + + + + + {% elif row.voucher_type == "Credit Note" %} + + + + + + + + + + + + {% elif row.voucher_type == "Payment Entry" %} + + + + + + + + + + + + {% endif %} + {% endfor %} + +
{{ _("Date") }}{{ _("Type") }}{{ _("Reference") }}{{ _("Due Date") }}{{ _("Age") }}{{ _("Invoiced") }}{{ _("Paid") }}{{ _("Credit Note") }}{{ _("Outstanding") }}
{{ label }}{{ frappe.utils.fmt_money(row.outstanding, currency=company_currency) }}
{{ label }}{{ frappe.utils.fmt_money(row.invoiced, currency=company_currency) }}{{ frappe.utils.fmt_money(row.paid, currency=company_currency) }}{{ frappe.utils.fmt_money(row.credit_note, currency=company_currency) }}{{ frappe.utils.fmt_money(row.outstanding, currency=company_currency) }}
{{ label }}{{ frappe.utils.fmt_money(row.outstanding, currency=company_currency) }}
{{ frappe.utils.formatdate(row.posting_date) }}{{ _(row.voucher_type) }}{{ row.voucher_no }}{{ frappe.utils.formatdate(row.due_date) }}{{ row.age }}{{ frappe.utils.fmt_money(row.invoiced, currency=company_currency) }}{{ frappe.utils.fmt_money(row.paid, currency=company_currency) }}{{ frappe.utils.fmt_money(row.outstanding, currency=company_currency) }}
{{ frappe.utils.formatdate(row.posting_date) }}{{ _(row.voucher_type) }}{{ row.voucher_no }}{{ frappe.utils.formatdate(row.due_date) }}{{ frappe.utils.fmt_money(row.credit_note, currency=company_currency) }}
{{ frappe.utils.formatdate(row.posting_date) }}{{ _(row.voucher_type) }}{{ row.voucher_no }}{{ frappe.utils.formatdate(row.posting_date) }}{{ frappe.utils.fmt_money(row.paid, currency=company_currency) }}
+ + + {% if vat and vat|length > 0 %} +
{{ _("VAT Summary") }}
+ + + + + + + + + + {% for v in vat %} + + + + + + {% endfor %} + + + + + + +
{{ _("VAT Rate") }}{{ _("Taxable Amount") }}{{ _("VAT Amount") }}
{{ v.rate }}%{{ frappe.utils.fmt_money(v.taxable_amount, currency=company_currency) }}{{ frappe.utils.fmt_money(v.tax_amount, currency=company_currency) }}
{{ _("Total") }}{{ frappe.utils.fmt_money(vat_total_taxable, currency=company_currency) }}{{ frappe.utils.fmt_money(vat_total_tax, currency=company_currency) }}
+ {% endif %} + + + {% if aging %} +
{{ _("Outstanding Aging") }}
+ + + + + + + + + + + + + + + + + + + + + +
{{ _("0-30 Days") }}{{ _("31-60 Days") }}{{ _("61-90 Days") }}{{ _("91-120 Days") }}{{ _("120+ Days") }}{{ _("Total Outstanding") }}
{{ frappe.utils.fmt_money(aging["0-30"], currency=company_currency) }}{{ frappe.utils.fmt_money(aging["31-60"], currency=company_currency) }}{{ frappe.utils.fmt_money(aging["61-90"], currency=company_currency) }}{{ frappe.utils.fmt_money(aging["91-120"], currency=company_currency) }}{{ frappe.utils.fmt_money(aging["120+"], currency=company_currency) }}{{ frappe.utils.fmt_money(closing, currency=company_currency) }}
+ {% endif %} + + + + +
diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.js b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.js new file mode 100644 index 0000000..5208028 --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.js @@ -0,0 +1,40 @@ +frappe.query_reports["Customer Statement"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + reqd: 1, + default: frappe.defaults.get_user_default("Company"), + }, + { + fieldname: "customer", + label: __("Customer"), + fieldtype: "Link", + options: "Customer", + reqd: 1, + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + reqd: 1, + default: frappe.datetime.month_start(), + }, + { + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + reqd: 1, + default: frappe.datetime.month_end(), + }, + { + fieldname: "ageing_based_on", + label: __("Ageing Based On"), + fieldtype: "Select", + options: ["Posting Date", "Due Date"], + default: "Posting Date", + }, + ], +}; diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.json b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.json new file mode 100644 index 0000000..ade763e --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.json @@ -0,0 +1,49 @@ +{ + "add_total_row": 0, + "add_translate_data": 0, + "columns": [], + "creation": "2026-05-16 16:33:10.084273", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letter_head": null, + "modified": "2026-05-16 16:33:10.084273", + "modified_by": "Administrator", + "module": "Aqrar Ext", + "name": "Customer Statement", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Customer", + "report_name": "Customer Statement", + "report_type": "Script Report", + "roles": [ + { + "role": "Stock User" + }, + { + "role": "Sales Manager" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Sales User" + }, + { + "role": "Stock Manager" + }, + { + "role": "Accounts User" + }, + { + "role": "Sales Master Manager" + }, + { + "role": "Branch User" + } + ], + "timeout": 0 +} \ No newline at end of file diff --git a/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.py b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.py new file mode 100644 index 0000000..3a5132e --- /dev/null +++ b/aqrar_ext/aqrar_ext/report/customer_statement/customer_statement.py @@ -0,0 +1,299 @@ +import frappe +from frappe import _ +from frappe.utils import flt, getdate, formatdate + + +def execute(filters=None): + if not filters: + filters = {} + if not filters.get("customer"): + return [], [] + + return get_columns(), get_data(filters) + + +def get_columns(): + return [ + {"label": _("Posting Date"), "fieldname": "posting_date", "fieldtype": "Date", "width": 100}, + {"label": _("Voucher Type"), "fieldname": "voucher_type", "fieldtype": "Data", "width": 120}, + {"label": _("Voucher No"), "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", "width": 180}, + {"label": _("Due Date"), "fieldname": "due_date", "fieldtype": "Date", "width": 100}, + {"label": _("Age (Days)"), "fieldname": "age", "fieldtype": "Int", "width": 80}, + {"label": _("Invoiced"), "fieldname": "invoiced", "fieldtype": "Currency", "width": 120}, + {"label": _("Paid"), "fieldname": "paid", "fieldtype": "Currency", "width": 120}, + {"label": _("Credit Note"), "fieldname": "credit_note", "fieldtype": "Currency", "width": 120}, + {"label": _("Outstanding"), "fieldname": "outstanding", "fieldtype": "Currency", "width": 120}, + ] + + +def get_data(filters): + customer = filters.get("customer") + company = filters.get("company") + from_date = filters.get("from_date") + to_date = filters.get("to_date") + ageing_based_on = filters.get("ageing_based_on", "Posting Date") + + receivable_account = _get_receivable_account(customer, company) + opening = _get_opening_balance(customer, company, from_date, receivable_account) + rows = [] + + # Sales Invoices (non-return) + invoices = _get_sales_invoices(customer, company, from_date, to_date, is_return=0) + for inv in invoices: + paid = _get_paid_amount(inv.name) + age_date = inv.due_date if ageing_based_on == "Due Date" else inv.posting_date + age = (getdate(to_date) - getdate(age_date)).days if getdate(age_date) <= getdate(to_date) else 0 + outstanding = flt(inv.grand_total) - flt(paid) + rows.append({ + "posting_date": inv.posting_date, + "voucher_type": "Sales Invoice", + "voucher_no": inv.name, + "due_date": inv.due_date, + "age": age, + "invoiced": flt(inv.grand_total), + "paid": paid, + "credit_note": 0, + "outstanding": outstanding, + }) + + # Credit Notes (Sales Returns) + credit_notes = _get_sales_invoices(customer, company, from_date, to_date, is_return=1) + for cn in credit_notes: + rows.append({ + "posting_date": cn.posting_date, + "voucher_type": "Credit Note", + "voucher_no": cn.name, + "due_date": cn.due_date, + "age": 0, + "invoiced": 0, + "paid": 0, + "credit_note": abs(flt(cn.grand_total)), + "outstanding": 0, + }) + + # Payment Entries + payments = _get_payment_entries(customer, company, from_date, to_date) + for pe in payments: + rows.append({ + "posting_date": pe.posting_date, + "voucher_type": "Payment Entry", + "voucher_no": pe.name, + "due_date": pe.posting_date, + "age": 0, + "invoiced": 0, + "paid": flt(pe.paid_amount), + "credit_note": 0, + "outstanding": 0, + }) + + rows.sort(key=lambda r: r["posting_date"]) + + # Totals + total_invoiced = sum(r["invoiced"] for r in rows) + total_paid = sum(r["paid"] for r in rows) + total_credit_note = sum(r["credit_note"] for r in rows) + closing = flt(opening) + total_invoiced - total_paid - total_credit_note + + # Prepend opening + rows.insert(0, { + "posting_date": None, + "voucher_type": "", + "voucher_no": _("Opening Balance"), + "due_date": None, + "age": 0, + "invoiced": 0, + "paid": 0, + "credit_note": 0, + "outstanding": flt(opening), + }) + + # Totals row + rows.append({ + "posting_date": None, + "voucher_type": "", + "voucher_no": _("Totals"), + "due_date": None, + "age": 0, + "invoiced": total_invoiced, + "paid": total_paid, + "credit_note": total_credit_note, + "outstanding": closing, + }) + + # Closing row + rows.append({ + "posting_date": None, + "voucher_type": "", + "voucher_no": _("Closing Balance"), + "due_date": None, + "age": 0, + "invoiced": 0, + "paid": 0, + "credit_note": 0, + "outstanding": closing, + }) + + return rows + + +def _get_receivable_account(customer, company): + acc = frappe.db.get_value("Party Account", { + "parenttype": "Customer", "parent": customer, "company": company + }, "account") + if not acc: + acc = frappe.db.get_value("Company", company, "default_receivable_account") + return acc + + +def _get_opening_balance(customer, company, from_date, receivable_account): + result = frappe.db.sql(""" + SELECT SUM(debit) - SUM(credit) + FROM `tabGL Entry` + WHERE party_type = 'Customer' + AND party = %s + AND company = %s + AND account = %s + AND posting_date < %s + AND is_cancelled = 0 + """, (customer, company, receivable_account, from_date)) + return flt(result[0][0]) if result and result[0][0] else 0.0 + + +def _get_sales_invoices(customer, company, from_date, to_date, is_return=0): + return frappe.db.get_all("Sales Invoice", filters={ + "customer": customer, + "company": company, + "docstatus": 1, + "is_return": is_return, + "posting_date": ["between", [from_date, to_date]], + }, fields=["name", "posting_date", "due_date", "grand_total"], order_by="posting_date") + + +def _get_paid_amount(invoice_name): + result = frappe.db.sql(""" + SELECT SUM(per.allocated_amount) + FROM `tabPayment Entry Reference` per + INNER JOIN `tabPayment Entry` pe ON pe.name = per.parent + WHERE per.reference_name = %s + AND per.reference_doctype = 'Sales Invoice' + AND pe.docstatus = 1 + """, (invoice_name,)) + return flt(result[0][0]) if result and result[0][0] else 0.0 + + +def _get_payment_entries(customer, company, from_date, to_date): + return frappe.db.get_all("Payment Entry", filters={ + "party_type": "Customer", + "party": customer, + "company": company, + "docstatus": 1, + "posting_date": ["between", [from_date, to_date]], + "payment_type": "Receive", + }, fields=["name", "posting_date", "paid_amount"], order_by="posting_date") + + +@frappe.whitelist() +def get_pdf(customer, company=None, from_date=None, to_date=None): + """Generate statement PDF for download.""" + if not company: + company = frappe.defaults.get_user_default("Company") + + filters = frappe._dict({ + "customer": customer, + "company": company, + "from_date": from_date, + "to_date": to_date, + }) + _columns, data = execute(filters) + if not data: + frappe.throw(_("No transactions found for this customer in the selected period.")) + + customer_doc = frappe.get_doc("Customer", customer) + company_currency = frappe.db.get_value("Company", company, "default_currency") + + aging = _get_aging(data, to_date, "Posting Date") + vat = _get_vat_summary(customer, company, from_date, to_date) + vat_total_taxable = sum(flt(v.taxable_amount) for v in vat) + vat_total_tax = sum(flt(v.tax_amount) for v in vat) + + opening = flt(data[0]["outstanding"]) if data else 0 + closing = flt(data[-1]["outstanding"]) if data else 0 + total_invoiced = total_paid = total_credit = 0 + for row in data: + if row.get("voucher_no") == _("Totals"): + total_invoiced = flt(row.get("invoiced", 0)) + total_paid = flt(row.get("paid", 0)) + total_credit = flt(row.get("credit_note", 0)) + break + + template_path = frappe.get_app_path( + "aqrar_ext", "aqrar_ext", "report", "customer_statement", "customer_statement.html" + ) + with open(template_path) as f: + template_str = f.read() + + html = frappe.get_jenv().from_string(template_str).render({ + "data": data, + "customer_doc": customer_doc, + "company": company, + "company_currency": company_currency, + "from_date": from_date, + "to_date": to_date, + "opening": opening, + "closing": closing, + "total_invoiced": total_invoiced, + "total_paid": total_paid, + "total_credit": total_credit, + "aging": aging, + "vat": vat, + "vat_total_taxable": vat_total_taxable, + "vat_total_tax": vat_total_tax, + "print_date": formatdate(frappe.utils.today()), + }) + + from frappe.utils.pdf import get_pdf as _get_pdf + pdf = _get_pdf(html, {"orientation": "Portrait"}) + + frappe.local.response.filename = f"Customer_Statement_{customer}.pdf" + frappe.local.response.filecontent = pdf + frappe.local.response.type = "download" + + +def _get_aging(rows, to_date, ageing_based_on): + buckets = {"0-30": 0.0, "31-60": 0.0, "61-90": 0.0, "91-120": 0.0, "120+": 0.0} + for row in rows: + if row.get("voucher_type") != "Sales Invoice": + continue + outstanding = row.get("outstanding", 0) + if outstanding <= 0: + continue + age = row.get("age", 0) + if age <= 30: + buckets["0-30"] += outstanding + elif age <= 60: + buckets["31-60"] += outstanding + elif age <= 90: + buckets["61-90"] += outstanding + elif age <= 120: + buckets["91-120"] += outstanding + else: + buckets["120+"] += outstanding + return buckets + + +def _get_vat_summary(customer, company, from_date, to_date): + return frappe.db.sql(""" + SELECT + stc.rate, + SUM(stc.tax_amount) AS tax_amount, + SUM(si.net_total) AS taxable_amount + FROM `tabSales Taxes and Charges` stc + INNER JOIN `tabSales Invoice` si ON stc.parent = si.name + WHERE si.customer = %s + AND si.company = %s + AND si.posting_date BETWEEN %s AND %s + AND si.docstatus = 1 + AND si.is_return = 0 + GROUP BY stc.rate + ORDER BY stc.rate + """, (customer, company, from_date, to_date), as_dict=True) diff --git a/aqrar_ext/aqrar_ext/report/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 = ` +
+ + + + + + + + + + + + + + + + + + + + + `; + + function build_row(r) { + return ` + + + + + + + + + `; + } + + rows.forEach(function (r) { + html += build_row(r); + }); + + html += ` + +
${__("Item Code")}${__("Item Name")}${party_col}${rate_col}${__("Qty")}${last_col}
${r.item_code}${r.item_name || ""}${r.party || r.customer || ""}${format_currency(r.rate)}${r.qty}${format_currency(last_price)}
+
+ `; + + $wrapper.html(html); + + // Filter logic + var $table = $wrapper.find(".price-history-table-wrap"); + + $table.on("input", ".ph-filter", function () { + var filters = []; + $table.find(".ph-filter").each(function () { + filters.push($(this).val().toLowerCase().trim()); + }); + + var tbody_html = ""; + var count = 0; + + rows.forEach(function (r) { + var cols = [ + (r.item_code || "").toLowerCase(), + (r.item_name || "").toLowerCase(), + (r.party || r.customer || "").toLowerCase(), + format_currency(r.rate).toLowerCase(), + String(r.qty || 0).toLowerCase(), + format_currency(last_price).toLowerCase(), + ]; + + var match = true; + for (var i = 0; i < 6; i++) { + if (filters[i] && cols[i].indexOf(filters[i]) === -1) { + match = false; + break; + } + } + + if (match) { + tbody_html += build_row(r); + count++; + } + }); + + if (!count) { + tbody_html = '' + __("No matching records") + ""; + } + + $table.find(".ph-tbody").html(tbody_html); + }); +} + +function update_all_last_prices(frm, config) { + + if (frm.doc.docstatus !== 0) return; + + (frm.doc.items || []).forEach(row => { + + if (row.item_code) { + update_row_last_price(frm, row, config); + } + }); +} + +function update_row_last_price(frm, row, config) { + + // Do not modify submitted/cancelled documents + if (frm.doc.docstatus !== 0) return; + + const party = frm.doc[config.customer_field]; + + frappe.call({ + method: "aqrar_ext.api.get_last_sold_price", + args: { + customer: party, + item_code: row.item_code, + source: config.source + }, + callback: function(r) { + + if (!r.message) return; + + frappe.model.set_value( + row.doctype, + row.name, + "custom_last_price", + r.message.last_price || 0 + ); + } + }); +} + +function show_price_popup(frm, row, config) { + + $(".customer-price-popup").remove(); + + const party = frm.doc[config.customer_field]; + + frappe.call({ + method: "aqrar_ext.api.get_item_insights", + args: { + customer: party, + item_code: row.item_code, + company: frm.doc.company, + source: config.source + }, + callback: function(r) { + + render_popup(frm, row, r.message || {}); + } + }); +} + +function render_popup(frm, row, data) { + + $(".customer-price-popup").remove(); + + row._popup_opened = true; + + const stock = data.stock || []; + const history = data.price_history || []; + const purchase_rate = data.last_purchase_rate || 0; + + let html = ` +
+ +
+
+ ${row.item_name || row.item_code} +
+ +
+ ✕ +
+
+ +
+ +
+ Last Purchase Rate +
+ +
+ ${purchase_rate} +
+ +
+ `; + + html += ` +
+ +
+ Stock By Warehouse +
+ `; + + stock.forEach(s => { + + html += ` +
+ ${s.warehouse} + ${s.projected_qty} +
+ `; + }); + + html += `
`; + + html += ` +
+ +
+ Customer Price History +
+ `; + + history.forEach(h => { + + html += ` +
+ +
+ ${format_currency(h.rate)} +
+ +
+ ${h.customer} +
+ +
+ ${frappe.datetime.str_to_user(h.posting_date)} +
+ +
+ `; + }); + + html += `
`; + + const $popup = $(html).appendTo("body"); + + const $row = $(`.grid-row[data-name="${row.name}"]`); + + if ($row.length) { + + const pos = $row.offset(); + const popup_h = 450; // approximate popup height + const popup_w = 430; + const win_h = $(window).height(); + const win_w = $(window).width(); + + let top = pos.top + 40; + let left = pos.left + 250; + + // Clamp to viewport — flip above row if near bottom + if (top + popup_h > win_h + $(window).scrollTop()) { + top = pos.top - popup_h - 10; + } + if (left + popup_w > win_w) { + left = win_w - popup_w - 20; + } + + $popup.css({ + top: top, + left: left + }); + } + + $popup.find(".cpp-close").on("click", function () { + + $(".customer-price-popup").remove(); + + row._popup_opened = false; + }); +} + +// aqrar_ext: Simplified Sales Invoice for Branch Users +frappe.ui.form.on("Sales Invoice", { + refresh(frm) { + if (!frappe.user.has_role("Branch User") || frappe.user.has_role("System Manager") || frappe.user.has_role("Stock Manager") || frm._branch_setup_done) return; + frm._branch_setup_done = true; + + // Hide unnecessary fields + [ + "posting_time", "set_posting_time", "due_date", + "is_pos", "pos_profile", "is_return", "is_debit_note", + "return_against", "amended_from", "scan_barcode", + "currency", "conversion_rate", "selling_price_list", "price_list_currency", + "plc_conversion_rate", "ignore_pricing_rule", + "apply_discount_on", "additional_discount_percentage", "discount_amount", + "additional_discount_account", "base_discount_amount", + "tax_category", "taxes_and_charges", "shipping_rule", "incoterm", "named_place", + "taxes", "total_taxes_and_charges", "base_total_taxes_and_charges", + "update_stock", "set_warehouse", "set_target_warehouse", + "po_no", "po_date", "commission_rate", "total_commission", "sales_partner", + "amount_eligible_for_commission", + "is_cash_or_non_trade_discount", + ].forEach(function (f) { frm.set_df_property(f, "hidden", 1); }); + + // Hide sections + [ + "accounting_dimensions_section", "currency_and_price_list", + "section_break_49", "taxes_section", "customer_po_details", + "more_info", "sales_team_section_break", "section_break2", + "edit_printing_settings", "more_information", "subscription_section", + ].forEach(function (s) { frm.set_df_property(s, "hidden", 1); }); + + // Hide tabs + ["payments_tab", "contact_and_address_tab", "terms_tab", "more_info_tab"] + .forEach(function (t) { frm.set_df_property(t, "hidden", 1); }); + + // naming_series — force hide via DOM (set_only_once blocks set_df_property) + frm.set_df_property("naming_series", "reqd", 0); + frm.set_df_property("naming_series", "hidden", 1); + $(frm.fields_dict.naming_series.wrapper).hide(); + + // Company read-only + frm.set_df_property("company", "read_only", 1); + + // Payment Mode required + frm.set_df_property("custom_payment_mode", "reqd", 1); + + // Auto-fill cost_center from Branch Configuration + if (!frm.doc.cost_center) { + frappe.call({ + method: "aqrar_ext.api.branch_config.get_user_branch_defaults", + callback: function (r) { + if (r.message && r.message.cost_center) { + frm.set_value("cost_center", r.message.cost_center); + } + if (r.message && r.message.warehouse && !frm.doc.set_warehouse) { + frm.set_value("set_warehouse", r.message.warehouse); + } + }, + }); + } + }, +}); + +$(document).on("click", function(e) { + + if ($(e.target).closest(".customer-price-popup").length) return; + + if ($(e.target).closest(".grid-row").length) return; + + $(".customer-price-popup").remove(); +}); + +$(` + + + +`).appendTo("head"); \ No newline at end of file diff --git a/aqrar_ext/public/js/customer_statement.js b/aqrar_ext/public/js/customer_statement.js new file mode 100644 index 0000000..4eaa187 --- /dev/null +++ b/aqrar_ext/public/js/customer_statement.js @@ -0,0 +1,71 @@ +// aqrar_ext: "Send Statement" button on Customer form +frappe.ui.form.on("Customer", { + refresh(frm) { + if (frm.doc.__islocal) return; + + frm.add_custom_button( + __("Send Statement"), + function () { + show_statement_dialog(frm); + }, + __("View") + ); + }, +}); + +function show_statement_dialog(frm) { + const d = new frappe.ui.Dialog({ + title: __("Customer Statement"), + size: "small", + fields: [ + { + fieldtype: "Date", + fieldname: "from_date", + label: __("From Date"), + reqd: 1, + default: frappe.datetime.month_start(), + }, + { + fieldtype: "Date", + fieldname: "to_date", + label: __("To Date"), + reqd: 1, + default: frappe.datetime.month_end(), + }, + ], + primary_action_label: __("Download PDF"), + primary_action(values) { + const { from_date, to_date } = values; + if (from_date > to_date) { + frappe.msgprint(__("From Date cannot be after To Date")); + return; + } + d.hide(); + const url = + "/api/method/aqrar_ext.aqrar_ext.report.customer_statement.customer_statement.get_pdf" + + "?customer=" + + encodeURIComponent(frm.doc.name) + + "&from_date=" + + encodeURIComponent(from_date) + + "&to_date=" + + encodeURIComponent(to_date); + window.open(url); + }, + secondary_action_label: __("View Report"), + secondary_action() { + const vals = d.get_values(); + if (!vals) return; + if (vals.from_date > vals.to_date) { + frappe.msgprint(__("From Date cannot be after To Date")); + return; + } + d.hide(); + frappe.set_route("query-report", "Customer Statement", { + customer: frm.doc.name, + from_date: vals.from_date, + to_date: vals.to_date, + }); + }, + }); + d.show(); +} diff --git a/aqrar_ext/public/js/item_selector.js b/aqrar_ext/public/js/item_selector.js new file mode 100644 index 0000000..72c206f --- /dev/null +++ b/aqrar_ext/public/js/item_selector.js @@ -0,0 +1,305 @@ +// aqrar_ext: Multi-select item picker with running search and quantity +// Replaces the stock "Add Multiple" LinkSelector for item grids. + +frappe.ui.form.ItemMultiSelector = class ItemMultiSelector { + constructor(opts) { + this.target = opts.target; // the grid object + this.item_field = opts.fieldname; // typically "item_code" + this.qty_field = opts.qty_fieldname; // typically "qty" + this.get_query = opts.get_query; + this.start = 0; + this.page_length = 20; + this.selected = {}; // { item_code: qty } + this.make(); + } + + make() { + var me = this; + + this.dialog = new frappe.ui.Dialog({ + title: __("Select Items"), + fields: [ + { + fieldtype: "Data", + fieldname: "search_txt", + label: __("Search Items"), + placeholder: __("Type to search..."), + onchange: function () { + me.debounced_search(); + }, + }, + { + fieldtype: "HTML", + fieldname: "results_area", + }, + ], + primary_action_label: __("Add Selected Items"), + primary_action: function () { + me.add_selected_to_grid(); + }, + }); + + this.dialog.show(); + this.search(); + } + + debounced_search() { + if (this._search_timeout) clearTimeout(this._search_timeout); + var me = this; + this._search_timeout = setTimeout(function () { + me.start = 0; + me.search(); + }, 300); + } + + search() { + var me = this; + var txt = this.dialog.fields_dict.search_txt.get_value() || ""; + + var args = { + txt: txt, + searchfield: "name", + start: this.start, + page_length: this.page_length, + }; + + // Apply custom query filters from the grid field + if ( + this.target.is_grid && + this.target.fieldinfo && + this.target.fieldinfo[this.item_field] && + this.target.fieldinfo[this.item_field].get_query + ) { + $.extend(args, this.target.fieldinfo[this.item_field].get_query(cur_frm.doc)); + } + + frappe.link_search("Item", args, function (results) { + me.render_results(results, args.start > 0); + }); + } + + render_results(results, append) { + var parent = this.dialog.fields_dict.results_area.$wrapper; + + if (!append) { + parent.empty(); + } + + if (!results.length && !append) { + parent.html( + '

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

" + ); + return; + } + + // Remove old Load More button before adding new rows + parent.find(".load-more").remove(); + + if (!append) { + // Build table header + var header = $( + '
' + + '' + + '' + __("Item") + '' + + '' + __("Description") + '' + + '' + __("Stock") + '' + + '' + __("Qty") + '' + + '
' + ).appendTo(parent); + var list = $('
').appendTo(parent); + } else { + // Append to existing row container + var list = parent.find(".item-selector-rows"); + } + var me = this; + + // Collect item codes for batch stock lookup + var item_codes = results.map(function (r) { return r[0]; }); + + // Render each row + results.forEach(function (r) { + var item_code = r[0]; + var item_name = r[1] || ""; + var checked_attr = me.selected[item_code] !== undefined ? "checked" : ""; + var qty_val = me.selected[item_code] || 1; + + var row = $( + '
' + + '' + + '' + + '' + + '' + item_code + '' + + '' + item_name + '' + + '' + + '...' + + '' + + '' + + '' + + '' + + '
' + ).appendTo(list); + + // Checkbox click + row.find(".item-check").on("change", function () { + var code = $(this).attr("data-item"); + if (this.checked) { + var qty = parseFloat(row.find(".item-qty").val()) || 1; + me.selected[code] = qty; + } else { + delete me.selected[code]; + } + }); + + // Qty change + row.find(".item-qty").on("change input", function () { + var code = $(this).attr("data-item"); + var val = parseFloat($(this).val()) || 0; + if (row.find(".item-check").is(":checked")) { + me.selected[code] = val; + } + }); + }); + + // Load stock info for all items + this.load_stock_info(item_codes); + + // Load More button + if (results.length >= this.page_length) { + var me = this; + $( + '" + ) + .appendTo(parent) + .on("click", function () { + me.start += me.page_length; + me.search(); + }); + } + } + + load_stock_info(item_codes) { + if (!item_codes.length) return; + var me = this; + + var warehouse = ( + cur_frm.doc.set_warehouse || + cur_frm.doc.set_source_warehouse || + "" + ); + + var filters = { item_code: ["in", item_codes] }; + if (warehouse) { + filters.warehouse = warehouse; + } + + frappe.call({ + method: "frappe.client.get_list", + args: { + doctype: "Bin", + fields: ["item_code", "actual_qty"], + filters: filters, + limit_page_length: 500, + }, + callback: function (r) { + if (!r.message) { + // API failed — show "--" for all items + item_codes.forEach(function (code) { + var badge = me.dialog.$wrapper.find('.stock-badge[data-item="' + code + '"]'); + badge.text("--").removeClass().addClass("stock-badge badge"); + }); + return; + } + // Sum actual_qty per item_code (across warehouses when no filter) + var stock_map = {}; + r.message.forEach(function (b) { + var code = b.item_code; + stock_map[code] = (stock_map[code] || 0) + (b.actual_qty || 0); + }); + item_codes.forEach(function (code) { + var badge = me.dialog.$wrapper.find('.stock-badge[data-item="' + code + '"]'); + if (stock_map[code] === undefined) { + badge.text("0").removeClass().addClass("stock-badge badge"); + } else if (stock_map[code] <= 0) { + badge.text("0").removeClass().addClass("stock-badge badge"); + } else { + badge.text(stock_map[code]).removeClass().addClass("stock-badge badge"); + } + }); + }, + }); + } + + add_selected_to_grid() { + var me = this; + var items = Object.keys(this.selected); + + if (!items.length) { + frappe.msgprint(__("Please select at least one item.")); + return; + } + + // Build list of rows to add + var to_add = items + .filter(function (code) { + return (me.selected[code] || 0) > 0; + }) + .map(function (code) { + return { item_code: code, qty: me.selected[code] }; + }); + + if (!to_add.length) { + frappe.msgprint(__("All selected items have qty 0.")); + return; + } + + // Add rows sequentially + var chain = Promise.resolve(); + to_add.forEach(function (row) { + chain = chain.then(function () { + return me.add_row_to_grid(row.item_code, row.qty); + }); + }); + + chain.then(function () { + me.dialog.hide(); + frappe.show_alert( + __("Added {0} items", [to_add.length]), + "green" + ); + }); + } + + add_row_to_grid(item_code, qty) { + var me = this; + return new Promise(function (resolve) { + var existing = (me.target.frm.doc[me.target.df.fieldname] || []).find(function (d) { + return d[me.item_field] === item_code; + }); + + if (existing) { + frappe.model + .set_value(existing.doctype, existing.name, me.qty_field, qty) + .then(function () { resolve(); }); + } else { + var d = me.target.add_new_row(); + // Set item_code first so item details are fetched, + // then set qty to prevent it being overwritten by item defaults + frappe.timeout(0.1).then(function () { + var item_args = {}; + item_args[me.item_field] = item_code; + frappe.model.set_value(d.doctype, d.name, item_args).then(function () { + frappe.model.set_value(d.doctype, d.name, me.qty_field, qty).then(function () { + resolve(); + }); + }); + }); + } + }); + } +}; diff --git a/aqrar_ext/public/js/item_selector_hook.js b/aqrar_ext/public/js/item_selector_hook.js new file mode 100644 index 0000000..b2ffa72 --- /dev/null +++ b/aqrar_ext/public/js/item_selector_hook.js @@ -0,0 +1,43 @@ +// aqrar_ext: Wire ItemMultiSelector into Sales Invoice, Quotation, Custom Quote + +var doctypes_with_items = ["Sales Invoice", "Quotation", "Custom Quote"]; + +doctypes_with_items.forEach(function (doctype) { + frappe.ui.form.on(doctype, { + refresh: function (frm) { + if (!frm.fields_dict.items || !frm.fields_dict.items.grid) return; + + var grid = frm.fields_dict.items.grid; + if (grid._aqrar_multisel_hooked) return; + grid._aqrar_multisel_hooked = true; + + // Detect which field is the item link + var child_doctype = grid.df.options; + var item_field = frappe.meta.get_docfield(child_doctype, "item_code") + ? "item_code" + : frappe.meta.get_docfield(child_doctype, "item") + ? "item" + : null; + + if (!item_field) return; + + // Only wire up if the item field links to Item doctype + var df = frappe.meta.get_docfield(child_doctype, item_field); + if (!df || df.fieldtype !== "Link" || df.options !== "Item") return; + + var qty_field = frappe.meta.get_docfield(child_doctype, "qty") ? "qty" : null; + + var btn = $(grid.wrapper).find(".grid-add-multiple-rows"); + btn.removeClass("hidden"); + btn.off("click.aqrar").on("click.aqrar", function (e) { + e.stopImmediatePropagation(); + e.preventDefault(); + new frappe.ui.form.ItemMultiSelector({ + target: grid, + fieldname: item_field, + qty_fieldname: qty_field, + }); + }); + }, + }); +}); diff --git a/aqrar_ext/public/js/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),