Repairing a detached invoice after tax and sales-order edits in Odoo
/Odoo technical runbook
Repairing a Detached Odoo Invoice-to-Sales-Order Link
A safe server-action workflow for cases where a customer invoice still displays the correct source document, but the invoice lines are no longer linked to the sales order lines.
Background
In Odoo, the visible Source Document on a customer invoice is not enough to make the sales order consider its lines invoiced. The sales order's invoicing status is driven by the invoice-line-to-sales-order-line relation.
| Object | Role |
|---|---|
account.move | Customer invoice / journal entry header. |
account.move.line | Invoice line / journal item. |
sale.order | Sales order header. |
sale.order.line | Sales order line. |
account.move.line.sale_line_ids | The relation that must be restored. |
sale_order_line_invoice_rel | The underlying many-to-many relation table. |
Pre-repair checklist
- Take a database backup.
- Confirm the invoice number and sales order number.
- Confirm invoice totals match the sales order totals.
- Confirm the invoice is not paid and has no payment allocation.
- Confirm the matching lines are unique by product, quantity, and untaxed subtotal.
Create the Server Action
- Go to Settings > Technical > Actions > Server Actions.
- Create a new action.
- Set Model to
Journal Entry/account.move. - Set Action To Do to
Execute Python Code. - Paste the code below.
- Save it, then click Create Contextual Action so it appears under the invoice's Action menu.
Server Action code
# ============================================================
# Relink an existing Odoo customer invoice to a sales order
# Odoo Server Action - Model: Journal Entry (account.move)
#
# First run:
# DRY_RUN = True -> checks only, no write
#
# Final run:
# DRY_RUN = False -> writes sale_line_ids on invoice lines
# ============================================================
DRY_RUN = True
ORDER_NAME = "SO_NUMBER"
INVOICE_NAME = "INVOICE_NUMBER"
def fail(message):
raise UserError(message)
def money_close(a, b, currency):
rounding = currency.rounding or 0.01
return abs(a - b) <= rounding + 0.0000001
def qty_close(a, b):
return abs(a - b) < 0.00001
def clean_name(value):
return value.replace("<", "").replace(">", "")
# Get selected invoice.
selected = records if records else record
if not selected or len(selected) != 1:
fail("Select exactly one invoice and run this action from that invoice.")
invoice = selected.sudo()
if invoice._name != "account.move":
fail("This server action must be run on model: Journal Entry / account.move.")
if invoice.move_type != "out_invoice":
fail("This action is only for customer invoices.")
if invoice.name != INVOICE_NAME and invoice.payment_reference != INVOICE_NAME:
fail(
"This action is intended only for invoice %s. Current invoice name: %s, payment reference: %s."
% (INVOICE_NAME, invoice.name, invoice.payment_reference)
)
# Find sale order.
order = env["sale.order"].sudo().search([
("name", "=", ORDER_NAME),
])
if len(order) != 1:
fail("Expected exactly one sale order %s, found %s." % (ORDER_NAME, len(order)))
# Basic safety checks.
currency = invoice.currency_id
if invoice.partner_id.commercial_partner_id != order.partner_id.commercial_partner_id:
fail(
"Customer mismatch. Invoice customer: %s. Sale order customer: %s."
% (invoice.partner_id.display_name, order.partner_id.display_name)
)
if not money_close(order.amount_untaxed, invoice.amount_untaxed, currency):
fail(
"Untaxed totals do not match. Sale order: %s. Invoice: %s."
% (order.amount_untaxed, invoice.amount_untaxed)
)
if not money_close(order.amount_total, invoice.amount_total, currency):
fail(
"Totals do not match. Sale order: %s. Invoice: %s."
% (order.amount_total, invoice.amount_total)
)
if invoice.invoice_origin and ORDER_NAME not in invoice.invoice_origin:
fail(
"Invoice Source Document does not contain %s. Current Source Document: %s."
% (ORDER_NAME, invoice.invoice_origin)
)
# Select normal sale order lines and invoice lines.
so_lines = order.order_line.filtered(
lambda line:
not line.display_type
and not line.is_downpayment
and line.product_id
)
inv_lines = invoice.invoice_line_ids.filtered(
lambda line:
line.display_type not in ("line_section", "line_note")
and line.product_id
)
if not so_lines:
fail("No normal product/shipping sale order lines found on %s." % ORDER_NAME)
if not inv_lines:
fail("No normal product/shipping invoice lines found on %s." % INVOICE_NAME)
if len(so_lines) != len(inv_lines):
fail(
"Line count mismatch. Sale order has %s normal lines. Invoice has %s normal lines."
% (len(so_lines), len(inv_lines))
)
# Match invoice lines to sale order lines.
# Criteria:
# same product
# same quantity
# same untaxed subtotal
unused_inv_lines = inv_lines
pairs = []
for sol in so_lines:
candidates = unused_inv_lines.filtered(
lambda aml:
aml.product_id.id == sol.product_id.id
and qty_close(aml.quantity, sol.product_uom_qty)
and money_close(aml.price_subtotal, sol.price_subtotal, currency)
)
if len(candidates) != 1:
candidate_info = []
for cand in candidates:
candidate_info.append(
"AML %s | %s | qty %s | subtotal %s"
% (
cand.id,
cand.product_id.display_name,
cand.quantity,
cand.price_subtotal,
)
)
fail(
"Could not uniquely match sale order line SOL %s: %s, qty %s, subtotal %s.\n\n"
"Candidate invoice lines found: %s\n\n"
"No changes were made."
% (
sol.id,
sol.product_id.display_name,
sol.product_uom_qty,
sol.price_subtotal,
"; ".join(candidate_info) or "none",
)
)
aml = candidates[0]
pairs.append((sol, aml))
unused_inv_lines -= aml
# Build readable result message.
result_lines = []
result_lines.append("Invoice: %s" % invoice.name)
result_lines.append("Sale Order: %s" % order.name)
result_lines.append("DRY_RUN: %s" % DRY_RUN)
result_lines.append("")
result_lines.append("Proposed links:")
for sol, aml in pairs:
result_lines.append(
"AML %s -> SOL %s | %s | qty %s | subtotal %s"
% (
aml.id,
sol.id,
sol.product_id.display_name,
aml.quantity,
aml.price_subtotal,
)
)
if DRY_RUN:
result_lines.append("")
result_lines.append("DRY RUN ONLY. No changes were made.")
result_lines.append("After checking the matches, set DRY_RUN = False and run again.")
action = {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": "Dry run complete",
"message": "\n".join(result_lines),
"type": "warning",
"sticky": True,
},
}
else:
for sol, aml in pairs:
aml.write({
"sale_line_ids": [(6, 0, [sol.id])]
})
# Trigger recomputation of sale order invoice status fields.
order.order_line._compute_qty_invoiced()
order.order_line._compute_qty_to_invoice()
order.order_line._compute_invoice_status()
order._compute_invoice_status()
invoice.message_post(
body="<br/>".join(
["Relinked invoice %s to sale order %s." % (invoice.name, order.name)]
+ [
"Invoice line AML %s linked to sale order line SOL %s: %s"
% (
aml.id,
sol.id,
clean_name(sol.product_id.display_name),
)
for sol, aml in pairs
]
)
)
result_lines.append("")
result_lines.append("DONE. Invoice lines were relinked.")
result_lines.append("Refresh sale order %s and check invoiced quantities." % ORDER_NAME)
action = {
"type": "ir.actions.client",
"tag": "display_notification",
"params": {
"title": "Relink complete",
"message": "\n".join(result_lines),
"type": "success",
"sticky": True,
},
}
Dry-run output to expect
First run the action with DRY_RUN = True. It should propose exactly the expected invoice-line-to-sales-order-line matches.
Dry run complete
Invoice: INVOICE_NUMBER
Sale Order: SO_NUMBER
DRY_RUN: True
Proposed links:
AML 999169 -> SOL 18722 | [MR2-20-NO-C00-B] MeteoRain 200 Pro, 0.2 mm resolution, NO version, without cable, with bird spikes | qty 1.0 | subtotal 122.14
AML 999170 -> SOL 18724 | Shipping and handling | qty 1.0 | subtotal 20.0
DRY RUN ONLY. No changes were made.
After checking the matches, set DRY_RUN = False and run again.
Final run
After validating the dry run, change only this line:
DRY_RUN = False
Then run the action again from the invoice. A successful final run should look like this:
Relink complete
Invoice: INVOICE_NUMBER
Sale Order: SO_NUMBER
DRY_RUN: False
Proposed links:
AML 999169 -> SOL 18722 | [MR2-20-NO-C00-B] MeteoRain 200 Pro, 0.2 mm resolution, NO version, without cable, with bird spikes | qty 1.0 | subtotal 122.14
AML 999170 -> SOL 18724 | Shipping and handling | qty 1.0 | subtotal 20.0
DONE. Invoice lines were relinked.
Refresh sale order SO_NUMBER and check invoiced quantities.
Post-repair validation
- Refresh the sales order.
- Confirm the invoice smart button shows the linked invoice.
- Confirm each sales order line shows the expected invoiced quantity.
- Confirm Odoo no longer offers to create a duplicate invoice for already-invoiced quantities.
- Confirm the invoice totals and tax remain unchanged.
Cleanup
Do not leave the server action active. Remove the contextual action and delete the temporary server action, or at least reset it to DRY_RUN = True.
Why this method is safer than resequencing
When the invoice number, date, tax, and totals are already correct, relinking the existing invoice avoids deleting, recreating, or resequencing invoices. It only repairs the missing technical relation between invoice lines and sales order lines.
