Initial commit
This commit is contained in:
481
Fusion Accounting/models/saft_export.py
Normal file
481
Fusion Accounting/models/saft_export.py
Normal file
@@ -0,0 +1,481 @@
|
||||
"""
|
||||
Fusion Accounting - SAF-T Export
|
||||
|
||||
Generates Standard Audit File for Tax (SAF-T) XML documents that
|
||||
conform to the OECD SAF-T Schema v2.0. The export wizard collects a
|
||||
date range from the user, queries general-ledger accounts, partners,
|
||||
tax tables and journal entries for the selected period, and builds a
|
||||
compliant XML tree.
|
||||
|
||||
The default namespace is the Portuguese SAF-T variant
|
||||
(urn:OECD:StandardAuditFile-Tax:PT_1.04_01) but can be overridden per
|
||||
company through the ``saft_namespace`` field so that the same generator
|
||||
serves multiple country-specific schemas.
|
||||
|
||||
Reference: OECD Standard Audit File – Tax 2.0 (2010).
|
||||
|
||||
Original implementation by Nexa Systems Inc.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Default namespace when not overridden on the company
|
||||
_DEFAULT_SAFT_NS = "urn:OECD:StandardAuditFile-Tax:PT_1.04_01"
|
||||
_SAFT_VERSION = "2.00_01"
|
||||
|
||||
|
||||
class FusionSAFTExport(models.TransientModel):
|
||||
"""
|
||||
Wizard that produces an SAF-T XML file for a given date range.
|
||||
|
||||
Workflow
|
||||
--------
|
||||
1. The user opens the wizard from *Accounting ▸ Reports ▸ Fiscal
|
||||
Compliance ▸ SAF-T Export*.
|
||||
2. A date range and optional filters are selected.
|
||||
3. Clicking **Generate** triggers ``action_generate_saft`` which
|
||||
delegates to ``generate_saft_xml()``.
|
||||
4. The resulting XML is stored as an ``ir.attachment`` and a download
|
||||
action is returned.
|
||||
|
||||
The XML tree follows the OECD SAF-T structure:
|
||||
|
||||
- ``<Header>``
|
||||
- ``<MasterFiles>`` (accounts, customers, suppliers, tax table)
|
||||
- ``<GeneralLedgerEntries>`` (journals → transactions → lines)
|
||||
"""
|
||||
|
||||
_name = "fusion.saft.export"
|
||||
_description = "SAF-T Export Wizard"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fields
|
||||
# ------------------------------------------------------------------
|
||||
date_from = fields.Date(
|
||||
string="Start Date",
|
||||
required=True,
|
||||
default=lambda self: date(date.today().year, 1, 1),
|
||||
help="First day of the reporting period (inclusive).",
|
||||
)
|
||||
date_to = fields.Date(
|
||||
string="End Date",
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
help="Last day of the reporting period (inclusive).",
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
comodel_name="res.company",
|
||||
string="Company",
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
("draft", "Draft"),
|
||||
("done", "Generated"),
|
||||
("error", "Error"),
|
||||
],
|
||||
string="Status",
|
||||
default="draft",
|
||||
readonly=True,
|
||||
)
|
||||
attachment_id = fields.Many2one(
|
||||
comodel_name="ir.attachment",
|
||||
string="Generated File",
|
||||
readonly=True,
|
||||
ondelete="set null",
|
||||
)
|
||||
saft_namespace = fields.Char(
|
||||
string="SAF-T Namespace",
|
||||
default=_DEFAULT_SAFT_NS,
|
||||
help=(
|
||||
"XML namespace written into the root element. Change this "
|
||||
"to match your country's SAF-T variant."
|
||||
),
|
||||
)
|
||||
error_message = fields.Text(
|
||||
string="Error Details",
|
||||
readonly=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Validation
|
||||
# ------------------------------------------------------------------
|
||||
@api.constrains("date_from", "date_to")
|
||||
def _check_date_range(self):
|
||||
for record in self:
|
||||
if record.date_from and record.date_to and record.date_to < record.date_from:
|
||||
raise ValidationError(
|
||||
_("The end date must not precede the start date.")
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public action
|
||||
# ------------------------------------------------------------------
|
||||
def action_generate_saft(self):
|
||||
"""Entry point called by the wizard button. Generates the XML and
|
||||
stores the result as a downloadable attachment."""
|
||||
self.ensure_one()
|
||||
try:
|
||||
xml_bytes = self.generate_saft_xml()
|
||||
filename = (
|
||||
f"SAF-T_{self.company_id.name}_{self.date_from}_{self.date_to}.xml"
|
||||
)
|
||||
attachment = self.env["ir.attachment"].create({
|
||||
"name": filename,
|
||||
"type": "binary",
|
||||
"datas": base64.b64encode(xml_bytes),
|
||||
"res_model": self._name,
|
||||
"res_id": self.id,
|
||||
"mimetype": "application/xml",
|
||||
})
|
||||
self.write({
|
||||
"state": "done",
|
||||
"attachment_id": attachment.id,
|
||||
"error_message": False,
|
||||
})
|
||||
return {
|
||||
"type": "ir.actions.act_url",
|
||||
"url": f"/web/content/{attachment.id}?download=true",
|
||||
"target": "new",
|
||||
}
|
||||
except Exception as exc:
|
||||
_log.exception("SAF-T generation failed for company %s", self.company_id.name)
|
||||
self.write({
|
||||
"state": "error",
|
||||
"error_message": str(exc),
|
||||
})
|
||||
raise UserError(
|
||||
_("SAF-T generation failed:\n%(error)s", error=exc)
|
||||
) from exc
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# XML generation
|
||||
# ------------------------------------------------------------------
|
||||
def generate_saft_xml(self):
|
||||
"""Build the full SAF-T XML document and return it as ``bytes``.
|
||||
|
||||
The tree mirrors the OECD SAF-T v2.0 specification:
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<AuditFile xmlns="...">
|
||||
<Header>...</Header>
|
||||
<MasterFiles>
|
||||
<GeneralLedgerAccounts>...</GeneralLedgerAccounts>
|
||||
<Customers>...</Customers>
|
||||
<Suppliers>...</Suppliers>
|
||||
<TaxTable>...</TaxTable>
|
||||
</MasterFiles>
|
||||
<GeneralLedgerEntries>
|
||||
<Journal>
|
||||
<Transaction>
|
||||
<Line>...</Line>
|
||||
</Transaction>
|
||||
</Journal>
|
||||
</GeneralLedgerEntries>
|
||||
</AuditFile>
|
||||
"""
|
||||
self.ensure_one()
|
||||
ns = self.saft_namespace or _DEFAULT_SAFT_NS
|
||||
|
||||
root = ET.Element("AuditFile", xmlns=ns)
|
||||
|
||||
self._build_header(root)
|
||||
master_files = ET.SubElement(root, "MasterFiles")
|
||||
self._build_general_ledger_accounts(master_files)
|
||||
self._build_customers(master_files)
|
||||
self._build_suppliers(master_files)
|
||||
self._build_tax_table(master_files)
|
||||
self._build_general_ledger_entries(root)
|
||||
|
||||
# Serialise to bytes with XML declaration
|
||||
buf = io.BytesIO()
|
||||
tree = ET.ElementTree(root)
|
||||
ET.indent(tree, space=" ")
|
||||
tree.write(buf, encoding="UTF-8", xml_declaration=True)
|
||||
return buf.getvalue()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Header
|
||||
# ------------------------------------------------------------------
|
||||
def _build_header(self, root):
|
||||
"""Populate the ``<Header>`` element with company metadata."""
|
||||
header = ET.SubElement(root, "Header")
|
||||
company = self.company_id
|
||||
|
||||
_add_text(header, "AuditFileVersion", _SAFT_VERSION)
|
||||
_add_text(header, "CompanyID", company.company_registry or company.vat or str(company.id))
|
||||
_add_text(header, "TaxRegistrationNumber", company.vat or "")
|
||||
_add_text(header, "TaxAccountingBasis", "F") # F = Facturação / Invoicing
|
||||
_add_text(header, "CompanyName", company.name or "")
|
||||
|
||||
# Company address block
|
||||
address = ET.SubElement(header, "CompanyAddress")
|
||||
_add_text(address, "StreetName", company.street or "")
|
||||
_add_text(address, "City", company.city or "")
|
||||
_add_text(address, "PostalCode", company.zip or "")
|
||||
_add_text(address, "Country", (company.country_id.code or "").upper())
|
||||
|
||||
_add_text(header, "FiscalYear", str(self.date_from.year))
|
||||
_add_text(header, "StartDate", str(self.date_from))
|
||||
_add_text(header, "EndDate", str(self.date_to))
|
||||
_add_text(header, "CurrencyCode", (company.currency_id.name or "EUR").upper())
|
||||
_add_text(header, "DateCreated", str(fields.Date.context_today(self)))
|
||||
_add_text(header, "TaxEntity", company.name or "")
|
||||
_add_text(header, "ProductCompanyTaxID", company.vat or "")
|
||||
_add_text(header, "SoftwareCertificateNumber", "0")
|
||||
_add_text(header, "ProductID", "Fusion Accounting/Nexa Systems Inc.")
|
||||
_add_text(header, "ProductVersion", "19.0")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MasterFiles – General Ledger Accounts
|
||||
# ------------------------------------------------------------------
|
||||
def _build_general_ledger_accounts(self, master_files):
|
||||
"""Add every active account for the company."""
|
||||
accounts = self.env["account.account"].search([
|
||||
("company_id", "=", self.company_id.id),
|
||||
])
|
||||
if not accounts:
|
||||
return
|
||||
|
||||
gl_section = ET.SubElement(master_files, "GeneralLedgerAccounts")
|
||||
for account in accounts:
|
||||
acct_el = ET.SubElement(gl_section, "Account")
|
||||
_add_text(acct_el, "AccountID", account.code or "")
|
||||
_add_text(acct_el, "AccountDescription", account.name or "")
|
||||
|
||||
# Map Odoo account types to SAF-T grouping categories
|
||||
saft_type = self._resolve_saft_account_type(account)
|
||||
_add_text(acct_el, "GroupingCategory", saft_type)
|
||||
_add_text(acct_el, "GroupingCode", account.code[:2] if account.code else "00")
|
||||
|
||||
# Opening and closing balances for the period
|
||||
opening, closing = self._compute_account_balances(account)
|
||||
_add_text(acct_el, "OpeningDebitBalance", f"{max(opening, 0):.2f}")
|
||||
_add_text(acct_el, "OpeningCreditBalance", f"{max(-opening, 0):.2f}")
|
||||
_add_text(acct_el, "ClosingDebitBalance", f"{max(closing, 0):.2f}")
|
||||
_add_text(acct_el, "ClosingCreditBalance", f"{max(-closing, 0):.2f}")
|
||||
|
||||
def _resolve_saft_account_type(self, account):
|
||||
"""Map an ``account.account`` to an SAF-T grouping category string.
|
||||
|
||||
Returns one of ``GR`` (Revenue/Income), ``GP`` (Expense),
|
||||
``GA`` (Asset), ``GL`` (Liability), or ``GM`` (Mixed/Other).
|
||||
"""
|
||||
account_type = account.account_type or ""
|
||||
if "receivable" in account_type or "income" in account_type:
|
||||
return "GR"
|
||||
if "payable" in account_type or "expense" in account_type:
|
||||
return "GP"
|
||||
if "asset" in account_type or "bank" in account_type or "cash" in account_type:
|
||||
return "GA"
|
||||
if "liability" in account_type:
|
||||
return "GL"
|
||||
return "GM"
|
||||
|
||||
def _compute_account_balances(self, account):
|
||||
"""Return ``(opening_balance, closing_balance)`` for *account*
|
||||
within the wizard's date range.
|
||||
|
||||
* ``opening_balance`` – net balance of all posted move-lines
|
||||
dated **before** ``date_from``.
|
||||
* ``closing_balance`` – opening balance plus net movement
|
||||
between ``date_from`` and ``date_to``.
|
||||
"""
|
||||
MoveLines = self.env["account.move.line"]
|
||||
domain_base = [
|
||||
("account_id", "=", account.id),
|
||||
("parent_state", "=", "posted"),
|
||||
("company_id", "=", self.company_id.id),
|
||||
]
|
||||
|
||||
# Opening balance: everything before the period
|
||||
opening_lines = MoveLines.search(
|
||||
domain_base + [("date", "<", self.date_from)]
|
||||
)
|
||||
opening = sum(opening_lines.mapped("debit")) - sum(opening_lines.mapped("credit"))
|
||||
|
||||
# Movement during the period
|
||||
period_lines = MoveLines.search(
|
||||
domain_base + [
|
||||
("date", ">=", self.date_from),
|
||||
("date", "<=", self.date_to),
|
||||
]
|
||||
)
|
||||
movement = sum(period_lines.mapped("debit")) - sum(period_lines.mapped("credit"))
|
||||
|
||||
return opening, opening + movement
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MasterFiles – Customers
|
||||
# ------------------------------------------------------------------
|
||||
def _build_customers(self, master_files):
|
||||
"""Export customer (receivable) partners referenced in the period."""
|
||||
partners = self._get_partners_for_type("asset_receivable")
|
||||
if not partners:
|
||||
return
|
||||
|
||||
customers_el = ET.SubElement(master_files, "Customers")
|
||||
for partner in partners:
|
||||
cust = ET.SubElement(customers_el, "Customer")
|
||||
_add_text(cust, "CustomerID", str(partner.id))
|
||||
_add_text(cust, "CustomerTaxID", partner.vat or "999999990")
|
||||
_add_text(cust, "CompanyName", partner.name or "")
|
||||
|
||||
address = ET.SubElement(cust, "BillingAddress")
|
||||
_add_text(address, "StreetName", partner.street or "Desconhecido")
|
||||
_add_text(address, "City", partner.city or "Desconhecido")
|
||||
_add_text(address, "PostalCode", partner.zip or "0000-000")
|
||||
_add_text(address, "Country", (partner.country_id.code or "").upper() or "PT")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MasterFiles – Suppliers
|
||||
# ------------------------------------------------------------------
|
||||
def _build_suppliers(self, master_files):
|
||||
"""Export supplier (payable) partners referenced in the period."""
|
||||
partners = self._get_partners_for_type("liability_payable")
|
||||
if not partners:
|
||||
return
|
||||
|
||||
suppliers_el = ET.SubElement(master_files, "Suppliers")
|
||||
for partner in partners:
|
||||
supp = ET.SubElement(suppliers_el, "Supplier")
|
||||
_add_text(supp, "SupplierID", str(partner.id))
|
||||
_add_text(supp, "SupplierTaxID", partner.vat or "999999990")
|
||||
_add_text(supp, "CompanyName", partner.name or "")
|
||||
|
||||
address = ET.SubElement(supp, "SupplierAddress")
|
||||
_add_text(address, "StreetName", partner.street or "Desconhecido")
|
||||
_add_text(address, "City", partner.city or "Desconhecido")
|
||||
_add_text(address, "PostalCode", partner.zip or "0000-000")
|
||||
_add_text(address, "Country", (partner.country_id.code or "").upper() or "PT")
|
||||
|
||||
def _get_partners_for_type(self, account_type):
|
||||
"""Return distinct partners that have posted move-lines on
|
||||
accounts of the given ``account_type`` within the period.
|
||||
"""
|
||||
lines = self.env["account.move.line"].search([
|
||||
("company_id", "=", self.company_id.id),
|
||||
("parent_state", "=", "posted"),
|
||||
("date", ">=", self.date_from),
|
||||
("date", "<=", self.date_to),
|
||||
("account_id.account_type", "=", account_type),
|
||||
("partner_id", "!=", False),
|
||||
])
|
||||
return lines.mapped("partner_id")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MasterFiles – Tax Table
|
||||
# ------------------------------------------------------------------
|
||||
def _build_tax_table(self, master_files):
|
||||
"""Export the company's active taxes."""
|
||||
taxes = self.env["account.tax"].search([
|
||||
("company_id", "=", self.company_id.id),
|
||||
("active", "=", True),
|
||||
])
|
||||
if not taxes:
|
||||
return
|
||||
|
||||
table_el = ET.SubElement(master_files, "TaxTable")
|
||||
for tax in taxes:
|
||||
entry = ET.SubElement(table_el, "TaxTableEntry")
|
||||
_add_text(entry, "TaxType", self._resolve_saft_tax_type(tax))
|
||||
_add_text(entry, "TaxCountryRegion", (self.company_id.country_id.code or "PT").upper())
|
||||
_add_text(entry, "TaxCode", tax.name or str(tax.id))
|
||||
_add_text(entry, "Description", tax.description or tax.name or "")
|
||||
_add_text(entry, "TaxPercentage", f"{tax.amount:.2f}")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_saft_tax_type(tax):
|
||||
"""Derive an SAF-T TaxType from the Odoo tax type_tax_use.
|
||||
|
||||
Returns ``IVA`` for sales/purchase VAT and ``IS`` for
|
||||
withholding / other types.
|
||||
"""
|
||||
if tax.type_tax_use in ("sale", "purchase"):
|
||||
return "IVA"
|
||||
return "IS"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GeneralLedgerEntries
|
||||
# ------------------------------------------------------------------
|
||||
def _build_general_ledger_entries(self, root):
|
||||
"""Write all posted journal entries for the period, grouped by
|
||||
journal (one ``<Journal>`` per Odoo ``account.journal``).
|
||||
"""
|
||||
entries_el = ET.SubElement(root, "GeneralLedgerEntries")
|
||||
|
||||
moves = self.env["account.move"].search([
|
||||
("company_id", "=", self.company_id.id),
|
||||
("state", "=", "posted"),
|
||||
("date", ">=", self.date_from),
|
||||
("date", "<=", self.date_to),
|
||||
], order="journal_id, date, id")
|
||||
|
||||
# Summary totals
|
||||
total_debit = 0.0
|
||||
total_credit = 0.0
|
||||
|
||||
# Group moves by journal
|
||||
journals_map = {}
|
||||
for move in moves:
|
||||
journals_map.setdefault(move.journal_id, self.env["account.move"])
|
||||
journals_map[move.journal_id] |= move
|
||||
|
||||
_add_text(entries_el, "NumberOfEntries", str(len(moves)))
|
||||
|
||||
for journal, journal_moves in journals_map.items():
|
||||
journal_el = ET.SubElement(entries_el, "Journal")
|
||||
_add_text(journal_el, "JournalID", journal.code or str(journal.id))
|
||||
_add_text(journal_el, "Description", journal.name or "")
|
||||
|
||||
for move in journal_moves:
|
||||
txn_el = ET.SubElement(journal_el, "Transaction")
|
||||
_add_text(txn_el, "TransactionID", move.name or str(move.id))
|
||||
_add_text(txn_el, "Period", str(move.date.month))
|
||||
_add_text(txn_el, "TransactionDate", str(move.date))
|
||||
_add_text(txn_el, "SourceID", (move.create_uid.login or "") if move.create_uid else "")
|
||||
_add_text(txn_el, "Description", move.ref or move.name or "")
|
||||
_add_text(txn_el, "GLPostingDate", str(move.date))
|
||||
|
||||
for line in move.line_ids:
|
||||
line_el = ET.SubElement(txn_el, "Line")
|
||||
_add_text(line_el, "RecordID", str(line.id))
|
||||
_add_text(line_el, "AccountID", line.account_id.code or "")
|
||||
_add_text(line_el, "SourceDocumentID", move.name or "")
|
||||
|
||||
if line.debit:
|
||||
debit_el = ET.SubElement(line_el, "DebitAmount")
|
||||
_add_text(debit_el, "Amount", f"{line.debit:.2f}")
|
||||
total_debit += line.debit
|
||||
else:
|
||||
credit_el = ET.SubElement(line_el, "CreditAmount")
|
||||
_add_text(credit_el, "Amount", f"{line.credit:.2f}")
|
||||
total_credit += line.credit
|
||||
|
||||
_add_text(line_el, "Description", line.name or "")
|
||||
|
||||
_add_text(entries_el, "TotalDebit", f"{total_debit:.2f}")
|
||||
_add_text(entries_el, "TotalCredit", f"{total_credit:.2f}")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Module-level helpers
|
||||
# ======================================================================
|
||||
|
||||
def _add_text(parent, tag, text):
|
||||
"""Create a child element with the given *tag* and *text* value."""
|
||||
el = ET.SubElement(parent, tag)
|
||||
el.text = str(text) if text is not None else ""
|
||||
return el
|
||||
Reference in New Issue
Block a user