diff --git a/taxes_az/taxes_az/doctype/tax_article_items_report/__init__.py b/taxes_az/taxes_az/doctype/tax_article_items_report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/taxes_az/taxes_az/doctype/tax_article_items_report/tax_article_items_report.js b/taxes_az/taxes_az/doctype/tax_article_items_report/tax_article_items_report.js new file mode 100644 index 0000000..3517dd0 --- /dev/null +++ b/taxes_az/taxes_az/doctype/tax_article_items_report/tax_article_items_report.js @@ -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 = ` +
+ + + + + + + + + + + + + + + + `; + + let total_amount = 0; + + data.forEach(function(row) { + html += ` + + + + + + + + + + + + `; + total_amount += (row.amount || 0); + }); + + html += ` + + + + + + + + +
Sales InvoiceDateCustomerItem CodeItem NameQtyRateAmountTax Article
${row.parent}${frappe.datetime.str_to_user(row.posting_date)}${row.customer || ''}${row.item_code || ''}${row.item_name || ''}${row.qty || 0}${format_currency(row.rate || 0)}${format_currency(row.amount || 0)}${row.tax_article || ''}
Total${format_currency(total_amount)}
+
+ `; + + 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('
Select Tax Article to see results
'); + frm.set_value('total_items_found', 0); +} \ No newline at end of file diff --git a/taxes_az/taxes_az/doctype/tax_article_items_report/tax_article_items_report.json b/taxes_az/taxes_az/doctype/tax_article_items_report/tax_article_items_report.json new file mode 100644 index 0000000..6983cfc --- /dev/null +++ b/taxes_az/taxes_az/doctype/tax_article_items_report/tax_article_items_report.json @@ -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": "
Select Tax Article and click Refresh to see results
" + } + ], + "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 +} \ No newline at end of file diff --git a/taxes_az/taxes_az/doctype/tax_article_items_report/tax_article_items_report.py b/taxes_az/taxes_az/doctype/tax_article_items_report/tax_article_items_report.py new file mode 100644 index 0000000..3590ac9 --- /dev/null +++ b/taxes_az/taxes_az/doctype/tax_article_items_report/tax_article_items_report.py @@ -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 {} \ No newline at end of file diff --git a/taxes_az/taxes_az/doctype/tax_article_items_report/test_tax_article_items_report.py b/taxes_az/taxes_az/doctype/tax_article_items_report/test_tax_article_items_report.py new file mode 100644 index 0000000..1e8929d --- /dev/null +++ b/taxes_az/taxes_az/doctype/tax_article_items_report/test_tax_article_items_report.py @@ -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 diff --git a/taxes_az/taxes_az/doctype/vat_allocation/__init__.py b/taxes_az/taxes_az/doctype/vat_allocation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/taxes_az/taxes_az/doctype/vat_allocation/test_vat_allocation.py b/taxes_az/taxes_az/doctype/vat_allocation/test_vat_allocation.py new file mode 100644 index 0000000..22991c2 --- /dev/null +++ b/taxes_az/taxes_az/doctype/vat_allocation/test_vat_allocation.py @@ -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 diff --git a/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.js b/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.js new file mode 100644 index 0000000..9211bed --- /dev/null +++ b/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.js @@ -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 = ` +
+
+
+ Total VAT Free: ${format_currency(data.payment_row.vat_free_amount)}
+ VAT Free Spent: ${format_currency(data.payment_row.vat_free_allocated || 0)} +
+
+ Total VAT Allocated: ${format_currency(data.payment_row.vat_allocated_amount)}
+ VAT Allocated Spent: ${format_currency(data.payment_row.vat_allocated_allocated || 0)} +
+
+
+ `; + 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 = '
'; + + // VAT Free items section + if (vat_free_items.length > 0) { + html += '
VAT Free Items (ƏDV 0%, ƏDV-dən azadolma)
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + vat_free_items.forEach(function(item) { + allocation_data[item.name] = { value: item.current_allocation || 0, type: 'vat_free' }; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + + html += '
Item Tax TemplateTax ArticleTotal AmountUnallocatedCurrent AllocNew Allocation
' + (item.item_tax_template || '-') + '' + (item.tax_article || '-') + '' + format_currency(item.amount) + '' + format_currency(item.unallocated_amount) + '' + format_currency(item.current_allocation || 0) + '
'; + } + + // VAT 18% items section + if (vat_items.length > 0) { + html += '
VAT 18% Items (ƏDV 18%, ƏDV daxil 18%)
'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + vat_items.forEach(function(item) { + allocation_data[item.name] = { value: item.current_allocation || 0, type: 'vat_allocated' }; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + + html += '
Item Tax TemplateTax ArticleTotal AmountUnallocatedCurrent AllocNew Allocation
' + (item.item_tax_template || '-') + '' + (item.tax_article || '-') + '' + format_currency(item.amount) + '' + format_currency(item.unallocated_amount) + '' + format_currency(item.current_allocation || 0) + '
'; + } + + // Summary section + let formatted_zero = $(frappe.format(0, {fieldtype: 'Currency'})).text(); + + html += '
'; + html += '
'; + html += '
Total VAT Free to Allocate: ' + formatted_zero + '
'; + html += '
Total VAT to Allocate: ' + formatted_zero + '
'; + html += '
'; + html += '
'; + html += '
Total: ' + formatted_zero + '
'; + html += '
'; + html += '
'; + + html += '
'; + + 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 + '
Total: ' + format_currency(r.message.total_allocated) + + '
VAT Free: ' + format_currency(r.message.vat_free_spent) + + '
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'}); +} \ No newline at end of file diff --git a/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.json b/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.json new file mode 100644 index 0000000..824c528 --- /dev/null +++ b/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.json @@ -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": [] +} \ No newline at end of file diff --git a/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.py b/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.py new file mode 100644 index 0000000..6a8faac --- /dev/null +++ b/taxes_az/taxes_az/doctype/vat_allocation/vat_allocation.py @@ -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""" + Allocation Updated
+ Payment Row: {payment_entry_row_name}
+ VAT Free: {total_vat_free}
+ VAT 18%: {total_vat_allocated}
+ Total: {total_vat_free + total_vat_allocated}
+ Items: {len([a for a in allocations if a['allocated'] > 0])}
+ User: {frappe.session.user}
+ 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 \ No newline at end of file diff --git a/taxes_az/taxes_az/doctype/vat_allocation_link/__init__.py b/taxes_az/taxes_az/doctype/vat_allocation_link/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/taxes_az/taxes_az/doctype/vat_allocation_link/vat_allocation_link.json b/taxes_az/taxes_az/doctype/vat_allocation_link/vat_allocation_link.json new file mode 100644 index 0000000..d90e056 --- /dev/null +++ b/taxes_az/taxes_az/doctype/vat_allocation_link/vat_allocation_link.json @@ -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": [] +} \ No newline at end of file diff --git a/taxes_az/taxes_az/doctype/vat_allocation_link/vat_allocation_link.py b/taxes_az/taxes_az/doctype/vat_allocation_link/vat_allocation_link.py new file mode 100644 index 0000000..986bfa8 --- /dev/null +++ b/taxes_az/taxes_az/doctype/vat_allocation_link/vat_allocation_link.py @@ -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 diff --git a/taxes_az/taxes_az/doctype/vat_allocation_payment_entry/__init__.py b/taxes_az/taxes_az/doctype/vat_allocation_payment_entry/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/taxes_az/taxes_az/doctype/vat_allocation_payment_entry/vat_allocation_payment_entry.json b/taxes_az/taxes_az/doctype/vat_allocation_payment_entry/vat_allocation_payment_entry.json new file mode 100644 index 0000000..3a721ba --- /dev/null +++ b/taxes_az/taxes_az/doctype/vat_allocation_payment_entry/vat_allocation_payment_entry.json @@ -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": [] +} \ No newline at end of file diff --git a/taxes_az/taxes_az/doctype/vat_allocation_payment_entry/vat_allocation_payment_entry.py b/taxes_az/taxes_az/doctype/vat_allocation_payment_entry/vat_allocation_payment_entry.py new file mode 100644 index 0000000..9968863 --- /dev/null +++ b/taxes_az/taxes_az/doctype/vat_allocation_payment_entry/vat_allocation_payment_entry.py @@ -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 diff --git a/taxes_az/taxes_az/doctype/vat_allocation_sales_invoice/__init__.py b/taxes_az/taxes_az/doctype/vat_allocation_sales_invoice/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..5d07bb8 --- /dev/null +++ b/taxes_az/taxes_az/doctype/vat_allocation_sales_invoice/vat_allocation_sales_invoice.json @@ -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": [] +} \ No newline at end of file diff --git a/taxes_az/taxes_az/doctype/vat_allocation_sales_invoice/vat_allocation_sales_invoice.py b/taxes_az/taxes_az/doctype/vat_allocation_sales_invoice/vat_allocation_sales_invoice.py new file mode 100644 index 0000000..184efad --- /dev/null +++ b/taxes_az/taxes_az/doctype/vat_allocation_sales_invoice/vat_allocation_sales_invoice.py @@ -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