feat(vat-allocation): group SI rows by customer+template with multi tax articles
Sales Invoice moved its single tax_article (Link) to a multi-select child table, which inflated VAT Allocation: one line citing N articles produced N rows of the full amount (e.g. 1500 -> 6000), so a 1500 payment only covered one article. Group sales-invoice rows by (customer, item_tax_template) and union the cited articles into a JSON tax_articles list, summing the amount once (the article aggregation is computed separately so the JOIN can no longer multiply amounts). The tax-articles report parses the list and replicates each row's amount/odenis into every cited maddE column (same turnover under several articles, as on e-taxes). The grid shows a translatable "N articles" cell that opens a read-only pill popup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7478d4d66c
commit
129a87711f
|
|
@ -23,6 +23,9 @@ frappe.ui.form.on('VAT allocation', {
|
||||||
// Setup filter for Payment Entry
|
// Setup filter for Payment Entry
|
||||||
setup_payment_entry_query(frm);
|
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 '<span class="tax-article-pill">' + frappe.utils.escape_html(a) + '</span>';
|
||||||
|
}).join('')
|
||||||
|
: '<div class="text-muted">' + __('No tax articles') + '</div>';
|
||||||
|
let d = new frappe.ui.Dialog({
|
||||||
|
title: __('Tax Articles'),
|
||||||
|
fields: [{
|
||||||
|
fieldtype: 'HTML',
|
||||||
|
fieldname: 'body',
|
||||||
|
options: '<div class="tax-article-pills">' + pills + '</div>'
|
||||||
|
}],
|
||||||
|
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 to fill Sales Invoice Items with filters - UPDATED to fill both tables
|
||||||
function fill_sales_invoice_items(frm, filters) {
|
function fill_sales_invoice_items(frm, filters) {
|
||||||
// Call both server methods - current month and previous months
|
// 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');
|
let row = frm.add_child('sales_invoice_table');
|
||||||
row.customer = item.customer;
|
row.customer = item.customer;
|
||||||
row.item_tax_template = item.item_tax_template;
|
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.amount = item.amount;
|
||||||
row.unallocated_amount = item.amount; // Default: all amount is unallocated
|
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');
|
let row = frm.add_child('sales_invoice_previous_table');
|
||||||
row.customer = item.customer;
|
row.customer = item.customer;
|
||||||
row.item_tax_template = item.item_tax_template;
|
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.amount = item.amount;
|
||||||
row.unallocated_amount = item.unallocated_amount;
|
row.unallocated_amount = item.unallocated_amount;
|
||||||
row.allocated_amount = item.allocated_amount;
|
row.allocated_amount = item.allocated_amount;
|
||||||
|
|
@ -707,7 +806,7 @@ function show_allocation_dialog(frm, payment_row_idx, customer) {
|
||||||
let item_data = {
|
let item_data = {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
item_tax_template: item.item_tax_template || '',
|
item_tax_template: item.item_tax_template || '',
|
||||||
tax_article: item.tax_article || '',
|
tax_articles: item.tax_articles || '',
|
||||||
amount: item.amount || 0,
|
amount: item.amount || 0,
|
||||||
unallocated_amount: item.unallocated_amount || 0,
|
unallocated_amount: item.unallocated_amount || 0,
|
||||||
allocated_amount: item.allocated_amount || 0,
|
allocated_amount: item.allocated_amount || 0,
|
||||||
|
|
@ -742,7 +841,7 @@ function show_allocation_dialog(frm, payment_row_idx, customer) {
|
||||||
let item_data = {
|
let item_data = {
|
||||||
name: item.name,
|
name: item.name,
|
||||||
item_tax_template: item.item_tax_template || '',
|
item_tax_template: item.item_tax_template || '',
|
||||||
tax_article: item.tax_article || '',
|
tax_articles: item.tax_articles || '',
|
||||||
amount: item.amount || 0,
|
amount: item.amount || 0,
|
||||||
unallocated_amount: item.unallocated_amount || 0,
|
unallocated_amount: item.unallocated_amount || 0,
|
||||||
allocated_amount: item.allocated_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 += '<table class="table table-bordered" style="margin-bottom: 20px;">';
|
html += '<table class="table table-bordered" style="margin-bottom: 20px;">';
|
||||||
html += '<thead><tr>';
|
html += '<thead><tr>';
|
||||||
html += '<th>Item Tax Template</th>';
|
html += '<th>Item Tax Template</th>';
|
||||||
html += '<th>Tax Article</th>';
|
html += '<th>Tax Articles</th>';
|
||||||
html += '<th>Total Amount</th>';
|
html += '<th>Total Amount</th>';
|
||||||
html += '<th>Unallocated</th>';
|
html += '<th>Unallocated</th>';
|
||||||
html += '<th>Current Alloc</th>';
|
html += '<th>Current Alloc</th>';
|
||||||
|
|
@ -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' };
|
allocation_data[item.name] = { value: item.current_allocation || 0, type: 'vat_free' };
|
||||||
html += '<tr>';
|
html += '<tr>';
|
||||||
html += '<td>' + (item.item_tax_template || '-') + '</td>';
|
html += '<td>' + (item.item_tax_template || '-') + '</td>';
|
||||||
html += '<td>' + (item.tax_article || '-') + '</td>';
|
html += '<td>' + format_tax_articles(item.tax_articles) + '</td>';
|
||||||
html += '<td>' + format_currency(item.amount) + '</td>';
|
html += '<td>' + format_currency(item.amount) + '</td>';
|
||||||
html += '<td>' + format_currency(item.unallocated_amount) + '</td>';
|
html += '<td>' + format_currency(item.unallocated_amount) + '</td>';
|
||||||
html += '<td style="background-color: #fff3cd;">' + format_currency(item.current_allocation || 0) + '</td>';
|
html += '<td style="background-color: #fff3cd;">' + format_currency(item.current_allocation || 0) + '</td>';
|
||||||
|
|
@ -1127,7 +1226,7 @@ function build_allocation_html(vat_free_items, vat_items, allocation_data, tab_t
|
||||||
html += '<table class="table table-bordered">';
|
html += '<table class="table table-bordered">';
|
||||||
html += '<thead><tr>';
|
html += '<thead><tr>';
|
||||||
html += '<th>Item Tax Template</th>';
|
html += '<th>Item Tax Template</th>';
|
||||||
html += '<th>Tax Article</th>';
|
html += '<th>Tax Articles</th>';
|
||||||
html += '<th>Total Amount</th>';
|
html += '<th>Total Amount</th>';
|
||||||
html += '<th>Unallocated</th>';
|
html += '<th>Unallocated</th>';
|
||||||
html += '<th>Current Alloc</th>';
|
html += '<th>Current Alloc</th>';
|
||||||
|
|
@ -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' };
|
allocation_data[item.name] = { value: item.current_allocation || 0, type: 'vat_allocated' };
|
||||||
html += '<tr>';
|
html += '<tr>';
|
||||||
html += '<td>' + (item.item_tax_template || '-') + '</td>';
|
html += '<td>' + (item.item_tax_template || '-') + '</td>';
|
||||||
html += '<td>' + (item.tax_article || '-') + '</td>';
|
html += '<td>' + format_tax_articles(item.tax_articles) + '</td>';
|
||||||
html += '<td>' + format_currency(item.amount) + '</td>';
|
html += '<td>' + format_currency(item.amount) + '</td>';
|
||||||
html += '<td>' + format_currency(item.unallocated_amount) + '</td>';
|
html += '<td>' + format_currency(item.unallocated_amount) + '</td>';
|
||||||
html += '<td style="background-color: #fff3cd;">' + format_currency(item.current_allocation || 0) + '</td>';
|
html += '<td style="background-color: #fff3cd;">' + format_currency(item.current_allocation || 0) + '</td>';
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,26 @@ import json
|
||||||
# Константа для tolerance округления
|
# Константа для tolerance округления
|
||||||
ROUNDING_TOLERANCE = Decimal('0.01')
|
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):
|
class VATallocation(Document):
|
||||||
def autoname(self):
|
def autoname(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -430,8 +450,9 @@ def get_journal_entry_query(doctype, txt, searchfield, start, page_len, filters)
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_sales_invoice_items(year, month, customers=None, min_amount=None, max_amount=None):
|
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
|
Get Sales Invoice items for specified month and year, grouped by customer and
|
||||||
Includes all items, even those without tax_article
|
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
|
customers can be a JSON string of customer list
|
||||||
"""
|
"""
|
||||||
# Convert month name to number
|
# 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:
|
if having_conditions:
|
||||||
having_clause = " HAVING " + " AND ".join(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"""
|
invoice_items = frappe.db.sql(f"""
|
||||||
SELECT
|
SELECT
|
||||||
si.customer,
|
si.customer,
|
||||||
sii.item_tax_template,
|
sii.item_tax_template,
|
||||||
sita.tax_article,
|
|
||||||
SUM(sii.amount) as amount,
|
SUM(sii.amount) as amount,
|
||||||
SUM(sii.net_amount) as net_amount,
|
SUM(sii.net_amount) as net_amount,
|
||||||
SUM(sii.qty) as total_qty
|
SUM(sii.qty) as total_qty
|
||||||
FROM
|
FROM
|
||||||
`tabSales Invoice` si
|
`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
|
`tabSales Invoice Item` sii ON si.name = sii.parent
|
||||||
INNER JOIN
|
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
|
`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
|
WHERE
|
||||||
{conditions}
|
{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)
|
""", 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
|
return invoice_items
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def get_sales_invoice_items_previous_months(year, month, customers=None, min_amount=None, max_amount=None):
|
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
|
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
|
# Convert month name to number
|
||||||
months = {
|
months = {
|
||||||
|
|
@ -580,10 +636,10 @@ def get_sales_invoice_items_previous_months(year, month, customers=None, min_amo
|
||||||
for vat_alloc in vat_allocations:
|
for vat_alloc in vat_allocations:
|
||||||
# Get sales invoice items from this VAT Allocation
|
# Get sales invoice items from this VAT Allocation
|
||||||
items = frappe.db.sql("""
|
items = frappe.db.sql("""
|
||||||
SELECT
|
SELECT
|
||||||
customer,
|
customer,
|
||||||
item_tax_template,
|
item_tax_template,
|
||||||
tax_article,
|
tax_articles,
|
||||||
unallocated_amount
|
unallocated_amount
|
||||||
FROM `tabVAT allocation sales invoice`
|
FROM `tabVAT allocation sales invoice`
|
||||||
WHERE parent = %(parent)s
|
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
|
'parent': vat_alloc.name
|
||||||
}, as_dict=1)
|
}, as_dict=1)
|
||||||
|
|
||||||
all_items.extend(items)
|
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 = {}
|
grouped_items = {}
|
||||||
|
|
||||||
for item in all_items:
|
for item in all_items:
|
||||||
# Apply customer filter
|
# Apply customer filter
|
||||||
if customer_list and item.customer not in customer_list:
|
if customer_list and item.customer not in customer_list:
|
||||||
continue
|
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:
|
if key not in grouped_items:
|
||||||
grouped_items[key] = {
|
grouped_items[key] = {
|
||||||
'customer': item.customer,
|
'customer': item.customer,
|
||||||
'item_tax_template': item.item_tax_template,
|
'item_tax_template': item.item_tax_template,
|
||||||
'tax_article': item.tax_article,
|
'tax_articles': set(),
|
||||||
'amount': Decimal('0')
|
'amount': Decimal('0')
|
||||||
}
|
}
|
||||||
|
|
||||||
grouped_items[key]['amount'] += Decimal(str(item.unallocated_amount))
|
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
|
# Convert to list and apply amount filters
|
||||||
result = []
|
result = []
|
||||||
for key, item in grouped_items.items():
|
for key, item in grouped_items.items():
|
||||||
amount = float(item['amount'])
|
amount = float(item['amount'])
|
||||||
|
|
||||||
# Apply min/max amount filters
|
# Apply min/max amount filters
|
||||||
if min_amount and amount < float(min_amount):
|
if min_amount and amount < float(min_amount):
|
||||||
continue
|
continue
|
||||||
if max_amount and amount > float(max_amount):
|
if max_amount and amount > float(max_amount):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result.append({
|
result.append({
|
||||||
'customer': item['customer'],
|
'customer': item['customer'],
|
||||||
'item_tax_template': item['item_tax_template'],
|
'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,
|
'amount': amount,
|
||||||
'unallocated_amount': amount, # Initially all is unallocated
|
'unallocated_amount': amount, # Initially all is unallocated
|
||||||
'allocated_amount': 0
|
'allocated_amount': 0
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort by customer, item_tax_template, tax_article
|
# Sort by customer, item_tax_template
|
||||||
result.sort(key=lambda x: (x['customer'], x['item_tax_template'] or '', x['tax_article'] or ''))
|
result.sort(key=lambda x: (x['customer'], x['item_tax_template'] or ''))
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
@ -705,7 +763,7 @@ def get_allocation_data(vat_allocation_name, payment_entry_row_name, customer):
|
||||||
item_data = {
|
item_data = {
|
||||||
'name': item.name,
|
'name': item.name,
|
||||||
'item_tax_template': item.item_tax_template or '',
|
'item_tax_template': item.item_tax_template or '',
|
||||||
'tax_article': item.tax_article or '',
|
'tax_articles': item.tax_articles or '',
|
||||||
'amount': item.amount,
|
'amount': item.amount,
|
||||||
'unallocated_amount': item.unallocated_amount,
|
'unallocated_amount': item.unallocated_amount,
|
||||||
'allocated_amount': item.allocated_amount or 0,
|
'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 = {
|
item_data = {
|
||||||
'name': item.name,
|
'name': item.name,
|
||||||
'item_tax_template': item.item_tax_template or '',
|
'item_tax_template': item.item_tax_template or '',
|
||||||
'tax_article': item.tax_article or '',
|
'tax_articles': item.tax_articles or '',
|
||||||
'amount': item.amount,
|
'amount': item.amount,
|
||||||
'unallocated_amount': item.unallocated_amount,
|
'unallocated_amount': item.unallocated_amount,
|
||||||
'allocated_amount': item.allocated_amount or 0,
|
'allocated_amount': item.allocated_amount or 0,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@
|
||||||
"customer",
|
"customer",
|
||||||
"payment_entry",
|
"payment_entry",
|
||||||
"item_tax_template",
|
"item_tax_template",
|
||||||
"tax_article",
|
"tax_articles_display",
|
||||||
|
"tax_articles",
|
||||||
"amount",
|
"amount",
|
||||||
"allocated_amount",
|
"allocated_amount",
|
||||||
"unallocated_amount"
|
"unallocated_amount"
|
||||||
|
|
@ -57,12 +58,17 @@
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "tax_article",
|
"fieldname": "tax_articles_display",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Data",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Tax Article",
|
"label": "Tax Articles",
|
||||||
"options": "Tax Article",
|
"read_only": 1
|
||||||
"precision": "2",
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "tax_articles",
|
||||||
|
"fieldtype": "Small Text",
|
||||||
|
"hidden": 1,
|
||||||
|
"label": "Tax Articles (raw)",
|
||||||
"read_only": 1
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# Copyright (c) 2026, Your Company and contributors
|
# Copyright (c) 2026, Your Company and contributors
|
||||||
# For license information, please see license.txt
|
# For license information, please see license.txt
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.utils import flt
|
from frappe.utils import flt
|
||||||
|
|
@ -11,6 +13,19 @@ def execute(filters=None):
|
||||||
return columns, data
|
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):
|
def get_data(filters):
|
||||||
"""Получаем данные по VAT Allocation с распределением по налоговым статьям"""
|
"""Получаем данные по VAT Allocation с распределением по налоговым статьям"""
|
||||||
|
|
||||||
|
|
@ -45,9 +60,13 @@ def get_data(filters):
|
||||||
allocations_data[allocation_name][field_key] = 0
|
allocations_data[allocation_name][field_key] = 0
|
||||||
allocations_data[allocation_name][f"{field_key}_odenis"] = 0
|
allocations_data[allocation_name][f"{field_key}_odenis"] = 0
|
||||||
|
|
||||||
# Получаем полное название статьи по сокращенному name
|
# Каждая строка несёт JSON-список статей; одна и та же сумма строки
|
||||||
tax_article_name = item['tax_article']
|
# попадает в КАЖДУЮ статью из списка (как на ФНО, где один оборот может
|
||||||
if tax_article_name and tax_article_name in tax_article_names_mapping:
|
# стоять под несколькими 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]
|
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
|
# Только строки с amount > 0 или allocated_amount > 0
|
||||||
conditions.append("(vasi.amount > 0 OR vasi.allocated_amount > 0)")
|
conditions.append("(vasi.amount > 0 OR vasi.allocated_amount > 0)")
|
||||||
|
|
||||||
# Только строки с tax_article
|
# Только строки с tax_articles (непустой JSON-список)
|
||||||
conditions.append("vasi.tax_article IS NOT NULL AND vasi.tax_article != ''")
|
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 ""
|
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
|
||||||
|
|
||||||
|
|
@ -112,7 +131,7 @@ def get_vat_allocation_items(filters):
|
||||||
va.month,
|
va.month,
|
||||||
STR_TO_DATE(CONCAT('01 ', va.month, ' ', va.year), '%%d %%M %%Y') as posting_date,
|
STR_TO_DATE(CONCAT('01 ', va.month, ' ', va.year), '%%d %%M %%Y') as posting_date,
|
||||||
vasi.customer,
|
vasi.customer,
|
||||||
vasi.tax_article,
|
vasi.tax_articles,
|
||||||
vasi.amount,
|
vasi.amount,
|
||||||
vasi.allocated_amount
|
vasi.allocated_amount
|
||||||
FROM
|
FROM
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue