feat(FTA Audit File): Enhance FAF generation logic and error handling; update currency handling in VAT reports

This commit is contained in:
Bibin
2026-06-14 11:37:35 +00:00
parent 806f30fa87
commit dffe4bd22d
3 changed files with 93 additions and 46 deletions

View File

@@ -3,10 +3,13 @@
frappe.ui.form.on("FTA Audit File", { frappe.ui.form.on("FTA Audit File", {
refresh: function (frm) { refresh: function (frm) {
// Add Generate FAF button for Draft status // Generate FAF — available from Draft (first generation) and Error
if (frm.doc.status === "Draft" && !frm.is_new()) { // (retry after a previous attempt failed). Queued/Generating are
// blocked by the server guard; Generated/Submitted are intentionally
// not re-generable.
if (!frm.is_new() && ["Draft", "Error"].includes(frm.doc.status)) {
frm.add_custom_button( frm.add_custom_button(
__("Generate FAF"), __(frm.doc.status === "Error" ? "Retry FAF Generation" : "Generate FAF"),
function () { function () {
frm.trigger("generate_faf"); frm.trigger("generate_faf");
}, },

View File

@@ -17,8 +17,10 @@ delimited by an explicit start/end marker row:
4. General Ledger (GLDataStart .. GLDataEnd) 4. General Ledger (GLDataStart .. GLDataEnd)
The footer of each transactional table carries running totals plus a The footer of each transactional table carries running totals plus a
transaction count. All amounts are in AED (foreign-currency mirrors are transaction count. Primary amount columns are in the company's accounting
emitted alongside when the source invoice is non-AED). currency (typically AED for a UAE-registered entity); foreign-currency
mirrors are emitted alongside when the source invoice is in a different
currency.
""" """
import csv import csv
@@ -169,7 +171,14 @@ class FTAAuditFile(Document):
err_doc.generation_log = (err_doc.generation_log or "") + f"\n\nError: {e}" err_doc.generation_log = (err_doc.generation_log or "") + f"\n\nError: {e}"
err_doc.save() err_doc.save()
except Exception: except Exception:
pass # Don't lose the original failure if persisting the Error
# state itself fails (e.g. row lock, validation regression);
# log the secondary failure with context, then re-raise the
# original ``e`` below so the job is still marked failed.
frappe.log_error(
title=_("FAF Error-state persistence failed"),
message=f"{self.doctype} {self.name}\n\n{frappe.get_traceback()}",
)
frappe.log_error( frappe.log_error(
title=_("FAF Generation Error"), title=_("FAF Generation Error"),
message=frappe.get_traceback(), message=frappe.get_traceback(),
@@ -307,8 +316,8 @@ class FTAAuditFile(Document):
company_currency = _company_currency(self.company) company_currency = _company_currency(self.company)
total_purchase_aed = 0.0 total_purchase_company = 0.0
total_vat_aed = 0.0 total_vat_company = 0.0
line_count = 0 line_count = 0
for inv in invoices: for inv in invoices:
@@ -316,10 +325,15 @@ class FTAAuditFile(Document):
fcy_code, fcy_factor = _fcy_for_invoice(inv.currency, inv.conversion_rate, company_currency) fcy_code, fcy_factor = _fcy_for_invoice(inv.currency, inv.conversion_rate, company_currency)
for item in items_by_invoice.get(inv.name, []): for item in items_by_invoice.get(inv.name, []):
net_aed = flt(item.base_net_amount, 2) # base_net_amount is company-currency; tax_amount is a UAE
vat_aed = flt(item.tax_amount or 0, 2) # custom field with options="currency" and therefore stored
net_fcy = flt((item.net_amount or 0) if fcy_code != "XXX" else 0, 2) # in the document's invoice currency. Multiply by the
vat_fcy = flt(vat_aed / fcy_factor if fcy_factor else 0, 2) if fcy_code != "XXX" else 0.0 # conversion rate to land in company currency.
net_company = flt(item.base_net_amount, 2)
vat_invoice = flt(item.tax_amount or 0, 2)
vat_company = flt(vat_invoice * fcy_factor, 2)
net_fcy = flt(item.net_amount or 0, 2) if fcy_code != "XXX" else 0.0
vat_fcy = vat_invoice if fcy_code != "XXX" else 0.0
writer.writerow( writer.writerow(
[ [
@@ -330,23 +344,23 @@ class FTAAuditFile(Document):
inv.permit_no or "", inv.permit_no or "",
item.idx, item.idx,
_clean(item.description or item.item_name or ""), _clean(item.description or item.item_name or ""),
_money(net_aed), _money(net_company),
_money(vat_aed), _money(vat_company),
_resolve_tax_code(item.item_tax_template, inv.posting_date, tax_code_bands), _resolve_tax_code(item.item_tax_template, inv.posting_date, tax_code_bands),
fcy_code, fcy_code,
_money(net_fcy), _money(net_fcy),
_money(vat_fcy), _money(vat_fcy),
] ]
) )
total_purchase_aed += net_aed total_purchase_company += net_company
total_vat_aed += vat_aed total_vat_company += vat_company
line_count += 1 line_count += 1
writer.writerow( writer.writerow(
[ [
"PurcDataEnd", "PurcDataEnd",
_money(total_purchase_aed), _money(total_purchase_company),
_money(total_vat_aed), _money(total_vat_company),
line_count, line_count,
] ]
) )
@@ -409,8 +423,8 @@ class FTAAuditFile(Document):
company_currency = _company_currency(self.company) company_currency = _company_currency(self.company)
total_supply_aed = 0.0 total_supply_company = 0.0
total_vat_aed = 0.0 total_vat_company = 0.0
line_count = 0 line_count = 0
for inv in invoices: for inv in invoices:
@@ -419,10 +433,14 @@ class FTAAuditFile(Document):
fcy_code, fcy_factor = _fcy_for_invoice(inv.currency, inv.conversion_rate, company_currency) fcy_code, fcy_factor = _fcy_for_invoice(inv.currency, inv.conversion_rate, company_currency)
for item in items_by_invoice.get(inv.name, []): for item in items_by_invoice.get(inv.name, []):
net_aed = flt(item.base_net_amount, 2) # See _write_purchase_listing for the currency convention:
vat_aed = flt(item.tax_amount or 0, 2) # tax_amount is invoice-currency, base_net_amount is
net_fcy = flt((item.net_amount or 0) if fcy_code != "XXX" else 0, 2) # company-currency, and fcy_factor converts invoice → company.
vat_fcy = flt(vat_aed / fcy_factor if fcy_factor else 0, 2) if fcy_code != "XXX" else 0.0 net_company = flt(item.base_net_amount, 2)
vat_invoice = flt(item.tax_amount or 0, 2)
vat_company = flt(vat_invoice * fcy_factor, 2)
net_fcy = flt(item.net_amount or 0, 2) if fcy_code != "XXX" else 0.0
vat_fcy = vat_invoice if fcy_code != "XXX" else 0.0
if item.is_zero_rated: if item.is_zero_rated:
tax_code = "ZR" tax_code = "ZR"
@@ -439,8 +457,8 @@ class FTAAuditFile(Document):
inv.name, inv.name,
item.idx, item.idx,
_clean(item.description or item.item_name or ""), _clean(item.description or item.item_name or ""),
_money(net_aed), _money(net_company),
_money(vat_aed), _money(vat_company),
tax_code, tax_code,
_clean(customer_country), _clean(customer_country),
fcy_code, fcy_code,
@@ -448,15 +466,15 @@ class FTAAuditFile(Document):
_money(vat_fcy), _money(vat_fcy),
] ]
) )
total_supply_aed += net_aed total_supply_company += net_company
total_vat_aed += vat_aed total_vat_company += vat_company
line_count += 1 line_count += 1
writer.writerow( writer.writerow(
[ [
"SuppDataEnd", "SuppDataEnd",
_money(total_supply_aed), _money(total_supply_company),
_money(total_vat_aed), _money(total_vat_company),
line_count, line_count,
] ]
) )
@@ -466,6 +484,8 @@ class FTAAuditFile(Document):
"""Emit General Ledger per Appendix 5 with end-of-table totals row.""" """Emit General Ledger per Appendix 5 with end-of-table totals row."""
writer.writerow(["GLDataStart"]) writer.writerow(["GLDataStart"])
company_currency = _company_currency(self.company)
entries = frappe.get_all( entries = frappe.get_all(
"GL Entry", "GL Entry",
filters={ filters={
@@ -487,7 +507,7 @@ class FTAAuditFile(Document):
order_by="posting_date asc, creation asc", order_by="posting_date asc, creation asc",
) )
if not entries: if not entries:
writer.writerow(["GLDataEnd", _money(0), _money(0), 0, "AED"]) writer.writerow(["GLDataEnd", _money(0), _money(0), 0, company_currency])
return 0 return 0
account_names = list({e.account for e in entries if e.account}) account_names = list({e.account for e in entries if e.account})
@@ -546,7 +566,7 @@ class FTAAuditFile(Document):
_money(total_debit), _money(total_debit),
_money(total_credit), _money(total_credit),
count, count,
"AED", company_currency,
] ]
) )
return count return count

View File

@@ -48,6 +48,7 @@ def _cached(fn):
def execute(filters=None): def execute(filters=None):
filters = filters or {}
validate_company_region(filters) validate_company_region(filters)
_cache.clear() _cache.clear()
columns = get_columns() columns = get_columns()
@@ -91,28 +92,27 @@ def get_data(filters=None):
append_vat_on_expenses(data, filters) append_vat_on_expenses(data, filters)
net_vat_due(data, filters, amounts_by_emirate) 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) dubai_label_override = _company_emirate_label(filters)
final_data = [] final_data = []
for row in data: for row in data:
key = row.get("_key")
legend = row.get("legend") legend = row.get("legend")
new_legend = legend new_legend = legend
if legend in emirate_drill_downs: if key and key.startswith("emirate:"):
emirate = emirate_drill_downs[legend] emirate = key.split(":", 1)[1]
label = dubai_label_override if legend == dubai_legend and dubai_label_override else legend label = dubai_label_override if emirate == "Dubai" and dubai_label_override else legend
new_legend = _drill_down_link( new_legend = _drill_down_link(
label, filters, doc_type="Sales Invoice", vat=emirate, category="Standard" label, filters, doc_type="Sales Invoice", vat=emirate, category="Standard"
) )
elif legend == "Supplies subject to the reverse charge provision": elif key == "reverse_charge_supplies":
new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice", reverse_charge="Y") new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice", reverse_charge="Y")
elif legend == "Zero Rated": elif key == "zero_rated":
new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Zero Rated") new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Zero Rated")
elif legend == "Exempt Supplies": elif key == "exempt_supplies":
new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Exempt Rated") new_legend = _drill_down_link(legend, filters, doc_type="Sales Invoice", category="Exempt Rated")
elif legend == "Standard Rated Expenses": elif key == "standard_rated_expenses":
new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice") new_legend = _drill_down_link(legend, filters, doc_type="Purchase Invoice")
final_data.append( final_data.append(
@@ -176,11 +176,26 @@ def append_vat_on_sales(data, filters):
_("Supplies subject to the reverse charge provision"), _("Supplies subject to the reverse charge provision"),
frappe.format(get_reverse_charge_total(filters), "Currency"), frappe.format(get_reverse_charge_total(filters), "Currency"),
frappe.format(get_reverse_charge_tax(filters), "Currency"), frappe.format(get_reverse_charge_tax(filters), "Currency"),
key="reverse_charge_supplies",
) )
append_data(data, "4", _("Zero Rated"), frappe.format(get_zero_rated_total(filters), "Currency"), "-") append_data(
data,
"4",
_("Zero Rated"),
frappe.format(get_zero_rated_total(filters), "Currency"),
"-",
key="zero_rated",
)
append_data(data, "5", _("Exempt Supplies"), frappe.format(get_exempt_total(filters), "Currency"), "-") append_data(
data,
"5",
_("Exempt Supplies"),
frappe.format(get_exempt_total(filters), "Currency"),
"-",
key="exempt_supplies",
)
append_data( append_data(
data, data,
@@ -230,6 +245,7 @@ def append_emiratewise_expenses(data, emirates, amounts_by_emirate):
if emirate in amounts_by_emirate: if emirate in amounts_by_emirate:
amounts_by_emirate[emirate]["no"] = _("1{0}").format(chr(no)) amounts_by_emirate[emirate]["no"] = _("1{0}").format(chr(no))
amounts_by_emirate[emirate]["legend"] = _("Standard rated supplies in {0}").format(emirate) amounts_by_emirate[emirate]["legend"] = _("Standard rated supplies in {0}").format(emirate)
amounts_by_emirate[emirate]["_key"] = f"emirate:{emirate}"
data.append(amounts_by_emirate[emirate]) data.append(amounts_by_emirate[emirate])
s_amount.append(amounts_by_emirate[emirate].get("raw_amount") or 0) s_amount.append(amounts_by_emirate[emirate].get("raw_amount") or 0)
@@ -241,6 +257,7 @@ def append_emiratewise_expenses(data, emirates, amounts_by_emirate):
_("Standard rated supplies in {0}").format(emirate), _("Standard rated supplies in {0}").format(emirate),
frappe.format(0, "Currency"), frappe.format(0, "Currency"),
frappe.format(0, "Currency"), frappe.format(0, "Currency"),
key=f"emirate:{emirate}",
) )
return amounts_by_emirate, s_amount, v_amount return amounts_by_emirate, s_amount, v_amount
@@ -254,6 +271,7 @@ def append_vat_on_expenses(data, filters):
_("Standard Rated Expenses"), _("Standard Rated Expenses"),
frappe.format(get_standard_rated_expenses_total(filters), "Currency"), frappe.format(get_standard_rated_expenses_total(filters), "Currency"),
frappe.format(get_standard_rated_expenses_tax(filters), "Currency"), frappe.format(get_standard_rated_expenses_tax(filters), "Currency"),
key="standard_rated_expenses",
) )
append_data( append_data(
data, data,
@@ -318,9 +336,15 @@ def net_vat_due(data, filters, amounts_by_emirate):
) )
def append_data(data, no, legend, amount, vat_amount): def append_data(data, no, legend, amount, vat_amount, key=None):
"""Returns data with appended value.""" """Append one row to ``data``.
data.append({"no": no, "legend": legend, "amount": amount, "vat_amount": vat_amount})
``key`` (when provided) is a language-independent identifier used by
``get_data`` to decide which rows get drill-down links. Without it,
dispatch would have to match the localized ``legend`` text and would
silently break under any non-English language.
"""
data.append({"no": no, "legend": legend, "amount": amount, "vat_amount": vat_amount, "_key": key})
def format_currency_signed(value): def format_currency_signed(value):