feat(sales-order): multi tax-article picker, mirroring Sales Invoice

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) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-07-14 12:20:41 +00:00
parent 76a9d57b48
commit df63b036e3
15 changed files with 636 additions and 295 deletions

View File

@ -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")

View File

@ -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 # Child DocType: "Sales Invoice Tax Article", parent table `custom_item_tax_articles`,
# the parent table `custom_item_tax_articles` (child DocType "Sales Invoice Tax # rows linked to an item row by `custom_si_row_key`.
# 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 from jey_erp.custom.item_tax_articles import sync
ROW_KEY = "custom_si_row_key"
def sync_item_tax_articles(doc, method=None): def sync_item_tax_articles(doc, method=None):
if doc.doctype != "Sales Invoice": if doc.doctype != "Sales Invoice":
return return
sync(doc, ROW_KEY)
# 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")

View File

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

View File

@ -741,6 +741,23 @@ def create_custom_fields():
fieldtype='Check', fieldtype='Check',
insert_after='taxes_doc', insert_after='taxes_doc',
default=0 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": [ "Sales Order Item": [
@ -797,13 +814,36 @@ def create_custom_fields():
), ),
dict( dict(
fieldname='tax_article', fieldname='tax_article',
label='Tax Article', label='Tax Article (primary)',
fieldtype='Link', fieldtype='Link',
options='Tax Article', options='Tax Article',
insert_after='vat_free_amount', 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, in_list_view=1,
hidden=0,
columns=2, columns=2,
description='Tax Article field for VAT purposes' description='Click to select tax articles'
) )
], ],
"Sales Invoice": [ "Sales Invoice": [

View File

@ -28,15 +28,20 @@ doctype_js = {
# overflowing it returns 502 (see jey_layout/hooks.py). # overflowing it returns 502 (see jey_layout/hooks.py).
# ORDER MATTERS: vat_calculator.js defines jey_erp.vat_calculator and the # 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. # *_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": [ "Sales Invoice": [
"public/js/vat_calculator.js", "public/js/vat_calculator.js",
"public/js/sales_invoice_vat.js", "public/js/sales_invoice_vat.js",
"public/js/item_tax_articles.js",
"public/js/sales_invoice_tax_articles.js", "public/js/sales_invoice_tax_articles.js",
"public/js/sales_invoice_certificates.js", "public/js/sales_invoice_certificates.js",
], ],
"Sales Order": [ "Sales Order": [
"public/js/vat_calculator.js", "public/js/vat_calculator.js",
"public/js/sales_order_vat.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", "Asset": "public/js/asset.js",
"Purchase Invoice": "public/js/purchase_invoice.js", "Purchase Invoice": "public/js/purchase_invoice.js",
@ -60,6 +65,13 @@ extend_doctype_class = {
"Payment Request": "jey_erp.custom.payment_request.PaymentRequestMixin", "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 = { override_doctype_class = {
"Bulk Salary Structure Assignment": "jey_erp.custom.bulk_salary_structure_assignment.CustomBulkSalaryStructureAssignment", "Bulk Salary Structure Assignment": "jey_erp.custom.bulk_salary_structure_assignment.CustomBulkSalaryStructureAssignment",
"Payroll Entry": "jey_erp.custom.payroll_entry.CustomPayrollEntry", "Payroll Entry": "jey_erp.custom.payroll_entry.CustomPayrollEntry",
@ -81,7 +93,8 @@ doc_events = {
}, },
"Sales Order": { "Sales Order": {
"validate": [ "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": [ "on_update": [
"jey_erp.custom.sales_order_vat.set_vat_fields_permissions" "jey_erp.custom.sales_order_vat.set_vat_fields_permissions"

View File

@ -21,7 +21,7 @@
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Tax Article", "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", "options": "Tax Article",
"reqd": 1 "reqd": 1
} }

View File

@ -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": []
}

View File

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

View File

@ -16,4 +16,5 @@ jey_erp.patches.drop_orphan_bank_doctypes
jey_erp.patches.backfill_si_tax_article_row_keys jey_erp.patches.backfill_si_tax_article_row_keys
jey_erp.patches.backfill_sales_invoice_tax_articles_history jey_erp.patches.backfill_sales_invoice_tax_articles_history
jey_erp.patches.add_tax_articles_to_si_grid_views jey_erp.patches.add_tax_articles_to_si_grid_views
jey_erp.patches.add_certificates_to_si_grid_views jey_erp.patches.add_certificates_to_si_grid_views
jey_erp.patches.add_tax_articles_to_so_grid_views

View File

@ -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")

View File

@ -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 };
})();

View File

@ -1,233 +1,17 @@
// Multiple Tax Articles per Sales Invoice Item row. // Sales Invoice binding for the shared multi tax-article picker.
// Click the read-only "Tax Articles" cell -> a dialog opens with a Table MultiSelect // All behaviour lives in item_tax_articles.js — this file is just the config.
// 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 // This dialog is the ONLY tax-article picker on Sales Invoice: the single
// (ƏDV 0% -> 0% group, ƏDV-dən azadolma -> exempt group, ƏDV 18% -> taxable-turnover // `Sales Invoice Item.tax_article` link field is hidden/read-only and deprecated
// group); the dialog only opens for those templates. Note: ƏDV daxil 18% (VAT // (kept for historic rows).
// included) is intentionally excluded — it gets no tax articles. //
// ORDER MATTERS: item_tax_articles.js must load first (see doctype_js in hooks.py).
const DIALOG_CLASS = "tax-articles-dialog"; jey_erp.item_tax_articles.init({
const CHILD_DOCTYPE = "Sales Invoice Tax Article"; doctype: "Sales Invoice",
const DISPLAY_FIELD = "custom_tax_articles_display"; item_doctype: "Sales Invoice Item",
child_doctype: "Sales Invoice Tax Article",
const GROUP_ZERO = "ƏDV-yə 0% dərəcə ilə tutulan əməliyyatlar"; table_field: "custom_item_tax_articles",
const GROUP_EXEMPT = "ƏDV-dən azad olunan əməliyyatlar"; row_key: "custom_si_row_key",
const GROUP_TAXABLE_18 = "ƏDV tutulan dövriyyə barədə məlumat"; cert_table_field: "custom_item_certificates",
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);
},
}); });
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();
}

View File

@ -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,
});

View File

@ -119,10 +119,13 @@ jey_erp.vat_calculator = {
// 3. ОБНОВЛЕННАЯ функция с поддержкой индивидуальных фильтров для tax_article // 3. ОБНОВЛЕННАЯ функция с поддержкой индивидуальных фильтров для tax_article
update_tax_article_readonly: function(frm, cdt, cdn) { update_tax_article_readonly: function(frm, cdt, cdn) {
// On Sales Invoice the single `tax_article` field is vestigial (hidden; // The single `tax_article` field is vestigial on both Sales Invoice and Sales
// replaced by the multi-select child table). Touching/clearing it here // Order (hidden; replaced by the multi-select child table driven by
// marks the form dirty on every refresh — skip it entirely for Sales Invoice. // item_tax_articles.js). Touching/clearing it here marks the form dirty on every
if (frm && frm.doctype === 'Sales Invoice') return; // 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]; const item = locals[cdt][cdn];
if (!item?.item_code) return; if (!item?.item_code) return;