Summary
openupgrade_scripts/scripts/sale_management/19.0.1.0/post-migration.py converts sale.order.option records into sale.order.line records with is_optional=True. The resulting lines are not treated as optional by Odoo 19, so they are added to the order total.
On a real database this silently increased the total of a draft quotation by ~12%.
Why the converted lines are not optional
In 19.0, is_optional is a property of a section, not of an individual line. sale_management/models/sale_order_line.py:
is_optional = fields.Boolean(
string="Optional Line",
copy=True,
default=False,
) # Whether this section's lines are optional in the portal.
def _is_line_optional(self):
self.ensure_one()
return (
self.parent_id.is_optional
or (
self.parent_id.display_type == 'line_subsection'
and self.parent_id.parent_id.is_optional
)
)
_is_line_optional() reads self.parent_id.is_optional, and parent_id is a computed, non-stored field derived from the ordering of the lines (sale/models/sale_order_line.py::_compute_parent_id, it resolves to the last section preceding the line).
The migration script creates each converted option as a bare product line:
line = SaleOrderLine.create({
"is_optional": True,
"order_id": order_id,
...
"sequence": sequence,
...
})
with is_optional set on the line itself and the sequence copied from the option (frequently 0). The line therefore sorts to the top of the order, has no preceding section, parent_id is empty, _is_line_optional() returns False, and the line is treated as a regular billable line.
Steps to reproduce
- On 18.0, create a quotation with regular lines and at least one
sale.order.option that was never added to the order (line_id IS NULL).
- Note
sale_order.amount_total.
- Migrate to 19.0 with OpenUpgrade.
amount_total has grown by the value of the option.
Observed
Migrating a production database (15.0 → 16 → 17 → 18 → 19, three sale.order.option rows in total):
| Quotation |
State |
Before |
After |
Delta |
| A |
draft |
494,000,000 |
554,375,000 |
+60,375,000 |
| B |
cancel |
180,525,103 |
191,335,103 |
+10,810,000 |
The sum of the non-optional lines after migration matches the pre-migration total exactly, which confirms the delta comes only from the converted options.
The migration completes with exit code 0 and no warning, so this is easy to miss.
Suggested fix
Either:
- place the converted options under a section with
is_optional=True (creating one per order if needed) and give them a sequence that puts them after it, so _compute_parent_id resolves correctly; or
- if that is considered out of scope for the script, skip options whose
line_id was NULL (they were never part of the order) and log them, rather than materialising them as billable lines.
Happy to send a PR if the maintainers have a preference between the two.
Versions
- OpenUpgrade branch
19.0 (cloned 2026-09-05)
- Odoo
19.0 official Docker image (19.0-20260817)
- PostgreSQL 16
Summary
openupgrade_scripts/scripts/sale_management/19.0.1.0/post-migration.pyconvertssale.order.optionrecords intosale.order.linerecords withis_optional=True. The resulting lines are not treated as optional by Odoo 19, so they are added to the order total.On a real database this silently increased the total of a draft quotation by ~12%.
Why the converted lines are not optional
In 19.0,
is_optionalis a property of a section, not of an individual line.sale_management/models/sale_order_line.py:_is_line_optional()readsself.parent_id.is_optional, andparent_idis a computed, non-stored field derived from the ordering of the lines (sale/models/sale_order_line.py::_compute_parent_id, it resolves to the last section preceding the line).The migration script creates each converted option as a bare product line:
with
is_optionalset on the line itself and thesequencecopied from the option (frequently0). The line therefore sorts to the top of the order, has no preceding section,parent_idis empty,_is_line_optional()returnsFalse, and the line is treated as a regular billable line.Steps to reproduce
sale.order.optionthat was never added to the order (line_id IS NULL).sale_order.amount_total.amount_totalhas grown by the value of the option.Observed
Migrating a production database (15.0 → 16 → 17 → 18 → 19, three
sale.order.optionrows in total):The sum of the non-optional lines after migration matches the pre-migration total exactly, which confirms the delta comes only from the converted options.
The migration completes with exit code 0 and no warning, so this is easy to miss.
Suggested fix
Either:
is_optional=True(creating one per order if needed) and give them asequencethat puts them after it, so_compute_parent_idresolves correctly; orline_idwasNULL(they were never part of the order) and log them, rather than materialising them as billable lines.Happy to send a PR if the maintainers have a preference between the two.
Versions
19.0(cloned 2026-09-05)19.0official Docker image (19.0-20260817)