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.

BARANI Open Sourced Kafka Streaming Manifests for IoT Data Infrastructure

BARANI meteo innovations open sourced kafka data streaming architecture

At BARANI, accurate environmental measurement does not stop at the sensor. Our weather stations and meteorological sensors generate data that must move reliably from the field into systems where it can be stored, transformed, analyzed, and acted on. That requires more than hardware. It requires dependable data infrastructure.  

Today, we are open sourcing part of that infrastructure: our Kafka Platform Manifests repository.

The repository contains Kubernetes manifests for a Kafka-centered streaming platform built around Strimzi and related services. It is designed as a single, structured open-source repository with component directories for Kafka, Schema Registry, ksqlDB, Kafka Bridge, Debezium Connect, and Camel Connect.  

This is not a release of our business-specific stream-processing logic. Instead, it shares the deployment patterns, component wiring, and infrastructure manifests that can help teams build and operate similar streaming data platforms. The repository defines Kafka topics, ingestion paths, supporting services, and example CDC and webhook ingestion components, while keeping production application logic outside the public repository.  

Why we are sharing this

IoT and environmental monitoring systems generate continuous streams of data. For us, this means handling telemetry, webhook payloads, device-related events, database changes, and downstream processing stages in a way that is repeatable and maintainable.

But this challenge is not unique to BARANI. Nonprofits, NGOs, environmental protection organizations, research teams, citizen-science projects, municipalities, smart city teams, and resilient city initiatives often face the same need: they collect valuable environmental data from sensors, field devices, databases, and partner systems, but need reliable infrastructure to move that data where it can be analyzed and acted on.

Kafka is well suited for this kind of architecture because it gives teams a durable event backbone. Kubernetes gives us a practical way to deploy, version, and reason about that infrastructure as manifests. Strimzi then provides the Kubernetes-native operator layer for Kafka.

By publishing these manifests, we want to make our approach easier to inspect, adapt, and improve. More importantly, we want to give mission-driven teams a realistic starting point for building Kafka-based ingestion and streaming systems on Kubernetes.

Our hope is that this work can support organizations focused on climate resilience, environmental monitoring, conservation, disaster preparedness, sustainable agriculture, air-quality monitoring, water-resource management, smart city infrastructure, resilient city planning, and other projects that help protect our planet and strengthen communities.

Reliable environmental decisions depend on reliable environmental data. So do smarter, more resilient cities. By sharing part of our infrastructure openly, we want to help more teams spend less time rebuilding the same platform foundations and more time using data to understand, protect, and restore the natural world while building communities that are better prepared for the future.

What is included

The repository is organized into component directories, each with its own README and deployment guidance.  

Kafka core

The kafka/ directory includes raw Kubernetes manifests for a Strimzi-managed Apache Kafka cluster running in KRaft mode, plus an optional Kafka UI deployment. It includes manifests for the Kafka node pool, Kafka custom resource, Kafka user, Kafka rebalance resource, and Kafka UI.  

Schema Registry

The schema-registry/ directory contains manifests for deploying Confluent Schema Registry alongside the Kafka cluster. It includes deployment configuration, an in-cluster service on port 8081, and optional ingress with TLS and basic authentication.  

ksqlDB

The ksqldb/ directory contains manifests for a ksqlDB server, helper CLI pod, persistent volume claim, service, and optional ingress. This provides a foundation for querying and working with streams once data is flowing through Kafka.  

Kafka Bridge

The kafka-bridge/ directory includes manifests for deploying Strimzi Kafka Bridge, allowing HTTP-based access patterns where they are useful. The included manifests define the Kafka Bridge custom resource and optional ingress with TLS and basic authentication.  

Debezium Connect

The debezium-connect/ directory contains manifests for a Strimzi Kafka Connect cluster with the Debezium PostgreSQL connector, example topic definitions, an example PostgreSQL source connector, and least-privilege RBAC for reading database credentials from a Kubernetes Secret.  

Camel Connect

The camel-connect/ directory contains manifests for a Strimzi Kafka Connect cluster using the Camel Netty HTTP connector to receive webhook requests and publish them to Kafka topics. It includes connector resources, topics, services, ingress, network policy, an optional webhook logger, and a PlantUML topology source.  

How the pieces fit together

The baseline deployment order starts with Kafka, then optional platform services such as Schema Registry, ksqlDB, and Kafka Bridge, followed by integration components such as Debezium Connect and Camel Connect.  

That structure reflects a practical streaming architecture:

First, the Kafka cluster provides the event backbone.

Next, services such as Schema Registry and ksqlDB support schema management, stream inspection, and query workflows.

Finally, ingestion components bring data into Kafka from external systems. Debezium Connect is used for PostgreSQL change data capture examples, while Camel Connect is used for webhook ingestion examples.  

What you should customize before using it

These manifests are intended as a reusable starting point, not a one-command production deployment. The root README documents shared assumptions such as the kafka namespace, the kafka-kraft cluster name, the Kafka bootstrap service, SCRAM Secret name, placeholder ingress hosts under example.com, and node labels that satisfy affinity rules.  

Before applying the manifests in your own environment, review and replace ingress hostnames, image registry placeholders, Secret names and contents, storage classes and storage sizes, node labels and affinity rules, replication factors, and sizing defaults.  

Security-sensitive values are intentionally not committed. The repository is designed to avoid live secrets, and referenced secrets must be created separately in your Kubernetes cluster. Some manifests use SASL_PLAINTEXT as an internal example, with guidance to switch to TLS or SASL_SSL when encryption in transit is required.  

Why this matters for IoT and environmental data

Reliable meteorological and IoT systems are built from multiple layers. Sensors must measure accurately. Connectivity must be dependable. Data pipelines must preserve events, handle scale, and support downstream analysis.

Open sourcing this repository gives developers, integrators, and infrastructure teams a clearer look at one way to build the data-streaming layer behind such systems. It also helps separate reusable infrastructure from proprietary product and domain logic.

That separation matters. The repository provides platform manifests, example wiring, validation commands, deployment order, and component-specific documentation. It does not expose the full production logic that transforms raw events into final domain outputs.  

License and contributions

The repository is released under the MIT License, with copyright attributed to BARANI DESIGN Technologies.  

We welcome careful review and useful contributions. The contributing guidance asks contributors to keep changes focused on manifests, examples, validation, and documentation; avoid committing secrets, internal hostnames, internal IPs, or private registry references; and validate changed YAML files before submitting.

Security-sensitive reports should not be opened as public issues. The security policy asks reporters to use private vulnerability reporting, a private security advisory, or another private maintainer channel before disclosing details publicly.  

Explore the repository

You can find the open-source Kafka streaming manifests on GitHub. Review the README, inspect the component directories, adapt the placeholders for your environment, and use the manifests as a starting point for your own Kafka-based IoT or streaming data platform.

Transparency Starts at the Sensor

Fix the instruments. Fix the forecast. Restore trust in climate warnings.

At MeteoExpo 2025 in Vienna, Jan Barani presented one of the most talked-about sessions at the Technology & Innovation Theatre — challenging the very foundations of today’s hydrometeorological measurement industry.

Our presentation, “Transparency Starts at the Sensor,” asks a simple but uncomfortable question:

How can we expect the world to trust our forecasts and climate warnings if the instruments we use to collect the data can’t be trusted themselves?

For too long, our industry has hidden behind laboratory certificates and “WMO-certified” marketing claims, while the quality of field measurements has quietly eroded.
From 3 °C temperature errors in official records to low-sampling ultrasonic anemometers that miss entire wind gusts, the credibility gap between data sheets and real-world truth is widening fastMET25_Technology & Innovation T…MET25_Technology & Innovation T….

Fix the Instruments, Not Just the Models

The presentation exposed how:

  • “All-in-one” weather sensors combine incompatible measurements that disturb the very air they’re trying to measureMET25_Technology & Innovation T….

  • Fan-aspirated radiation shields (FARS) distort measurement height and introduce thermal noise instead of removing itMET25_Technology & Innovation T….

  • Low-power ultrasonics with 1 Hz or slower sampling rates can’t capture a 3-second gust and yet are still accepted in automatic weather stationsMET25_Technology & Innovation T….

  • WMO’s own time-constant guidelines omit wind-speed context entirely, making the term meaningless in practical meteorologyMET25_Technology & Innovation T….

Rebuilding Credibility

If we want people to trust early warnings again, we must start with transparent instrumentation and open intercomparison testing.
The talk proposes a simple path forward:

  • Proposal to implement Continuous WMO intercomparisons open to all manufacturers, funded by HMEI fees— not invite-only.

  • Proposal to implement a wind-invariant constant and dimensionless response index to make temperature-sensor performance directly comparable across airspeeds.

  • A call for “WMO certification through transparency”, where every sensor is verified in field conditions, not just in a chamber of a calibration laboratory.

Download the Full Presentation

The full slide deck — complete with illustrations, caricatures, and technical appendices — is available here:
👉 Download the PDF Presentation

About the Author

Jan Barani, founder and CEO of BARANI DESIGN Technologies, has spent over two decades challenging conventional thinking in meteorological instrumentation. His innovations — from the MeteoShield Pro to the MeteoHelix IoT Pro — have redefined field measurement accuracy across the globe.

At MeteoExpo 2025, his message was clear:

“Forecasts graded by the public on outcomes, not on calibration certificates, deserve better inputs. Let’s fix the instruments first.”

BARANI DESIGN Technologies is a manufacturer of professional weather stations