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 <noreply@anthropic.com>
This commit is contained in:
parent
4c1f582db9
commit
ea8818d07f
|
|
@ -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")
|
||||||
|
|
@ -416,6 +416,14 @@ def create_custom_fields():
|
||||||
fieldtype='Small Text',
|
fieldtype='Small Text',
|
||||||
insert_after='payment_type',
|
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": [
|
"Purchase Order": [
|
||||||
dict(
|
dict(
|
||||||
|
|
@ -497,7 +505,7 @@ def create_custom_fields():
|
||||||
label='E-Taxes Purchase Act',
|
label='E-Taxes Purchase Act',
|
||||||
fieldtype='Section Break',
|
fieldtype='Section Break',
|
||||||
insert_after='amended_from',
|
insert_after='amended_from',
|
||||||
collapsible=1
|
collapsible=0
|
||||||
),
|
),
|
||||||
dict(
|
dict(
|
||||||
fieldname='act_kind',
|
fieldname='act_kind',
|
||||||
|
|
@ -723,6 +731,21 @@ def create_custom_fields():
|
||||||
insert_after='act_type',
|
insert_after='act_type',
|
||||||
depends_on='eval:doc.purchase_type && doc.act_type == "Import"'
|
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
|
# New comments section after time_sheet_list
|
||||||
dict(
|
dict(
|
||||||
fieldname='comments_section',
|
fieldname='comments_section',
|
||||||
|
|
@ -880,13 +903,35 @@ 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='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,
|
in_list_view=1,
|
||||||
columns=2,
|
columns=2,
|
||||||
description='Tax Article field for VAT purposes'
|
description='Click to select tax articles'
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
"Purchase Invoice Item": [
|
"Purchase Invoice Item": [
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ app_include_js = [
|
||||||
"/assets/jey_erp/js/vat_calculator.js",
|
"/assets/jey_erp/js/vat_calculator.js",
|
||||||
"/assets/jey_erp/js/sales_invoice.js",
|
"/assets/jey_erp/js/sales_invoice.js",
|
||||||
"/assets/jey_erp/js/sales_invoice_vat.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/sales_order_vat.js",
|
||||||
"/assets/jey_erp/js/asset.js",
|
"/assets/jey_erp/js/asset.js",
|
||||||
"/assets/jey_erp/js/link_field_fix.js",
|
"/assets/jey_erp/js/link_field_fix.js",
|
||||||
|
|
@ -56,6 +57,7 @@ doc_events = {
|
||||||
"Sales Invoice": {
|
"Sales Invoice": {
|
||||||
"validate": [
|
"validate": [
|
||||||
"jey_erp.custom.sales_invoice.calculate_tax_free_amounts",
|
"jey_erp.custom.sales_invoice.calculate_tax_free_amounts",
|
||||||
|
"jey_erp.custom.sales_invoice_tax_articles.sync_item_tax_articles",
|
||||||
],
|
],
|
||||||
"on_update": [
|
"on_update": [
|
||||||
"jey_erp.custom.sales_invoice.calculate_tax_free_amounts",
|
"jey_erp.custom.sales_invoice.calculate_tax_free_amounts",
|
||||||
|
|
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -11,4 +11,6 @@ jey_erp.patches.create_asset_categories_post_migration
|
||||||
jey_erp.patches.remove_old_won_lost_fields
|
jey_erp.patches.remove_old_won_lost_fields
|
||||||
jey_erp.patches.remove_won_lost_breaks
|
jey_erp.patches.remove_won_lost_breaks
|
||||||
jey_erp.patches.remove_won_lost_checkboxes
|
jey_erp.patches.remove_won_lost_checkboxes
|
||||||
jey_erp.patches.drop_bank_integration_account_mapping
|
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
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,12 @@ jey_erp.vat_calculator = {
|
||||||
// 1. Стандартные триггеры Frappe
|
// 1. Стандартные триггеры Frappe
|
||||||
frappe.ui.form.on(doctype, {
|
frappe.ui.form.on(doctype, {
|
||||||
refresh: (frm) => {
|
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);
|
this.setup_vat_system(frm, child_doctype);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +119,11 @@ 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;
|
||||||
|
// 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];
|
const item = locals[cdt][cdn];
|
||||||
if (!item?.item_code) return;
|
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) {
|
calculate_vat_fields: function(cdt, cdn, frm = null) {
|
||||||
const item = locals[cdt][cdn];
|
const item = locals[cdt][cdn];
|
||||||
|
|
||||||
// Получаем актуальные значения
|
// amount считаем из значений МОДЕЛИ (locals), а не из DOM.
|
||||||
let amount;
|
// Прежний вариант читал qty/rate через grid_row.get_field(...), который
|
||||||
if (frm) {
|
// бросает "fieldname qty not found", когда колонка не отрисована / строка
|
||||||
const values = this.get_current_values(frm, cdt, cdn);
|
// свёрнута — это исключение рвало всю последовательную refresh-цепочку
|
||||||
amount = values.amount;
|
// Sales Invoice (frappe.run_serially) и отключало остальные refresh-хендлеры.
|
||||||
} else {
|
// К моменту триггеров qty/rate/amount значения в locals уже актуальны.
|
||||||
// Fallback: вычисляем из значений в locals
|
const amount = flt(item.qty || 0) * flt(item.rate || 0);
|
||||||
const qty = flt(item.qty || 0);
|
|
||||||
const rate = flt(item.rate || 0);
|
|
||||||
amount = qty * rate;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Очищаем все поля НДС перед расчетом
|
// Очищаем все поля НДС перед расчетом
|
||||||
this.clear_all_vat_fields(cdt, cdn);
|
this.clear_all_vat_fields(cdt, cdn);
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ _lt("Area")
|
||||||
_lt("Asset Type")
|
_lt("Asset Type")
|
||||||
_lt("Ata adı")
|
_lt("Ata adı")
|
||||||
_lt("Auto-calculated as 5% of amount when Tax Type is Taxable")
|
_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 Accounts")
|
||||||
_lt("Bank Code")
|
_lt("Bank Code")
|
||||||
_lt("Bank Integration")
|
_lt("Bank Integration")
|
||||||
|
|
@ -41,6 +42,7 @@ _lt("Cash Registers")
|
||||||
_lt("Chief Executive Officer")
|
_lt("Chief Executive Officer")
|
||||||
_lt("Citizenship Country")
|
_lt("Citizenship Country")
|
||||||
_lt("City (for print formats)")
|
_lt("City (for print formats)")
|
||||||
|
_lt("Click to select tax articles")
|
||||||
_lt("Code of the Cadastral Valuation District")
|
_lt("Code of the Cadastral Valuation District")
|
||||||
_lt("Code of the Territorial Unit")
|
_lt("Code of the Territorial Unit")
|
||||||
_lt("Comment")
|
_lt("Comment")
|
||||||
|
|
@ -92,6 +94,7 @@ _lt("Is Sub Account")
|
||||||
_lt("Is Taxpayer in Cancellation Process")
|
_lt("Is Taxpayer in Cancellation Process")
|
||||||
_lt("Is taxes document")
|
_lt("Is taxes document")
|
||||||
_lt("Item")
|
_lt("Item")
|
||||||
|
_lt("Item Tax Articles")
|
||||||
_lt("Job Applicant")
|
_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("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")
|
_lt("Land")
|
||||||
|
|
@ -141,6 +144,7 @@ _lt("Quality Groups")
|
||||||
_lt("Reason")
|
_lt("Reason")
|
||||||
_lt("Registration Information")
|
_lt("Registration Information")
|
||||||
_lt("Residence Permit FIN")
|
_lt("Residence Permit FIN")
|
||||||
|
_lt("Row Key")
|
||||||
_lt("SSN")
|
_lt("SSN")
|
||||||
_lt("Selected customer object from E-Taxes (auto-filled)")
|
_lt("Selected customer object from E-Taxes (auto-filled)")
|
||||||
_lt("Seller")
|
_lt("Seller")
|
||||||
|
|
@ -153,7 +157,9 @@ _lt("Suspension End Date")
|
||||||
_lt("Suspension Start Date")
|
_lt("Suspension Start Date")
|
||||||
_lt("TIN Type")
|
_lt("TIN Type")
|
||||||
_lt("Tax Article")
|
_lt("Tax Article")
|
||||||
|
_lt("Tax Article (primary)")
|
||||||
_lt("Tax Article field for VAT purposes")
|
_lt("Tax Article field for VAT purposes")
|
||||||
|
_lt("Tax Articles")
|
||||||
_lt("Tax Authority")
|
_lt("Tax Authority")
|
||||||
_lt("Tax Closing Wizards")
|
_lt("Tax Closing Wizards")
|
||||||
_lt("Tax Exempt Assets Information")
|
_lt("Tax Exempt Assets Information")
|
||||||
|
|
@ -184,6 +190,7 @@ _lt("VAT free amount")
|
||||||
_lt("VAT registration date")
|
_lt("VAT registration date")
|
||||||
_lt("Vergi")
|
_lt("Vergi")
|
||||||
_lt("Wizards")
|
_lt("Wizards")
|
||||||
|
_lt("Yalnız 0% və ƏDV-dən azad olunan əməliyyatlar üzrə maddələr")
|
||||||
_lt("taxes_doc")
|
_lt("taxes_doc")
|
||||||
_lt("İcbari Tibbi Sığorta")
|
_lt("İcbari Tibbi Sığorta")
|
||||||
_lt("İcbari tibbi sığorta üzrə")
|
_lt("İcbari tibbi sığorta üzrə")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue