added vat_allocation

This commit is contained in:
Ali 2025-10-01 22:52:40 +04:00
parent 7fff108480
commit f121789432
19 changed files with 2211 additions and 0 deletions

View File

@ -0,0 +1,113 @@
frappe.ui.form.on('Tax Article Items Report', {
tax_article: function(frm) {
if (frm.doc.tax_article) {
refresh_items_list(frm);
} else {
clear_results(frm);
}
},
from_date: function(frm) {
if (frm.doc.tax_article) {
refresh_items_list(frm);
}
},
to_date: function(frm) {
if (frm.doc.tax_article) {
refresh_items_list(frm);
}
},
company: function(frm) {
if (frm.doc.tax_article) {
refresh_items_list(frm);
}
},
refresh_button: function(frm) {
if (frm.doc.tax_article) {
refresh_items_list(frm);
} else {
frappe.msgprint(__('Please select a Tax Article first'));
}
}
});
function refresh_items_list(frm) {
frappe.call({
method: 'taxes_az.taxes_az.doctype.tax_article_items_report.tax_article_items_report.get_items_by_tax_article',
args: {
tax_article: frm.doc.tax_article,
from_date: frm.doc.from_date,
to_date: frm.doc.to_date,
company: frm.doc.company
},
callback: function(r) {
if (r.message) {
display_items_list(frm, r.message);
}
}
});
}
function display_items_list(frm, data) {
let html = `
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>Sales Invoice</th>
<th>Date</th>
<th>Customer</th>
<th>Item Code</th>
<th>Item Name</th>
<th>Qty</th>
<th>Rate</th>
<th>Amount</th>
<th>Tax Article</th>
</tr>
</thead>
<tbody>
`;
let total_amount = 0;
data.forEach(function(row) {
html += `
<tr>
<td><a href="/app/sales-invoice/${row.parent}" target="_blank">${row.parent}</a></td>
<td>${frappe.datetime.str_to_user(row.posting_date)}</td>
<td>${row.customer || ''}</td>
<td>${row.item_code || ''}</td>
<td>${row.item_name || ''}</td>
<td>${row.qty || 0}</td>
<td>${format_currency(row.rate || 0)}</td>
<td>${format_currency(row.amount || 0)}</td>
<td>${row.tax_article || ''}</td>
</tr>
`;
total_amount += (row.amount || 0);
});
html += `
</tbody>
<tfoot>
<tr class="text-bold">
<td colspan="7">Total</td>
<td>${format_currency(total_amount)}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
`;
frm.fields_dict.items_html.$wrapper.html(html);
frm.set_value('total_items_found', data.length);
}
function clear_results(frm) {
frm.fields_dict.items_html.$wrapper.html('<div class="text-muted">Select Tax Article to see results</div>');
frm.set_value('total_items_found', 0);
}

View File

@ -0,0 +1,129 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "naming_series:",
"creation": "2025-01-25 12:00:00",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"main_section",
"naming_series",
"tax_article",
"date_range_section",
"from_date",
"to_date",
"column_break_1",
"company",
"refresh_button",
"results_section",
"total_items_found",
"items_html"
],
"fields": [
{
"fieldname": "main_section",
"fieldtype": "Section Break",
"label": "Filter Settings"
},
{
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Series",
"options": "TAX-REP-.YYYY.-",
"default": "TAX-REP-.YYYY.-",
"reqd": 1
},
{
"fieldname": "tax_article",
"fieldtype": "Link",
"label": "Tax Article",
"options": "Tax Article",
"reqd": 1,
"in_list_view": 1
},
{
"fieldname": "date_range_section",
"fieldtype": "Section Break",
"label": "Date Range (Optional)"
},
{
"fieldname": "from_date",
"fieldtype": "Date",
"label": "From Date"
},
{
"fieldname": "to_date",
"fieldtype": "Date",
"label": "To Date"
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company"
},
{
"fieldname": "refresh_button",
"fieldtype": "Button",
"label": "Refresh Results"
},
{
"fieldname": "results_section",
"fieldtype": "Section Break",
"label": "Results"
},
{
"fieldname": "total_items_found",
"fieldtype": "Int",
"label": "Total Items Found",
"read_only": 1
},
{
"fieldname": "items_html",
"fieldtype": "HTML",
"label": "Items List",
"options": "<div id='tax-article-items-container'>Select Tax Article and click Refresh to see results</div>"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-01-25 12:00:00",
"modified_by": "Administrator",
"module": "Taxes Az",
"name": "Tax Article Items Report",
"naming_rule": "By Naming Series",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}

View File

@ -0,0 +1,115 @@
# Copyright (c) 2025, Jey Soft and contributors
# For license information, please see license.txt
from frappe.model.document import Document
import frappe
from frappe.model.document import Document
class TaxArticleItemsReport(Document):
pass
@frappe.whitelist()
def get_items_by_tax_article(tax_article, from_date=None, to_date=None, company=None):
"""
Get all Sales Invoice Items that have the specified tax_article
"""
conditions = ["sii.tax_article = %s"]
values = [tax_article]
# Add date filters
if from_date:
conditions.append("si.posting_date >= %s")
values.append(from_date)
if to_date:
conditions.append("si.posting_date <= %s")
values.append(to_date)
# Add company filter
if company:
conditions.append("si.company = %s")
values.append(company)
# Add docstatus condition (only submitted invoices)
conditions.append("si.docstatus = 1")
where_clause = " AND ".join(conditions)
query = f"""
SELECT
sii.parent,
si.posting_date,
si.customer,
si.customer_name,
sii.item_code,
sii.item_name,
sii.qty,
sii.rate,
sii.amount,
sii.tax_article,
si.company,
si.currency
FROM
`tabSales Invoice Item` sii
INNER JOIN
`tabSales Invoice` si ON sii.parent = si.name
WHERE
{where_clause}
ORDER BY
si.posting_date DESC, sii.parent, sii.idx
"""
try:
results = frappe.db.sql(query, values, as_dict=True)
return results
except Exception as e:
frappe.log_error(f"Error in get_items_by_tax_article: {str(e)}")
frappe.throw(f"Error retrieving data: {str(e)}")
@frappe.whitelist()
def get_tax_article_summary(tax_article, from_date=None, to_date=None, company=None):
"""
Get summary statistics for a tax article
"""
conditions = ["sii.tax_article = %s"]
values = [tax_article]
if from_date:
conditions.append("si.posting_date >= %s")
values.append(from_date)
if to_date:
conditions.append("si.posting_date <= %s")
values.append(to_date)
if company:
conditions.append("si.company = %s")
values.append(company)
conditions.append("si.docstatus = 1")
where_clause = " AND ".join(conditions)
query = f"""
SELECT
COUNT(DISTINCT sii.parent) as total_invoices,
COUNT(sii.name) as total_items,
SUM(sii.qty) as total_qty,
SUM(sii.amount) as total_amount,
AVG(sii.rate) as avg_rate
FROM
`tabSales Invoice Item` sii
INNER JOIN
`tabSales Invoice` si ON sii.parent = si.name
WHERE
{where_clause}
"""
try:
result = frappe.db.sql(query, values, as_dict=True)
return result[0] if result else {}
except Exception as e:
frappe.log_error(f"Error in get_tax_article_summary: {str(e)}")
return {}

View File

@ -0,0 +1,30 @@
# Copyright (c) 2025, Jey Soft and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record depdendencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestTaxArticleItemsReport(UnitTestCase):
"""
Unit tests for TaxArticleItemsReport.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestTaxArticleItemsReport(IntegrationTestCase):
"""
Integration tests for TaxArticleItemsReport.
Use this class for testing interactions between multiple components.
"""
pass

View File

@ -0,0 +1,30 @@
# Copyright (c) 2025, Jey Soft and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record depdendencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestVATallocation(UnitTestCase):
"""
Unit tests for VATallocation.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestVATallocation(IntegrationTestCase):
"""
Integration tests for VATallocation.
Use this class for testing interactions between multiple components.
"""
pass

View File

@ -0,0 +1,706 @@
frappe.ui.form.on('VAT allocation', {
refresh: function(frm) {
// Add button for filling Payment Entries with filters
if (!frm.is_new()) {
frm.add_custom_button(__('Fill Payment Entries'), function() {
show_payment_entry_filters(frm);
});
// Add button for filling Sales Invoice Items
frm.add_custom_button(__('Fill Sales Invoice Items'), function() {
show_sales_invoice_filters(frm);
});
}
// Setup filter for Journal Entry
setup_journal_entry_query(frm);
}
});
// Function to show filter dialog for Payment Entries
function show_payment_entry_filters(frm) {
// Check that year and month are selected
if (!frm.doc.year || !frm.doc.month) {
frappe.msgprint(__('Please select Year and Month first'));
return;
}
// Create dialog with filters
let dialog = new frappe.ui.Dialog({
title: __('Payment Entry Filters'),
fields: [
{
fieldname: 'customers_section',
fieldtype: 'Section Break',
label: __('Select Customers (Optional)')
},
{
fieldname: 'customers',
fieldtype: 'Table',
label: __('Customers'),
description: __('Leave empty to get all customers'),
fields: [
{
fieldname: 'customer',
fieldtype: 'Link',
label: __('Customer'),
options: 'Customer',
in_list_view: 1
}
]
},
{
fieldname: 'amount_section',
fieldtype: 'Section Break',
label: __('Amount Range (Optional)')
},
{
fieldname: 'min_amount',
fieldtype: 'Currency',
label: __('Minimum Amount'),
description: __('Only payments >= this amount')
},
{
fieldname: 'column_break_1',
fieldtype: 'Column Break'
},
{
fieldname: 'max_amount',
fieldtype: 'Currency',
label: __('Maximum Amount'),
description: __('Only payments <= this amount')
}
],
primary_action_label: __('Fill Table'),
primary_action: function(values) {
fill_payment_entries(frm, values);
dialog.hide();
}
});
dialog.show();
// Hide scrollbar
setTimeout(function() {
dialog.$wrapper.find('.frappe-control[data-fieldname="customers"] .grid-scroll-bar').css({
'visibility': 'hidden',
'height': '0px'
});
}, 100);
}
// Function to show filter dialog for Sales Invoice Items
function show_sales_invoice_filters(frm) {
// Check that year and month are selected
if (!frm.doc.year || !frm.doc.month) {
frappe.msgprint(__('Please select Year and Month first'));
return;
}
// Create dialog with filters
let dialog = new frappe.ui.Dialog({
title: __('Sales Invoice Items Filters'),
fields: [
{
fieldname: 'customers_section',
fieldtype: 'Section Break',
label: __('Select Customers (Optional)')
},
{
fieldname: 'customers',
fieldtype: 'Table',
label: __('Customers'),
description: __('Leave empty to get all customers'),
fields: [
{
fieldname: 'customer',
fieldtype: 'Link',
label: __('Customer'),
options: 'Customer',
in_list_view: 1
}
]
},
{
fieldname: 'amount_section',
fieldtype: 'Section Break',
label: __('Amount Range (Optional)')
},
{
fieldname: 'min_amount',
fieldtype: 'Currency',
label: __('Minimum Amount'),
description: __('Only grouped amounts >= this amount')
},
{
fieldname: 'column_break_1',
fieldtype: 'Column Break'
},
{
fieldname: 'max_amount',
fieldtype: 'Currency',
label: __('Maximum Amount'),
description: __('Only grouped amounts <= this amount')
}
],
primary_action_label: __('Fill Table'),
primary_action: function(values) {
fill_sales_invoice_items(frm, values);
dialog.hide();
}
});
dialog.show();
// Hide scrollbar
setTimeout(function() {
dialog.$wrapper.find('.frappe-control[data-fieldname="customers"] .grid-scroll-bar').css({
'visibility': 'hidden',
'height': '0px'
});
}, 100);
}
// Function to fill Payment Entries with filters
function fill_payment_entries(frm, filters) {
// Call server method
frappe.call({
method: 'taxes_az.taxes_az.doctype.vat_allocation.vat_allocation.get_payment_entries',
args: {
year: frm.doc.year,
month: frm.doc.month,
customers: filters.customers ? JSON.stringify(filters.customers) : null,
min_amount: filters.min_amount || null,
max_amount: filters.max_amount || null
},
callback: function(r) {
if (r.message && r.message.length > 0) {
// Clear existing table
frm.clear_table('payment_entry_table');
// Add new rows
r.message.forEach(function(payment) {
let row = frm.add_child('payment_entry_table');
row.customer = payment.customer;
row.payment_entry = payment.payment_entry;
row.payment_entry_amount = payment.payment_entry_amount;
row.vat_free_amount = payment.payment_entry_amount; // Default: all amount is VAT free
row.unallocated_amount = payment.payment_entry_amount;
});
frm.refresh_field('payment_entry_table');
frappe.msgprint(__('Payment Entries loaded successfully: {0} records', [r.message.length]));
} else {
frappe.msgprint(__('No Payment Entries found for selected period'));
}
}
});
}
// Function to fill Sales Invoice Items with filters
function fill_sales_invoice_items(frm, filters) {
// Call server method
frappe.call({
method: 'taxes_az.taxes_az.doctype.vat_allocation.vat_allocation.get_sales_invoice_items',
args: {
year: frm.doc.year,
month: frm.doc.month,
customers: filters.customers ? JSON.stringify(filters.customers) : null,
min_amount: filters.min_amount || null,
max_amount: filters.max_amount || null
},
callback: function(r) {
if (r.message && r.message.length > 0) {
// Clear existing table
frm.clear_table('sales_invoice_table');
// Add new rows
r.message.forEach(function(item) {
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.amount = item.amount;
row.unallocated_amount = item.amount; // Default: all amount is unallocated
});
frm.refresh_field('sales_invoice_table');
frappe.msgprint(__('Sales Invoice Items loaded successfully: {0} records', [r.message.length]));
} else {
frappe.msgprint(__('No Sales Invoice Items found for selected period'));
}
}
});
}
// Setup query filter for Journal Entry
function setup_journal_entry_query(frm) {
frm.fields_dict.payment_entry_table.grid.get_field('vat_journal_entry').get_query = function(doc, cdt, cdn) {
let child = locals[cdt][cdn];
return {
query: 'taxes_az.taxes_az.doctype.vat_allocation.vat_allocation.get_journal_entry_query',
filters: {
customer: child.customer,
year: doc.year,
month: doc.month
}
};
};
}
// Setup for VAT Journal Entry
frappe.ui.form.on('VAT allocation payment entry', {
vat_journal_entry: function(frm, cdt, cdn) {
let row = locals[cdt][cdn];
if (row.vat_journal_entry) {
// Get Total Debit from Journal Entry
frappe.call({
method: 'frappe.client.get_value',
args: {
doctype: 'Journal Entry',
filters: { name: row.vat_journal_entry },
fieldname: ['total_debit', 'docstatus']
},
callback: function(r) {
if (r.message) {
// Check if Journal Entry is submitted
if (r.message.docstatus !== 1) {
frappe.msgprint(__('Warning: Selected Journal Entry is not submitted'));
frappe.model.set_value(cdt, cdn, 'vat_journal_entry', '');
return;
}
// Fill vat_amount
frappe.model.set_value(cdt, cdn, 'vat_amount', r.message.total_debit);
// Calculate vat_allocated_amount
let vat_allocated = r.message.total_debit / 0.18;
frappe.model.set_value(cdt, cdn, 'vat_allocated_amount', vat_allocated);
// Calculate vat_free_amount
let vat_free = row.payment_entry_amount - vat_allocated;
frappe.model.set_value(cdt, cdn, 'vat_free_amount', vat_free);
// allocated_amount remains 0 for now (will be calculated from links)
frappe.model.set_value(cdt, cdn, 'allocated_amount', 0);
}
}
});
} else {
// If journal entry is cleared or not selected
frappe.model.set_value(cdt, cdn, 'vat_amount', 0);
frappe.model.set_value(cdt, cdn, 'vat_allocated_amount', 0);
frappe.model.set_value(cdt, cdn, 'vat_free_amount', row.payment_entry_amount);
frappe.model.set_value(cdt, cdn, 'allocated_amount', 0);
}
},
// Setup query filter for vat_journal_entry when form loads
payment_entry_table_add: function(frm, cdt, cdn) {
setup_journal_entry_query(frm);
},
// If payment_entry_amount changes and no journal entry selected
payment_entry_amount: function(frm, cdt, cdn) {
let row = locals[cdt][cdn];
if (!row.vat_journal_entry) {
frappe.model.set_value(cdt, cdn, 'vat_free_amount', row.payment_entry_amount);
frappe.model.set_value(cdt, cdn, 'unallocated_amount', row.payment_entry_amount);
}
},
// Handle Allocate button click
allocate_button: function(frm, cdt, cdn) {
let row = locals[cdt][cdn];
// Validate that customer is selected
if (!row.customer) {
frappe.msgprint(__('Please select a customer first'));
return;
}
// Check if customer is active
frappe.call({
method: 'frappe.client.get_value',
args: {
doctype: 'Customer',
filters: { name: row.customer },
fieldname: ['disabled']
},
callback: function(r) {
if (r.message && r.message.disabled) {
frappe.msgprint(__('Cannot allocate to disabled customer'));
return;
}
// Open allocation dialog
show_allocation_dialog(frm, row.name, row.customer);
}
});
}
});
// Show allocation dialog
function show_allocation_dialog(frm, payment_row_name, customer) {
// Get allocation data from server
frappe.call({
method: 'taxes_az.taxes_az.doctype.vat_allocation.vat_allocation.get_allocation_data',
args: {
vat_allocation_name: frm.doc.name,
payment_entry_row_name: payment_row_name,
customer: customer
},
callback: function(r) {
if (!r.message) {
frappe.msgprint(__('No data available for allocation'));
return;
}
let data = r.message;
let vat_free_items = data.vat_free_items;
let vat_items = data.vat_items;
// Check if there are items to allocate to
if (vat_free_items.length === 0 && vat_items.length === 0) {
frappe.msgprint(__('No sales invoice items available for this customer'));
return;
}
// Create allocation dialog
create_allocation_dialog(frm, payment_row_name, data, vat_free_items, vat_items);
}
});
}
function create_allocation_dialog(frm, payment_row_name, data, vat_free_items, vat_items) {
let allocation_data = {};
let dialog = new frappe.ui.Dialog({
title: __('Allocate Amount'),
size: 'large',
fields: [
{
fieldname: 'info_section',
fieldtype: 'Section Break',
label: __('Payment Entry Information')
},
{
fieldname: 'payment_info_html',
fieldtype: 'HTML'
},
{
fieldname: 'available_section',
fieldtype: 'Section Break',
label: __('Available for Allocation')
},
{
fieldname: 'vat_free_available',
fieldtype: 'Currency',
label: __('VAT Free Available (ƏDV 0%, ƏDV-dən azadolma)'),
read_only: 1,
default: data.vat_free_available
},
{
fieldname: 'column_break_avail',
fieldtype: 'Column Break'
},
{
fieldname: 'vat_allocated_available',
fieldtype: 'Currency',
label: __('VAT Allocated Available (ƏDV 18%, ƏDV daxil 18%)'),
read_only: 1,
default: data.vat_allocated_available
},
{
fieldname: 'items_section',
fieldtype: 'Section Break',
label: __('Sales Invoice Items - Edit Allocations')
},
{
fieldname: 'allocation_html',
fieldtype: 'HTML'
}
],
primary_action_label: __('Save Allocation'),
primary_action: function() {
save_allocation_data(frm, payment_row_name, allocation_data, dialog);
},
secondary_action_label: __('Auto Allocate'),
secondary_action: function() {
auto_allocate(frm, payment_row_name, data, vat_free_items, vat_items, allocation_data, dialog);
}
});
// Build payment info HTML
let info_html = `
<div style="padding: 10px; background-color: #f8f9fa; border-radius: 5px; margin-bottom: 10px;">
<div class="row">
<div class="col-md-6">
<strong>Total VAT Free:</strong> ${format_currency(data.payment_row.vat_free_amount)}<br>
<strong>VAT Free Spent:</strong> ${format_currency(data.payment_row.vat_free_allocated || 0)}
</div>
<div class="col-md-6">
<strong>Total VAT Allocated:</strong> ${format_currency(data.payment_row.vat_allocated_amount)}<br>
<strong>VAT Allocated Spent:</strong> ${format_currency(data.payment_row.vat_allocated_allocated || 0)}
</div>
</div>
</div>
`;
dialog.fields_dict.payment_info_html.$wrapper.html(info_html);
// Build allocation HTML
let html = build_allocation_html(vat_free_items, vat_items, allocation_data);
dialog.fields_dict.allocation_html.$wrapper.html(html);
// Setup input handlers
setup_allocation_inputs(dialog, allocation_data, data.vat_free_available, data.vat_allocated_available);
dialog.show();
}
function build_allocation_html(vat_free_items, vat_items, allocation_data) {
let html = '<div class="allocation-container" style="max-height: 400px; overflow-y: auto;">';
// VAT Free items section
if (vat_free_items.length > 0) {
html += '<h5 style="margin-top: 10px; color: #2490ef;">VAT Free Items (ƏDV 0%, ƏDV-dən azadolma)</h5>';
html += '<table class="table table-bordered" style="margin-bottom: 20px;">';
html += '<thead><tr>';
html += '<th>Item Tax Template</th>';
html += '<th>Tax Article</th>';
html += '<th>Total Amount</th>';
html += '<th>Unallocated</th>';
html += '<th>Current Alloc</th>';
html += '<th style="width: 150px;">New Allocation</th>';
html += '</tr></thead><tbody>';
vat_free_items.forEach(function(item) {
allocation_data[item.name] = { value: item.current_allocation || 0, type: 'vat_free' };
html += '<tr>';
html += '<td>' + (item.item_tax_template || '-') + '</td>';
html += '<td>' + (item.tax_article || '-') + '</td>';
html += '<td>' + format_currency(item.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><input type="number" class="form-control allocation-input" data-name="' + item.name + '" data-type="vat_free" data-max="' + (item.unallocated_amount + (item.current_allocation || 0)) + '" value="' + (item.current_allocation || 0) + '" min="0" step="0.01"></td>';
html += '</tr>';
});
html += '</tbody></table>';
}
// VAT 18% items section
if (vat_items.length > 0) {
html += '<h5 style="margin-top: 10px; color: #f56b00;">VAT 18% Items (ƏDV 18%, ƏDV daxil 18%)</h5>';
html += '<table class="table table-bordered">';
html += '<thead><tr>';
html += '<th>Item Tax Template</th>';
html += '<th>Tax Article</th>';
html += '<th>Total Amount</th>';
html += '<th>Unallocated</th>';
html += '<th>Current Alloc</th>';
html += '<th style="width: 150px;">New Allocation</th>';
html += '</tr></thead><tbody>';
vat_items.forEach(function(item) {
allocation_data[item.name] = { value: item.current_allocation || 0, type: 'vat_allocated' };
html += '<tr>';
html += '<td>' + (item.item_tax_template || '-') + '</td>';
html += '<td>' + (item.tax_article || '-') + '</td>';
html += '<td>' + format_currency(item.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><input type="number" class="form-control allocation-input" data-name="' + item.name + '" data-type="vat_allocated" data-max="' + (item.unallocated_amount + (item.current_allocation || 0)) + '" value="' + (item.current_allocation || 0) + '" min="0" step="0.01"></td>';
html += '</tr>';
});
html += '</tbody></table>';
}
// Summary section
let formatted_zero = $(frappe.format(0, {fieldtype: 'Currency'})).text();
html += '<div class="allocation-summary" style="margin-top: 20px; padding: 10px; background-color: #f8f9fa; border-radius: 5px;">';
html += '<div class="row">';
html += '<div class="col-md-6"><strong>Total VAT Free to Allocate:</strong> <span id="total_vat_free" class="text-primary">' + formatted_zero + '</span></div>';
html += '<div class="col-md-6"><strong>Total VAT to Allocate:</strong> <span id="total_vat_allocated" class="text-warning">' + formatted_zero + '</span></div>';
html += '</div>';
html += '<div class="row" style="margin-top: 10px;">';
html += '<div class="col-md-12"><strong>Total:</strong> <span id="total_allocated" class="text-success">' + formatted_zero + '</span></div>';
html += '</div>';
html += '</div>';
html += '</div>';
return html;
}
function setup_allocation_inputs(dialog, allocation_data, max_vat_free, max_vat_allocated) {
let $wrapper = dialog.fields_dict.allocation_html.$wrapper;
// Initial calculation
setTimeout(function() {
$wrapper.find('.allocation-input').first().trigger('input');
}, 100);
$wrapper.find('.allocation-input').on('input', function() {
let $input = $(this);
let name = $input.data('name');
let type = $input.data('type');
let max_value = parseFloat($input.data('max'));
let value = parseFloat($input.val()) || 0;
// Validate against item's max
if (value > max_value) {
value = max_value;
$input.val(value.toFixed(2));
}
if (value < 0) {
value = 0;
$input.val(value.toFixed(2));
}
allocation_data[name] = {
value: value,
type: type
};
// Calculate totals
let total_vat_free = 0;
let total_vat_allocated = 0;
$wrapper.find('.allocation-input').each(function() {
let item_type = $(this).data('type');
let item_value = parseFloat($(this).val()) || 0;
if (item_type === 'vat_free') {
total_vat_free += item_value;
} else {
total_vat_allocated += item_value;
}
});
// Validate totals against available amounts (with tolerance)
const TOLERANCE = 0.01;
if (total_vat_free > max_vat_free + TOLERANCE) {
frappe.show_alert({
message: __('Total VAT Free allocation exceeds available amount'),
indicator: 'red'
});
// Reset this input
allocation_data[name] = { value: 0, type: type };
$input.val(0);
total_vat_free -= value;
}
// Update summary
$wrapper.find('#total_vat_free').text($(frappe.format(total_vat_free, {fieldtype: 'Currency'})).text());
$wrapper.find('#total_vat_allocated').text($(frappe.format(total_vat_allocated, {fieldtype: 'Currency'})).text());
$wrapper.find('#total_allocated').text($(frappe.format(total_vat_free + total_vat_allocated, {fieldtype: 'Currency'})).text());
});
}
function auto_allocate(frm, payment_row_name, data, vat_free_items, vat_items, allocation_data, dialog) {
// Call server method to calculate auto allocation
frappe.call({
method: 'taxes_az.taxes_az.doctype.vat_allocation.vat_allocation.calculate_auto_allocation',
args: {
vat_free_available: data.vat_free_available,
vat_allocated_available: data.vat_allocated_available,
vat_free_items: JSON.stringify(vat_free_items),
vat_items: JSON.stringify(vat_items)
},
callback: function(r) {
if (r.message) {
let allocations = r.message;
let $wrapper = dialog.fields_dict.allocation_html.$wrapper;
// Reset all inputs
$wrapper.find('.allocation-input').val(0);
// Update allocation data
for (let key in allocation_data) {
allocation_data[key].value = 0;
}
// Apply auto allocations
allocations.forEach(function(alloc) {
allocation_data[alloc.name].value = alloc.allocated;
$wrapper.find('.allocation-input[data-name="' + alloc.name + '"]').val(alloc.allocated.toFixed(2));
});
// Trigger input event to update summary
$wrapper.find('.allocation-input').first().trigger('input');
frappe.show_alert({
message: __('Auto allocation completed'),
indicator: 'green'
});
}
}
});
}
function save_allocation_data(frm, payment_row_name, allocation_data, dialog) {
// Prepare allocations array
let allocations = [];
for (let name in allocation_data) {
let data = allocation_data[name];
allocations.push({
name: name,
allocated: data.value,
type: data.type
});
}
// Save to server with loading indicator
frappe.dom.freeze(__('Saving allocation...'));
frappe.call({
method: 'taxes_az.taxes_az.doctype.vat_allocation.vat_allocation.save_allocation',
args: {
vat_allocation_name: frm.doc.name,
payment_entry_row_name: payment_row_name,
allocations: JSON.stringify(allocations)
},
callback: function(r) {
frappe.dom.unfreeze();
if (r.message && r.message.success) {
frappe.show_alert({
message: r.message.message + '<br>Total: ' + format_currency(r.message.total_allocated) +
'<br>VAT Free: ' + format_currency(r.message.vat_free_spent) +
'<br>VAT 18%: ' + format_currency(r.message.vat_allocated_spent),
indicator: 'green'
}, 7);
dialog.hide();
frm.reload_doc();
} else if (r.message && !r.message.success) {
frappe.msgprint({
title: __('Error'),
message: r.message.message,
indicator: 'red'
});
}
},
error: function(r) {
frappe.dom.unfreeze();
frappe.msgprint({
title: __('Error'),
message: __('Failed to save allocation. Please try again.'),
indicator: 'red'
});
}
});
}
function format_currency(value) {
return frappe.format(value, {fieldtype: 'Currency'});
}

View File

@ -0,0 +1,97 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-09-29 19:03:22.767217",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"date_section",
"year",
"column_break_ruqk",
"month",
"payment_entry_section",
"payment_entry_table",
"sales_invoice_section",
"sales_invoice_table",
"allocation_links_section",
"allocation_links"
],
"fields": [
{
"fieldname": "date_section",
"fieldtype": "Section Break",
"label": "Date"
},
{
"fieldname": "year",
"fieldtype": "Int",
"label": "Year"
},
{
"fieldname": "column_break_ruqk",
"fieldtype": "Column Break"
},
{
"fieldname": "month",
"fieldtype": "Select",
"label": "Month",
"options": "January\nFebruary\nMarch\nApril\nMay\nJune\nJuly\nAugust\nSeptember\nOctober\nNovember\nDecember"
},
{
"fieldname": "payment_entry_section",
"fieldtype": "Section Break"
},
{
"fieldname": "payment_entry_table",
"fieldtype": "Table",
"label": "Payment Entry",
"options": "VAT allocation payment entry"
},
{
"fieldname": "sales_invoice_section",
"fieldtype": "Section Break"
},
{
"fieldname": "sales_invoice_table",
"fieldtype": "Table",
"label": "Sales Invoice",
"options": "VAT allocation sales invoice"
},
{
"fieldname": "allocation_links_section",
"fieldtype": "Section Break",
"label": "Allocation Links"
},
{
"fieldname": "allocation_links",
"fieldtype": "Table",
"hidden": 1,
"label": "Allocation Links",
"options": "VAT Allocation Link"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-10-01 21:26:07.923819",
"modified_by": "Administrator",
"module": "Taxes Az",
"name": "VAT allocation",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,704 @@
import frappe
from frappe import _
from frappe.model.document import Document
from decimal import Decimal, ROUND_HALF_UP
import json
# Константа для tolerance округления
ROUNDING_TOLERANCE = Decimal('0.01')
class VATallocation(Document):
def validate(self):
# Check if document with same year and month already exists
self.validate_unique_year_month()
# Recalculate all totals from links
self.recalculate_totals_from_links()
# Validate all allocations
self.validate_allocations()
def validate_unique_year_month(self):
"""
Ensure only one VAT allocation document exists per year and month
"""
if not self.year or not self.month:
return
existing = frappe.db.exists({
'doctype': 'VAT allocation',
'year': self.year,
'month': self.month,
'name': ['!=', self.name]
})
if existing:
frappe.throw(_(f"VAT allocation for {self.month} {self.year} already exists: {existing}"))
def recalculate_totals_from_links(self):
"""
Recalculate all allocated/unallocated amounts from allocation links
"""
# Reset all allocated amounts
for pe_row in self.payment_entry_table:
pe_row.vat_free_allocated = 0
pe_row.vat_allocated_allocated = 0
pe_row.allocated_amount = 0
for si_row in self.sales_invoice_table:
si_row.allocated_amount = 0
# Calculate from links
for link in self.allocation_links:
allocated = Decimal(str(link.allocated_amount or 0))
if allocated <= 0:
continue
# Update payment entry row
for pe_row in self.payment_entry_table:
if pe_row.name == link.payment_entry_row:
current_allocated = Decimal(str(pe_row.allocated_amount or 0))
pe_row.allocated_amount = float(current_allocated + allocated)
if link.allocation_type == 'vat_free':
current_vat_free = Decimal(str(pe_row.vat_free_allocated or 0))
pe_row.vat_free_allocated = float(current_vat_free + allocated)
else:
current_vat_allocated = Decimal(str(pe_row.vat_allocated_allocated or 0))
pe_row.vat_allocated_allocated = float(current_vat_allocated + allocated)
break
# Update sales invoice row
for si_row in self.sales_invoice_table:
if si_row.name == link.sales_invoice_row:
current_allocated = Decimal(str(si_row.allocated_amount or 0))
si_row.allocated_amount = float(current_allocated + allocated)
break
# Calculate unallocated amounts
for pe_row in self.payment_entry_table:
total = Decimal(str(pe_row.payment_entry_amount or 0))
allocated = Decimal(str(pe_row.allocated_amount or 0))
pe_row.unallocated_amount = float(max(Decimal('0'), total - allocated))
for si_row in self.sales_invoice_table:
total = Decimal(str(si_row.amount or 0))
allocated = Decimal(str(si_row.allocated_amount or 0))
si_row.unallocated_amount = float(max(Decimal('0'), total - allocated))
def validate_allocations(self):
"""
Validate that all allocations are mathematically correct
"""
# Validate payment entries
for pe_row in self.payment_entry_table:
vat_free_total = Decimal(str(pe_row.vat_free_amount or 0))
vat_free_spent = Decimal(str(pe_row.vat_free_allocated or 0))
vat_allocated_total = Decimal(str(pe_row.vat_allocated_amount or 0))
vat_allocated_spent = Decimal(str(pe_row.vat_allocated_allocated or 0))
# Check non-negative
if vat_free_spent < 0 or vat_allocated_spent < 0:
frappe.throw(_(f"Spent amounts cannot be negative for payment entry {pe_row.payment_entry}"))
# Check not exceeding totals (with tolerance)
if vat_free_spent > vat_free_total + ROUNDING_TOLERANCE:
frappe.throw(_(f"VAT Free spent exceeds total for payment entry {pe_row.payment_entry}"))
if vat_allocated_spent > vat_allocated_total + ROUNDING_TOLERANCE:
frappe.throw(_(f"VAT Allocated spent exceeds total for payment entry {pe_row.payment_entry}"))
# Validate sales invoice items
for si_row in self.sales_invoice_table:
amount = Decimal(str(si_row.amount or 0))
allocated = Decimal(str(si_row.allocated_amount or 0))
unallocated = Decimal(str(si_row.unallocated_amount or 0))
# Check non-negative
if allocated < 0 or unallocated < -ROUNDING_TOLERANCE:
frappe.throw(_(f"Allocated amounts cannot be negative for {si_row.item_tax_template}"))
# Check sum equals total (with tolerance)
total_check = allocated + unallocated
if abs(total_check - amount) > ROUNDING_TOLERANCE:
frappe.throw(_(f"Allocated + Unallocated != Amount for {si_row.item_tax_template}"))
@frappe.whitelist()
def validate_all_allocations(vat_allocation_name):
"""
Manual validation function that can be called from UI
"""
try:
doc = frappe.get_doc('VAT allocation', vat_allocation_name)
doc.validate_allocations()
return {
'success': True,
'message': _('All allocations are valid')
}
except Exception as e:
return {
'success': False,
'message': str(e)
}
@frappe.whitelist()
def get_payment_entries(year, month, customers=None, min_amount=None, max_amount=None):
"""
Get Payment Entries for specified month and year with optional filters
customers can be a JSON string of customer list
"""
# Convert month name to number
months = {
"January": 1, "February": 2, "March": 3, "April": 4,
"May": 5, "June": 6, "July": 7, "August": 8,
"September": 9, "October": 10, "November": 11, "December": 12
}
month_number = months.get(month)
if not month_number:
frappe.throw(_("Invalid month"))
# Build WHERE conditions
conditions = """
pe.docstatus = 1
AND YEAR(pe.posting_date) = %(year)s
AND MONTH(pe.posting_date) = %(month)s
AND pe.payment_type = 'Receive'
AND pe.party_type = 'Customer'
"""
params = {
'year': year,
'month': month_number
}
# Add optional filters
if customers:
# Parse customers JSON string if it's a string
if isinstance(customers, str):
customers = json.loads(customers)
if customers and len(customers) > 0:
customer_list = [c.get('customer') for c in customers if c.get('customer')]
if customer_list:
conditions += " AND pe.party IN %(customers)s"
params['customers'] = customer_list
if min_amount:
conditions += " AND pe.paid_amount >= %(min_amount)s"
params['min_amount'] = min_amount
if max_amount:
conditions += " AND pe.paid_amount <= %(max_amount)s"
params['max_amount'] = max_amount
# SQL query to get Payment Entries
payment_entries = frappe.db.sql(f"""
SELECT
pe.name as payment_entry,
pe.party as customer,
pe.paid_amount as payment_entry_amount,
pe.posting_date
FROM
`tabPayment Entry` pe
WHERE
{conditions}
ORDER BY
pe.posting_date
""", params, as_dict=1)
return payment_entries
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_journal_entry_query(doctype, txt, searchfield, start, page_len, filters):
"""
Custom query for filtering Journal Entries
"""
customer = filters.get('customer')
year = filters.get('year')
month = filters.get('month')
if not all([customer, year, month]):
return []
# Convert month name to number
months = {
"January": 1, "February": 2, "March": 3, "April": 4,
"May": 5, "June": 6, "July": 7, "August": 8,
"September": 9, "October": 10, "November": 11, "December": 12
}
month_number = months.get(month)
if not month_number:
return []
# SQL query to get matching Journal Entries
# txt is already sanitized by @frappe.validate_and_sanitize_search_inputs decorator
return frappe.db.sql("""
SELECT DISTINCT
je.name, je.posting_date, je.total_debit
FROM
`tabJournal Entry` je
WHERE
je.docstatus = 1
AND YEAR(je.posting_date) = %(year)s
AND MONTH(je.posting_date) = %(month)s
AND je.name LIKE %(txt)s
AND EXISTS (
SELECT 1
FROM `tabJournal Entry Account` jea_credit
WHERE jea_credit.parent = je.name
AND jea_credit.account LIKE '211%%'
AND jea_credit.credit_in_account_currency > 0
AND jea_credit.party = %(customer)s
)
AND EXISTS (
SELECT 1
FROM `tabJournal Entry Account` jea_debit
WHERE jea_debit.parent = je.name
AND jea_debit.account LIKE '226%%'
AND jea_debit.debit_in_account_currency > 0
)
ORDER BY
je.posting_date DESC
LIMIT %(start)s, %(page_len)s
""", {
'year': year,
'month': month_number,
'customer': customer,
'txt': f"%{txt}%",
'start': start,
'page_len': page_len
})
@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
customers can be a JSON string of customer list
"""
# Convert month name to number
months = {
"January": 1, "February": 2, "March": 3, "April": 4,
"May": 5, "June": 6, "July": 7, "August": 8,
"September": 9, "October": 10, "November": 11, "December": 12
}
month_number = months.get(month)
if not month_number:
frappe.throw(_("Invalid month"))
# Build WHERE conditions
conditions = """
si.docstatus = 1
AND YEAR(si.posting_date) = %(year)s
AND MONTH(si.posting_date) = %(month)s
"""
params = {
'year': year,
'month': month_number
}
# Add optional customer filter
if customers:
# Parse customers JSON string if it's a string
if isinstance(customers, str):
customers = json.loads(customers)
if customers and len(customers) > 0:
customer_list = [c.get('customer') for c in customers if c.get('customer')]
if customer_list:
conditions += " AND si.customer IN %(customers)s"
params['customers'] = customer_list
# SQL query with HAVING clause for amount filters after grouping
having_conditions = []
if min_amount:
having_conditions.append("SUM(sii.amount) >= %(min_amount)s")
params['min_amount'] = min_amount
if max_amount:
having_conditions.append("SUM(sii.amount) <= %(max_amount)s")
params['max_amount'] = max_amount
having_clause = ""
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
invoice_items = frappe.db.sql(f"""
SELECT
si.customer,
sii.item_tax_template,
sii.tax_article,
SUM(sii.amount) as amount,
SUM(sii.net_amount) as net_amount,
SUM(sii.qty) as total_qty
FROM
`tabSales Invoice` si
INNER JOIN
`tabSales Invoice Item` sii ON si.name = sii.parent
WHERE
{conditions}
GROUP BY
si.customer, sii.item_tax_template, sii.tax_article
{having_clause}
ORDER BY
si.customer, sii.item_tax_template, sii.tax_article
""", params, as_dict=1)
return invoice_items
@frappe.whitelist()
def get_allocation_data(vat_allocation_name, payment_entry_row_name, customer):
"""
Get data needed for allocation dialog with existing allocations
"""
# Validate customer is active
customer_doc = frappe.get_cached_value('Customer', customer, ['disabled'], as_dict=True)
if customer_doc and customer_doc.get('disabled'):
frappe.throw(_("Customer {0} is disabled").format(customer))
# Get the payment entry row
payment_row = frappe.db.get_value(
'VAT allocation payment entry',
payment_entry_row_name,
[
'vat_free_amount',
'vat_allocated_amount',
'vat_free_allocated',
'vat_allocated_allocated',
'allocated_amount',
'unallocated_amount'
],
as_dict=1
)
if not payment_row:
frappe.throw(_("Payment entry row not found"))
# Calculate available amounts using Decimal for precision
vat_free_amount = Decimal(str(payment_row.vat_free_amount or 0))
vat_free_allocated = Decimal(str(payment_row.vat_free_allocated or 0))
vat_allocated_amount = Decimal(str(payment_row.vat_allocated_amount or 0))
vat_allocated_allocated = Decimal(str(payment_row.vat_allocated_allocated or 0))
vat_free_available = float(vat_free_amount - vat_free_allocated)
vat_allocated_available = float(vat_allocated_amount - vat_allocated_allocated)
# Get sales invoice items for this customer from the same VAT allocation document
doc = frappe.get_doc('VAT allocation', vat_allocation_name)
# Get existing allocations from this payment row
existing_allocations = {}
for link in doc.allocation_links:
if link.payment_entry_row == payment_entry_row_name:
existing_allocations[link.sales_invoice_row] = {
'amount': link.allocated_amount,
'type': link.allocation_type
}
# VAT Free templates (ƏDV 0% and ƏDV-dən azadolma)
vat_free_items = []
# VAT templates (ƏDV 18% and ƏDV daxil 18%)
vat_items = []
for item in doc.sales_invoice_table:
if item.customer == customer:
# Get existing allocation for this item
existing_alloc = existing_allocations.get(item.name, {'amount': 0, 'type': None})
item_data = {
'name': item.name,
'item_tax_template': item.item_tax_template or '',
'tax_article': item.tax_article or '',
'amount': item.amount,
'unallocated_amount': item.unallocated_amount,
'allocated_amount': item.allocated_amount or 0,
'current_allocation': existing_alloc['amount'] # Allocation from THIS payment row
}
template = (item.item_tax_template or '').lower()
# Check if it's VAT free template
if 'ədv 0%' in template or 'ədv-dən azadolma' in template:
vat_free_items.append(item_data)
# Check if it's VAT 18% template
elif 'ədv 18%' in template or 'ədv daxil 18%' in template:
vat_items.append(item_data)
return {
'payment_row': payment_row,
'vat_free_available': vat_free_available,
'vat_allocated_available': vat_allocated_available,
'vat_free_items': vat_free_items,
'vat_items': vat_items
}
def round_decimal(value, precision=2):
"""
Round decimal value to specified precision
"""
if isinstance(value, (int, float)):
value = Decimal(str(value))
quantize_value = Decimal('0.1') ** precision
return float(value.quantize(quantize_value, rounding=ROUND_HALF_UP))
@frappe.whitelist()
def save_allocation(vat_allocation_name, payment_entry_row_name, allocations):
"""
Save allocation data using links table
allocations is a JSON string with format:
[
{'name': 'si_row_name', 'allocated': amount, 'type': 'vat_free' or 'vat_allocated'},
...
]
"""
try:
# Parse allocations
if isinstance(allocations, str):
allocations = json.loads(allocations)
# Validate input
if not isinstance(allocations, list):
frappe.throw(_("Invalid allocations data"))
# Get document
doc = frappe.get_doc('VAT allocation', vat_allocation_name)
# Check if document is submitted or cancelled
if doc.docstatus == 1:
frappe.throw(_("Cannot modify submitted document"))
if doc.docstatus == 2:
frappe.throw(_("Cannot modify cancelled document"))
# Find payment entry row
payment_row = None
for pe_row in doc.payment_entry_table:
if pe_row.name == payment_entry_row_name:
payment_row = pe_row
break
if not payment_row:
frappe.throw(_("Payment entry row not found"))
# Calculate what we're trying to allocate
total_vat_free_new = Decimal('0')
total_vat_allocated_new = Decimal('0')
for allocation in allocations:
allocated_amount = Decimal(str(allocation['allocated']))
if allocated_amount < 0:
frappe.throw(_("Allocated amount cannot be negative"))
if allocation['type'] == 'vat_free':
total_vat_free_new += allocated_amount
else:
total_vat_allocated_new += allocated_amount
# Check available amounts
vat_free_amount = Decimal(str(payment_row.vat_free_amount or 0))
vat_allocated_amount = Decimal(str(payment_row.vat_allocated_amount or 0))
# Validate totals with tolerance
if total_vat_free_new > vat_free_amount + ROUNDING_TOLERANCE:
frappe.throw(_(
"Total VAT Free allocation ({0}) exceeds total amount ({1})"
).format(total_vat_free_new, vat_free_amount))
if total_vat_allocated_new > vat_allocated_amount + ROUNDING_TOLERANCE:
frappe.throw(_(
"Total VAT Allocated ({0}) exceeds total amount ({1})"
).format(total_vat_allocated_new, vat_allocated_amount))
# Validate each SI item
si_items_map = {item.name: item for item in doc.sales_invoice_table}
for allocation in allocations:
si_row_name = allocation['name']
allocated_amount = Decimal(str(allocation['allocated']))
item = si_items_map.get(si_row_name)
if not item:
frappe.throw(_(f"Sales invoice item {si_row_name} not found"))
# Calculate how much is already allocated to this SI from OTHER payment rows
other_allocations = Decimal('0')
for link in doc.allocation_links:
if link.sales_invoice_row == si_row_name and link.payment_entry_row != payment_entry_row_name:
other_allocations += Decimal(str(link.allocated_amount or 0))
# Check if total (new + other) exceeds SI amount
total_for_si = allocated_amount + other_allocations
si_amount = Decimal(str(item.amount))
if total_for_si > si_amount + ROUNDING_TOLERANCE:
frappe.throw(_(
"Cannot allocate {0} to {1}. Total allocation would be {2}, but amount is only {3}."
).format(allocated_amount, item.item_tax_template, total_for_si, si_amount))
# Remove existing links for this payment row
links_to_remove = []
for link in doc.allocation_links:
if link.payment_entry_row == payment_entry_row_name:
links_to_remove.append(link)
for link in links_to_remove:
doc.allocation_links.remove(link)
# Create new links
for allocation in allocations:
allocated_amount = Decimal(str(allocation['allocated']))
# Skip if amount is 0
if allocated_amount <= 0:
continue
link_row = doc.append('allocation_links', {})
link_row.payment_entry_row = payment_entry_row_name
link_row.sales_invoice_row = allocation['name']
link_row.allocated_amount = float(allocated_amount)
link_row.allocation_type = allocation['type']
# Save document (this will trigger recalculate_totals_from_links in validate)
doc.save()
# Create audit log
create_allocation_log(
vat_allocation_name=vat_allocation_name,
payment_entry_row_name=payment_entry_row_name,
allocations=allocations,
total_vat_free=float(total_vat_free_new),
total_vat_allocated=float(total_vat_allocated_new)
)
frappe.db.commit()
return {
'success': True,
'message': _('Allocation saved successfully'),
'vat_free_spent': round_decimal(total_vat_free_new),
'vat_allocated_spent': round_decimal(total_vat_allocated_new),
'total_allocated': round_decimal(total_vat_free_new + total_vat_allocated_new)
}
except Exception as e:
frappe.db.rollback()
frappe.log_error(f"Allocation Save Error: {str(e)}", "VAT Allocation Error")
return {
'success': False,
'message': str(e)
}
def create_allocation_log(vat_allocation_name, payment_entry_row_name, allocations,
total_vat_free, total_vat_allocated):
"""
Create audit log for allocation
"""
try:
log_doc = frappe.get_doc({
'doctype': 'Comment',
'comment_type': 'Info',
'reference_doctype': 'VAT allocation',
'reference_name': vat_allocation_name,
'content': f"""
<b>Allocation Updated</b><br>
Payment Row: {payment_entry_row_name}<br>
VAT Free: {total_vat_free}<br>
VAT 18%: {total_vat_allocated}<br>
Total: {total_vat_free + total_vat_allocated}<br>
Items: {len([a for a in allocations if a['allocated'] > 0])}<br>
User: {frappe.session.user}<br>
Time: {frappe.utils.now_datetime()}
"""
})
log_doc.insert(ignore_permissions=True)
except Exception as e:
frappe.log_error(f"Failed to create allocation log: {str(e)}", "Allocation Log Error")
@frappe.whitelist()
def calculate_auto_allocation(vat_free_available, vat_allocated_available, vat_free_items, vat_items):
"""
Calculate smart auto allocation with improved rounding
"""
if isinstance(vat_free_items, str):
vat_free_items = json.loads(vat_free_items)
if isinstance(vat_items, str):
vat_items = json.loads(vat_items)
vat_free_available = Decimal(str(vat_free_available))
vat_allocated_available = Decimal(str(vat_allocated_available))
allocations = []
# Allocate VAT free amount proportionally to VAT free items
if vat_free_available > 0 and len(vat_free_items) > 0:
# Calculate available for each item (total unallocated minus current allocation from this PE)
items_with_available = []
for item in vat_free_items:
available = Decimal(str(item['unallocated_amount'])) + Decimal(str(item.get('current_allocation', 0)))
if available > 0:
items_with_available.append({
'name': item['name'],
'available': available
})
if items_with_available:
total_available = sum(item['available'] for item in items_with_available)
remaining = vat_free_available
for i, item in enumerate(items_with_available):
if i == len(items_with_available) - 1:
# Last item gets the remainder
allocated = min(remaining, item['available'])
else:
# Proportional allocation
proportion = item['available'] / total_available
allocated = min(vat_free_available * proportion, item['available'])
allocated = allocated.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
if allocated > 0:
allocations.append({
'name': item['name'],
'allocated': float(allocated),
'type': 'vat_free'
})
remaining -= allocated
# Allocate VAT amount proportionally to VAT items
if vat_allocated_available > 0 and len(vat_items) > 0:
# Calculate available for each item
items_with_available = []
for item in vat_items:
available = Decimal(str(item['unallocated_amount'])) + Decimal(str(item.get('current_allocation', 0)))
if available > 0:
items_with_available.append({
'name': item['name'],
'available': available
})
if items_with_available:
total_available = sum(item['available'] for item in items_with_available)
remaining = vat_allocated_available
for i, item in enumerate(items_with_available):
if i == len(items_with_available) - 1:
# Last item gets the remainder
allocated = min(remaining, item['available'])
else:
# Proportional allocation
proportion = item['available'] / total_available
allocated = min(vat_allocated_available * proportion, item['available'])
allocated = allocated.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
if allocated > 0:
allocations.append({
'name': item['name'],
'allocated': float(allocated),
'type': 'vat_allocated'
})
remaining -= allocated
return allocations

View File

@ -0,0 +1,56 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-10-01 20:00:00.000000",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"payment_entry_row",
"sales_invoice_row",
"allocated_amount",
"allocation_type"
],
"fields": [
{
"fieldname": "payment_entry_row",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Payment Entry Row",
"read_only": 1
},
{
"fieldname": "sales_invoice_row",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Sales Invoice Row",
"read_only": 1
},
{
"fieldname": "allocated_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Allocated Amount",
"precision": "2"
},
{
"fieldname": "allocation_type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "vat_free\nvat_allocated"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-01 20:00:00.000000",
"modified_by": "Administrator",
"module": "Taxes Az",
"name": "VAT Allocation Link",
"owner": "Administrator",
"permissions": [],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,9 @@
# Copyright (c) 2025, Jey Soft and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class VATAllocationLink(Document):
pass

View File

@ -0,0 +1,122 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-09-29 19:05:02.860158",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"customer",
"payment_entry",
"payment_entry_amount",
"vat_journal_entry",
"vat_amount",
"vat_allocated_amount",
"vat_allocated_allocated",
"vat_free_amount",
"vat_free_allocated",
"allocated_amount",
"unallocated_amount",
"allocate_button"
],
"fields": [
{
"fieldname": "customer",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Customer",
"options": "Customer"
},
{
"fieldname": "payment_entry",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Payment Entry",
"options": "Payment Entry"
},
{
"fieldname": "payment_entry_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Payment Entry Amount",
"precision": "2"
},
{
"fieldname": "vat_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "VAT Amount",
"precision": "2"
},
{
"fieldname": "vat_journal_entry",
"fieldtype": "Link",
"in_list_view": 1,
"label": "VAT Journal Entry",
"options": "Journal Entry",
"precision": "2"
},
{
"fieldname": "vat_allocated_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "VAT Allocated Amount",
"precision": "2",
"read_only": 1
},
{
"fieldname": "vat_allocated_allocated",
"fieldtype": "Currency",
"label": "VAT Allocated (Spent)",
"precision": "2",
"read_only": 1
},
{
"fieldname": "vat_free_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "VAT Free Amount",
"precision": "2",
"read_only": 1
},
{
"fieldname": "vat_free_allocated",
"fieldtype": "Currency",
"label": "VAT Free (Spent)",
"precision": "2",
"read_only": 1
},
{
"fieldname": "allocated_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Allocated amount",
"precision": "2"
},
{
"fieldname": "unallocated_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Unallocated amount",
"precision": "2"
},
{
"fieldname": "allocate_button",
"fieldtype": "Button",
"in_list_view": 1,
"label": "Allocate"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-10-01 19:51:09.389265",
"modified_by": "Administrator",
"module": "Taxes Az",
"name": "VAT allocation payment entry",
"owner": "Administrator",
"permissions": [],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,9 @@
# Copyright (c) 2025, Jey Soft and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class VATallocationpaymententry(Document):
pass

View File

@ -0,0 +1,82 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2025-09-29 20:34:31.450432",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"customer",
"payment_entry",
"item_tax_template",
"tax_article",
"amount",
"allocated_amount",
"unallocated_amount"
],
"fields": [
{
"fieldname": "customer",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Customer",
"options": "Customer"
},
{
"fieldname": "payment_entry",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Payment Entry",
"options": "Payment Entry"
},
{
"fieldname": "allocated_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Allocated amount",
"precision": "2"
},
{
"fieldname": "unallocated_amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Unallocated amount",
"precision": "2"
},
{
"fieldname": "item_tax_template",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Item tax template",
"options": "Item Tax Template",
"precision": "2"
},
{
"fieldname": "tax_article",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Tax Article",
"options": "Tax Article",
"precision": "2"
},
{
"fieldname": "amount",
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Amount",
"precision": "2"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-09-29 20:36:21.708944",
"modified_by": "Administrator",
"module": "Taxes Az",
"name": "VAT allocation sales invoice",
"owner": "Administrator",
"permissions": [],
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,9 @@
# Copyright (c) 2025, Jey Soft and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class VATallocationsalesinvoice(Document):
pass