""" 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).", )