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 += ''; - html += ''; + html += ''; html += ''; html += ''; html += ''; @@ -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 += ''; - html += ''; + html += ''; html += ''; html += ''; html += ''; @@ -1127,7 +1226,7 @@ function build_allocation_html(vat_free_items, vat_items, allocation_data, tab_t html += '
Item Tax TemplateTax ArticleTax ArticlesTotal AmountUnallocatedCurrent Alloc
' + (item.item_tax_template || '-') + '' + (item.tax_article || '-') + '' + format_tax_articles(item.tax_articles) + '' + format_currency(item.amount) + '' + format_currency(item.unallocated_amount) + '' + format_currency(item.current_allocation || 0) + '
'; html += ''; html += ''; - html += ''; + html += ''; html += ''; html += ''; html += ''; @@ -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 += ''; - html += ''; + html += ''; html += ''; html += ''; html += ''; 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
Item Tax TemplateTax ArticleTax ArticlesTotal AmountUnallocatedCurrent Alloc
' + (item.item_tax_template || '-') + '' + (item.tax_article || '-') + '' + format_tax_articles(item.tax_articles) + '' + format_currency(item.amount) + '' + format_currency(item.unallocated_amount) + '' + format_currency(item.current_allocation || 0) + '