diff --git a/erpnext/patches/v16_0/add_arabic_company_name_field.py b/erpnext/patches/v16_0/add_arabic_company_name_field.py index 57b8db59f97..100bf5502a4 100644 --- a/erpnext/patches/v16_0/add_arabic_company_name_field.py +++ b/erpnext/patches/v16_0/add_arabic_company_name_field.py @@ -1,10 +1,21 @@ import frappe - -from erpnext.regional.united_arab_emirates.setup import make_custom_fields +from frappe.custom.doctype.custom_field.custom_field import create_custom_fields def execute(): - if not frappe.db.get_value("Company", {"country": "United Arab Emirates"}): + if not frappe.db.exists("Company", {"country": "United Arab Emirates"}): return - make_custom_fields() + create_custom_fields( + { + "Company": [ + { + "fieldname": "company_name_in_arabic", + "label": "Company Name in Arabic", + "fieldtype": "Data", + "insert_after": "company_name", + } + ] + }, + ignore_validate=True, + ) diff --git a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.json b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.json index db64126ae59..061b50e219f 100644 --- a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.json +++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.json @@ -112,7 +112,7 @@ "fieldname": "file_type", "fieldtype": "Select", "label": "File Type", - "options": "VAT\nExcise", + "options": "VAT", "default": "VAT", "reqd": 1 }, diff --git a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py index fd8d937735e..1356c76a744 100644 --- a/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py +++ b/erpnext/regional/doctype/fta_audit_file/fta_audit_file.py @@ -35,6 +35,23 @@ DEFAULT_COUNTRY = "United Arab Emirates" DEFAULT_DATE = "31-12-9999" PRODUCT_VERSION = "ERPNext" +# Inputs that define what the FAF file represents. Once the file has been +# Generated or Submitted, changing any of these would silently desync the +# attached CSV from the form, so we lock them. +LOCKED_INPUT_FIELDS = ( + "company", + "from_date", + "to_date", + "file_type", + "include_opening_balance", + "tax_agency_name", + "tan", + "tax_agent_name", + "taan", +) +LOCKED_STATUSES = ("Generated", "Submitted") +IN_FLIGHT_STATUSES = ("Queued", "Generating") + class FTAAuditFile(Document): def validate(self): @@ -48,9 +65,31 @@ class FTAAuditFile(Document): ) ) + self._guard_locked_fields() + if self.status != "Error": self.error_message = None + def _guard_locked_fields(self): + """Block edits to FAF inputs once the file is Generated or Submitted. + + The status field alone is read-only in the UI, but a user with write + permission can still patch fields via REST or scripts; this enforces + immutability server-side so the attached CSV always matches the form. + """ + if self.is_new(): + return + + previous_status = self.get_db_value("status") + if previous_status not in LOCKED_STATUSES: + return + + changed = [f for f in LOCKED_INPUT_FIELDS if self.has_value_changed(f)] + if changed: + frappe.throw( + _("Cannot modify {0} after the FAF has been {1}.").format(", ".join(changed), previous_status) + ) + @frappe.whitelist() def generate_faf(self): """Queue FAF generation as a background job. @@ -64,6 +103,14 @@ class FTAAuditFile(Document): Under ``frappe.flags.in_test`` the job runs synchronously so tests can assert on the post-generation state without polling. """ + # Re-read status from DB so two concurrent button clicks can't both + # enqueue a job — the second one sees Queued/Generating and bails. + current_status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True) + if current_status in IN_FLIGHT_STATUSES: + frappe.throw(_("FAF generation is already {0} for this document.").format(current_status)) + if current_status == "Submitted": + frappe.throw(_("Cannot regenerate a Submitted FAF.")) + self.status = "Queued" self.generation_log = "" self.error_message = None @@ -140,9 +187,6 @@ class FTAAuditFile(Document): log(f"Period: {self.from_date} to {self.to_date}") log(f"File Type: {self.file_type}") - if self.file_type != "VAT": - frappe.throw(_("FAF generation for {0} is not yet implemented").format(self.file_type)) - output = io.StringIO() writer = csv.writer(output) @@ -252,11 +296,14 @@ class FTAAuditFile(Document): "item_tax_template", ], ) - tax_code_map = { - t: _resolve_tax_code(t) - for t in {item.item_tax_template for items in items_by_invoice.values() for item in items} - if t - } + tax_code_bands = _bulk_tax_code_bands( + { + item.item_tax_template + for items in items_by_invoice.values() + for item in items + if item.item_tax_template + } + ) company_currency = _company_currency(self.company) @@ -285,7 +332,7 @@ class FTAAuditFile(Document): _clean(item.description or item.item_name or ""), _money(net_aed), _money(vat_aed), - tax_code_map.get(item.item_tax_template, "SR"), + _resolve_tax_code(item.item_tax_template, inv.posting_date, tax_code_bands), fcy_code, _money(net_fcy), _money(vat_fcy), @@ -351,11 +398,14 @@ class FTAAuditFile(Document): "is_exempt", ], ) - tax_code_map = { - t: _resolve_tax_code(t) - for t in {item.item_tax_template for items in items_by_invoice.values() for item in items} - if t - } + tax_code_bands = _bulk_tax_code_bands( + { + item.item_tax_template + for items in items_by_invoice.values() + for item in items + if item.item_tax_template + } + ) company_currency = _company_currency(self.company) @@ -379,7 +429,7 @@ class FTAAuditFile(Document): elif item.is_exempt: tax_code = "EX" else: - tax_code = tax_code_map.get(item.item_tax_template, "SR") + tax_code = _resolve_tax_code(item.item_tax_template, inv.posting_date, tax_code_bands) writer.writerow( [ @@ -563,7 +613,14 @@ def _bulk_party_field(doctype, names, field): def _bulk_party_country(party_doctype, party_names): - """Return ``{party_name: country}`` taken from each party's first address with a country.""" + """Return ``{party_name: country}`` for each party. + + Picks deterministically when a party has multiple addresses: prefer the + one flagged ``is_primary_address``, then ``is_shipping_address``, then + the lowest address name. Without this ordering, MariaDB would return + rows in storage-engine order and the FAF would be non-reproducible + across runs. + """ if not party_names: return {} @@ -579,6 +636,9 @@ def _bulk_party_country(party_doctype, party_names): .where(addr.country.isnotnull()) .where(addr.country != "") .select(dl.link_name, addr.country) + .orderby(addr.is_primary_address, order=frappe.qb.desc) + .orderby(addr.is_shipping_address, order=frappe.qb.desc) + .orderby(addr.name) .run(as_dict=True) ) out = {} @@ -638,22 +698,51 @@ def _bulk_invoice_items(child_doctype, invoice_names, fields): _FTA_TAX_CODES = ("SR", "ZR", "EX", "RC", "IG", "OA", "IA") -def _resolve_tax_code(item_tax_template): +def _bulk_tax_code_bands(item_tax_templates): + """Return ``{template: [(valid_from, tax_category), ...]}`` sorted desc by valid_from. + + One ``Item Tax Template`` can have multiple ``Item Tax`` rows with + different ``valid_from`` dates (e.g. tax code changing on a regulator + cutover). Fetching them all up-front lets ``_resolve_tax_code`` pick + the row that was in force on each invoice's posting date without an + extra DB hit per line item. + """ + if not item_tax_templates: + return {} + rows = frappe.get_all( + "Item Tax", + filters={"item_tax_template": ["in", list(item_tax_templates)]}, + fields=["item_tax_template", "tax_category", "valid_from"], + order_by="valid_from desc", + ) + out = {} + for r in rows: + out.setdefault(r["item_tax_template"], []).append((r.get("valid_from"), r.get("tax_category"))) + return out + + +def _resolve_tax_code(item_tax_template, posting_date, bands_map): """Derive FTA tax code (SR/ZR/EX/RC/IG/OA/IA) from one Item Tax Template. - Uses the Item Tax row's ``tax_category`` if it matches an FTA code; - otherwise defaults to ``SR`` (Standard Rated). Setting ``tax_category`` - on each Item Tax is the supported way to control this — there is no - heuristic fallback on the template name. + Picks the Item Tax row whose ``valid_from`` is the most recent value + that is still on or before ``posting_date``; rows with no + ``valid_from`` are treated as always-valid and used only as a + fallback. Defaults to ``SR`` (Standard Rated) when nothing matches or + the chosen ``tax_category`` isn't one of the FTA codes. """ if not item_tax_template: return "SR" - tax_category = frappe.db.get_value( - "Item Tax", - {"item_tax_template": item_tax_template}, - "tax_category", - ) - if tax_category and tax_category in _FTA_TAX_CODES: - return tax_category + bands = bands_map.get(item_tax_template) or [] + posting = getdate(posting_date) if posting_date else None + fallback_category = None + for valid_from, tax_category in bands: + if valid_from is None: + fallback_category = fallback_category or tax_category + continue + if posting is None or getdate(valid_from) <= posting: + return tax_category if tax_category in _FTA_TAX_CODES else "SR" + + if fallback_category and fallback_category in _FTA_TAX_CODES: + return fallback_category return "SR" diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.py b/erpnext/regional/report/uae_vat_201/uae_vat_201.py index 6dc18501af7..ae50b88187c 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.py @@ -2,9 +2,13 @@ # For license information, please see license.txt +from html import escape +from urllib.parse import urlencode + import frappe from frappe import _ -from frappe.query_builder.functions import Sum +from frappe.query_builder.functions import Coalesce, Sum +from frappe.utils import flt from erpnext import get_region @@ -14,6 +18,25 @@ from erpnext import get_region _cache = {} +def _drill_down_link(text, filters, **extra): + """Return an `` tag pointing at the UAE VAT Register report. + + Filter values are URL-encoded so company names with ``&`` or other + reserved characters don't break the query string, and the link text + is HTML-escaped to prevent injection from user-controlled fields. + """ + params = {} + for key in ("company", "from_date", "to_date"): + value = (filters or {}).get(key) + if value: + params[key] = value + for key, value in extra.items(): + if value is not None: + params[key] = value + query = urlencode(params) + return f'{escape(str(text))}' + + def _cached(fn): def wrapper(filters, *args, **kwargs): key = (fn.__name__, tuple(sorted((filters or {}).items()))) @@ -68,179 +91,68 @@ def get_data(filters=None): append_vat_on_expenses(data, filters) net_vat_due(data, filters, amounts_by_emirate) + emirate_drill_downs = {f"Standard rated supplies in {emirate}": emirate for emirate in get_emirates()} + dubai_legend = "Standard rated supplies in Dubai" + dubai_label_override = _company_emirate_label(filters) + final_data = [] - for i in range(0, len(data)): - if data[i].get("legend") == "Standard rated supplies in Abu Dhabi": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Standard rated supplies in Dubai": - company = frappe.defaults.get_user_default("Company") - company_filters = [ - ["Dynamic Link", "link_doctype", "=", "Company"], - ["Dynamic Link", "link_name", "=", company], - ["Address", "is_your_company_address", "=", 1], - ] - company_fields = [ - "name", - "address_line1", - "address_line2", - "city", - "state", - "country", - "emirate", - ] - address = frappe.get_all("Address", filters=company_filters, fields=company_fields) + for row in data: + legend = row.get("legend") + new_legend = legend - if address: - if address[0].get("emirate"): - name = "Standard rated supplies in" + " " + address[0].get("emirate") - else: - name = "Standard rated supplies in Dubai" - else: - name = "Standard rated supplies in Dubai" + if legend in emirate_drill_downs: + emirate = emirate_drill_downs[legend] + label = dubai_label_override if legend == dubai_legend and dubai_label_override else legend + new_legend = _drill_down_link( + label, filters, doc_type="Sales Invoice", vat=emirate, category="Standard" + ) + elif legend == "Supplies subject to the reverse charge provision": + new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice", reverse_charge="Y") + elif legend == "Zero Rated": + new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Zero Rated") + elif legend == "Exempt Supplies": + new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Exempt Rated") + elif legend == "Standard Rated Expenses": + new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice") - legend_link = f""" - {name} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - - elif data[i].get("legend") == "Standard rated supplies in Sharjah": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Standard rated supplies in Ajman": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Standard rated supplies in Umm Al Quwain": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Standard rated supplies in Ras Al Khaimah": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Standard rated supplies in Fujairah": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Supplies subject to the reverse charge provision": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Zero Rated": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Exempt Supplies": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - elif data[i].get("legend") == "Standard Rated Expenses": - legend_link = f""" - {data[i].get("legend")} - """ - final_data.append( - { - "no": data[i].get("no"), - "legend": legend_link, - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) - else: - final_data.append( - { - "no": data[i].get("no"), - "legend": data[i].get("legend"), - "amount": data[i].get("amount"), - "vat_amount": data[i].get("vat_amount"), - } - ) + final_data.append( + { + "no": row.get("no"), + "legend": new_legend, + "amount": row.get("amount"), + "vat_amount": row.get("vat_amount"), + } + ) return final_data +def _company_emirate_label(filters): + """Return the home-emirate label for the company in ``filters`` if any. + + The Dubai row is conventionally relabeled with the actual emirate of + the filtered company's primary address. Falls back to ``None`` when + no company filter is set or the address has no emirate, in which case + callers keep the original "Standard rated supplies in Dubai" wording. + """ + company = (filters or {}).get("company") + if not company: + return None + address = frappe.get_all( + "Address", + filters=[ + ["Dynamic Link", "link_doctype", "=", "Company"], + ["Dynamic Link", "link_name", "=", company], + ["Address", "is_your_company_address", "=", 1], + ], + fields=["emirate"], + limit=1, + ) + if address and address[0].get("emirate"): + return _("Standard rated supplies in {0}").format(address[0]["emirate"]) + return None + + def append_vat_on_sales(data, filters): """Appends Sales and All Other Outputs.""" append_data(data, "", _("VAT on Sales and All Other Outputs"), "", "") @@ -384,7 +296,7 @@ def net_vat_due(data, filters, amounts_by_emirate): append_data( data, "13", - _("Total value of recoverable tax for the period "), + _("Total value of recoverable tax for the period"), frappe.format(0.00, "Currency"), frappe.format( get_standard_rated_expenses_tax(filters) + get_reverse_charge_recoverable_tax(filters), @@ -424,22 +336,24 @@ def format_currency_signed(value): @_cached def get_total_emiratewise(filters): """Returns Emiratewise Amount and Taxes.""" - i = frappe.qb.DocType("Sales Invoice Item") - s = frappe.qb.DocType("Sales Invoice") + si = frappe.qb.DocType("Sales Invoice") + sii = frappe.qb.DocType("Sales Invoice Item") query = ( - frappe.qb.from_(i) - .inner_join(s) - .on(i.parent == s.name) - .select(s.vat_emirate.as_("emirate"), Sum(i.base_net_amount).as_("total"), Sum(i.tax_amount)) - .where((s.docstatus == 1) & (i.is_exempt != 1) & (i.is_zero_rated != 1)) - .groupby(s.vat_emirate) + frappe.qb.from_(sii) + .inner_join(si) + .on(sii.parent == si.name) + .where(si.docstatus == 1) + .where(sii.is_exempt != 1) + .where(sii.is_zero_rated != 1) + .groupby(si.vat_emirate) + .select( + si.vat_emirate.as_("emirate"), + Coalesce(Sum(sii.base_net_amount), 0).as_("total"), + Coalesce(Sum(sii.tax_amount), 0), + ) ) - for condition in get_conditions(filters, s): - query = query.where(condition) - try: - return query.run() - except (IndexError, TypeError): - return 0 + query = _apply_period_filters(query, si, filters) + return query.run() def get_emirates(): @@ -447,255 +361,185 @@ def get_emirates(): return ["Abu Dhabi", "Dubai", "Sharjah", "Ajman", "Umm Al Quwain", "Ras Al Khaimah", "Fujairah"] -def get_filters(filters): - """The conditions to be used to filter data to calculate the total sale.""" - query_filters = [] +def _apply_period_filters(query, table, filters): + """Apply company / posting-date filters from ``filters`` to a frappe.qb query.""" + filters = filters or {} if filters.get("company"): - query_filters.append(["company", "=", filters["company"]]) + query = query.where(table.company == filters["company"]) if filters.get("from_date"): - query_filters.append(["posting_date", ">=", filters["from_date"]]) + query = query.where(table.posting_date >= filters["from_date"]) if filters.get("to_date"): - query_filters.append(["posting_date", "<=", filters["to_date"]]) - return query_filters + query = query.where(table.posting_date <= filters["to_date"]) + return query + + +def _sum_invoice_field(doctype, field, filters, extra_where=None): + """Return ``sum(field)`` on a submitted invoice doctype with the standard + period filters. ``extra_where(table)`` may yield additional ``Criterion``s.""" + table = frappe.qb.DocType(doctype) + query = frappe.qb.from_(table).where(table.docstatus == 1).select(Coalesce(Sum(table[field]), 0)) + query = _apply_period_filters(query, table, filters) + if extra_where is not None: + for criterion in extra_where(table): + query = query.where(criterion) + result = query.run() + return flt(result[0][0]) if result else 0 + + +def _sum_item_field(parent_doctype, child_doctype, field, filters, extra_item_where=None): + """Return ``sum(child.field)`` for child rows of submitted parents in the period.""" + parent = frappe.qb.DocType(parent_doctype) + child = frappe.qb.DocType(child_doctype) + query = ( + frappe.qb.from_(child) + .inner_join(parent) + .on(child.parent == parent.name) + .where(parent.docstatus == 1) + .select(Coalesce(Sum(child[field]), 0)) + ) + query = _apply_period_filters(query, parent, filters) + if extra_item_where is not None: + for criterion in extra_item_where(child): + query = query.where(criterion) + result = query.run() + return flt(result[0][0]) if result else 0 + + +def _sum_vat_account_debit(filters, recoverable=False): + """Sum of GL debit for reverse-charge purchases booked to UAE VAT Accounts. + + With ``recoverable=True``, multiplies the debit by the invoice's + ``recoverable_reverse_charge`` percentage (and only sums rows with a + non-zero recoverable rate). Returns 0 when no company filter is set, + since UAE VAT Accounts are scoped per company. + """ + if not (filters or {}).get("company"): + return 0 + + pi = frappe.qb.DocType("Purchase Invoice") + gl = frappe.qb.DocType("GL Entry") + uva = frappe.qb.DocType("UAE VAT Account") + + vat_accounts = frappe.qb.from_(uva).where(uva.parent == filters["company"]).select(uva.account) + + amount = gl.debit + if recoverable: + amount = amount * pi.recoverable_reverse_charge / 100 + + query = ( + frappe.qb.from_(pi) + .inner_join(gl) + .on(gl.voucher_no == pi.name) + .where(pi.reverse_charge == "Y") + .where(pi.docstatus == 1) + .where(gl.docstatus == 1) + .where(gl.account.isin(vat_accounts)) + .select(Coalesce(Sum(amount), 0)) + ) + if recoverable: + query = query.where(pi.recoverable_reverse_charge > 0) + query = _apply_period_filters(query, pi, filters) + result = query.run() + return flt(result[0][0]) if result else 0 @_cached def get_reverse_charge_total(filters): """Returns the sum of the total of each Purchase invoice made.""" - query_filters = get_filters(filters) - query_filters.append(["reverse_charge", "=", "Y"]) - query_filters.append(["docstatus", "=", 1]) - try: - return ( - frappe.db.get_all( - "Purchase Invoice", - filters=query_filters, - fields=["sum(base_net_total)"], - as_list=True, - limit=1, - )[0][0] - or 0 - ) - except (IndexError, TypeError): - return 0 + return _sum_invoice_field( + "Purchase Invoice", + "base_net_total", + filters, + extra_where=lambda t: [t.reverse_charge == "Y"], + ) @_cached def get_reverse_charge_tax(filters): """Returns the sum of the tax of each Purchase invoice made.""" - p = frappe.qb.DocType("Purchase Invoice") - gl = frappe.qb.DocType("GL Entry") - uae_vat = frappe.qb.DocType("UAE VAT Account") - query = ( - frappe.qb.from_(p) - .inner_join(gl) - .on(gl.voucher_no == p.name) - .select(Sum(gl.debit)) - .where( - (p.reverse_charge == "Y") - & (p.docstatus == 1) - & (gl.docstatus == 1) - & gl.account.isin( - frappe.qb.from_(uae_vat) - .select(uae_vat.account) - .where(uae_vat.parent == filters.get("company")) - ) - ) - ) - for condition in get_conditions_join(filters, p): - query = query.where(condition) - return query.run()[0][0] or 0 + return _sum_vat_account_debit(filters) @_cached def get_reverse_charge_recoverable_total(filters): """Returns the sum of the total of each Purchase invoice made with recoverable reverse charge.""" - query_filters = get_filters(filters) - query_filters.append(["reverse_charge", "=", "Y"]) - query_filters.append(["recoverable_reverse_charge", ">", "0"]) - query_filters.append(["docstatus", "=", 1]) - try: - return ( - frappe.db.get_all( - "Purchase Invoice", - filters=query_filters, - fields=["sum(base_net_total)"], - as_list=True, - limit=1, - )[0][0] - or 0 - ) - except (IndexError, TypeError): - return 0 + return _sum_invoice_field( + "Purchase Invoice", + "base_net_total", + filters, + extra_where=lambda t: [t.reverse_charge == "Y", t.recoverable_reverse_charge > 0], + ) @_cached def get_reverse_charge_recoverable_tax(filters): """Returns the sum of the tax of each Purchase invoice made.""" - p = frappe.qb.DocType("Purchase Invoice") - gl = frappe.qb.DocType("GL Entry") - uae_vat = frappe.qb.DocType("UAE VAT Account") - query = ( - frappe.qb.from_(p) - .inner_join(gl) - .on(gl.voucher_no == p.name) - .select(Sum(gl.debit * p.recoverable_reverse_charge / 100)) - .where( - (p.reverse_charge == "Y") - & (p.docstatus == 1) - & (p.recoverable_reverse_charge > 0) - & (gl.docstatus == 1) - & gl.account.isin( - frappe.qb.from_(uae_vat) - .select(uae_vat.account) - .where(uae_vat.parent == filters.get("company")) - ) - ) - ) - for condition in get_conditions_join(filters, p): - query = query.where(condition) - return query.run()[0][0] or 0 - - -def get_conditions_join(filters, p): - """The conditions to be used to filter data to calculate the total vat.""" - conditions = [] - if filters.get("company"): - conditions.append(p.company == filters.get("company")) - if filters.get("from_date"): - conditions.append(p.posting_date >= filters.get("from_date")) - if filters.get("to_date"): - conditions.append(p.posting_date <= filters.get("to_date")) - return conditions + return _sum_vat_account_debit(filters, recoverable=True) @_cached def get_standard_rated_expenses_total(filters): """Returns the sum of the total of each Purchase invoice made with recoverable reverse charge.""" - query_filters = get_filters(filters) - query_filters.append(["recoverable_standard_rated_expenses", ">", 0]) - query_filters.append(["docstatus", "=", 1]) - try: - return ( - frappe.db.get_all( - "Purchase Invoice", - filters=query_filters, - fields=["sum(base_net_total)"], - as_list=True, - limit=1, - )[0][0] - or 0 - ) - except (IndexError, TypeError): - return 0 + return _sum_invoice_field( + "Purchase Invoice", + "base_net_total", + filters, + extra_where=lambda t: [t.recoverable_standard_rated_expenses > 0], + ) @_cached def get_standard_rated_expenses_tax(filters): """Returns the sum of the tax of each Purchase invoice made.""" - query_filters = get_filters(filters) - query_filters.append(["recoverable_standard_rated_expenses", ">", 0]) - query_filters.append(["docstatus", "=", 1]) - try: - return ( - frappe.db.get_all( - "Purchase Invoice", - filters=query_filters, - fields=["sum(recoverable_standard_rated_expenses)"], - as_list=True, - limit=1, - )[0][0] - or 0 - ) - except (IndexError, TypeError): - return 0 + return _sum_invoice_field( + "Purchase Invoice", + "recoverable_standard_rated_expenses", + filters, + extra_where=lambda t: [t.recoverable_standard_rated_expenses > 0], + ) @_cached def get_tourist_tax_return_total(filters): """Returns the sum of the total of each Sales invoice with non zero tourist_tax_return.""" - query_filters = get_filters(filters) - query_filters.append(["tourist_tax_return", ">", 0]) - query_filters.append(["docstatus", "=", 1]) - try: - return ( - frappe.db.get_all( - "Sales Invoice", filters=query_filters, fields=["sum(base_net_total)"], as_list=True, limit=1 - )[0][0] - or 0 - ) - except (IndexError, TypeError): - return 0 + return _sum_invoice_field( + "Sales Invoice", + "base_net_total", + filters, + extra_where=lambda t: [t.tourist_tax_return > 0], + ) @_cached def get_tourist_tax_return_tax(filters): """Returns the sum of the tax of each Sales invoice with non zero tourist_tax_return.""" - query_filters = get_filters(filters) - query_filters.append(["tourist_tax_return", ">", 0]) - query_filters.append(["docstatus", "=", 1]) - try: - return ( - frappe.db.get_all( - "Sales Invoice", - filters=query_filters, - fields=["sum(tourist_tax_return)"], - as_list=True, - limit=1, - )[0][0] - or 0 - ) - except (IndexError, TypeError): - return 0 + return _sum_invoice_field( + "Sales Invoice", + "tourist_tax_return", + filters, + extra_where=lambda t: [t.tourist_tax_return > 0], + ) @_cached def get_zero_rated_total(filters): """Returns the sum of each Sales Invoice Item Amount which is zero rated.""" - i = frappe.qb.DocType("Sales Invoice Item") - s = frappe.qb.DocType("Sales Invoice") - query = ( - frappe.qb.from_(i) - .inner_join(s) - .on(i.parent == s.name) - .select(Sum(i.base_net_amount).as_("total")) - .where((s.docstatus == 1) & (i.is_zero_rated == 1)) + return _sum_item_field( + "Sales Invoice", + "Sales Invoice Item", + "base_net_amount", + filters, + extra_item_where=lambda i: [i.is_zero_rated == 1], ) - for condition in get_conditions(filters, s): - query = query.where(condition) - try: - return query.run()[0][0] or 0 - except (IndexError, TypeError): - return 0 @_cached def get_exempt_total(filters): """Returns the sum of each Sales Invoice Item Amount which is Vat Exempt.""" - i = frappe.qb.DocType("Sales Invoice Item") - s = frappe.qb.DocType("Sales Invoice") - query = ( - frappe.qb.from_(i) - .inner_join(s) - .on(i.parent == s.name) - .select(Sum(i.base_net_amount).as_("total")) - .where((s.docstatus == 1) & (i.is_exempt == 1)) + return _sum_item_field( + "Sales Invoice", + "Sales Invoice Item", + "base_net_amount", + filters, + extra_item_where=lambda i: [i.is_exempt == 1], ) - for condition in get_conditions(filters, s): - query = query.where(condition) - try: - return query.run()[0][0] or 0 - except (IndexError, TypeError): - return 0 - - -def get_conditions(filters, s): - """The conditions to be used to filter data to calculate the total sale.""" - conditions = [] - if filters.get("company"): - conditions.append(s.company == filters.get("company")) - if filters.get("from_date"): - conditions.append(s.posting_date >= filters.get("from_date")) - if filters.get("to_date"): - conditions.append(s.posting_date <= filters.get("to_date")) - return conditions diff --git a/erpnext/regional/report/uae_vat_register/uae_vat_register.py b/erpnext/regional/report/uae_vat_register/uae_vat_register.py index 04427f7359a..534e914936c 100644 --- a/erpnext/regional/report/uae_vat_register/uae_vat_register.py +++ b/erpnext/regional/report/uae_vat_register/uae_vat_register.py @@ -4,6 +4,7 @@ import frappe from frappe import _ +from frappe.query_builder.functions import Coalesce, Sum def execute(filters=None): @@ -88,129 +89,109 @@ def get_columns(filters): def get_data(filters): doc_type = filters.get("doc_type") or "Sales Invoice" if doc_type == "Sales Invoice": - return fetch_sales_rows(filters) + return _fetch_rows(filters, is_sales=True) if doc_type == "Purchase Invoice": - return fetch_purchase_rows(filters) + return _fetch_rows(filters, is_sales=False) return [] -def fetch_sales_rows(filters): - conditions, params = build_conditions(filters) - category_clause = sales_category_clause(filters.get("category")) +def _fetch_rows(filters, is_sales): + """Build the VAT register query for either Sales or Purchase Invoices. - emirate_clause = "" - if filters.get("vat"): - emirate_clause = "AND s.vat_emirate = %(vat)s" - params["vat"] = filters["vat"] + Item-wise mode returns one row per Sales/Purchase Invoice Item; the + default mode aggregates back to one row per invoice with summed qty, + net, VAT, and total. ``COALESCE(i.tax_amount, 0)`` is used everywhere + so a missing VAT amount surfaces as 0 instead of NULL — matching the + currency display and avoiding NULLs in client-side totals. + """ + parent_doctype = "Sales Invoice" if is_sales else "Purchase Invoice" + child_doctype = "Sales Invoice Item" if is_sales else "Purchase Invoice Item" + parent = frappe.qb.DocType(parent_doctype) + child = frappe.qb.DocType(child_doctype) + item_wise = bool(filters.get("item_wise")) - if filters.get("item_wise"): - return frappe.db.sql( - f""" - SELECT - s.name, s.posting_date, s.customer AS party, - COALESCE(i.cost_center, s.cost_center) AS cost_center, - s.vat_emirate AS emirate, - i.item_code, i.qty, i.rate, - i.base_net_amount AS net_amount, - i.tax_amount AS vat_amount, - (i.base_net_amount + COALESCE(i.tax_amount, 0)) AS total_amount - FROM `tabSales Invoice` s - INNER JOIN `tabSales Invoice Item` i ON i.parent = s.name - WHERE s.docstatus = 1 {conditions} {category_clause} {emirate_clause} - ORDER BY s.posting_date, s.name, i.idx - """, - params, - as_dict=True, + party_field = parent.customer if is_sales else parent.supplier + party_extra = parent.vat_emirate.as_("emirate") if is_sales else parent.reverse_charge + + tax_amount = Coalesce(child.tax_amount, 0) + gross = child.base_net_amount + tax_amount + + if item_wise: + query = ( + frappe.qb.from_(parent) + .inner_join(child) + .on(child.parent == parent.name) + .where(parent.docstatus == 1) + .select( + parent.name, + parent.posting_date, + party_field.as_("party"), + Coalesce(child.cost_center, parent.cost_center).as_("cost_center"), + party_extra, + child.item_code, + child.qty, + child.rate, + child.base_net_amount.as_("net_amount"), + tax_amount.as_("vat_amount"), + gross.as_("total_amount"), + ) + .orderby(parent.posting_date) + .orderby(parent.name) + .orderby(child.idx) + ) + else: + cost_center = parent.cost_center + query = ( + frappe.qb.from_(parent) + .inner_join(child) + .on(child.parent == parent.name) + .where(parent.docstatus == 1) + .select( + parent.name, + parent.posting_date, + party_field.as_("party"), + cost_center, + party_extra, + Sum(child.qty).as_("qty"), + Coalesce(Sum(child.base_net_amount), 0).as_("net_amount"), + Coalesce(Sum(tax_amount), 0).as_("vat_amount"), + Coalesce(Sum(gross), 0).as_("total_amount"), + ) + .groupby(parent.name, parent.posting_date, party_field, cost_center, party_extra) + .orderby(parent.posting_date) + .orderby(parent.name) ) - return frappe.db.sql( - f""" - SELECT - s.name, s.posting_date, s.customer AS party, s.cost_center, - s.vat_emirate AS emirate, - SUM(i.qty) AS qty, - SUM(i.base_net_amount) AS net_amount, - SUM(i.tax_amount) AS vat_amount, - SUM(i.base_net_amount + COALESCE(i.tax_amount, 0)) AS total_amount - FROM `tabSales Invoice` s - INNER JOIN `tabSales Invoice Item` i ON i.parent = s.name - WHERE s.docstatus = 1 {conditions} {category_clause} {emirate_clause} - GROUP BY s.name, s.posting_date, s.customer, s.cost_center, s.vat_emirate - ORDER BY s.posting_date, s.name - """, - params, - as_dict=True, - ) + query = _apply_period_filters(query, parent, filters) + + if is_sales and filters.get("vat"): + query = query.where(parent.vat_emirate == filters["vat"]) + if not is_sales and filters.get("reverse_charge") in ("Y", "N"): + query = query.where(parent.reverse_charge == filters["reverse_charge"]) + if is_sales: + category_criterion = _sales_category_criterion(child, filters.get("category")) + if category_criterion is not None: + query = query.where(category_criterion) + + return query.run(as_dict=True) -def fetch_purchase_rows(filters): - conditions, params = build_conditions(filters) - - rc_clause = "" - if filters.get("reverse_charge") in ("Y", "N"): - rc_clause = "AND s.reverse_charge = %(reverse_charge)s" - params["reverse_charge"] = filters["reverse_charge"] - - if filters.get("item_wise"): - return frappe.db.sql( - f""" - SELECT - s.name, s.posting_date, s.supplier AS party, - COALESCE(i.cost_center, s.cost_center) AS cost_center, - s.reverse_charge, - i.item_code, i.qty, i.rate, - i.base_net_amount AS net_amount, - i.tax_amount AS vat_amount, - (i.base_net_amount + COALESCE(i.tax_amount, 0)) AS total_amount - FROM `tabPurchase Invoice` s - INNER JOIN `tabPurchase Invoice Item` i ON i.parent = s.name - WHERE s.docstatus = 1 {conditions} {rc_clause} - ORDER BY s.posting_date, s.name, i.idx - """, - params, - as_dict=True, - ) - - return frappe.db.sql( - f""" - SELECT - s.name, s.posting_date, s.supplier AS party, s.cost_center, - s.reverse_charge, - SUM(i.qty) AS qty, - SUM(i.base_net_amount) AS net_amount, - SUM(i.tax_amount) AS vat_amount, - SUM(i.base_net_amount + COALESCE(i.tax_amount, 0)) AS total_amount - FROM `tabPurchase Invoice` s - INNER JOIN `tabPurchase Invoice Item` i ON i.parent = s.name - WHERE s.docstatus = 1 {conditions} {rc_clause} - GROUP BY s.name, s.posting_date, s.supplier, s.cost_center, s.reverse_charge - ORDER BY s.posting_date, s.name - """, - params, - as_dict=True, - ) - - -def build_conditions(filters): - conditions = "" - params = {} +def _apply_period_filters(query, parent, filters): if filters.get("company"): - conditions += " AND s.company = %(company)s" - params["company"] = filters["company"] + query = query.where(parent.company == filters["company"]) if filters.get("from_date"): - conditions += " AND s.posting_date >= %(from_date)s" - params["from_date"] = filters["from_date"] + query = query.where(parent.posting_date >= filters["from_date"]) if filters.get("to_date"): - conditions += " AND s.posting_date <= %(to_date)s" - params["to_date"] = filters["to_date"] - return conditions, params + query = query.where(parent.posting_date <= filters["to_date"]) + return query -def sales_category_clause(category): +def _sales_category_criterion(child, category): + """Translate the ``category`` filter into a Sales Invoice Item criterion.""" if category == "Standard": - return "AND i.is_zero_rated != 1 AND i.is_exempt != 1" + return (child.is_zero_rated != 1) & (child.is_exempt != 1) if category == "Zero Rated": - return "AND i.is_zero_rated = 1" + return child.is_zero_rated == 1 if category == "Exempt Rated": - return "AND i.is_exempt = 1" - return "" + return child.is_exempt == 1 + return None