Odoo Intrastat Cleanup Scripts: Helping EU SMEs Stay Compliant Without Drowning in Paperwork
/Odoo Intrastat Cleanup Scripts: Helping EU SMEs Stay Compliant Without Drowning in Paperwork
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.
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:
For non‑EU or domestic invoices, should remain blank. But Odoo can still require the hidden field . The user cannot always fix it from the invoice form because the field may be hidden, required, or stale in the browser state.
The three safe Server Actions
The operating rule
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
- Go to Settings → Technical → Actions → Server Actions.
- Create a new Server Action.
- Set Model to Journal Entry. In Odoo, invoices are stored on the technical model .
- Set Action To Do to Execute Python Code.
- Paste the relevant script below.
- Save.
- Click Create Contextual Action.
- Go to Accounting → Customers → Invoices.
- Select one test invoice from the list view.
- Run the action from the Action menu.
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.
# 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.
# 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 field. This script is draft-focused and should be run from the invoice list view whenever possible.
# 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 , , and usually or more, depending on goods lines.
EU service-only example
For a Germany service-only invoice, the EU script should show and .
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 and .
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:
set or require Intrastat Country, set or require Intrastat Transport Mode, and apply normal line-level Intrastat logic.
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.
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.
