412 lines
13 KiB
Python
412 lines
13 KiB
Python
"""
|
||
Fusion Accounting - Intrastat Reporting
|
||
|
||
Implements the EU Intrastat statistical declaration for intra-Community
|
||
trade in goods. The module introduces:
|
||
|
||
* ``fusion.intrastat.code`` – the Combined Nomenclature (CN8) commodity
|
||
code table that products are classified against.
|
||
* ``fusion.intrastat.report`` – a transient wizard that aggregates
|
||
invoice data for a given period and produces declaration-ready output
|
||
grouped by commodity code, partner country, and transaction type.
|
||
|
||
Products gain ``intrastat_code_id``, ``origin_country_id`` and
|
||
``weight`` fields; invoice lines gain ``intrastat_transaction_code``.
|
||
|
||
Reference: Regulation (EC) No 638/2004, Commission Regulation (EC)
|
||
No 1982/2004.
|
||
|
||
Original implementation by Nexa Systems Inc.
|
||
"""
|
||
|
||
import logging
|
||
|
||
from odoo import api, fields, models, _
|
||
from odoo.exceptions import UserError, ValidationError
|
||
|
||
_log = logging.getLogger(__name__)
|
||
|
||
# Standard Intrastat transaction nature codes (1-digit, most common)
|
||
INTRASTAT_TRANSACTION_CODES = [
|
||
("1", "1 - Purchase / Sale"),
|
||
("2", "2 - Return"),
|
||
("3", "3 - Trade without payment"),
|
||
("4", "4 - Processing under contract"),
|
||
("5", "5 - After processing under contract"),
|
||
("6", "6 - Repairs"),
|
||
("7", "7 - Military operations"),
|
||
("8", "8 - Construction materials"),
|
||
("9", "9 - Other transactions"),
|
||
]
|
||
|
||
|
||
# ======================================================================
|
||
# Intrastat Commodity Code
|
||
# ======================================================================
|
||
|
||
class FusionIntrastatCode(models.Model):
|
||
"""
|
||
Combined Nomenclature (CN8) commodity code for Intrastat reporting.
|
||
|
||
Each record represents a single 8-digit code from the European
|
||
Commission's Combined Nomenclature. Products reference these codes
|
||
to determine which statistical heading they fall under when goods
|
||
cross EU internal borders.
|
||
"""
|
||
|
||
_name = "fusion.intrastat.code"
|
||
_description = "Intrastat Commodity Code"
|
||
_order = "code"
|
||
_rec_name = "display_name"
|
||
|
||
code = fields.Char(
|
||
string="CN8 Code",
|
||
required=True,
|
||
size=8,
|
||
help="8-digit Combined Nomenclature code (e.g. 84713000).",
|
||
)
|
||
name = fields.Char(
|
||
string="Description",
|
||
required=True,
|
||
translate=True,
|
||
help="Human-readable description of the commodity group.",
|
||
)
|
||
active = fields.Boolean(
|
||
string="Active",
|
||
default=True,
|
||
)
|
||
supplementary_unit = fields.Char(
|
||
string="Supplementary Unit",
|
||
help=(
|
||
"Unit of measurement required for this heading in addition "
|
||
"to net mass (e.g. 'p/st' for pieces, 'l' for litres)."
|
||
),
|
||
)
|
||
|
||
_sql_constraints = [
|
||
(
|
||
"unique_code",
|
||
"UNIQUE(code)",
|
||
"The Intrastat commodity code must be unique.",
|
||
),
|
||
]
|
||
|
||
@api.depends("code", "name")
|
||
def _compute_display_name(self):
|
||
for record in self:
|
||
record.display_name = f"[{record.code}] {record.name}" if record.code else record.name or ""
|
||
|
||
|
||
# ======================================================================
|
||
# Mixin for product fields
|
||
# ======================================================================
|
||
|
||
class FusionProductIntrastat(models.Model):
|
||
"""
|
||
Extends ``product.template`` with Intrastat-specific fields.
|
||
|
||
These fields are used by the Intrastat report wizard to determine
|
||
the commodity code, country of origin, and net weight of goods.
|
||
"""
|
||
|
||
_inherit = "product.template"
|
||
|
||
intrastat_code_id = fields.Many2one(
|
||
comodel_name="fusion.intrastat.code",
|
||
string="Intrastat Code",
|
||
help="Combined Nomenclature (CN8) commodity code for this product.",
|
||
)
|
||
origin_country_id = fields.Many2one(
|
||
comodel_name="res.country",
|
||
string="Country of Origin",
|
||
help="Country where the goods were produced or manufactured.",
|
||
)
|
||
intrastat_weight = fields.Float(
|
||
string="Net Weight (kg)",
|
||
digits="Stock Weight",
|
||
help="Net weight in kilograms for Intrastat reporting.",
|
||
)
|
||
|
||
|
||
# ======================================================================
|
||
# Mixin for invoice-line fields
|
||
# ======================================================================
|
||
|
||
class FusionMoveLineIntrastat(models.Model):
|
||
"""
|
||
Extends ``account.move.line`` with an Intrastat transaction code.
|
||
|
||
The transaction code indicates the nature of the transaction
|
||
(purchase, return, processing, etc.) and is required in each
|
||
Intrastat declaration line.
|
||
"""
|
||
|
||
_inherit = "account.move.line"
|
||
|
||
intrastat_transaction_code = fields.Selection(
|
||
selection=INTRASTAT_TRANSACTION_CODES,
|
||
string="Intrastat Transaction",
|
||
help="Nature of the transaction for Intrastat reporting.",
|
||
)
|
||
|
||
|
||
# ======================================================================
|
||
# Intrastat Report Wizard
|
||
# ======================================================================
|
||
|
||
class FusionIntrastatReport(models.TransientModel):
|
||
"""
|
||
Wizard that aggregates invoice data into Intrastat declaration lines.
|
||
|
||
The user selects a period and flow direction (arrivals or
|
||
dispatches), then the wizard queries posted invoices that involve
|
||
intra-EU partners and products with an Intrastat commodity code.
|
||
|
||
Results are grouped by commodity code, partner country, and
|
||
transaction nature code to produce aggregated statistical values
|
||
(value, net mass, supplementary quantity).
|
||
"""
|
||
|
||
_name = "fusion.intrastat.report"
|
||
_description = "Intrastat Report Wizard"
|
||
|
||
# ------------------------------------------------------------------
|
||
# Fields
|
||
# ------------------------------------------------------------------
|
||
date_from = fields.Date(
|
||
string="From",
|
||
required=True,
|
||
help="Start of the reporting period (inclusive).",
|
||
)
|
||
date_to = fields.Date(
|
||
string="To",
|
||
required=True,
|
||
help="End of the reporting period (inclusive).",
|
||
)
|
||
company_id = fields.Many2one(
|
||
comodel_name="res.company",
|
||
string="Company",
|
||
required=True,
|
||
default=lambda self: self.env.company,
|
||
)
|
||
flow_type = fields.Selection(
|
||
selection=[
|
||
("arrival", "Arrivals (Purchases)"),
|
||
("dispatch", "Dispatches (Sales)"),
|
||
],
|
||
string="Flow",
|
||
required=True,
|
||
default="dispatch",
|
||
help="Direction of goods movement across EU borders.",
|
||
)
|
||
line_ids = fields.One2many(
|
||
comodel_name="fusion.intrastat.report.line",
|
||
inverse_name="report_id",
|
||
string="Declaration Lines",
|
||
readonly=True,
|
||
)
|
||
state = fields.Selection(
|
||
selection=[
|
||
("draft", "Draft"),
|
||
("done", "Computed"),
|
||
],
|
||
string="Status",
|
||
default="draft",
|
||
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.")
|
||
)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Computation
|
||
# ------------------------------------------------------------------
|
||
def action_compute(self):
|
||
"""Aggregate invoice lines into Intrastat declaration rows.
|
||
|
||
Grouping key: ``(commodity_code, partner_country, transaction_code)``
|
||
|
||
For each group the wizard sums:
|
||
* ``total_value`` – invoice line amounts in company currency
|
||
* ``total_weight`` – product net weight × quantity
|
||
* ``supplementary_qty`` – quantity in supplementary units (if
|
||
the commodity code requires it)
|
||
"""
|
||
self.ensure_one()
|
||
|
||
# Clear previous results
|
||
self.line_ids.unlink()
|
||
|
||
invoice_lines = self._get_eligible_lines()
|
||
if not invoice_lines:
|
||
raise UserError(
|
||
_("No eligible invoice lines found for the selected period and flow type.")
|
||
)
|
||
|
||
aggregation = {} # (code_id, country_id, txn_code) -> dict
|
||
|
||
for line in invoice_lines:
|
||
product = line.product_id.product_tmpl_id
|
||
code = product.intrastat_code_id
|
||
if not code:
|
||
continue
|
||
|
||
partner = line.partner_id
|
||
country = partner.country_id
|
||
if not country:
|
||
continue
|
||
|
||
txn_code = line.intrastat_transaction_code or "1"
|
||
|
||
key = (code.id, country.id, txn_code)
|
||
bucket = aggregation.setdefault(key, {
|
||
"intrastat_code_id": code.id,
|
||
"country_id": country.id,
|
||
"transaction_code": txn_code,
|
||
"total_value": 0.0,
|
||
"total_weight": 0.0,
|
||
"supplementary_qty": 0.0,
|
||
})
|
||
|
||
qty = abs(line.quantity)
|
||
unit_weight = product.intrastat_weight or 0.0
|
||
|
||
# Use price_subtotal as the statistical value
|
||
bucket["total_value"] += abs(line.price_subtotal)
|
||
bucket["total_weight"] += unit_weight * qty
|
||
bucket["supplementary_qty"] += qty
|
||
|
||
# Create declaration lines
|
||
ReportLine = self.env["fusion.intrastat.report.line"]
|
||
for vals in aggregation.values():
|
||
vals["report_id"] = self.id
|
||
ReportLine.create(vals)
|
||
|
||
self.write({"state": "done"})
|
||
return self._reopen_wizard()
|
||
|
||
def _get_eligible_lines(self):
|
||
"""Return ``account.move.line`` records for invoices that
|
||
qualify for Intrastat reporting.
|
||
|
||
Eligibility criteria:
|
||
* Invoice is posted
|
||
* Invoice date falls within the period
|
||
* Partner country is in the EU and differs from the company country
|
||
* Product has an Intrastat commodity code assigned
|
||
* Move type matches the selected flow direction
|
||
"""
|
||
company_country = self.company_id.country_id
|
||
|
||
if self.flow_type == "dispatch":
|
||
move_types = ("out_invoice", "out_refund")
|
||
else:
|
||
move_types = ("in_invoice", "in_refund")
|
||
|
||
lines = self.env["account.move.line"].search([
|
||
("company_id", "=", self.company_id.id),
|
||
("parent_state", "=", "posted"),
|
||
("date", ">=", self.date_from),
|
||
("date", "<=", self.date_to),
|
||
("move_id.move_type", "in", move_types),
|
||
("product_id", "!=", False),
|
||
("product_id.product_tmpl_id.intrastat_code_id", "!=", False),
|
||
("partner_id.country_id", "!=", False),
|
||
("partner_id.country_id", "!=", company_country.id),
|
||
])
|
||
|
||
# Filter to EU countries only
|
||
eu_countries = self._get_eu_country_ids()
|
||
if eu_countries:
|
||
lines = lines.filtered(
|
||
lambda l: l.partner_id.country_id.id in eu_countries
|
||
)
|
||
|
||
return lines
|
||
|
||
def _get_eu_country_ids(self):
|
||
"""Return a set of ``res.country`` IDs for current EU member states."""
|
||
eu_group = self.env.ref("base.europe", raise_if_not_found=False)
|
||
if eu_group:
|
||
return set(eu_group.country_ids.ids)
|
||
return set()
|
||
|
||
def _reopen_wizard(self):
|
||
"""Return an action that re-opens this wizard form."""
|
||
return {
|
||
"type": "ir.actions.act_window",
|
||
"res_model": self._name,
|
||
"res_id": self.id,
|
||
"view_mode": "form",
|
||
"target": "new",
|
||
}
|
||
|
||
|
||
# ======================================================================
|
||
# Intrastat Report Line
|
||
# ======================================================================
|
||
|
||
class FusionIntrastatReportLine(models.TransientModel):
|
||
"""
|
||
One aggregated row in an Intrastat declaration.
|
||
|
||
Each line represents a unique combination of commodity code, partner
|
||
country, and transaction nature code. Statistical values (amount,
|
||
weight, supplementary quantity) are totals across all qualifying
|
||
invoice lines that match the grouping key.
|
||
"""
|
||
|
||
_name = "fusion.intrastat.report.line"
|
||
_description = "Intrastat Report Line"
|
||
_order = "intrastat_code_id, country_id"
|
||
|
||
report_id = fields.Many2one(
|
||
comodel_name="fusion.intrastat.report",
|
||
string="Report",
|
||
required=True,
|
||
ondelete="cascade",
|
||
)
|
||
intrastat_code_id = fields.Many2one(
|
||
comodel_name="fusion.intrastat.code",
|
||
string="Commodity Code",
|
||
required=True,
|
||
readonly=True,
|
||
)
|
||
country_id = fields.Many2one(
|
||
comodel_name="res.country",
|
||
string="Partner Country",
|
||
required=True,
|
||
readonly=True,
|
||
)
|
||
transaction_code = fields.Selection(
|
||
selection=INTRASTAT_TRANSACTION_CODES,
|
||
string="Transaction Type",
|
||
readonly=True,
|
||
)
|
||
total_value = fields.Float(
|
||
string="Statistical Value",
|
||
digits="Account",
|
||
readonly=True,
|
||
help="Total invoice value in company currency.",
|
||
)
|
||
total_weight = fields.Float(
|
||
string="Net Mass (kg)",
|
||
digits="Stock Weight",
|
||
readonly=True,
|
||
help="Total net weight in kilograms.",
|
||
)
|
||
supplementary_qty = fields.Float(
|
||
string="Supplementary Qty",
|
||
digits="Product Unit of Measure",
|
||
readonly=True,
|
||
help="Total quantity in supplementary units (if applicable).",
|
||
)
|