Odoo Intrastat Cleanup Scripts: Helping EU SMEs Stay Compliant Without Drowning in Paperwork

Odoo, Intrastat, and EU paperwork

Odoo Intrastat Cleanup Scripts: Helping EU SMEs Stay Compliant Without Drowning in Paperwork

Intrastat is useful. It helps measure the movement of goods inside the European Union. But for small manufacturers, exporters, repair shops, and technology companies, it can also become another layer of administrative overhead. This updated article shares a safer, DRY_RUN = True-protected Odoo workaround package for three related cases: EU Intrastat invoice backfill, Non‑EU/domestic hidden transport-mode unblocking, and blocked draft invoices created from new quotations.

Intrastat makes sense in principle. When goods move between EU Member States, national statistical offices need reliable data. Since the creation of the Single Market, customs declarations are no longer the normal source of this intra‑EU goods data, so Intrastat fills the gap. Eurostat explains the Intrastat system and intra‑EU goods data collection here.

In Odoo, Intrastat reporting depends on invoice data, product data, partner data, and logistics data. Odoo’s documentation describes report fields such as country, transaction code, commodity code, origin country, partner VAT, transport code, Incoterm, weight, supplementary units, and value. Odoo’s Intrastat documentation is available here.

The data has value. The burden is real. The best answer is not to ignore compliance, but to automate the repetitive parts safely.

What changed in this update

The original cleanup focused on historical invoices. The attached fix adds a third safer action for blocked draft invoices created from new quotations. This matters because a non‑EU draft invoice can still fail at Preview, Proforma, Save, or Confirm with:

Invalid fields: Intrastat Transport Mode

For non‑EU or domestic invoices, Intrastat Country should remain blank. But Odoo can still require the hidden field intrastat_transport_mode_id. The user cannot always fix it from the invoice form because the field may be hidden, required, or stale in the browser state.

Updated fix package: this article now separates the workaround into three actions. Do not mix them. Germany, Netherlands, France, Austria, etc. use the EU action. USA, Nigeria, Switzerland, UK, Slovakia domestic, and other non‑EU/domestic cases use the Non‑EU/Domestic actions.

The three safe Server Actions

ActionUse forWritesDoes not write
1 - EU Intrastat Backfill: EU deliveriesGermany, Netherlands, France, Austria, and other EU physical delivery countriesIntrastat Country, Transport Mode, and line transaction code on likely goods linesDoes not reset, repost, renumber, change totals, taxes, dates, or payments
2 - Non‑EU or Domestic Transport UnblockExisting old invoices that are non‑EU, domestic, or otherwise not Intrastat-reportableOnly intrastat_transport_mode_idDoes not set Intrastat Country or line transaction codes
3 - TEMP Draft Intrastat Transport UnblockBlocked draft invoices created from new quotations, especially non‑EU or domestic deliveriesOnly intrastat_transport_mode_id, optionally a marker field if it existsDoes not post, confirm, set Intrastat Country, or set line transaction codes

The operating rule

Physical delivery destinationCorrect actionExpected result
Germany, Netherlands, France, Austria, etc.1 - EU Intrastat BackfillEU goods invoices get header + goods-line metadata. EU service-only invoices get header only.
USA, Nigeria, Switzerland, UK, UAE, etc.2 or 3 - Non‑EU/Domestic Transport UnblockOnly the hidden transport mode is filled. Intrastat Country stays blank.
Slovakia domestic2 or 3 - Non‑EU/Domestic Transport Unblock, only if the save error appearsOnly the hidden transport mode is filled. Intrastat Country stays blank.

Critical safety rule: all three scripts start with DRY_RUN = True. Run the dry run first. If the popup output is correct, change to DRY_RUN = False, run the real update, and immediately change the action back to DRY_RUN = True.

Why not just reset invoices to draft?

Resetting old paid invoices to draft may sound simple, but it is risky. It can affect payment status, reconciliation, invoice workflow, audit trail, and internal controls. For a small company trying to remain compliant, creating more accounting risk is not a solution.

The safer approach is to write only the missing non-accounting metadata needed to unblock the invoice form, without changing totals, taxes, dates, payments, or invoice numbers.

The wider issue: compliance as a hidden tax on SMEs

We support accurate trade data. We also believe Europe needs competitive manufacturers. Those two goals should not be in conflict.

The challenge is that every new reporting layer has a cost. A large company absorbs it through departments and consultants. A small company absorbs it by taking time away from engineering, production, customer service, quality control, and exports.

In practice, bureaucracy can become a hidden tax on small EU companies. It may not appear as a line on an invoice, but it consumes time, attention, and competitiveness. If Europe wants strong SMEs, compliance systems must be accurate, but they also must be practical.

How to create each Odoo Server Action

  1. Go to Settings → Technical → Actions → Server Actions.
  2. Create a new Server Action.
  3. Set Model to Journal Entry. In Odoo, invoices are stored on the technical model account.move.
  4. Set Action To Do to Execute Python Code.
  5. Paste the relevant script below.
  6. Save.
  7. Click Create Contextual Action.
  8. Go to Accounting → Customers → Invoices.
  9. Select one test invoice from the list view.
  10. Run the action from the Action menu.

Recommended names:
1 - EU Intrastat Backfill: EU deliveries
2 - Non‑EU or Domestic Transport Unblock
3 - TEMP Draft Intrastat Transport Unblock

Script 1: EU Intrastat Backfill

Use this for real EU deliveries, such as Slovakia to Germany, Netherlands, France, or Austria. It fills the invoice header fields and, where safe, the line-level Intrastat transaction code for goods lines.

1 - EU Intrastat Backfill: EU deliveries
# TEMPORARY EU INTRASTAT BACKFILL / UNBLOCK ACTION
#
# Odoo Server Action safe_eval compatible version:
# - no lambda
# - no def
# - no nested functions
# - no list comprehensions
#
# Purpose:
# 1) Fill missing invoice-header Intrastat Country and Intrastat Transport Mode.
# 2) Fill missing line-level Intrastat Transaction Code only on likely goods lines.
#
# Use for:
# - EU delivery countries such as Germany, France, Netherlands, Austria, etc.
# - EU goods invoices: header + line transaction code
# - EU service-only invoices: header only, no line transaction code
#
# It does NOT:
# - reset invoices to draft
# - post invoices
# - change invoice numbers
# - change invoice dates
# - change invoice amounts
# - change taxes
# - change payment reconciliation
# - overwrite existing Intrastat values
# - add transaction codes to service lines by default

# ============================================================
# SETTINGS
# ============================================================

DRY_RUN = True

START_DATE = "2026-01-01"

TRANSPORT_CODE = "3"       # 3 = Road transport
TRANSACTION_CODE = "11"    # Normal sale transaction code; change if needed

UPDATE_LINE_TRANSACTION_CODES = True

SKIP_ZERO_VALUE_LINES = True

EXCLUDE_LINE_TEXT_CONTAINS = [
    "shipping",
    "handling",
    "freight",
    "delivery",
    "doprava",
    "preprava",
    "poštovné",
    "postovne",
]

INCLUDE_SERVICE_PRODUCTS_WITH_CUSTOMS_DATA = False

ALLOW_DRAFT_INVOICES = True

# Use only for carefully selected batches where all selected invoices
# have the same known EU destination, but Odoo cannot detect it.
# Example: FORCE_COUNTRY_CODE = "DE"
FORCE_COUNTRY_CODE = False

EU_CODES = [
    "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
    "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
    "PL", "PT", "RO", "SK", "SI", "ES", "SE",
    "XI",
]

CUSTOMS_FIELD_NAMES = [
    "hs_code",
    "intrastat_code_id",
    "commodity_code_id",
    "country_of_origin_id",
    "origin_country_id",
    "product_origin_country_id",
]

# ============================================================
# MODELS
# ============================================================

IntrastatCode = env["account.intrastat.code"]
Country = env["res.country"]
SaleOrder = env["sale.order"]
AccountMoveLine = env["account.move.line"]

# ============================================================
# VALIDATE FORCED COUNTRY
# ============================================================

forced_country = Country.browse([])

if FORCE_COUNTRY_CODE:
    forced_country = Country.search([
        ("code", "=", FORCE_COUNTRY_CODE.upper()),
    ], limit=1)

    if not forced_country:
        raise UserError("Forced country code %s was not found." % FORCE_COUNTRY_CODE)

# ============================================================
# MAIN
# ============================================================

selected_count = len(records)
processed = 0
header_updates = 0
line_updates = 0

details = []
skipped = []

if not records:
    raise UserError("No invoices were selected.")

for move in records:
    label = move.name or str(move.id)

    # Customer invoices only. Credit notes/refunds should be handled separately.
    if move.move_type != "out_invoice":
        skipped.append("%s skipped: not a customer invoice" % label)
        continue

    if ALLOW_DRAFT_INVOICES:
        if move.state not in ("posted", "draft"):
            skipped.append("%s skipped: state is %s" % (label, move.state))
            continue
    else:
        if move.state != "posted":
            skipped.append("%s skipped: not posted" % label)
            continue

    ref_date = str(move.invoice_date or move.date or "")

    if not ref_date:
        skipped.append("%s skipped: no invoice/accounting date" % label)
        continue

    if START_DATE and ref_date < START_DATE:
        skipped.append("%s skipped: before START_DATE %s" % (label, START_DATE))
        continue

    company_country = move.company_id.country_id

    if not company_country:
        skipped.append("%s skipped: company has no country set" % label)
        continue

    # ------------------------------------------------------------
    # Detect destination country
    # ------------------------------------------------------------

    destination_country = Country.browse([])
    destination_source = ""
    seen = []

    if move.intrastat_country_id:
        destination_country = move.intrastat_country_id
        destination_source = "existing Intrastat Country"

    elif forced_country:
        destination_country = forced_country
        destination_source = "FORCED country %s" % FORCE_COUNTRY_CODE

    else:
        delivery_candidate_ids = []
        delivery_candidate_names = []
        concrete_non_foreign_ids = []
        concrete_non_foreign_names = []

        # 1) Invoice delivery partner
        partner_sources = []

        if move.partner_shipping_id:
            partner_sources.append(["invoice delivery", move.partner_shipping_id])

            if move.partner_shipping_id.parent_id:
                partner_sources.append(["invoice delivery parent", move.partner_shipping_id.parent_id])

            if move.partner_shipping_id.commercial_partner_id and move.partner_shipping_id.commercial_partner_id.id != move.partner_shipping_id.id:
                partner_sources.append(["invoice delivery commercial", move.partner_shipping_id.commercial_partner_id])

        for source_item in partner_sources:
            source_label = source_item[0]
            source_partner = source_item[1]
            country = source_partner.country_id

            if country:
                seen.append("%s=%s" % (source_label, country.display_name))

                country_code = (country.code or "").upper()

                if country.id != company_country.id and country_code in EU_CODES:
                    if country.id not in delivery_candidate_ids:
                        delivery_candidate_ids.append(country.id)
                        delivery_candidate_names.append(country.display_name)
                else:
                    if country.id not in concrete_non_foreign_ids:
                        concrete_non_foreign_ids.append(country.id)
                        concrete_non_foreign_names.append(country.display_name)
            else:
                seen.append("%s=MISSING" % source_label)

        # 2) Linked sale orders
        sale_orders = SaleOrder.browse([])

        if "sale_line_ids" in AccountMoveLine._fields:
            for line in move.invoice_line_ids:
                sale_orders = sale_orders | line.sale_line_ids.order_id

        if not sale_orders and move.invoice_origin:
            origin_names = []
            origin_text = move.invoice_origin.replace(",", "\n").replace(";", "\n")

            for origin in origin_text.split("\n"):
                origin = origin.strip()
                if origin:
                    origin_names.append(origin)

            if origin_names:
                sale_orders = sale_orders | SaleOrder.search([
                    ("name", "in", origin_names),
                ])

        for so in sale_orders:
            so_partner_sources = []

            if so.partner_shipping_id:
                so_partner_sources.append(["sale order %s delivery" % so.name, so.partner_shipping_id])

                if so.partner_shipping_id.parent_id:
                    so_partner_sources.append(["sale order %s delivery parent" % so.name, so.partner_shipping_id.parent_id])

                if so.partner_shipping_id.commercial_partner_id and so.partner_shipping_id.commercial_partner_id.id != so.partner_shipping_id.id:
                    so_partner_sources.append(["sale order %s delivery commercial" % so.name, so.partner_shipping_id.commercial_partner_id])

            for source_item in so_partner_sources:
                source_label = source_item[0]
                source_partner = source_item[1]
                country = source_partner.country_id

                if country:
                    seen.append("%s=%s" % (source_label, country.display_name))

                    country_code = (country.code or "").upper()

                    if country.id != company_country.id and country_code in EU_CODES:
                        if country.id not in delivery_candidate_ids:
                            delivery_candidate_ids.append(country.id)
                            delivery_candidate_names.append(country.display_name)
                    else:
                        if country.id not in concrete_non_foreign_ids:
                            concrete_non_foreign_ids.append(country.id)
                            concrete_non_foreign_names.append(country.display_name)
                else:
                    seen.append("%s=MISSING" % source_label)

            # 3) Delivery orders / pickings
            if "picking_ids" in so._fields:
                for picking in so.picking_ids:
                    picking_partner_sources = []

                    if picking.partner_id:
                        picking_partner_sources.append(["delivery %s partner" % picking.name, picking.partner_id])

                        if picking.partner_id.parent_id:
                            picking_partner_sources.append(["delivery %s partner parent" % picking.name, picking.partner_id.parent_id])

                        if picking.partner_id.commercial_partner_id and picking.partner_id.commercial_partner_id.id != picking.partner_id.id:
                            picking_partner_sources.append(["delivery %s partner commercial" % picking.name, picking.partner_id.commercial_partner_id])

                    for source_item in picking_partner_sources:
                        source_label = source_item[0]
                        source_partner = source_item[1]
                        country = source_partner.country_id

                        if country:
                            seen.append("%s=%s" % (source_label, country.display_name))

                            country_code = (country.code or "").upper()

                            if country.id != company_country.id and country_code in EU_CODES:
                                if country.id not in delivery_candidate_ids:
                                    delivery_candidate_ids.append(country.id)
                                    delivery_candidate_names.append(country.display_name)
                            else:
                                if country.id not in concrete_non_foreign_ids:
                                    concrete_non_foreign_ids.append(country.id)
                                    concrete_non_foreign_names.append(country.display_name)
                        else:
                            seen.append("%s=MISSING" % source_label)

        # 4) Decide based on delivery-related sources first
        if delivery_candidate_ids and concrete_non_foreign_ids:
            skipped.append(
                "%s skipped: conflicting delivery countries; foreign EU=%s, non-foreign/domestic=%s. Seen: %s"
                % (
                    label,
                    ", ".join(delivery_candidate_names),
                    ", ".join(concrete_non_foreign_names),
                    "; ".join(seen) or "none",
                )
            )
            continue

        if len(delivery_candidate_ids) == 1:
            destination_country = Country.browse(delivery_candidate_ids[0])
            destination_source = "delivery-related source"

        elif len(delivery_candidate_ids) > 1:
            skipped.append(
                "%s skipped: multiple foreign EU delivery candidates: %s. Seen: %s"
                % (
                    label,
                    ", ".join(delivery_candidate_names),
                    "; ".join(seen) or "none",
                )
            )
            continue

        elif concrete_non_foreign_ids:
            skipped.append(
                "%s skipped: delivery country is not foreign EU: %s. Seen: %s"
                % (
                    label,
                    ", ".join(concrete_non_foreign_names),
                    "; ".join(seen) or "none",
                )
            )
            continue

        else:
            # 5) Customer/billing fallback only when no concrete delivery country exists.
            customer_candidate_ids = []
            customer_candidate_names = []

            customer_sources = []

            if move.partner_id:
                customer_sources.append(["customer", move.partner_id])

                if move.partner_id.parent_id:
                    customer_sources.append(["customer parent", move.partner_id.parent_id])

                if move.partner_id.commercial_partner_id and move.partner_id.commercial_partner_id.id != move.partner_id.id:
                    customer_sources.append(["customer commercial", move.partner_id.commercial_partner_id])

            for source_item in customer_sources:
                source_label = source_item[0]
                source_partner = source_item[1]
                country = source_partner.country_id

                if country:
                    seen.append("%s=%s" % (source_label, country.display_name))

                    country_code = (country.code or "").upper()

                    if country.id != company_country.id and country_code in EU_CODES:
                        if country.id not in customer_candidate_ids:
                            customer_candidate_ids.append(country.id)
                            customer_candidate_names.append(country.display_name)
                else:
                    seen.append("%s=MISSING" % source_label)

            if len(customer_candidate_ids) == 1:
                destination_country = Country.browse(customer_candidate_ids[0])
                destination_source = "customer-country fallback"

            elif len(customer_candidate_ids) > 1:
                skipped.append(
                    "%s skipped: multiple foreign EU customer-country candidates: %s. Seen: %s"
                    % (
                        label,
                        ", ".join(customer_candidate_names),
                        "; ".join(seen) or "none",
                    )
                )
                continue

            else:
                skipped.append(
                    "%s skipped: no foreign EU destination found. Seen: %s"
                    % (
                        label,
                        "; ".join(seen) or "none",
                    )
                )
                continue

    # Final destination safety check
    destination_code = (destination_country.code or "").upper()

    if not destination_country or destination_country.id == company_country.id or destination_code not in EU_CODES:
        skipped.append(
            "%s skipped: destination %s is not a foreign EU country for company country %s"
            % (
                label,
                destination_country.display_name or "MISSING",
                company_country.display_name,
            )
        )
        continue

    # ------------------------------------------------------------
    # Find transport mode
    # ------------------------------------------------------------

    transport_mode = IntrastatCode.search([
        ("type", "=", "transport"),
        ("code", "=", TRANSPORT_CODE),
        "|",
        ("expiry_date", ">", ref_date),
        ("expiry_date", "=", False),
        "|",
        ("start_date", "<=", ref_date),
        ("start_date", "=", False),
    ], limit=1)

    if not transport_mode:
        skipped.append(
            "%s skipped: Intrastat transport code %s not active/found for date %s"
            % (label, TRANSPORT_CODE, ref_date)
        )
        continue

    # ------------------------------------------------------------
    # Header values: fill only if missing
    # ------------------------------------------------------------

    vals = {}

    if not move.intrastat_country_id:
        vals["intrastat_country_id"] = destination_country.id

    if not move.intrastat_transport_mode_id:
        vals["intrastat_transport_mode_id"] = transport_mode.id

    # ------------------------------------------------------------
    # Line values: fill only safe goods candidates
    # ------------------------------------------------------------

    line_candidates = AccountMoveLine.browse([])
    lines_to_fill = AccountMoveLine.browse([])
    transaction_code = False

    if UPDATE_LINE_TRANSACTION_CODES:
        for line in move.invoice_line_ids:
            if line.display_type not in (False, "product"):
                continue

            if not line.product_id:
                continue

            product_name = line.product_id.display_name or ""
            line_name = line.name or ""
            line_text = (product_name + " " + line_name).lower()

            excluded_by_text = False

            for blocked_text in EXCLUDE_LINE_TEXT_CONTAINS:
                if blocked_text and blocked_text.lower() in line_text:
                    excluded_by_text = True

            if excluded_by_text:
                continue

            if SKIP_ZERO_VALUE_LINES and not line.price_subtotal:
                continue

            tmpl = line.product_id.product_tmpl_id
            product_type = ""

            if "detailed_type" in tmpl._fields:
                product_type = tmpl.detailed_type or ""
            elif "type" in tmpl._fields:
                product_type = tmpl.type or ""

            include_line = False

            if product_type != "service":
                include_line = True
            else:
                if INCLUDE_SERVICE_PRODUCTS_WITH_CUSTOMS_DATA:
                    has_customs_data = False

                    for field_name in CUSTOMS_FIELD_NAMES:
                        if field_name in line._fields and line[field_name]:
                            has_customs_data = True

                    for field_name in CUSTOMS_FIELD_NAMES:
                        if field_name in line.product_id._fields and line.product_id[field_name]:
                            has_customs_data = True

                    for field_name in CUSTOMS_FIELD_NAMES:
                        if field_name in tmpl._fields and tmpl[field_name]:
                            has_customs_data = True

                    if has_customs_data:
                        include_line = True

            if include_line:
                line_candidates = line_candidates | line

                if not line.intrastat_transaction_id:
                    lines_to_fill = lines_to_fill | line

        if lines_to_fill:
            transaction_code = IntrastatCode.search([
                ("type", "=", "transaction"),
                ("code", "=", TRANSACTION_CODE),
                "|",
                ("expiry_date", ">", ref_date),
                ("expiry_date", "=", False),
                "|",
                ("start_date", "<=", ref_date),
                ("start_date", "=", False),
            ], limit=1)

            if not transaction_code:
                skipped.append(
                    "%s skipped: goods lines need transaction code, but Intrastat transaction code %s was not active/found for date %s. No changes made."
                    % (label, TRANSACTION_CODE, ref_date)
                )
                continue

    # ------------------------------------------------------------
    # Apply changes
    # ------------------------------------------------------------

    processed += 1

    header_fields_to_fill = ", ".join(vals.keys()) or "none"

    if vals:
        header_updates += 1

        if not DRY_RUN:
            move.sudo().write(vals)

    invoice_line_updates = len(lines_to_fill)
    line_updates += invoice_line_updates

    if lines_to_fill and not DRY_RUN:
        for line in lines_to_fill:
            line.sudo().write({
                "intrastat_transaction_id": transaction_code.id,
            })

    details.append(
        "%s: country=%s via %s | transport=%s | header fields to fill=%s | goods line candidates=%s | line codes to fill=%s"
        % (
            label,
            destination_country.display_name,
            destination_source,
            transport_mode.display_name,
            header_fields_to_fill,
            len(line_candidates),
            invoice_line_updates,
        )
    )

# ============================================================
# RESULT MESSAGE
# ============================================================

mode = "DRY RUN - no records changed" if DRY_RUN else "REAL UPDATE COMPLETE"

shown_details = []
detail_counter = 0

for detail in details:
    if detail_counter < 40:
        shown_details.append(detail)
    detail_counter += 1

if len(details) > 40:
    shown_details.append("... %s more invoice details omitted from popup" % (len(details) - 40))

shown_skipped = []
skip_counter = 0

for skip_line in skipped:
    if skip_counter < 40:
        shown_skipped.append(skip_line)
    skip_counter += 1

if len(skipped) > 40:
    shown_skipped.append("... %s more skipped items omitted from popup" % (len(skipped) - 40))

message = (
    "%s\n"
    "Selected records: %s\n"
    "Eligible processed invoices: %s\n"
    "Header updates %s: %s\n"
    "Invoice line transaction updates %s: %s\n\n"
    "Details:\n%s\n\n"
    "Skipped:\n%s"
) % (
    mode,
    selected_count,
    processed,
    "that would be made" if DRY_RUN else "made",
    header_updates,
    "that would be made" if DRY_RUN else "made",
    line_updates,
    "\n".join(shown_details) or "none",
    "\n".join(shown_skipped) or "none",
)

log(message, level="info")

action = {
    "type": "ir.actions.client",
    "tag": "display_notification",
    "params": {
        "title": "EU Intrastat Backfill",
        "message": message,
        "sticky": True,
        "type": "warning" if DRY_RUN else "success",
    },
}

Script 2: Non‑EU or Domestic Transport Unblock

Use this for existing invoices that are non‑EU, domestic, or otherwise not Intrastat-reportable, but are blocked by the hidden transport-mode field. It only fills the transport mode.

2 - Non‑EU or Domestic Transport Unblock
# TEMPORARY NON-EU / NON-INTRASTAT TRANSPORT UNBLOCK ACTION
#
# Odoo Server Action safe_eval compatible version:
# - no lambda
# - no def
# - no nested functions
# - no list comprehensions
#
# Purpose:
# - Fix old customer invoices that show:
#     Invalid fields: Intrastat Transport Mode
#   even though they are non-EU / domestic / not Intrastat-reportable.
#
# This script only fills:
#   account.move.intrastat_transport_mode_id
#
# It does NOT:
# - set Intrastat Country
# - set invoice-line Intrastat transaction codes
# - reset invoices to draft
# - post invoices
# - change invoice numbers
# - change invoice dates
# - change amounts
# - change taxes
# - change payment reconciliation
#
# Use the EU Intrastat Backfill script for actual EU Intrastat invoices.
# Use this script only to unblock non-EU/domestic/unreportable invoices from the hidden required-field problem.

# ============================================================
# SETTINGS
# ============================================================

DRY_RUN = True

START_DATE = "2026-01-01"

TRANSPORT_CODE = "3"  # 3 = Road transport

ALLOW_DRAFT_INVOICES = True

EU_CODES = [
    "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
    "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
    "PL", "PT", "RO", "SK", "SI", "ES", "SE",
    "XI",
]

# ============================================================
# MODELS
# ============================================================

IntrastatCode = env["account.intrastat.code"]
SaleOrder = env["sale.order"]
AccountMoveLine = env["account.move.line"]

# ============================================================
# MAIN
# ============================================================

selected_count = len(records)
processed = 0
transport_updates = 0

details = []
skipped = []

if not records:
    raise UserError("No invoices were selected.")

for move in records:
    label = move.name or str(move.id)

    if move.move_type != "out_invoice":
        skipped.append("%s skipped: not a customer invoice" % label)
        continue

    if ALLOW_DRAFT_INVOICES:
        if move.state not in ("posted", "draft"):
            skipped.append("%s skipped: state is %s" % (label, move.state))
            continue
    else:
        if move.state != "posted":
            skipped.append("%s skipped: not posted" % label)
            continue

    ref_date = str(move.invoice_date or move.date or "")

    if not ref_date:
        skipped.append("%s skipped: no invoice/accounting date" % label)
        continue

    if START_DATE and ref_date < START_DATE:
        skipped.append("%s skipped: before START_DATE %s" % (label, START_DATE))
        continue

    company_country = move.company_id.country_id

    if not company_country:
        skipped.append("%s skipped: company has no country set" % label)
        continue

    # ------------------------------------------------------------
    # If this invoice already has a foreign-EU Intrastat Country,
    # it is a real Intrastat candidate. Do not use this non-EU unblock.
    # Use the EU Intrastat Backfill script instead.
    # ------------------------------------------------------------

    if move.intrastat_country_id:
        existing_country = move.intrastat_country_id
        existing_code = (existing_country.code or "").upper()

        if existing_country.id != company_country.id and existing_code in EU_CODES:
            skipped.append(
                "%s skipped: existing Intrastat Country %s is foreign EU; use EU Intrastat Backfill"
                % (label, existing_country.display_name)
            )
            continue

    # ------------------------------------------------------------
    # Detect whether the invoice looks like foreign-EU movement.
    # If yes, skip it and use the EU script.
    # If no, it is safe to fill only transport mode.
    # ------------------------------------------------------------

    delivery_foreign_eu_ids = []
    delivery_foreign_eu_names = []
    delivery_non_foreign_ids = []
    delivery_non_foreign_names = []
    seen = []

    # Invoice delivery partner sources
    partner_sources = []

    if move.partner_shipping_id:
        partner_sources.append(["invoice delivery", move.partner_shipping_id])

        if move.partner_shipping_id.parent_id:
            partner_sources.append(["invoice delivery parent", move.partner_shipping_id.parent_id])

        if move.partner_shipping_id.commercial_partner_id and move.partner_shipping_id.commercial_partner_id.id != move.partner_shipping_id.id:
            partner_sources.append(["invoice delivery commercial", move.partner_shipping_id.commercial_partner_id])

    for source_item in partner_sources:
        source_label = source_item[0]
        source_partner = source_item[1]
        country = source_partner.country_id

        if country:
            seen.append("%s=%s" % (source_label, country.display_name))

            country_code = (country.code or "").upper()

            if country.id != company_country.id and country_code in EU_CODES:
                if country.id not in delivery_foreign_eu_ids:
                    delivery_foreign_eu_ids.append(country.id)
                    delivery_foreign_eu_names.append(country.display_name)
            else:
                if country.id not in delivery_non_foreign_ids:
                    delivery_non_foreign_ids.append(country.id)
                    delivery_non_foreign_names.append(country.display_name)
        else:
            seen.append("%s=MISSING" % source_label)

    # Linked sale orders
    sale_orders = SaleOrder.browse([])

    if "sale_line_ids" in AccountMoveLine._fields:
        for line in move.invoice_line_ids:
            sale_orders = sale_orders | line.sale_line_ids.order_id

    if not sale_orders and move.invoice_origin:
        origin_names = []
        origin_text = move.invoice_origin.replace(",", "\n").replace(";", "\n")

        for origin in origin_text.split("\n"):
            origin = origin.strip()
            if origin:
                origin_names.append(origin)

        if origin_names:
            sale_orders = sale_orders | SaleOrder.search([
                ("name", "in", origin_names),
            ])

    for so in sale_orders:
        so_partner_sources = []

        if so.partner_shipping_id:
            so_partner_sources.append(["sale order %s delivery" % so.name, so.partner_shipping_id])

            if so.partner_shipping_id.parent_id:
                so_partner_sources.append(["sale order %s delivery parent" % so.name, so.partner_shipping_id.parent_id])

            if so.partner_shipping_id.commercial_partner_id and so.partner_shipping_id.commercial_partner_id.id != so.partner_shipping_id.id:
                so_partner_sources.append(["sale order %s delivery commercial" % so.name, so.partner_shipping_id.commercial_partner_id])

        for source_item in so_partner_sources:
            source_label = source_item[0]
            source_partner = source_item[1]
            country = source_partner.country_id

            if country:
                seen.append("%s=%s" % (source_label, country.display_name))

                country_code = (country.code or "").upper()

                if country.id != company_country.id and country_code in EU_CODES:
                    if country.id not in delivery_foreign_eu_ids:
                        delivery_foreign_eu_ids.append(country.id)
                        delivery_foreign_eu_names.append(country.display_name)
                else:
                    if country.id not in delivery_non_foreign_ids:
                        delivery_non_foreign_ids.append(country.id)
                        delivery_non_foreign_names.append(country.display_name)
            else:
                seen.append("%s=MISSING" % source_label)

        # Delivery orders / pickings
        if "picking_ids" in so._fields:
            for picking in so.picking_ids:
                picking_partner_sources = []

                if picking.partner_id:
                    picking_partner_sources.append(["delivery %s partner" % picking.name, picking.partner_id])

                    if picking.partner_id.parent_id:
                        picking_partner_sources.append(["delivery %s partner parent" % picking.name, picking.partner_id.parent_id])

                    if picking.partner_id.commercial_partner_id and picking.partner_id.commercial_partner_id.id != picking.partner_id.id:
                        picking_partner_sources.append(["delivery %s partner commercial" % picking.name, picking.partner_id.commercial_partner_id])

                for source_item in picking_partner_sources:
                    source_label = source_item[0]
                    source_partner = source_item[1]
                    country = source_partner.country_id

                    if country:
                        seen.append("%s=%s" % (source_label, country.display_name))

                        country_code = (country.code or "").upper()

                        if country.id != company_country.id and country_code in EU_CODES:
                            if country.id not in delivery_foreign_eu_ids:
                                delivery_foreign_eu_ids.append(country.id)
                                delivery_foreign_eu_names.append(country.display_name)
                        else:
                            if country.id not in delivery_non_foreign_ids:
                                delivery_non_foreign_ids.append(country.id)
                                delivery_non_foreign_names.append(country.display_name)
                    else:
                        seen.append("%s=MISSING" % source_label)

    # If delivery indicates foreign-EU, this is not a non-EU unblock case.
    if delivery_foreign_eu_ids:
        skipped.append(
            "%s skipped: foreign-EU delivery detected: %s. Use EU Intrastat Backfill. Seen: %s"
            % (
                label,
                ", ".join(delivery_foreign_eu_names),
                "; ".join(seen) or "none",
            )
        )
        continue

    # If no delivery country was found, inspect customer country as a fallback signal.
    # If customer is foreign-EU and no non-EU delivery info exists, skip to avoid hiding a true EU Intrastat issue.
    if not delivery_non_foreign_ids:
        customer_foreign_eu_ids = []
        customer_foreign_eu_names = []
        customer_non_foreign_names = []

        customer_sources = []

        if move.partner_id:
            customer_sources.append(["customer", move.partner_id])

            if move.partner_id.parent_id:
                customer_sources.append(["customer parent", move.partner_id.parent_id])

            if move.partner_id.commercial_partner_id and move.partner_id.commercial_partner_id.id != move.partner_id.id:
                customer_sources.append(["customer commercial", move.partner_id.commercial_partner_id])

        for source_item in customer_sources:
            source_label = source_item[0]
            source_partner = source_item[1]
            country = source_partner.country_id

            if country:
                seen.append("%s=%s" % (source_label, country.display_name))

                country_code = (country.code or "").upper()

                if country.id != company_country.id and country_code in EU_CODES:
                    if country.id not in customer_foreign_eu_ids:
                        customer_foreign_eu_ids.append(country.id)
                        customer_foreign_eu_names.append(country.display_name)
                else:
                    if country.display_name not in customer_non_foreign_names:
                        customer_non_foreign_names.append(country.display_name)
            else:
                seen.append("%s=MISSING" % source_label)

        if customer_foreign_eu_ids:
            skipped.append(
                "%s skipped: customer country is foreign-EU and no non-EU delivery country was found: %s. Use EU Intrastat Backfill or inspect manually. Seen: %s"
                % (
                    label,
                    ", ".join(customer_foreign_eu_names),
                    "; ".join(seen) or "none",
                )
            )
            continue

    # ------------------------------------------------------------
    # Find transport mode
    # ------------------------------------------------------------

    transport_mode = IntrastatCode.search([
        ("type", "=", "transport"),
        ("code", "=", TRANSPORT_CODE),
        "|",
        ("expiry_date", ">", ref_date),
        ("expiry_date", "=", False),
        "|",
        ("start_date", "<=", ref_date),
        ("start_date", "=", False),
    ], limit=1)

    if not transport_mode:
        skipped.append(
            "%s skipped: Intrastat transport code %s not active/found for date %s"
            % (label, TRANSPORT_CODE, ref_date)
        )
        continue

    processed += 1

    fields_to_fill = []

    if not move.intrastat_transport_mode_id:
        fields_to_fill.append("intrastat_transport_mode_id")

        if not DRY_RUN:
            move.sudo().write({
                "intrastat_transport_mode_id": transport_mode.id,
            })

        transport_updates += 1

    details.append(
        "%s: non-EU/domestic/unreportable unblock | transport=%s | fields to fill=%s | seen=%s"
        % (
            label,
            transport_mode.display_name,
            ", ".join(fields_to_fill) or "none",
            "; ".join(seen) or "none",
        )
    )

# ============================================================
# RESULT MESSAGE
# ============================================================

mode = "DRY RUN - no records changed" if DRY_RUN else "REAL UPDATE COMPLETE"

shown_details = []
detail_counter = 0

for detail in details:
    if detail_counter < 40:
        shown_details.append(detail)
    detail_counter += 1

if len(details) > 40:
    shown_details.append("... %s more invoice details omitted from popup" % (len(details) - 40))

shown_skipped = []
skip_counter = 0

for skip_line in skipped:
    if skip_counter < 40:
        shown_skipped.append(skip_line)
    skip_counter += 1

if len(skipped) > 40:
    shown_skipped.append("... %s more skipped items omitted from popup" % (len(skipped) - 40))

message = (
    "%s\n"
    "Selected records: %s\n"
    "Eligible non-EU/domestic/unreportable invoices processed: %s\n"
    "Transport-mode updates %s: %s\n\n"
    "Details:\n%s\n\n"
    "Skipped:\n%s"
) % (
    mode,
    selected_count,
    processed,
    "that would be made" if DRY_RUN else "made",
    transport_updates,
    "\n".join(shown_details) or "none",
    "\n".join(shown_skipped) or "none",
)

log(message, level="info")

action = {
    "type": "ir.actions.client",
    "tag": "display_notification",
    "params": {
        "title": "Non-EU Intrastat Transport Unblock",
        "message": message,
        "sticky": True,
        "type": "warning" if DRY_RUN else "success",
    },
}

Script 3: Temporary Draft Invoice Transport Unblock

Use this for a blocked draft invoice created from a new quotation, where Preview, Proforma, Save, or Confirm fails because Odoo requires the hidden Intrastat Transport Mode field. This script is draft-focused and should be run from the invoice list view whenever possible.

After running the real update on a draft invoice, reopen the invoice fresh from the list view before clicking Preview, Proforma, or Confirm. An already-open Odoo form can still hold stale blank values in the browser state.

3 - TEMP Draft Intrastat Transport Unblock
# TEMPORARY DRAFT INVOICE INTRASTAT TRANSPORT UNBLOCK ACTION
#
# Odoo Server Action safe_eval compatible version:
# - no lambda
# - no def
# - no nested functions
# - no list comprehensions
#
# Purpose:
# - Temporarily unblock draft customer invoices that show:
#     Invalid fields: Intrastat Transport Mode
#   or cannot be previewed / printed / confirmed because Odoo requires the hidden field:
#     account.move.intrastat_transport_mode_id
#
# Use for:
# - Non-EU deliveries such as USA, Nigeria, Switzerland, UK, etc.
# - Domestic Slovakia deliveries.
# - Other invoices that are not Intrastat-reportable but are blocked by the hidden transport field.
#
# This script only fills:
#   account.move.intrastat_transport_mode_id
#
# It does NOT:
# - set Intrastat Country
# - set invoice-line Intrastat transaction codes
# - reset invoices to draft
# - post invoices
# - change invoice numbers
# - change invoice dates
# - change amounts
# - change taxes
# - change payment terms
# - change payment reconciliation
#
# Recommended operation:
# 1) Leave DRY_RUN = True.
# 2) Select the blocked draft invoice(s).
# 3) Run the action and read the popup.
# 4) If the popup is correct, change DRY_RUN = False.
# 5) Run the action again.
# 6) Change DRY_RUN back to True immediately.
#
# Use the EU Intrastat Backfill script for real EU Intrastat invoices.

# ============================================================
# SETTINGS
# ============================================================

DRY_RUN = True

TRANSPORT_CODE = "3"  # 3 = Road transport

ONLY_DRAFT_INVOICES = True

ALLOW_CUSTOMER_REFUNDS = False

# Safety: if a foreign-EU destination is detected, skip the invoice.
# Such invoices should use the EU Intrastat Backfill action instead.
SKIP_IF_FOREIGN_EU_DESTINATION_DETECTED = True

EU_CODES = [
    "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
    "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
    "PL", "PT", "RO", "SK", "SI", "ES", "SE",
    "XI",
]

# Optional marker field.
# If you create this Boolean custom field on account.move, this action will mark invoices it updated:
#   Field name: x_barani_intrastat_tempfix_applied
#   Model: account.move / Journal Entry
#   Type: Boolean
MARKER_FIELD_NAME = "x_barani_intrastat_tempfix_applied"

# ============================================================
# MODELS
# ============================================================

IntrastatCode = env["account.intrastat.code"]
SaleOrder = env["sale.order"]
AccountMoveLine = env["account.move.line"]

# ============================================================
# MAIN
# ============================================================

selected_count = len(records)
eligible_count = 0
transport_updates = 0
already_ok_count = 0

details = []
skipped = []

if not records:
    raise UserError("No invoices were selected.")

for move in records:
    label = move.name or move.display_name or str(move.id)

    if "intrastat_transport_mode_id" not in move._fields:
        skipped.append("%s skipped: field intrastat_transport_mode_id does not exist on this database/model" % label)
        continue

    if move.move_type == "out_refund":
        if not ALLOW_CUSTOMER_REFUNDS:
            skipped.append("%s skipped: customer refund; set ALLOW_CUSTOMER_REFUNDS = True if needed" % label)
            continue
    elif move.move_type != "out_invoice":
        skipped.append("%s skipped: not a customer invoice" % label)
        continue

    if ONLY_DRAFT_INVOICES:
        if move.state != "draft":
            skipped.append("%s skipped: state is %s, not draft" % (label, move.state))
            continue

    company_country = move.company_id.country_id

    if not company_country:
        skipped.append("%s skipped: company has no country set" % label)
        continue

    # ------------------------------------------------------------
    # Safety check: skip real foreign-EU Intrastat candidates.
    # This action is only for non-EU/domestic/unreportable unblocking.
    # ------------------------------------------------------------

    seen = []
    foreign_eu_names = []
    foreign_eu_ids = []

    if SKIP_IF_FOREIGN_EU_DESTINATION_DETECTED:
        # Existing Intrastat Country
        if move.intrastat_country_id:
            country = move.intrastat_country_id
            country_code = (country.code or "").upper()
            seen.append("existing Intrastat Country=%s" % country.display_name)

            if country.id != company_country.id and country_code in EU_CODES:
                if country.id not in foreign_eu_ids:
                    foreign_eu_ids.append(country.id)
                    foreign_eu_names.append(country.display_name)

        # Invoice delivery address
        partner_sources = []

        if move.partner_shipping_id:
            partner_sources.append(["invoice delivery", move.partner_shipping_id])

            if move.partner_shipping_id.parent_id:
                partner_sources.append(["invoice delivery parent", move.partner_shipping_id.parent_id])

            if move.partner_shipping_id.commercial_partner_id and move.partner_shipping_id.commercial_partner_id.id != move.partner_shipping_id.id:
                partner_sources.append(["invoice delivery commercial", move.partner_shipping_id.commercial_partner_id])

        for source_item in partner_sources:
            source_label = source_item[0]
            source_partner = source_item[1]
            country = source_partner.country_id

            if country:
                country_code = (country.code or "").upper()
                seen.append("%s=%s" % (source_label, country.display_name))

                if country.id != company_country.id and country_code in EU_CODES:
                    if country.id not in foreign_eu_ids:
                        foreign_eu_ids.append(country.id)
                        foreign_eu_names.append(country.display_name)
            else:
                seen.append("%s=MISSING" % source_label)

        # Linked sales orders
        sale_orders = SaleOrder.browse([])

        if "sale_line_ids" in AccountMoveLine._fields:
            for line in move.invoice_line_ids:
                sale_orders = sale_orders | line.sale_line_ids.order_id

        if not sale_orders and move.invoice_origin:
            origin_names = []
            origin_text = move.invoice_origin.replace(",", "\n").replace(";", "\n")

            for origin in origin_text.split("\n"):
                origin = origin.strip()
                if origin:
                    origin_names.append(origin)

            if origin_names:
                sale_orders = sale_orders | SaleOrder.search([
                    ("name", "in", origin_names),
                ])

        for so in sale_orders:
            so_partner_sources = []

            if so.partner_shipping_id:
                so_partner_sources.append(["sale order %s delivery" % so.name, so.partner_shipping_id])

                if so.partner_shipping_id.parent_id:
                    so_partner_sources.append(["sale order %s delivery parent" % so.name, so.partner_shipping_id.parent_id])

                if so.partner_shipping_id.commercial_partner_id and so.partner_shipping_id.commercial_partner_id.id != so.partner_shipping_id.id:
                    so_partner_sources.append(["sale order %s delivery commercial" % so.name, so.partner_shipping_id.commercial_partner_id])

            for source_item in so_partner_sources:
                source_label = source_item[0]
                source_partner = source_item[1]
                country = source_partner.country_id

                if country:
                    country_code = (country.code or "").upper()
                    seen.append("%s=%s" % (source_label, country.display_name))

                    if country.id != company_country.id and country_code in EU_CODES:
                        if country.id not in foreign_eu_ids:
                            foreign_eu_ids.append(country.id)
                            foreign_eu_names.append(country.display_name)
                else:
                    seen.append("%s=MISSING" % source_label)

            # Delivery orders / pickings
            if "picking_ids" in so._fields:
                for picking in so.picking_ids:
                    picking_partner_sources = []

                    if picking.partner_id:
                        picking_partner_sources.append(["delivery %s partner" % picking.name, picking.partner_id])

                        if picking.partner_id.parent_id:
                            picking_partner_sources.append(["delivery %s partner parent" % picking.name, picking.partner_id.parent_id])

                        if picking.partner_id.commercial_partner_id and picking.partner_id.commercial_partner_id.id != picking.partner_id.id:
                            picking_partner_sources.append(["delivery %s partner commercial" % picking.name, picking.partner_id.commercial_partner_id])

                    for source_item in picking_partner_sources:
                        source_label = source_item[0]
                        source_partner = source_item[1]
                        country = source_partner.country_id

                        if country:
                            country_code = (country.code or "").upper()
                            seen.append("%s=%s" % (source_label, country.display_name))

                            if country.id != company_country.id and country_code in EU_CODES:
                                if country.id not in foreign_eu_ids:
                                    foreign_eu_ids.append(country.id)
                                    foreign_eu_names.append(country.display_name)
                        else:
                            seen.append("%s=MISSING" % source_label)

        if foreign_eu_ids:
            skipped.append(
                "%s skipped: foreign-EU destination detected (%s). Use EU Intrastat Backfill instead. Seen: %s"
                % (
                    label,
                    ", ".join(foreign_eu_names),
                    "; ".join(seen) or "none",
                )
            )
            continue

    # ------------------------------------------------------------
    # Find Road transport mode
    # ------------------------------------------------------------

    ref_date = str(move.invoice_date or move.date or fields.Date.context_today(move))

    transport_mode = IntrastatCode.search([
        ("type", "=", "transport"),
        ("code", "=", TRANSPORT_CODE),
        "|",
        ("expiry_date", ">", ref_date),
        ("expiry_date", "=", False),
        "|",
        ("start_date", "<=", ref_date),
        ("start_date", "=", False),
    ], limit=1)

    if not transport_mode:
        skipped.append(
            "%s skipped: Intrastat transport code %s was not active/found for date %s"
            % (label, TRANSPORT_CODE, ref_date)
        )
        continue

    eligible_count += 1

    fields_to_fill = []

    if move.intrastat_transport_mode_id:
        already_ok_count += 1
    else:
        fields_to_fill.append("intrastat_transport_mode_id")

        vals = {
            "intrastat_transport_mode_id": transport_mode.id,
        }

        if MARKER_FIELD_NAME in move._fields:
            vals[MARKER_FIELD_NAME] = True
            fields_to_fill.append(MARKER_FIELD_NAME)

        if not DRY_RUN:
            move.sudo().write(vals)

        transport_updates += 1

    details.append(
        "%s: eligible draft non-EU/domestic/unreportable unblock | transport=%s | fields to fill=%s | seen=%s"
        % (
            label,
            transport_mode.display_name,
            ", ".join(fields_to_fill) or "none; already filled",
            "; ".join(seen) or "none",
        )
    )

# ============================================================
# RESULT MESSAGE
# ============================================================

mode = "DRY RUN - no records changed" if DRY_RUN else "REAL UPDATE COMPLETE"

shown_details = []
detail_counter = 0

for detail in details:
    if detail_counter < 40:
        shown_details.append(detail)
    detail_counter += 1

if len(details) > 40:
    shown_details.append("... %s more invoice details omitted from popup" % (len(details) - 40))

shown_skipped = []
skip_counter = 0

for skip_line in skipped:
    if skip_counter < 40:
        shown_skipped.append(skip_line)
    skip_counter += 1

if len(skipped) > 40:
    shown_skipped.append("... %s more skipped items omitted from popup" % (len(skipped) - 40))

message = (
    "%s\n"
    "Selected records: %s\n"
    "Eligible draft invoices processed: %s\n"
    "Already had transport mode: %s\n"
    "Transport-mode updates %s: %s\n\n"
    "Details:\n%s\n\n"
    "Skipped:\n%s"
) % (
    mode,
    selected_count,
    eligible_count,
    already_ok_count,
    "that would be made" if DRY_RUN else "made",
    transport_updates,
    "\n".join(shown_details) or "none",
    "\n".join(shown_skipped) or "none",
)

log(message, level="info")

action = {
    "type": "ir.actions.client",
    "tag": "display_notification",
    "params": {
        "title": "TEMP Draft Intrastat Transport Unblock",
        "message": message,
        "sticky": True,
        "type": "warning" if DRY_RUN else "success",
    },
}

Expected dry-run outputs

EU delivery example

For a Germany goods invoice, the EU script should show Eligible processed invoices: 1, Header updates that would be made: 1, and usually Invoice line transaction updates that would be made: 1 or more, depending on goods lines.

EU service-only example

For a Germany service-only invoice, the EU script should show Header updates that would be made: 1 and Invoice line transaction updates that would be made: 0.

Non‑EU draft invoice example

For a blocked draft invoice going to UAE, Nigeria, USA, Switzerland, or another non‑EU country, the draft unblock script should show Eligible draft invoices processed: 1 and Transport-mode updates that would be made: 1.

What these scripts do not fix

These Server Actions do not replace proper Intrastat setup. EU goods invoices still need correct product and partner data: CN/HS commodity codes, country of origin, weight, supplementary units where required, and appropriate transaction code.

The scripts also do not remove the need for a permanent implementation. The permanent fix should make Intrastat requirements conditional on actual Intrastat reportability.

The permanent fix we still want

The temporary scripts unblock work, but the correct long-term behavior should be:

If physical delivery country is foreign EU:
set or require Intrastat Country, set or require Intrastat Transport Mode, and apply normal line-level Intrastat logic.

If physical delivery country is non‑EU or domestic:
keep Intrastat Country blank, do not set line-level Intrastat transaction codes, and do not block posting because of Intrastat Transport Mode.

That permanent correction should be implemented through a proper Odoo module, Studio/automation only where appropriate, or by the Odoo implementer after reviewing the native Intrastat stack and any inherited views or customizations.

Why we are publishing this

We are publishing this because small European companies need practical tools, not more friction.

Intrastat data has value. It helps create a clearer picture of trade inside the EU. But when regulation is implemented through hidden fields, scattered metadata, old invoice records, and manual cleanup, the burden falls hardest on the companies least able to absorb it.

For an SME, compliance must be efficient. If the same data already exists in sales orders, delivery orders, product records, and invoices, the ERP should help reuse it. That is not avoiding regulation. That is good engineering.

Europe needs accurate trade data. It also needs manufacturers that can spend more time building products and less time repairing administrative edge cases.

Summary

This Odoo workaround package helps with three related Intrastat problems:

  • EU invoices missing Intrastat header or goods-line transaction data;
  • non‑EU or domestic invoices blocked by a hidden required transport-mode field;
  • new draft invoices from quotations that cannot be previewed, printed, saved, or confirmed because of the same hidden field.

Each script is narrow, DRY_RUN-protected, and designed not to touch accounting amounts, taxes, invoice numbers, or payment reconciliation.

Compliance should be accurate. It should also be practical.

Sources and further reading

Disclaimer: This is a technical Odoo workaround, not tax or legal advice. Each company should verify its Intrastat obligations, commodity codes, transaction codes, reporting thresholds, and national requirements with its accountant or statistical authority. Test in staging or with one invoice first before using on batches.