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:
parent
76a9d57b48
commit
df63b036e3
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
jey_erp.patches.add_certificates_to_si_grid_views
|
||||
jey_erp.patches.add_tax_articles_to_so_grid_views
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue