From df63b036e391912880a587e60deb9a015c42dcbc Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Tue, 14 Jul 2026 12:20:41 +0000 Subject: [PATCH] feat(sales-order): multi tax-article picker, mirroring Sales Invoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sales Order had a single visible `tax_article` link field while Sales Invoice had long since moved to a multi-select: several articles per item row, held in a parent-level table and tied to the row by a generated key. The two had to match, so Sales Order now gets the same picker — and the same exclusions. Rather than copy 250 lines of dialog code into a second file that would drift, the behaviour moves to item_tax_articles.js, which takes the doctype, the item doctype, the child doctype, the table field and the row-key field as config. sales_invoice_tax_articles.js and sales_order_tax_articles.js are now shims that call .init() with their own config. Same split server-side: item_tax_articles.py holds the validate logic, the two custom/*_tax_articles.py are entry points. Sales Order keeps its own child DocType rather than reusing the invoice's. The downstream consumers (VAT Allocation, the tax reports) anchor their joins on `tabSales Invoice`, so sharing one table would be safe today — but a single future query written without that join would pull sales orders into a filed tax return, and that is not a trap worth leaving armed. Articles ride into the invoice on conversion: get_mapped_doc only walks the standard field map, so make_sales_invoice is wrapped to carry them across, matching order rows to invoice rows via `so_detail`. Row keys are regenerated, not copied — they are no_copy and the two documents key their rows independently. `Sales Order Item.tax_article` becomes hidden/read-only/deprecated, exactly as on Sales Invoice. Nothing referenced it (the table is empty), and vat_calculator.js — which drove it, and which still serves any doctype using the plain link field — now skips Sales Order the same way it already skipped Sales Invoice, so it no longer clears a hidden field and dirties the form on every refresh. The 302.6 / 302.6.1 exclusion added for Sales Invoice was reachable around: the dialog filtered them out, but the collapsible "Item Tax Articles" table on the parent let you pick them directly. Both child DocTypes now carry the same `not like 302.6%` in link_filters, which is what guards that path. Verified end-to-end against the live site (rolled back): an order with two articles converts to an invoice that keeps both, under a fresh row key, surviving validate. Both forms checked headless — shared module loads, cells render, legacy field hidden, no JS errors, and Sales Invoice is unregressed by the refactor. Co-Authored-By: Claude Opus 4.8 (1M context) --- jey_erp/custom/item_tax_articles.py | 63 ++++ jey_erp/custom/sales_invoice_tax_articles.py | 66 +--- jey_erp/custom/sales_order_tax_articles.py | 77 +++++ jey_erp/custom_fields.py | 44 ++- jey_erp/hooks.py | 15 +- .../sales_invoice_tax_article.json | 2 +- .../sales_order_tax_article/__init__.py | 0 .../sales_order_tax_article.json | 41 +++ .../sales_order_tax_article.py | 8 + jey_erp/patches.txt | 3 +- .../add_tax_articles_to_so_grid_views.py | 56 ++++ jey_erp/public/js/item_tax_articles.js | 282 ++++++++++++++++++ .../public/js/sales_invoice_tax_articles.js | 244 +-------------- jey_erp/public/js/sales_order_tax_articles.js | 19 ++ jey_erp/public/js/vat_calculator.js | 11 +- 15 files changed, 636 insertions(+), 295 deletions(-) create mode 100644 jey_erp/custom/item_tax_articles.py create mode 100644 jey_erp/custom/sales_order_tax_articles.py create mode 100644 jey_erp/jey_erp/doctype/sales_order_tax_article/__init__.py create mode 100644 jey_erp/jey_erp/doctype/sales_order_tax_article/sales_order_tax_article.json create mode 100644 jey_erp/jey_erp/doctype/sales_order_tax_article/sales_order_tax_article.py create mode 100644 jey_erp/patches/add_tax_articles_to_so_grid_views.py create mode 100644 jey_erp/public/js/item_tax_articles.js create mode 100644 jey_erp/public/js/sales_order_tax_articles.js diff --git a/jey_erp/custom/item_tax_articles.py b/jey_erp/custom/item_tax_articles.py new file mode 100644 index 0000000..85dcd44 --- /dev/null +++ b/jey_erp/custom/item_tax_articles.py @@ -0,0 +1,63 @@ +# Multi tax-article per item row — shared server-side foundation. +# +# Source of truth for the (possibly multiple) tax articles of a line is a parent-level +# table (Frappe has no nested child tables), whose rows point back at an item row by a +# generated row key. Sales Invoice and Sales Order each have their own child DocType +# and row-key field; everything else is identical, so the logic lives here once. +# +# The validate hook: +# 1. guarantees every item row has a stable row key (so SQL joins from the item table +# to the article table are reliable), +# 2. drops ONLY orphaned article rows (whose parent_row matches no item row) — the +# user's selection is otherwise kept regardless of tax template, +# 3. refreshes the `custom_tax_articles_display` text (the grid cell summary). +# +# It deliberately does NOT write the legacy single `tax_article` field: the consumers +# (VAT Allocation, tax reports) JOIN the child table, so that field is unused — and +# writing it fought vat_calculator.js (which clears it on refresh for non-0% lines), +# leaving the form perpetually dirty. + +import frappe + +DISPLAY_FIELD = "custom_tax_articles_display" +TABLE_FIELD = "custom_item_tax_articles" + + +def sync(doc, row_key_field): + """Keep item rows, their row keys and their article rows consistent on save.""" + # 1) ensure each item row has a unique, stable row key (links it to its articles) + seen = set() + for item in doc.get("items", []): + key = item.get(row_key_field) + if not key or key in seen: + key = "r" + frappe.generate_hash(length=10) + item.set(row_key_field, key) + seen.add(key) + + # 2) keep every article row that points at a real item row; drop only orphans. + # NOTE: we intentionally do NOT drop by item_tax_template — the user's selection + # must survive saving even if the template is empty/non-0%. + item_keys = {item.get(row_key_field) for item in doc.get("items", [])} + rows = [ + r + for r in (doc.get(TABLE_FIELD) or []) + if r.get("parent_row") in item_keys and r.get("tax_article") + ] + doc.set(TABLE_FIELD, rows) + + # 3) refresh the per-row "Tax Articles" display label + by_row = {} + for r in rows: + by_row.setdefault(r.parent_row, []).append(r.tax_article) + + for item in doc.get("items", []): + articles = by_row.get(item.get(row_key_field), []) + if hasattr(item, DISPLAY_FIELD): + item.set(DISPLAY_FIELD, display_label(len(articles))) + + +def display_label(n: int) -> str: + """Short summary shown in the grid cell (avoids the Data field length limit).""" + if n: + return frappe._("{0} selected").format(n) + return frappe._("Click to select") diff --git a/jey_erp/custom/sales_invoice_tax_articles.py b/jey_erp/custom/sales_invoice_tax_articles.py index d7c4e55..1224be8 100644 --- a/jey_erp/custom/sales_invoice_tax_articles.py +++ b/jey_erp/custom/sales_invoice_tax_articles.py @@ -1,61 +1,15 @@ -# Sales Invoice multi tax-article — server-side foundation. +# Sales Invoice binding for the shared multi tax-article foundation. +# All logic lives in item_tax_articles.py — this file is just the validate entry point. # -# Source of truth for the (possibly multiple) tax articles of an invoice line is -# the parent table `custom_item_tax_articles` (child DocType "Sales Invoice Tax -# Article"), whose rows are linked to an item row by `custom_si_row_key`. -# -# This validate hook: -# 1. guarantees every item row has a stable `custom_si_row_key` (so SQL joins -# from `Sales Invoice Item` to `Sales Invoice Tax Article` are reliable), -# 2. drops ONLY orphaned article rows (whose parent_row matches no item row) — -# the user's selection is otherwise kept regardless of tax template, -# 3. refreshes the `custom_tax_articles_display` text (the grid cell summary). -# -# It deliberately does NOT write the legacy single `tax_article` field: after -# Phase 2 the consumers (VAT Allocation, tax reports) JOIN the child table, so the -# single field is unused on Sales Invoice — and writing it fought vat_calculator.js -# (which clears it on refresh for non-0% lines), leaving the form perpetually dirty. +# Child DocType: "Sales Invoice Tax Article", parent table `custom_item_tax_articles`, +# rows linked to an item row by `custom_si_row_key`. -import frappe +from jey_erp.custom.item_tax_articles import sync + +ROW_KEY = "custom_si_row_key" def sync_item_tax_articles(doc, method=None): - if doc.doctype != "Sales Invoice": - return - - # 1) ensure each item row has a unique, stable row key (links it to its articles) - seen = set() - for item in doc.get("items", []): - key = item.get("custom_si_row_key") - if not key or key in seen: - key = "r" + frappe.generate_hash(length=10) - item.custom_si_row_key = key - seen.add(key) - - # 2) keep every article row that points at a real item row; drop only orphans. - # NOTE: we intentionally do NOT drop by item_tax_template — the user's - # selection must survive saving even if the template is empty/non-0%. - item_keys = {item.custom_si_row_key for item in doc.get("items", [])} - rows = [ - r - for r in (doc.get("custom_item_tax_articles") or []) - if r.get("parent_row") in item_keys and r.get("tax_article") - ] - doc.custom_item_tax_articles = rows - - # 3) refresh the per-row "Tax Articles" display label - by_row = {} - for r in rows: - by_row.setdefault(r.parent_row, []).append(r.tax_article) - - for item in doc.get("items", []): - articles = by_row.get(item.custom_si_row_key, []) - if hasattr(item, "custom_tax_articles_display"): - item.custom_tax_articles_display = _display_label(len(articles)) - - -def _display_label(n: int) -> str: - """Short summary shown in the grid cell (avoids the Data field length limit).""" - if n: - return frappe._("{0} selected").format(n) - return frappe._("Click to select") + if doc.doctype != "Sales Invoice": + return + sync(doc, ROW_KEY) diff --git a/jey_erp/custom/sales_order_tax_articles.py b/jey_erp/custom/sales_order_tax_articles.py new file mode 100644 index 0000000..dfaa095 --- /dev/null +++ b/jey_erp/custom/sales_order_tax_articles.py @@ -0,0 +1,77 @@ +# Sales Order binding for the shared multi tax-article foundation, plus the carry-over +# into the Sales Invoice when an order is converted. +# +# Child DocType: "Sales Order Tax Article", parent table `custom_item_tax_articles`, +# rows linked to an item row by `custom_so_row_key`. + +import frappe + +from jey_erp.custom.item_tax_articles import display_label, sync + +ROW_KEY = "custom_so_row_key" +SI_ROW_KEY = "custom_si_row_key" +TABLE_FIELD = "custom_item_tax_articles" + + +def sync_item_tax_articles(doc, method=None): + if doc.doctype != "Sales Order": + return + sync(doc, ROW_KEY) + + +@frappe.whitelist() +def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): + """ERPNext's Sales Order -> Sales Invoice mapper, plus the tax articles. + + get_mapped_doc only walks the standard field map, so a custom parent-level table + (and the row keys that tie it to the item rows) is not carried over — without this + the accountant would pick articles on the order and find the invoice empty. + + Row keys are regenerated rather than copied: `custom_si_row_key` is no_copy, and the + two documents key their rows independently. The order's rows are matched to the + invoice's via `so_detail`, which ERPNext stamps on each mapped invoice line. + + Only fires on the whitelisted (UI "Create > Sales Invoice") path. A Sales Invoice + built by other means still gets valid — just empty — article tables, and the + accountant can fill them in as usual. + """ + from erpnext.selling.doctype.sales_order.sales_order import ( + make_sales_invoice as _make_sales_invoice, + ) + + target = _make_sales_invoice(source_name, target_doc, ignore_permissions) + _carry_over_tax_articles(source_name, target) + return target + + +def _carry_over_tax_articles(source_name, target): + so = frappe.get_doc("Sales Order", source_name) + + # so item row name -> that row's article key + key_by_item = { + item.name: item.get(ROW_KEY) for item in so.get("items", []) if item.get(ROW_KEY) + } + if not key_by_item: + return + + # article key -> [tax article, ...] + articles_by_key = {} + for row in so.get(TABLE_FIELD) or []: + if row.parent_row and row.tax_article: + articles_by_key.setdefault(row.parent_row, []).append(row.tax_article) + if not articles_by_key: + return + + for item in target.get("items", []): + so_key = key_by_item.get(item.get("so_detail")) + articles = articles_by_key.get(so_key) if so_key else None + if not articles: + continue + + si_key = item.get(SI_ROW_KEY) or ("r" + frappe.generate_hash(length=10)) + item.set(SI_ROW_KEY, si_key) + + for article in articles: + target.append(TABLE_FIELD, {"parent_row": si_key, "tax_article": article}) + + item.set("custom_tax_articles_display", display_label(len(articles))) diff --git a/jey_erp/custom_fields.py b/jey_erp/custom_fields.py index eb00ac3..0d2ae50 100644 --- a/jey_erp/custom_fields.py +++ b/jey_erp/custom_fields.py @@ -741,6 +741,23 @@ def create_custom_fields(): fieldtype='Check', insert_after='taxes_doc', default=0 + ), + # Per-item multiple tax articles: parent-level backing table, linked to each + # item row by custom_so_row_key (Frappe has no nested child tables, so this + # lives on the parent, not the row). Mirrors Sales Invoice. + dict( + fieldname='custom_item_tax_articles_section', + label='Item Tax Articles', + fieldtype='Section Break', + insert_after='items', + collapsible=1 + ), + dict( + fieldname='custom_item_tax_articles', + label='Item Tax Articles', + fieldtype='Table', + options='Sales Order Tax Article', + insert_after='custom_item_tax_articles_section' ) ], "Sales Order Item": [ @@ -797,13 +814,36 @@ def create_custom_fields(): ), dict( fieldname='tax_article', - label='Tax Article', + label='Tax Article (primary)', fieldtype='Link', options='Tax Article', insert_after='vat_free_amount', + in_list_view=0, + hidden=1, + read_only=1, + no_copy=1, + description='Deprecated single-value field, no longer populated. Tax articles are stored per item row in the "Tax Articles" multi-select cell (custom_item_tax_articles). Kept for historic data only.' + ), + # --- multi tax-article per item row (click the cell to edit) --- + dict( + fieldname='custom_so_row_key', + label='Row Key', + fieldtype='Data', + insert_after='tax_article', + read_only=1, + hidden=1, + no_copy=1 + ), + dict( + fieldname='custom_tax_articles_display', + label='Tax Articles', + fieldtype='Data', + insert_after='custom_so_row_key', + read_only=1, in_list_view=1, + hidden=0, columns=2, - description='Tax Article field for VAT purposes' + description='Click to select tax articles' ) ], "Sales Invoice": [ diff --git a/jey_erp/hooks.py b/jey_erp/hooks.py index 3f4ce67..b808f6d 100644 --- a/jey_erp/hooks.py +++ b/jey_erp/hooks.py @@ -28,15 +28,20 @@ doctype_js = { # overflowing it returns 502 (see jey_layout/hooks.py). # ORDER MATTERS: vat_calculator.js defines jey_erp.vat_calculator and the # *_vat.js scripts call .init() at load time, so it must be listed first. + # Same for item_tax_articles.js, which defines jey_erp.item_tax_articles and is + # .init()-ed by the *_tax_articles.js config shims below it. "Sales Invoice": [ "public/js/vat_calculator.js", "public/js/sales_invoice_vat.js", + "public/js/item_tax_articles.js", "public/js/sales_invoice_tax_articles.js", "public/js/sales_invoice_certificates.js", ], "Sales Order": [ "public/js/vat_calculator.js", "public/js/sales_order_vat.js", + "public/js/item_tax_articles.js", + "public/js/sales_order_tax_articles.js", ], "Asset": "public/js/asset.js", "Purchase Invoice": "public/js/purchase_invoice.js", @@ -60,6 +65,13 @@ extend_doctype_class = { "Payment Request": "jey_erp.custom.payment_request.PaymentRequestMixin", } +# get_mapped_doc only walks the standard field map, so the per-item tax articles picked +# on a Sales Order would be dropped on conversion. Wrap the mapper to carry them over. +override_whitelisted_methods = { + "erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice": + "jey_erp.custom.sales_order_tax_articles.make_sales_invoice", +} + override_doctype_class = { "Bulk Salary Structure Assignment": "jey_erp.custom.bulk_salary_structure_assignment.CustomBulkSalaryStructureAssignment", "Payroll Entry": "jey_erp.custom.payroll_entry.CustomPayrollEntry", @@ -81,7 +93,8 @@ doc_events = { }, "Sales Order": { "validate": [ - "jey_erp.custom.sales_order_vat.set_vat_fields_permissions" + "jey_erp.custom.sales_order_vat.set_vat_fields_permissions", + "jey_erp.custom.sales_order_tax_articles.sync_item_tax_articles", ], "on_update": [ "jey_erp.custom.sales_order_vat.set_vat_fields_permissions" diff --git a/jey_erp/jey_erp/doctype/sales_invoice_tax_article/sales_invoice_tax_article.json b/jey_erp/jey_erp/doctype/sales_invoice_tax_article/sales_invoice_tax_article.json index 13a89bb..6888fe0 100644 --- a/jey_erp/jey_erp/doctype/sales_invoice_tax_article/sales_invoice_tax_article.json +++ b/jey_erp/jey_erp/doctype/sales_invoice_tax_article/sales_invoice_tax_article.json @@ -21,7 +21,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Tax Article", - "link_filters": "[[\"Tax Article\",\"parent_tax_article\",\"in\",[\"ƏDV-yə 0% dərəcə ilə tutulan əməliyyatlar\",\"ƏDV-dən azad olunan əməliyyatlar\"]]]", + "link_filters": "[[\"Tax Article\",\"parent_tax_article\",\"in\",[\"ƏDV-yə 0% dərəcə ilə tutulan əməliyyatlar\",\"ƏDV-dən azad olunan əməliyyatlar\"]],[\"Tax Article\",\"name\",\"not like\",\"302.6%\"]]", "options": "Tax Article", "reqd": 1 } diff --git a/jey_erp/jey_erp/doctype/sales_order_tax_article/__init__.py b/jey_erp/jey_erp/doctype/sales_order_tax_article/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jey_erp/jey_erp/doctype/sales_order_tax_article/sales_order_tax_article.json b/jey_erp/jey_erp/doctype/sales_order_tax_article/sales_order_tax_article.json new file mode 100644 index 0000000..d93e37e --- /dev/null +++ b/jey_erp/jey_erp/doctype/sales_order_tax_article/sales_order_tax_article.json @@ -0,0 +1,41 @@ +{ + "actions": [], + "creation": "2026-07-14 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "parent_row", + "tax_article" + ], + "fields": [ + { + "fieldname": "parent_row", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Item Row Key", + "read_only": 1 + }, + { + "fieldname": "tax_article", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Tax Article", + "link_filters": "[[\"Tax Article\",\"parent_tax_article\",\"in\",[\"ƏDV-yə 0% dərəcə ilə tutulan əməliyyatlar\",\"ƏDV-dən azad olunan əməliyyatlar\"]],[\"Tax Article\",\"name\",\"not like\",\"302.6%\"]]", + "options": "Tax Article", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-07-14 00:00:00.000000", + "modified_by": "Administrator", + "module": "Jey Erp", + "name": "Sales Order Tax Article", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/jey_erp/jey_erp/doctype/sales_order_tax_article/sales_order_tax_article.py b/jey_erp/jey_erp/doctype/sales_order_tax_article/sales_order_tax_article.py new file mode 100644 index 0000000..191e946 --- /dev/null +++ b/jey_erp/jey_erp/doctype/sales_order_tax_article/sales_order_tax_article.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026, Ali and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class SalesOrderTaxArticle(Document): + pass diff --git a/jey_erp/patches.txt b/jey_erp/patches.txt index 8e84eee..f5b9d87 100644 --- a/jey_erp/patches.txt +++ b/jey_erp/patches.txt @@ -16,4 +16,5 @@ jey_erp.patches.drop_orphan_bank_doctypes jey_erp.patches.backfill_si_tax_article_row_keys jey_erp.patches.backfill_sales_invoice_tax_articles_history jey_erp.patches.add_tax_articles_to_si_grid_views -jey_erp.patches.add_certificates_to_si_grid_views \ No newline at end of file +jey_erp.patches.add_certificates_to_si_grid_views +jey_erp.patches.add_tax_articles_to_so_grid_views diff --git a/jey_erp/patches/add_tax_articles_to_so_grid_views.py b/jey_erp/patches/add_tax_articles_to_so_grid_views.py new file mode 100644 index 0000000..2cc89de --- /dev/null +++ b/jey_erp/patches/add_tax_articles_to_so_grid_views.py @@ -0,0 +1,56 @@ +import json + +import frappe + + +def execute(): + """Inject the new "Tax Articles" column into saved Sales Order Item grid layouts. + + Same story as add_tax_articles_to_si_grid_views, one doctype over. Per-user grid + column layouts live in `__UserSettings` (GridView). A layout saved before the multi + tax-article feature lists the legacy `tax_article` column but not the new + `custom_tax_articles_display`. Frappe renders that saved list verbatim and ignores + the new in_list_view field, so the "Tax Articles" cell never appears for those users + until they add it manually via the grid gear. + + Add `custom_tax_articles_display` to every saved layout that doesn't already have it, + positioned near the tax fields. The legacy `tax_article` column is left in place if a + layout lists it — the field is hidden now, so Frappe simply won't render it. + Idempotent. + """ + rows = frappe.db.sql( + "select user, data from `__UserSettings` where doctype = 'Sales Order'", + as_dict=True, + ) + for r in rows: + try: + data = json.loads(r.data or "{}") + except (ValueError, TypeError): + continue + + grid_view = data.get("GridView") or {} + cols = grid_view.get("Sales Order Item") + if not cols: + continue + + names = [c.get("fieldname") for c in cols] + if "custom_tax_articles_display" in names: + continue + + new_col = {"fieldname": "custom_tax_articles_display", "columns": 2} + if "item_tax_template" in names: + cols.insert(names.index("item_tax_template") + 1, new_col) + elif "tax_article" in names: + cols.insert(names.index("tax_article") + 1, new_col) + else: + cols.append(new_col) + + grid_view["Sales Order Item"] = cols + data["GridView"] = grid_view + frappe.db.sql( + "update `__UserSettings` set data = %s where user = %s and doctype = 'Sales Order'", + (json.dumps(data), r.user), + ) + + frappe.db.commit() + frappe.cache().delete_value("_user_settings") diff --git a/jey_erp/public/js/item_tax_articles.js b/jey_erp/public/js/item_tax_articles.js new file mode 100644 index 0000000..6a9fd40 --- /dev/null +++ b/jey_erp/public/js/item_tax_articles.js @@ -0,0 +1,282 @@ +// Multiple Tax Articles per item row — shared by Sales Invoice and Sales Order. +// +// Click the read-only "Tax Articles" cell -> a dialog opens with a Table MultiSelect +// field (a Link control under the hood => native search + native Advanced Search) +// pre-filled with the row's articles as pills. Source of truth = a parent-level table +// (Frappe has no nested child tables), whose rows point back at an item row by a +// generated row key. +// +// The article list is filtered to the parent group matching the line's tax template +// (ƏDV 0% -> 0% group, ƏDV-dən azadolma -> exempt group, ƏDV 18% -> taxable-turnover +// group); the dialog only opens for those templates. Note: ƏDV daxil 18% (VAT +// included) is intentionally excluded — it gets no tax articles. +// +// Callers pass a config (see sales_invoice_tax_articles.js / sales_order_tax_articles.js): +// doctype "Sales Invoice" parent form +// item_doctype "Sales Invoice Item" item grid rows +// child_doctype "Sales Invoice Tax Article" the parent-level backing table's DocType +// table_field "custom_item_tax_articles" Table field on the parent +// row_key "custom_si_row_key" Data field on the item row +// cert_table_field "custom_item_certificates" optional; lines with a certificate are +// locked (the certificate drives the 0% +// treatment). Pass null where absent. + +if (!window.jey_erp) { + window.jey_erp = {}; +} + +jey_erp.item_tax_articles = (function () { + const DIALOG_CLASS = "tax-articles-dialog"; + const DISPLAY_FIELD = "custom_tax_articles_display"; + + const GROUP_ZERO = "ƏDV-yə 0% dərəcə ilə tutulan əməliyyatlar"; + const GROUP_EXEMPT = "ƏDV-dən azad olunan əməliyyatlar"; + const GROUP_TAXABLE_18 = "ƏDV tutulan dövriyyə barədə məlumat"; + + // 302.6 (production-sharing / pipeline / oil-and-gas agreements) and its 302.6.1 + // advance-payment sub-line are not something a sales line gets tagged with, so they + // never appear in this picker. They stay selectable everywhere else — Payment Entry, + // Asset, the Tax Article tree itself. + // + // Matched on the ФНО code rather than the article name: names get re-worded to track + // the e-taxes cabinet, and a stale name in an exclusion list fails open — the article + // quietly reappears in the picker. The trailing % catches 302.6 and 302.6.1 while + // leaving 302.7 / 302.11 / 302.12 alone. The same pattern is mirrored in the child + // DocType's link_filters, which is what guards the parent-level table when it is + // edited directly instead of through this dialog. + const EXCLUDED_CODE_PATTERN = "302.6%"; + + function tax_article_group_for(template) { + const t = (template || "").trim(); + if (t.indexOf("ƏDV 0%") === 0) return GROUP_ZERO; + if (t.indexOf("ƏDV-dən azadolma") === 0) return GROUP_EXEMPT; + // "ƏDV 18%" (VAT on top) only — "ƏDV daxil 18%" does NOT match this prefix, + // so the VAT-included variant deliberately gets no tax articles. + if (t.indexOf("ƏDV 18%") === 0) return GROUP_TAXABLE_18; + return null; + } + + function inject_styles() { + if (document.getElementById("taxmulti-style")) return; + const style = document.createElement("style"); + style.id = "taxmulti-style"; + const F = DISPLAY_FIELD; + const D = "." + DIALOG_CLASS; + style.textContent = [ + // grid cell: clickable, click passes through the read-only input + '.grid-row [data-fieldname="' + F + '"]{cursor:pointer;}', + '.grid-row [data-fieldname="' + F + '"] input,', + '.grid-row [data-fieldname="' + F + '"] textarea,', + '.grid-row [data-fieldname="' + F + '"] .like-disabled-input{pointer-events:none;}', + + // keep the dropdown (incl. native "Advanced Search") reachable, not clipped + D + " .modal-body," + D + " .frappe-control," + D + " .form-group," + + D + " .control-input-wrapper{overflow:visible!important;}", + D + " .awesomplete > ul{max-height:260px;overflow-y:auto;z-index:1080;}", + + // ONE seamless surface: keep only the single .modal-content gradient + D + " .modal-header," + D + " .modal-body," + D + " .modal-footer," + + D + " .form-layout," + D + " .form-page," + D + " .form-section," + + D + " .section-body," + D + " .form-column," + D + " .table-multiselect{" + + "background:transparent!important;background-image:none!important;" + + "box-shadow:none!important;border:none!important;}", + + // thin dividers under the title and above the footer + D + " .modal-header{border-bottom:1px solid rgba(0,0,0,.15)!important;}", + D + " .modal-footer{border-top:1px solid rgba(0,0,0,.12)!important;}", + + // trim the empty space above the field + D + " .modal-body{padding-top:14px!important;}", + + // the search input as its own bordered field, just below the pills + D + " .table-multiselect .awesomplete{display:block;width:100%;margin-top:8px;}", + D + " .table-multiselect .awesomplete input{display:block;width:100%;" + + "border:1px solid var(--border-color,#999)!important;" + + "border-radius:var(--border-radius,6px)!important;padding:4px 8px!important;min-height:28px;}", + ].join(""); + document.head.appendChild(style); + } + + function ensure_setup(frm, cfg) { + if (frm.__tax_articles_bound) return; + frm.__tax_articles_bound = true; + + // Namespaced per doctype: Sales Invoice and Sales Order both bind this, and a + // shared namespace would make the second .off() tear the first one's handler off + // the document — silently breaking the cell click on whichever form was opened + // first. The doctype guard below keeps the other form's handler inert. + const ns = "mousedown.taxmulti_" + cfg.doctype.replace(/\s+/g, "_"); + + $(document) + .off(ns) + .on(ns, '[data-fieldname="' + DISPLAY_FIELD + '"]', function (ev) { + const grid_row = $(this).closest(".grid-row").data("grid_row"); + const doc = grid_row && grid_row.doc; + if (!doc || doc.doctype !== cfg.item_doctype) return; + if (!frm.fields_dict.items || !frm.fields_dict.items.grid) return; + ev.stopPropagation(); + open_dialog(frm, doc.name, cfg); + }); + } + + function open_dialog(frm, cdn, cfg) { + const row = locals[cfg.item_doctype][cdn]; + if (!row) return; + + // A line with a presented certificate is locked — the certificate, not the + // article, drives its 0% treatment. Sales Order has no certificates, so cfg + // leaves cert_table_field null and this check is skipped. + if (cfg.cert_table_field) { + const cert_key = row[cfg.row_key]; + const has_cert = + cert_key && + (frm.doc[cfg.cert_table_field] || []).some( + (r) => r.parent_row === cert_key && r.certificate + ); + if (has_cert) { + frappe.show_alert({ + message: __( + "Tax articles are read-only while a certificate is selected for this line. Remove the certificate to edit them." + ), + indicator: "orange", + }); + return; + } + } + + const group = tax_article_group_for(row.item_tax_template); + if (!group) { + frappe.show_alert({ + message: __( + "Tax articles apply only to ƏDV 0% / ƏDV-dən azadolma / ƏDV 18% lines. Set the item's Tax Template first." + ), + indicator: "orange", + }); + return; + } + + if (!row[cfg.row_key]) { + row[cfg.row_key] = "r" + Math.random().toString(36).slice(2, 12); + } + const key = row[cfg.row_key]; + + // Table MultiSelect value = array of child-row objects keyed by the link field. + const current = (frm.doc[cfg.table_field] || []) + .filter((r) => r.parent_row === key && r.tax_article) + .map((r) => ({ tax_article: r.tax_article })); + + const d = new frappe.ui.Dialog({ + title: __("Tax Articles") + " — " + (row.item_code || "#" + row.idx), + fields: [ + { + fieldname: "articles", + fieldtype: "Table MultiSelect", + label: __("Select tax articles"), + options: cfg.child_doctype, + get_query() { + return { + filters: { + parent_tax_article: group, + name: ["not like", EXCLUDED_CODE_PATTERN], + }, + }; + }, + }, + ], + primary_action_label: __("Save"), + primary_action(values) { + const selected = Array.from( + new Set((values.articles || []).map((r) => r.tax_article).filter(Boolean)) + ); + frm.doc[cfg.table_field] = (frm.doc[cfg.table_field] || []).filter( + (r) => r.parent_row !== key + ); + selected.forEach((a) => { + const c = frm.add_child(cfg.table_field); + c.parent_row = key; + c.tax_article = a; + }); + const display = selected.length + ? __("{0} selected", [selected.length]) + : __("Click to select"); + frappe.model.set_value(row.doctype, row.name, DISPLAY_FIELD, display); + frm.refresh_field(cfg.table_field); + frm.refresh_field("items"); + d.hide(); + }, + }); + + d.$wrapper.addClass(DIALOG_CLASS); + d.add_custom_action(__("Clear all"), () => d.set_value("articles", [])); + + // Robust pill removal inside a Dialog. Table MultiSelect's own `.btn-remove` + // handler does not update the field value in a dialog (it relies on a form's child + // docs), which causes the "needs a second click / removes the wrong pills" + // behaviour. We intercept the click in the CAPTURE phase, stop the control's broken + // handler, and do the removal ourselves: value = current minus the clicked article. + const fld = d.fields_dict.articles; + fld.$wrapper[0].addEventListener( + "click", + function (e) { + const btn = e.target.closest(".btn-remove"); + if (!btn) return; + e.stopPropagation(); + const pill = btn.closest(".tb-selected-value"); + const removed = pill ? decodeURIComponent(pill.getAttribute("data-value") || "") : ""; + const remaining = (fld.get_value() || []) + .map((r) => (typeof r === "string" ? r : r && r.tax_article)) + .filter((n) => n && n !== removed) + .map((n) => ({ tax_article: n })); + fld.set_value(remaining); + }, + true + ); + + d.set_value("articles", current); + d.show(); + } + + // Wire a parent doctype up. Called once per form script at load time. + function init(cfg) { + inject_styles(); + + frappe.ui.form.on(cfg.doctype, { + onload: (frm) => ensure_setup(frm, cfg), + refresh: (frm) => ensure_setup(frm, cfg), + }); + + frappe.ui.form.on(cfg.item_doctype, { + // When the tax template changes, the row's already-selected articles belong to + // the previous template's group and are no longer valid -> clear them and warn. + item_tax_template(frm, cdt, cdn) { + const row = locals[cdt][cdn]; + if (!row || !row[cfg.row_key]) return; + + const key = row[cfg.row_key]; + const had = (frm.doc[cfg.table_field] || []).some((r) => r.parent_row === key); + if (!had) return; + + frm.doc[cfg.table_field] = (frm.doc[cfg.table_field] || []).filter( + (r) => r.parent_row !== key + ); + frm.refresh_field(cfg.table_field); + + const allowed = !!tax_article_group_for(row.item_tax_template); + frappe.model.set_value(cdt, cdn, DISPLAY_FIELD, allowed ? __("Click to select") : ""); + + frappe.show_alert( + { + message: __( + "Tax articles for {0} were cleared because the tax template changed.", + [row.item_code || "#" + row.idx] + ), + indicator: "orange", + }, + 7 + ); + }, + }); + } + + return { init, tax_article_group_for, EXCLUDED_CODE_PATTERN }; +})(); diff --git a/jey_erp/public/js/sales_invoice_tax_articles.js b/jey_erp/public/js/sales_invoice_tax_articles.js index d549dc2..232a7ce 100644 --- a/jey_erp/public/js/sales_invoice_tax_articles.js +++ b/jey_erp/public/js/sales_invoice_tax_articles.js @@ -1,233 +1,17 @@ -// Multiple Tax Articles per Sales Invoice Item row. -// Click the read-only "Tax Articles" cell -> a dialog opens with a Table MultiSelect -// field (a Link control under the hood => native search + native Advanced Search) -// pre-filled with the row's articles as pills. Source of truth = parent table -// `custom_item_tax_articles`, linked to each item row by `custom_si_row_key`. +// Sales Invoice binding for the shared multi tax-article picker. +// All behaviour lives in item_tax_articles.js — this file is just the config. // -// The article list is filtered to the parent group matching the line's tax template -// (ƏDV 0% -> 0% group, ƏDV-dən azadolma -> exempt group, ƏDV 18% -> taxable-turnover -// group); the dialog only opens for those templates. Note: ƏDV daxil 18% (VAT -// included) is intentionally excluded — it gets no tax articles. +// This dialog is the ONLY tax-article picker on Sales Invoice: the single +// `Sales Invoice Item.tax_article` link field is hidden/read-only and deprecated +// (kept for historic rows). +// +// ORDER MATTERS: item_tax_articles.js must load first (see doctype_js in hooks.py). -const DIALOG_CLASS = "tax-articles-dialog"; -const CHILD_DOCTYPE = "Sales Invoice Tax Article"; -const DISPLAY_FIELD = "custom_tax_articles_display"; - -const GROUP_ZERO = "ƏDV-yə 0% dərəcə ilə tutulan əməliyyatlar"; -const GROUP_EXEMPT = "ƏDV-dən azad olunan əməliyyatlar"; -const GROUP_TAXABLE_18 = "ƏDV tutulan dövriyyə barədə məlumat"; - -function tax_article_group_for(template) { - const t = (template || "").trim(); - if (t.indexOf("ƏDV 0%") === 0) return GROUP_ZERO; - if (t.indexOf("ƏDV-dən azadolma") === 0) return GROUP_EXEMPT; - // "ƏDV 18%" (VAT on top) only — "ƏDV daxil 18%" does NOT match this prefix, - // so the VAT-included variant deliberately gets no tax articles. - if (t.indexOf("ƏDV 18%") === 0) return GROUP_TAXABLE_18; - return null; -} - -if (!document.getElementById("taxmulti-style")) { - const style = document.createElement("style"); - style.id = "taxmulti-style"; - const F = DISPLAY_FIELD; - const D = "." + DIALOG_CLASS; - style.textContent = [ - // grid cell: clickable, click passes through the read-only input - '.grid-row [data-fieldname="' + F + '"]{cursor:pointer;}', - '.grid-row [data-fieldname="' + F + '"] input,', - '.grid-row [data-fieldname="' + F + '"] textarea,', - '.grid-row [data-fieldname="' + F + '"] .like-disabled-input{pointer-events:none;}', - - // keep the dropdown (incl. native "Advanced Search") reachable, not clipped - D + " .modal-body," + D + " .frappe-control," + D + " .form-group," + - D + " .control-input-wrapper{overflow:visible!important;}", - D + " .awesomplete > ul{max-height:260px;overflow-y:auto;z-index:1080;}", - - // ONE seamless surface: keep only the single .modal-content gradient - D + " .modal-header," + D + " .modal-body," + D + " .modal-footer," + - D + " .form-layout," + D + " .form-page," + D + " .form-section," + - D + " .section-body," + D + " .form-column," + D + " .table-multiselect{" + - "background:transparent!important;background-image:none!important;" + - "box-shadow:none!important;border:none!important;}", - - // thin dividers under the title and above the footer - D + " .modal-header{border-bottom:1px solid rgba(0,0,0,.15)!important;}", - D + " .modal-footer{border-top:1px solid rgba(0,0,0,.12)!important;}", - - // trim the empty space above the field - D + " .modal-body{padding-top:14px!important;}", - - // the search input as its own bordered field, just below the pills - D + " .table-multiselect .awesomplete{display:block;width:100%;margin-top:8px;}", - D + " .table-multiselect .awesomplete input{display:block;width:100%;" + - "border:1px solid var(--border-color,#999)!important;" + - "border-radius:var(--border-radius,6px)!important;padding:4px 8px!important;min-height:28px;}", - ].join(""); - document.head.appendChild(style); -} - -frappe.ui.form.on("Sales Invoice", { - onload(frm) { - ensure_setup(frm); - }, - refresh(frm) { - ensure_setup(frm); - }, +jey_erp.item_tax_articles.init({ + doctype: "Sales Invoice", + item_doctype: "Sales Invoice Item", + child_doctype: "Sales Invoice Tax Article", + table_field: "custom_item_tax_articles", + row_key: "custom_si_row_key", + cert_table_field: "custom_item_certificates", }); - -frappe.ui.form.on("Sales Invoice Item", { - // When the tax template changes, the row's already-selected articles belong to the - // previous template's group and are no longer valid -> clear them and warn. - item_tax_template(frm, cdt, cdn) { - const row = locals[cdt][cdn]; - if (!row || !row.custom_si_row_key) return; - - const key = row.custom_si_row_key; - const had = (frm.doc.custom_item_tax_articles || []).some((r) => r.parent_row === key); - if (!had) return; - - frm.doc.custom_item_tax_articles = (frm.doc.custom_item_tax_articles || []).filter( - (r) => r.parent_row !== key - ); - frm.refresh_field("custom_item_tax_articles"); - - const allowed = !!tax_article_group_for(row.item_tax_template); - frappe.model.set_value(cdt, cdn, DISPLAY_FIELD, allowed ? __("Click to select") : ""); - - frappe.show_alert( - { - message: __("Tax articles for {0} were cleared because the tax template changed.", [ - row.item_code || "#" + row.idx, - ]), - indicator: "orange", - }, - 7 - ); - }, -}); - -function ensure_setup(frm) { - if (frm.__tax_articles_bound) return; - frm.__tax_articles_bound = true; - - $(document) - .off("mousedown.taxmulti") - .on("mousedown.taxmulti", '[data-fieldname="' + DISPLAY_FIELD + '"]', function (ev) { - const grid_row = $(this).closest(".grid-row").data("grid_row"); - const cdn = grid_row && grid_row.doc && grid_row.doc.name; - if (cdn && frm.fields_dict.items && frm.fields_dict.items.grid) { - ev.stopPropagation(); - open_tax_article_dialog(frm, cdn); - } - }); -} - -function open_tax_article_dialog(frm, cdn) { - const row = locals["Sales Invoice Item"][cdn]; - if (!row) return; - - // Tax articles are locked (read-only) for a line that already has a presented - // certificate — the certificate determines the 0% treatment for that line. - const cert_key = row.custom_si_row_key; - const has_cert = - cert_key && - (frm.doc.custom_item_certificates || []).some( - (r) => r.parent_row === cert_key && r.certificate - ); - if (has_cert) { - frappe.show_alert({ - message: __( - "Tax articles are read-only while a certificate is selected for this line. Remove the certificate to edit them." - ), - indicator: "orange", - }); - return; - } - - const group = tax_article_group_for(row.item_tax_template); - if (!group) { - frappe.show_alert({ - message: __( - "Tax articles apply only to ƏDV 0% / ƏDV-dən azadolma / ƏDV 18% lines. Set the item's Tax Template first." - ), - indicator: "orange", - }); - return; - } - - if (!row.custom_si_row_key) { - row.custom_si_row_key = "r" + Math.random().toString(36).slice(2, 12); - } - const key = row.custom_si_row_key; - - // Table MultiSelect value = array of child-row objects keyed by the link field. - const current = (frm.doc.custom_item_tax_articles || []) - .filter((r) => r.parent_row === key && r.tax_article) - .map((r) => ({ tax_article: r.tax_article })); - - const d = new frappe.ui.Dialog({ - title: __("Tax Articles") + " — " + (row.item_code || "#" + row.idx), - fields: [ - { - fieldname: "articles", - fieldtype: "Table MultiSelect", - label: __("Select tax articles"), - options: CHILD_DOCTYPE, - get_query() { - return { filters: { parent_tax_article: group } }; - }, - }, - ], - primary_action_label: __("Save"), - primary_action(values) { - const selected = Array.from( - new Set((values.articles || []).map((r) => r.tax_article).filter(Boolean)) - ); - frm.doc.custom_item_tax_articles = (frm.doc.custom_item_tax_articles || []).filter( - (r) => r.parent_row !== key - ); - selected.forEach((a) => { - const c = frm.add_child("custom_item_tax_articles"); - c.parent_row = key; - c.tax_article = a; - }); - const display = selected.length - ? __("{0} selected", [selected.length]) - : __("Click to select"); - frappe.model.set_value(row.doctype, row.name, DISPLAY_FIELD, display); - frm.refresh_field("custom_item_tax_articles"); - frm.refresh_field("items"); - d.hide(); - }, - }); - - d.$wrapper.addClass(DIALOG_CLASS); - d.add_custom_action(__("Clear all"), () => d.set_value("articles", [])); - - // Robust pill removal inside a Dialog. Table MultiSelect's own `.btn-remove` - // handler does not update the field value in a dialog (it relies on a form's child - // docs), which causes the "needs a second click / removes the wrong pills" - // behaviour. We intercept the click in the CAPTURE phase, stop the control's broken - // handler, and do the removal ourselves: value = current minus the clicked article. - const fld = d.fields_dict.articles; - fld.$wrapper[0].addEventListener( - "click", - function (e) { - const btn = e.target.closest(".btn-remove"); - if (!btn) return; - e.stopPropagation(); - const pill = btn.closest(".tb-selected-value"); - const removed = pill ? decodeURIComponent(pill.getAttribute("data-value") || "") : ""; - const remaining = (fld.get_value() || []) - .map((r) => (typeof r === "string" ? r : r && r.tax_article)) - .filter((n) => n && n !== removed) - .map((n) => ({ tax_article: n })); - fld.set_value(remaining); - }, - true - ); - - d.set_value("articles", current); - d.show(); -} diff --git a/jey_erp/public/js/sales_order_tax_articles.js b/jey_erp/public/js/sales_order_tax_articles.js new file mode 100644 index 0000000..d0c1f0a --- /dev/null +++ b/jey_erp/public/js/sales_order_tax_articles.js @@ -0,0 +1,19 @@ +// Sales Order binding for the shared multi tax-article picker. +// All behaviour lives in item_tax_articles.js — this file is just the config. +// +// Mirrors Sales Invoice one-for-one, minus certificates (Sales Order has none), so +// cert_table_field is null and the certificate lock is skipped. +// +// The articles selected here ride along into the Sales Invoice when the order is +// converted — see jey_erp.custom.sales_order_tax_articles.make_sales_invoice. +// +// ORDER MATTERS: item_tax_articles.js must load first (see doctype_js in hooks.py). + +jey_erp.item_tax_articles.init({ + doctype: "Sales Order", + item_doctype: "Sales Order Item", + child_doctype: "Sales Order Tax Article", + table_field: "custom_item_tax_articles", + row_key: "custom_so_row_key", + cert_table_field: null, +}); diff --git a/jey_erp/public/js/vat_calculator.js b/jey_erp/public/js/vat_calculator.js index 0e2b7aa..53a4327 100644 --- a/jey_erp/public/js/vat_calculator.js +++ b/jey_erp/public/js/vat_calculator.js @@ -119,10 +119,13 @@ jey_erp.vat_calculator = { // 3. ОБНОВЛЕННАЯ функция с поддержкой индивидуальных фильтров для tax_article update_tax_article_readonly: function(frm, cdt, cdn) { - // On Sales Invoice the single `tax_article` field is vestigial (hidden; - // replaced by the multi-select child table). Touching/clearing it here - // marks the form dirty on every refresh — skip it entirely for Sales Invoice. - if (frm && frm.doctype === 'Sales Invoice') return; + // The single `tax_article` field is vestigial on both Sales Invoice and Sales + // Order (hidden; replaced by the multi-select child table driven by + // item_tax_articles.js). Touching/clearing it here marks the form dirty on every + // refresh, so skip it entirely. Nothing else calls this — the helpers below + // (is_tax_article_allowed / get_tax_article_filter / validate_tax_article_against_filter) + // are kept only for any doctype that still uses the plain link field. + if (frm && (frm.doctype === 'Sales Invoice' || frm.doctype === 'Sales Order')) return; const item = locals[cdt][cdn]; if (!item?.item_code) return;