diff --git a/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.js b/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.js
index d197555..07f95a4 100644
--- a/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.js
+++ b/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.js
@@ -23,6 +23,9 @@ frappe.ui.form.on('VAT allocation', {
// Setup filter for Payment Entry
setup_payment_entry_query(frm);
+ // Tax Articles: grid labels + click-to-view popup
+ setup_tax_articles_viewer(frm);
+
}
});
@@ -225,6 +228,100 @@ function setup_payment_entry_query(frm) {
};
}
+// ---- Tax Articles helpers (stored as a JSON list of Tax Article names) ----
+
+// Parse the stored tax_articles value into an array of names.
+function parse_tax_articles_value(value) {
+ if (!value) return [];
+ if (Array.isArray(value)) return value.filter(Boolean);
+ if (typeof value === 'string') {
+ try {
+ let parsed = JSON.parse(value);
+ if (Array.isArray(parsed)) return parsed.filter(Boolean);
+ return parsed ? [parsed] : [];
+ } catch (e) {
+ return [value];
+ }
+ }
+ return [String(value)];
+}
+
+// Comma-separated label (used inside the allocation dialog).
+function format_tax_articles(value) {
+ let articles = parse_tax_articles_value(value);
+ return articles.length ? frappe.utils.escape_html(articles.join(', ')) : '-';
+}
+
+// Short label shown in the grid cell, e.g. "3 articles" (translatable).
+function format_tax_articles_count(value) {
+ let n = parse_tax_articles_value(value).length;
+ return n ? __('{0} articles', [n]) : '';
+}
+
+// Read-only popup showing the articles as pills (same look as the Sales Invoice
+// "Tax Articles" control). Rendered as plain styled chips so it does not depend on
+// the Sales Invoice Tax Article meta being loaded on this form, and cannot be edited.
+function show_tax_articles_view(value) {
+ let items = parse_tax_articles_value(value);
+ let pills = items.length
+ ? items.map(function(a) {
+ return '' + frappe.utils.escape_html(a) + '';
+ }).join('')
+ : '
' + __('No tax articles') + '
';
+ let d = new frappe.ui.Dialog({
+ title: __('Tax Articles'),
+ fields: [{
+ fieldtype: 'HTML',
+ fieldname: 'body',
+ options: '' + pills + '
'
+ }],
+ primary_action_label: __('Close'),
+ primary_action() { d.hide(); }
+ });
+ d.show();
+}
+
+// Recompute grid labels for loaded rows and bind the click-to-view handler.
+function setup_tax_articles_viewer(frm) {
+ ['sales_invoice_table', 'sales_invoice_previous_table'].forEach(function(tbl) {
+ (frm.doc[tbl] || []).forEach(function(row) {
+ let label = format_tax_articles_count(row.tax_articles);
+ if (row.tax_articles_display !== label) {
+ row.tax_articles_display = label; // direct set: cosmetic, must not dirty the form
+ }
+ });
+ frm.refresh_field(tbl);
+ });
+
+ if (frm.__tax_articles_viewer_bound) return;
+ frm.__tax_articles_viewer_bound = true;
+
+ if (!document.getElementById('vat-tax-articles-style')) {
+ let style = document.createElement('style');
+ style.id = 'vat-tax-articles-style';
+ style.textContent =
+ '.grid-row [data-fieldname="tax_articles_display"]{cursor:pointer;}' +
+ '.grid-row [data-fieldname="tax_articles_display"] input,' +
+ '.grid-row [data-fieldname="tax_articles_display"] .like-disabled-input{pointer-events:none;}' +
+ // read-only pill chips (same look as the SI Tax Articles control)
+ '.tax-article-pills{display:flex;flex-wrap:wrap;gap:6px;padding:4px 0;}' +
+ '.tax-article-pill{display:inline-block;background:var(--control-bg,#f4f5f6);' +
+ 'border:1px solid var(--border-color,#d1d8dd);border-radius:10px;' +
+ 'padding:4px 10px;font-size:12px;line-height:1.5;color:var(--text-color,#1f272e);}';
+ document.head.appendChild(style);
+ }
+
+ $(document)
+ .off('mousedown.vataa')
+ .on('mousedown.vataa', '[data-fieldname="tax_articles_display"]', function(ev) {
+ let grid_row = $(this).closest('.grid-row').data('grid_row');
+ if (grid_row && grid_row.doc) {
+ ev.stopPropagation();
+ show_tax_articles_view(grid_row.doc.tax_articles);
+ }
+ });
+}
+
// Function to fill Sales Invoice Items with filters - UPDATED to fill both tables
function fill_sales_invoice_items(frm, filters) {
// Call both server methods - current month and previous months
@@ -247,7 +344,8 @@ function fill_sales_invoice_items(frm, filters) {
let row = frm.add_child('sales_invoice_table');
row.customer = item.customer;
row.item_tax_template = item.item_tax_template;
- row.tax_article = item.tax_article;
+ row.tax_articles = item.tax_articles;
+ row.tax_articles_display = format_tax_articles_count(item.tax_articles);
row.amount = item.amount;
row.unallocated_amount = item.amount; // Default: all amount is unallocated
});
@@ -285,7 +383,8 @@ function load_previous_months_items(frm, filters) {
let row = frm.add_child('sales_invoice_previous_table');
row.customer = item.customer;
row.item_tax_template = item.item_tax_template;
- row.tax_article = item.tax_article;
+ row.tax_articles = item.tax_articles;
+ row.tax_articles_display = format_tax_articles_count(item.tax_articles);
row.amount = item.amount;
row.unallocated_amount = item.unallocated_amount;
row.allocated_amount = item.allocated_amount;
@@ -707,7 +806,7 @@ function show_allocation_dialog(frm, payment_row_idx, customer) {
let item_data = {
name: item.name,
item_tax_template: item.item_tax_template || '',
- tax_article: item.tax_article || '',
+ tax_articles: item.tax_articles || '',
amount: item.amount || 0,
unallocated_amount: item.unallocated_amount || 0,
allocated_amount: item.allocated_amount || 0,
@@ -742,7 +841,7 @@ function show_allocation_dialog(frm, payment_row_idx, customer) {
let item_data = {
name: item.name,
item_tax_template: item.item_tax_template || '',
- tax_article: item.tax_article || '',
+ tax_articles: item.tax_articles || '',
amount: item.amount || 0,
unallocated_amount: item.unallocated_amount || 0,
allocated_amount: item.allocated_amount || 0,
@@ -1099,7 +1198,7 @@ function build_allocation_html(vat_free_items, vat_items, allocation_data, tab_t
html += '';
html += '';
html += '| Item Tax Template | ';
- html += 'Tax Article | ';
+ html += 'Tax Articles | ';
html += 'Total Amount | ';
html += 'Unallocated | ';
html += 'Current Alloc | ';
@@ -1110,7 +1209,7 @@ function build_allocation_html(vat_free_items, vat_items, allocation_data, tab_t
allocation_data[item.name] = { value: item.current_allocation || 0, type: 'vat_free' };
html += '
';
html += '| ' + (item.item_tax_template || '-') + ' | ';
- html += '' + (item.tax_article || '-') + ' | ';
+ html += '' + format_tax_articles(item.tax_articles) + ' | ';
html += '' + format_currency(item.amount) + ' | ';
html += '' + format_currency(item.unallocated_amount) + ' | ';
html += '' + format_currency(item.current_allocation || 0) + ' | ';
@@ -1127,7 +1226,7 @@ function build_allocation_html(vat_free_items, vat_items, allocation_data, tab_t
html += '';
html += '';
html += '| Item Tax Template | ';
- html += 'Tax Article | ';
+ html += 'Tax Articles | ';
html += 'Total Amount | ';
html += 'Unallocated | ';
html += 'Current Alloc | ';
@@ -1138,7 +1237,7 @@ function build_allocation_html(vat_free_items, vat_items, allocation_data, tab_t
allocation_data[item.name] = { value: item.current_allocation || 0, type: 'vat_allocated' };
html += '
';
html += '| ' + (item.item_tax_template || '-') + ' | ';
- html += '' + (item.tax_article || '-') + ' | ';
+ html += '' + format_tax_articles(item.tax_articles) + ' | ';
html += '' + format_currency(item.amount) + ' | ';
html += '' + format_currency(item.unallocated_amount) + ' | ';
html += '' + format_currency(item.current_allocation || 0) + ' | ';
diff --git a/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.py b/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.py
index 7a6aacc..37e18b2 100644
--- a/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.py
+++ b/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.py
@@ -7,6 +7,26 @@ import json
# Константа для tolerance округления
ROUNDING_TOLERANCE = Decimal('0.01')
+
+def parse_tax_articles(value):
+ """Parse the stored ``tax_articles`` value into a list of Tax Article names.
+
+ Rows store the set of articles as a JSON list (e.g. ``["A", "B"]``). This
+ tolerates empty values and a legacy single-article string.
+ """
+ if not value:
+ return []
+ if isinstance(value, (list, tuple, set)):
+ return [a for a in value if a]
+ try:
+ parsed = json.loads(value)
+ except (ValueError, TypeError):
+ return [value]
+ if isinstance(parsed, list):
+ return [a for a in parsed if a]
+ return [parsed] if parsed else []
+
+
class VATallocation(Document):
def autoname(self):
"""
@@ -430,8 +450,9 @@ def get_journal_entry_query(doctype, txt, searchfield, start, page_len, filters)
@frappe.whitelist()
def get_sales_invoice_items(year, month, customers=None, min_amount=None, max_amount=None):
"""
- Get Sales Invoice items for specified month and year, grouped by customer, item_tax_template, and tax_article
- Includes all items, even those without tax_article
+ Get Sales Invoice items for specified month and year, grouped by customer and
+ item_tax_template. Only lines that carry at least one tax article are included;
+ their articles are unioned into a JSON ``tax_articles`` list per group.
customers can be a JSON string of customer list
"""
# Convert month name to number
@@ -484,37 +505,72 @@ def get_sales_invoice_items(year, month, customers=None, min_amount=None, max_am
if having_conditions:
having_clause = " HAVING " + " AND ".join(having_conditions)
- # SQL query to get Sales Invoice items grouped by customer, item_tax_template, and tax_article
+ # Amounts grouped by (customer, item_tax_template). We deliberately do NOT
+ # join the tax-article child table here: a line carrying N articles would be
+ # multiplied into N joined rows and SUM(amount) would inflate N-fold. Only
+ # lines that carry at least one tax article participate (EXISTS), matching the
+ # previous behaviour where article-less lines were excluded.
invoice_items = frappe.db.sql(f"""
- SELECT
+ SELECT
si.customer,
sii.item_tax_template,
- sita.tax_article,
SUM(sii.amount) as amount,
SUM(sii.net_amount) as net_amount,
SUM(sii.qty) as total_qty
- FROM
+ FROM
`tabSales Invoice` si
- INNER JOIN
+ INNER JOIN
+ `tabSales Invoice Item` sii ON si.name = sii.parent
+ WHERE
+ {conditions}
+ AND EXISTS (
+ SELECT 1 FROM `tabSales Invoice Tax Article` sita
+ WHERE sita.parent = sii.parent
+ AND sita.parentfield = 'custom_item_tax_articles'
+ AND sita.parent_row = sii.custom_si_row_key
+ )
+ GROUP BY
+ si.customer, sii.item_tax_template
+ {having_clause}
+ ORDER BY
+ si.customer, sii.item_tax_template
+ """, params, as_dict=1)
+
+ # Distinct tax articles per (customer, item_tax_template), aggregated
+ # separately so the JOIN cannot inflate amounts, then unioned in Python.
+ article_rows = frappe.db.sql(f"""
+ SELECT DISTINCT
+ si.customer,
+ sii.item_tax_template,
+ sita.tax_article
+ FROM
+ `tabSales Invoice` si
+ INNER JOIN
`tabSales Invoice Item` sii ON si.name = sii.parent
INNER JOIN
`tabSales Invoice Tax Article` sita ON sita.parent = sii.parent AND sita.parentfield = 'custom_item_tax_articles' AND sita.parent_row = sii.custom_si_row_key
WHERE
{conditions}
- GROUP BY
- si.customer, sii.item_tax_template, sita.tax_article
- {having_clause}
- ORDER BY
- si.customer, sii.item_tax_template, sita.tax_article
""", params, as_dict=1)
-
+
+ articles_by_group = {}
+ for row in article_rows:
+ if not row.tax_article:
+ continue
+ key = (row.customer, row.item_tax_template or '')
+ articles_by_group.setdefault(key, set()).add(row.tax_article)
+
+ for item in invoice_items:
+ key = (item.customer, item.item_tax_template or '')
+ item['tax_articles'] = json.dumps(sorted(articles_by_group.get(key, set())), ensure_ascii=False)
+
return invoice_items
@frappe.whitelist()
def get_sales_invoice_items_previous_months(year, month, customers=None, min_amount=None, max_amount=None):
"""
Get Sales Invoice items from previous VAT Allocation documents where unallocated_amount > 0
- Grouped by customer, item_tax_template, and tax_article
+ Grouped by customer and item_tax_template, unioning their tax_articles lists
"""
# Convert month name to number
months = {
@@ -580,10 +636,10 @@ def get_sales_invoice_items_previous_months(year, month, customers=None, min_amo
for vat_alloc in vat_allocations:
# Get sales invoice items from this VAT Allocation
items = frappe.db.sql("""
- SELECT
+ SELECT
customer,
item_tax_template,
- tax_article,
+ tax_articles,
unallocated_amount
FROM `tabVAT allocation sales invoice`
WHERE parent = %(parent)s
@@ -591,51 +647,53 @@ def get_sales_invoice_items_previous_months(year, month, customers=None, min_amo
""", {
'parent': vat_alloc.name
}, as_dict=1)
-
+
all_items.extend(items)
-
- # Group by customer, item_tax_template, tax_article and sum unallocated amounts
+
+ # Group by customer, item_tax_template and sum unallocated amounts,
+ # unioning the tax-article sets across rows.
grouped_items = {}
-
+
for item in all_items:
# Apply customer filter
if customer_list and item.customer not in customer_list:
continue
-
- key = (item.customer, item.item_tax_template or '', item.tax_article or '')
-
+
+ key = (item.customer, item.item_tax_template or '')
+
if key not in grouped_items:
grouped_items[key] = {
'customer': item.customer,
'item_tax_template': item.item_tax_template,
- 'tax_article': item.tax_article,
+ 'tax_articles': set(),
'amount': Decimal('0')
}
-
+
grouped_items[key]['amount'] += Decimal(str(item.unallocated_amount))
-
+ grouped_items[key]['tax_articles'].update(parse_tax_articles(item.get('tax_articles')))
+
# Convert to list and apply amount filters
result = []
for key, item in grouped_items.items():
amount = float(item['amount'])
-
+
# Apply min/max amount filters
if min_amount and amount < float(min_amount):
continue
if max_amount and amount > float(max_amount):
continue
-
+
result.append({
'customer': item['customer'],
'item_tax_template': item['item_tax_template'],
- 'tax_article': item['tax_article'],
+ 'tax_articles': json.dumps(sorted(item['tax_articles']), ensure_ascii=False),
'amount': amount,
'unallocated_amount': amount, # Initially all is unallocated
'allocated_amount': 0
})
-
- # Sort by customer, item_tax_template, tax_article
- result.sort(key=lambda x: (x['customer'], x['item_tax_template'] or '', x['tax_article'] or ''))
+
+ # Sort by customer, item_tax_template
+ result.sort(key=lambda x: (x['customer'], x['item_tax_template'] or ''))
return result
@@ -705,7 +763,7 @@ def get_allocation_data(vat_allocation_name, payment_entry_row_name, customer):
item_data = {
'name': item.name,
'item_tax_template': item.item_tax_template or '',
- 'tax_article': item.tax_article or '',
+ 'tax_articles': item.tax_articles or '',
'amount': item.amount,
'unallocated_amount': item.unallocated_amount,
'allocated_amount': item.allocated_amount or 0,
@@ -730,7 +788,7 @@ def get_allocation_data(vat_allocation_name, payment_entry_row_name, customer):
item_data = {
'name': item.name,
'item_tax_template': item.item_tax_template or '',
- 'tax_article': item.tax_article or '',
+ 'tax_articles': item.tax_articles or '',
'amount': item.amount,
'unallocated_amount': item.unallocated_amount,
'allocated_amount': item.allocated_amount or 0,
diff --git a/taxes_az/taxes_az/doctype/vat_allocation_sales_invoice/vat_allocation_sales_invoice.json b/taxes_az/taxes_az/doctype/vat_allocation_sales_invoice/vat_allocation_sales_invoice.json
index b150c8c..62ffe37 100644
--- a/taxes_az/taxes_az/doctype/vat_allocation_sales_invoice/vat_allocation_sales_invoice.json
+++ b/taxes_az/taxes_az/doctype/vat_allocation_sales_invoice/vat_allocation_sales_invoice.json
@@ -9,7 +9,8 @@
"customer",
"payment_entry",
"item_tax_template",
- "tax_article",
+ "tax_articles_display",
+ "tax_articles",
"amount",
"allocated_amount",
"unallocated_amount"
@@ -57,12 +58,17 @@
"read_only": 1
},
{
- "fieldname": "tax_article",
- "fieldtype": "Link",
+ "fieldname": "tax_articles_display",
+ "fieldtype": "Data",
"in_list_view": 1,
- "label": "Tax Article",
- "options": "Tax Article",
- "precision": "2",
+ "label": "Tax Articles",
+ "read_only": 1
+ },
+ {
+ "fieldname": "tax_articles",
+ "fieldtype": "Small Text",
+ "hidden": 1,
+ "label": "Tax Articles (raw)",
"read_only": 1
},
{
diff --git a/taxes_az/taxes_az/report/vat_allocation_tax_articles_report/vat_allocation_tax_articles_report.py b/taxes_az/taxes_az/report/vat_allocation_tax_articles_report/vat_allocation_tax_articles_report.py
index 0317597..38f664d 100644
--- a/taxes_az/taxes_az/report/vat_allocation_tax_articles_report/vat_allocation_tax_articles_report.py
+++ b/taxes_az/taxes_az/report/vat_allocation_tax_articles_report/vat_allocation_tax_articles_report.py
@@ -1,6 +1,8 @@
# Copyright (c) 2026, Your Company and contributors
# For license information, please see license.txt
+import json
+
import frappe
from frappe import _
from frappe.utils import flt
@@ -11,6 +13,19 @@ def execute(filters=None):
return columns, data
+def _parse_tax_articles(value):
+ """Parse the stored ``tax_articles`` JSON list; tolerate empty/legacy values."""
+ if not value:
+ return []
+ try:
+ parsed = json.loads(value)
+ except (ValueError, TypeError):
+ return [value]
+ if isinstance(parsed, list):
+ return [a for a in parsed if a]
+ return [parsed] if parsed else []
+
+
def get_data(filters):
"""Получаем данные по VAT Allocation с распределением по налоговым статьям"""
@@ -45,9 +60,13 @@ def get_data(filters):
allocations_data[allocation_name][field_key] = 0
allocations_data[allocation_name][f"{field_key}_odenis"] = 0
- # Получаем полное название статьи по сокращенному name
- tax_article_name = item['tax_article']
- if tax_article_name and tax_article_name in tax_article_names_mapping:
+ # Каждая строка несёт JSON-список статей; одна и та же сумма строки
+ # попадает в КАЖДУЮ статью из списка (как на ФНО, где один оборот может
+ # стоять под несколькими maddə).
+ for tax_article_name in _parse_tax_articles(item.get('tax_articles')):
+ if tax_article_name not in tax_article_names_mapping:
+ continue
+
full_article_name = tax_article_names_mapping[tax_article_name]
# Проверяем есть ли маппинг для полного названия
@@ -100,8 +119,8 @@ def get_vat_allocation_items(filters):
# Только строки с amount > 0 или allocated_amount > 0
conditions.append("(vasi.amount > 0 OR vasi.allocated_amount > 0)")
- # Только строки с tax_article
- conditions.append("vasi.tax_article IS NOT NULL AND vasi.tax_article != ''")
+ # Только строки с tax_articles (непустой JSON-список)
+ conditions.append("vasi.tax_articles IS NOT NULL AND vasi.tax_articles != '' AND vasi.tax_articles != '[]'")
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
@@ -112,7 +131,7 @@ def get_vat_allocation_items(filters):
va.month,
STR_TO_DATE(CONCAT('01 ', va.month, ' ', va.year), '%%d %%M %%Y') as posting_date,
vasi.customer,
- vasi.tax_article,
+ vasi.tax_articles,
vasi.amount,
vasi.allocated_amount
FROM