From ea8818d07ffa457ecbdc7c34f742a21e431895cb Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Tue, 2 Jun 2026 12:25:24 +0000 Subject: [PATCH] feat(sales-invoice): multiple Tax Articles per item row + Payment Entry multi-select Per-row multi-select of Tax Article records on Sales Invoice item rows (VAT 0% / VAT-exempt, codes 302.x/303.x) via a parent child table linked by a stable row key; Tax Article multi-select on Payment Entry. Legacy single tax_article demoted to hidden/read-only. - custom_fields.py: new fields + section; legacy tax_article -> primary - custom/sales_invoice_tax_articles.py: validate hook (row keys + display) - doctype/{sales_invoice,payment_entry}_tax_article: child tables (0%/exempt) - public/js: Table MultiSelect dialog + vat_calculator SI refresh-chain fix - patches: backfill row keys + historic single-article values Co-Authored-By: Claude Opus 4.8 --- jey_erp/custom/sales_invoice_tax_articles.py | 61 +++++ jey_erp/custom_fields.py | 51 ++++- jey_erp/hooks.py | 2 + .../payment_entry_tax_article/__init__.py | 0 .../payment_entry_tax_article.json | 33 +++ .../payment_entry_tax_article.py | 8 + .../sales_invoice_tax_article/__init__.py | 0 .../sales_invoice_tax_article.json | 41 ++++ .../sales_invoice_tax_article.py | 8 + jey_erp/patches.txt | 4 +- ...fill_sales_invoice_tax_articles_history.py | 66 ++++++ .../backfill_si_tax_article_row_keys.py | 30 +++ .../public/js/sales_invoice_tax_articles.js | 210 ++++++++++++++++++ jey_erp/public/js/vat_calculator.js | 66 ++---- jey_erp/translation_markers.py | 7 + 15 files changed, 535 insertions(+), 52 deletions(-) create mode 100644 jey_erp/custom/sales_invoice_tax_articles.py create mode 100644 jey_erp/jey_erp/doctype/payment_entry_tax_article/__init__.py create mode 100644 jey_erp/jey_erp/doctype/payment_entry_tax_article/payment_entry_tax_article.json create mode 100644 jey_erp/jey_erp/doctype/payment_entry_tax_article/payment_entry_tax_article.py create mode 100644 jey_erp/jey_erp/doctype/sales_invoice_tax_article/__init__.py create mode 100644 jey_erp/jey_erp/doctype/sales_invoice_tax_article/sales_invoice_tax_article.json create mode 100644 jey_erp/jey_erp/doctype/sales_invoice_tax_article/sales_invoice_tax_article.py create mode 100644 jey_erp/patches/backfill_sales_invoice_tax_articles_history.py create mode 100644 jey_erp/patches/backfill_si_tax_article_row_keys.py create mode 100644 jey_erp/public/js/sales_invoice_tax_articles.js diff --git a/jey_erp/custom/sales_invoice_tax_articles.py b/jey_erp/custom/sales_invoice_tax_articles.py new file mode 100644 index 0000000..62d7612 --- /dev/null +++ b/jey_erp/custom/sales_invoice_tax_articles.py @@ -0,0 +1,61 @@ +# Sales Invoice multi tax-article — server-side foundation. +# +# 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. + +import frappe + + +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) mirror first article -> single field + refresh the 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") diff --git a/jey_erp/custom_fields.py b/jey_erp/custom_fields.py index d524b20..dbd4a23 100644 --- a/jey_erp/custom_fields.py +++ b/jey_erp/custom_fields.py @@ -416,6 +416,14 @@ def create_custom_fields(): fieldtype='Small Text', insert_after='payment_type', ), + dict( + fieldname='tax_article', + label='Tax Article', + fieldtype='Table MultiSelect', + options='Payment Entry Tax Article', + insert_after='purpose', + description='Yalnız 0% və ƏDV-dən azad olunan əməliyyatlar üzrə maddələr', + ), ], "Purchase Order": [ dict( @@ -497,7 +505,7 @@ def create_custom_fields(): label='E-Taxes Purchase Act', fieldtype='Section Break', insert_after='amended_from', - collapsible=1 + collapsible=0 ), dict( fieldname='act_kind', @@ -723,6 +731,21 @@ def create_custom_fields(): insert_after='act_type', depends_on='eval:doc.purchase_type && doc.act_type == "Import"' ), + # --- TEST: backing store for per-item multi tax-article --- + 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 Invoice Tax Article', + insert_after='custom_item_tax_articles_section' + ), # New comments section after time_sheet_list dict( fieldname='comments_section', @@ -880,13 +903,35 @@ 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='Auto-synced to the first selected article. Replaced by the multi-select "Tax Articles (multi)" cell.' + ), + # --- multi tax-article per item row (click the cell to edit) --- + dict( + fieldname='custom_si_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_si_row_key', + read_only=1, in_list_view=1, columns=2, - description='Tax Article field for VAT purposes' + description='Click to select tax articles' ) ], "Purchase Invoice Item": [ diff --git a/jey_erp/hooks.py b/jey_erp/hooks.py index 6419906..258450c 100644 --- a/jey_erp/hooks.py +++ b/jey_erp/hooks.py @@ -12,6 +12,7 @@ app_include_js = [ "/assets/jey_erp/js/vat_calculator.js", "/assets/jey_erp/js/sales_invoice.js", "/assets/jey_erp/js/sales_invoice_vat.js", + "/assets/jey_erp/js/sales_invoice_tax_articles.js", "/assets/jey_erp/js/sales_order_vat.js", "/assets/jey_erp/js/asset.js", "/assets/jey_erp/js/link_field_fix.js", @@ -56,6 +57,7 @@ doc_events = { "Sales Invoice": { "validate": [ "jey_erp.custom.sales_invoice.calculate_tax_free_amounts", + "jey_erp.custom.sales_invoice_tax_articles.sync_item_tax_articles", ], "on_update": [ "jey_erp.custom.sales_invoice.calculate_tax_free_amounts", diff --git a/jey_erp/jey_erp/doctype/payment_entry_tax_article/__init__.py b/jey_erp/jey_erp/doctype/payment_entry_tax_article/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jey_erp/jey_erp/doctype/payment_entry_tax_article/payment_entry_tax_article.json b/jey_erp/jey_erp/doctype/payment_entry_tax_article/payment_entry_tax_article.json new file mode 100644 index 0000000..429260c --- /dev/null +++ b/jey_erp/jey_erp/doctype/payment_entry_tax_article/payment_entry_tax_article.json @@ -0,0 +1,33 @@ +{ + "actions": [], + "creation": "2026-06-01 00:00:00.000000", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "tax_article" + ], + "fields": [ + { + "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\"]]]", + "options": "Tax Article", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-06-01 00:00:00.000000", + "modified_by": "Administrator", + "module": "Jey Erp", + "name": "Payment Entry Tax Article", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/jey_erp/jey_erp/doctype/payment_entry_tax_article/payment_entry_tax_article.py b/jey_erp/jey_erp/doctype/payment_entry_tax_article/payment_entry_tax_article.py new file mode 100644 index 0000000..239a511 --- /dev/null +++ b/jey_erp/jey_erp/doctype/payment_entry_tax_article/payment_entry_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 PaymentEntryTaxArticle(Document): + pass diff --git a/jey_erp/jey_erp/doctype/sales_invoice_tax_article/__init__.py b/jey_erp/jey_erp/doctype/sales_invoice_tax_article/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..13a89bb --- /dev/null +++ b/jey_erp/jey_erp/doctype/sales_invoice_tax_article/sales_invoice_tax_article.json @@ -0,0 +1,41 @@ +{ + "actions": [], + "creation": "2026-06-01 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\"]]]", + "options": "Tax Article", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2026-06-01 00:00:00.000000", + "modified_by": "Administrator", + "module": "Jey Erp", + "name": "Sales Invoice Tax Article", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/jey_erp/jey_erp/doctype/sales_invoice_tax_article/sales_invoice_tax_article.py b/jey_erp/jey_erp/doctype/sales_invoice_tax_article/sales_invoice_tax_article.py new file mode 100644 index 0000000..36ce2c3 --- /dev/null +++ b/jey_erp/jey_erp/doctype/sales_invoice_tax_article/sales_invoice_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 SalesInvoiceTaxArticle(Document): + pass diff --git a/jey_erp/patches.txt b/jey_erp/patches.txt index 7137b3d..36ccad3 100644 --- a/jey_erp/patches.txt +++ b/jey_erp/patches.txt @@ -11,4 +11,6 @@ jey_erp.patches.create_asset_categories_post_migration jey_erp.patches.remove_old_won_lost_fields jey_erp.patches.remove_won_lost_breaks jey_erp.patches.remove_won_lost_checkboxes -jey_erp.patches.drop_bank_integration_account_mapping \ No newline at end of file +jey_erp.patches.drop_bank_integration_account_mapping +jey_erp.patches.backfill_si_tax_article_row_keys +jey_erp.patches.backfill_sales_invoice_tax_articles_history \ No newline at end of file diff --git a/jey_erp/patches/backfill_sales_invoice_tax_articles_history.py b/jey_erp/patches/backfill_sales_invoice_tax_articles_history.py new file mode 100644 index 0000000..7c532c2 --- /dev/null +++ b/jey_erp/patches/backfill_sales_invoice_tax_articles_history.py @@ -0,0 +1,66 @@ +import frappe + + +def execute(): + """Migrate historic single `tax_article` values into the multi child table. + + Phase 2 of the multi tax-article migration. The Sales-Invoice tax_article + consumers (VAT Allocation, tax-authority reports) now JOIN + `Sales Invoice Tax Article` (parent table `custom_item_tax_articles`, linked by + `custom_si_row_key`). Historic Sales Invoice Items still carry their old single + `tax_article`; copy each into a child row so reports over past data keep working. + Idempotent: skips rows already present. + """ + if not frappe.db.has_column("Sales Invoice Item", "custom_si_row_key"): + return + if not frappe.db.exists("DocType", "Sales Invoice Tax Article"): + return + + items = frappe.db.sql( + """ + select sii.parent as si, sii.custom_si_row_key as rk, sii.tax_article as ta + from `tabSales Invoice Item` sii + where ifnull(sii.tax_article, '') != '' + and ifnull(sii.custom_si_row_key, '') != '' + """, + as_dict=True, + ) + + created = 0 + for it in items: + already = frappe.db.exists( + "Sales Invoice Tax Article", + { + "parenttype": "Sales Invoice", + "parent": it.si, + "parentfield": "custom_item_tax_articles", + "parent_row": it.rk, + "tax_article": it.ta, + }, + ) + if already: + continue + + max_idx = frappe.db.sql( + """ + select ifnull(max(idx), 0) from `tabSales Invoice Tax Article` + where parent=%s and parenttype='Sales Invoice' + and parentfield='custom_item_tax_articles' + """, + it.si, + )[0][0] + + child = frappe.new_doc("Sales Invoice Tax Article") + child.parent = it.si + child.parenttype = "Sales Invoice" + child.parentfield = "custom_item_tax_articles" + child.parent_row = it.rk + child.tax_article = it.ta + child.idx = (max_idx or 0) + 1 + child.insert(ignore_permissions=True) + created += 1 + + frappe.db.commit() + frappe.logger().info( + f"backfill_sales_invoice_tax_articles_history: created {created} child rows" + ) diff --git a/jey_erp/patches/backfill_si_tax_article_row_keys.py b/jey_erp/patches/backfill_si_tax_article_row_keys.py new file mode 100644 index 0000000..78ac0bc --- /dev/null +++ b/jey_erp/patches/backfill_si_tax_article_row_keys.py @@ -0,0 +1,30 @@ +import frappe + + +def execute(): + """Backfill a stable custom_si_row_key for existing Sales Invoice Item rows. + + Phase 1 of the multi tax-article migration. Only fills missing row keys so + that future joins from `Sales Invoice Item` to `Sales Invoice Tax Article` + are reliable. Does NOT touch tax_article values (history keeps its single + value, which the taxes_az consumers still read during the transition). + """ + if not frappe.db.has_column("Sales Invoice Item", "custom_si_row_key"): + return + + rows = frappe.db.get_all( + "Sales Invoice Item", + filters={"custom_si_row_key": ["in", ["", None]]}, + pluck="name", + ) + for name in rows: + frappe.db.set_value( + "Sales Invoice Item", + name, + "custom_si_row_key", + "r" + frappe.generate_hash(length=10), + update_modified=False, + ) + + frappe.db.commit() + frappe.logger().info(f"backfill_si_tax_article_row_keys: filled {len(rows)} row keys") diff --git a/jey_erp/public/js/sales_invoice_tax_articles.js b/jey_erp/public/js/sales_invoice_tax_articles.js new file mode 100644 index 0000000..eeadacd --- /dev/null +++ b/jey_erp/public/js/sales_invoice_tax_articles.js @@ -0,0 +1,210 @@ +// 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`. +// +// 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); the dialog only opens for +// those templates. + +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"; + +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; + 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); + }, +}); + +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; + + 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 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/vat_calculator.js b/jey_erp/public/js/vat_calculator.js index 6e6c2d9..0e2b7aa 100644 --- a/jey_erp/public/js/vat_calculator.js +++ b/jey_erp/public/js/vat_calculator.js @@ -13,7 +13,12 @@ jey_erp.vat_calculator = { // 1. Стандартные триггеры Frappe frappe.ui.form.on(doctype, { refresh: (frm) => { - if (frm.doc.docstatus === 0) { + // Do NOT recompute on every open: clear_all_vat_fields() does + // set_value(...,0) which marks the form dirty on load. VAT fields are + // recomputed by the qty/rate/amount/item_tax_template triggers when the + // user edits, and authoritatively on the server at save. Skipping the + // refresh recompute for Sales Invoice keeps it clean ("only when needed"). + if (frm.doc.docstatus === 0 && doctype !== 'Sales Invoice') { this.setup_vat_system(frm, child_doctype); } } @@ -114,6 +119,11 @@ 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; + const item = locals[cdt][cdn]; if (!item?.item_code) return; @@ -237,57 +247,17 @@ jey_erp.vat_calculator = { }); }, - // Альтернативный метод получения актуальных значений из DOM - get_current_values: function(frm, cdt, cdn) { - const item = locals[cdt][cdn]; - const row_index = frm.doc.items.findIndex(i => i.name === cdn); - - if (row_index === -1) { - return { - qty: flt(item.qty || 0), - rate: flt(item.rate || 0), - amount: flt(item.qty || 0) * flt(item.rate || 0) - }; - } - - // Пытаемся получить значения из DOM полей - const grid_row = frm.fields_dict.items.grid?.grid_rows?.[row_index]; - if (grid_row) { - const qty_field = grid_row.get_field('qty'); - const rate_field = grid_row.get_field('rate'); - - const qty = qty_field ? flt(qty_field.get_value()) : flt(item.qty || 0); - const rate = rate_field ? flt(rate_field.get_value()) : flt(item.rate || 0); - - return { - qty: qty, - rate: rate, - amount: qty * rate - }; - } - - // Fallback к обычному способу - return { - qty: flt(item.qty || 0), - rate: flt(item.rate || 0), - amount: flt(item.qty || 0) * flt(item.rate || 0) - }; - }, calculate_vat_fields: function(cdt, cdn, frm = null) { const item = locals[cdt][cdn]; - // Получаем актуальные значения - let amount; - if (frm) { - const values = this.get_current_values(frm, cdt, cdn); - amount = values.amount; - } else { - // Fallback: вычисляем из значений в locals - const qty = flt(item.qty || 0); - const rate = flt(item.rate || 0); - amount = qty * rate; - } + // amount считаем из значений МОДЕЛИ (locals), а не из DOM. + // Прежний вариант читал qty/rate через grid_row.get_field(...), который + // бросает "fieldname qty not found", когда колонка не отрисована / строка + // свёрнута — это исключение рвало всю последовательную refresh-цепочку + // Sales Invoice (frappe.run_serially) и отключало остальные refresh-хендлеры. + // К моменту триггеров qty/rate/amount значения в locals уже актуальны. + const amount = flt(item.qty || 0) * flt(item.rate || 0); // Очищаем все поля НДС перед расчетом this.clear_all_vat_fields(cdt, cdn); diff --git a/jey_erp/translation_markers.py b/jey_erp/translation_markers.py index 3666b89..9d4b147 100644 --- a/jey_erp/translation_markers.py +++ b/jey_erp/translation_markers.py @@ -30,6 +30,7 @@ _lt("Area") _lt("Asset Type") _lt("Ata adı") _lt("Auto-calculated as 5% of amount when Tax Type is Taxable") +_lt("Auto-synced to the first selected article. Replaced by the multi-select \"Tax Articles (multi)\" cell.") _lt("Bank Accounts") _lt("Bank Code") _lt("Bank Integration") @@ -41,6 +42,7 @@ _lt("Cash Registers") _lt("Chief Executive Officer") _lt("Citizenship Country") _lt("City (for print formats)") +_lt("Click to select tax articles") _lt("Code of the Cadastral Valuation District") _lt("Code of the Territorial Unit") _lt("Comment") @@ -92,6 +94,7 @@ _lt("Is Sub Account") _lt("Is Taxpayer in Cancellation Process") _lt("Is taxes document") _lt("Item") +_lt("Item Tax Articles") _lt("Job Applicant") _lt("Kassa metodu — gəlir və xərclər yalnız ödəniş zamanı tanınır. ƏDV öhdəliyi yalnız ödəniş alındıqda yaranır.\nHesablama metodu — gəlir və xərclər faktura zamanı tanınır.") _lt("Land") @@ -141,6 +144,7 @@ _lt("Quality Groups") _lt("Reason") _lt("Registration Information") _lt("Residence Permit FIN") +_lt("Row Key") _lt("SSN") _lt("Selected customer object from E-Taxes (auto-filled)") _lt("Seller") @@ -153,7 +157,9 @@ _lt("Suspension End Date") _lt("Suspension Start Date") _lt("TIN Type") _lt("Tax Article") +_lt("Tax Article (primary)") _lt("Tax Article field for VAT purposes") +_lt("Tax Articles") _lt("Tax Authority") _lt("Tax Closing Wizards") _lt("Tax Exempt Assets Information") @@ -184,6 +190,7 @@ _lt("VAT free amount") _lt("VAT registration date") _lt("Vergi") _lt("Wizards") +_lt("Yalnız 0% və ƏDV-dən azad olunan əməliyyatlar üzrə maddələr") _lt("taxes_doc") _lt("İcbari Tibbi Sığorta") _lt("İcbari tibbi sığorta üzrə")