Built a full demo against a real BuildEase instance and three documented facts turned out to be wrong, each costing a debugging round. buildease installs nothing. Its manifest depends on base alone, so the "depends on nothing and installs the set" line left an empty database with no error to catch it. Route and install every module explicitly. The purge chain had invoices after statements. An invoiced sale.progress refuses action_cancel outright -- cancelling its invoice is what reverts it to approved, the first state it can be cancelled from. The documented order deadlocks on the one statement you invoiced. custom_product_sale, custom_product_bom and bom_on_sale_order_line are absent from mev but present on the Callista demo servers, so "not in the repo" is not the same as "not installable" when pricing. Also records the revision-formula object graph, which no reference covered: the coefficients live on formula.input, not on revision.formula, and that model hangs off either the take-off (contract S, I) or the statement (that period's s, i). That split is what makes "de index van die maand, niet van vandaag" work. Its rows come from an @api.onchange that write() never fires over XML-RPC, and a stale initial_index_value computes a plausible but wildly wrong revision with no guard behind it -- a factor of 65 instead of 1.005 in this build. Plus: percentage is a cumulative fraction not 0-100, index_value truncates to 3 decimals, and button methods return an action dict Odoo cannot marshal over XML-RPC after the transition has already committed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
16 KiB
BuildEase demo data — object graph, required fields, creation order
Everything here is read from mev source, not inferred. Verified against
callista-tools/mev @ 43c2e19 (quantity_take_off_base 19.0.1.2.2) and Odoo 19 core.
Re-verify after a mev bump; the guards below are enforced in Python, so they change.
Read this first: there is no BuildEase config schema
callista-odoo-demo's scripts/build_demo.py is a per-engagement template, not an
engine — its own docstring says so. It has hard-coded blocks for products, fleet
vehicles, credit limits and prestaged receivables, and no concept of a take-off. It also
reads every key with cfg.get(...), so any key it doesn't know is silently ignored.
Feed it a config full of take_offs and it prints DONE over an empty database.
So: write the staging script per engagement, using this document for the shape. The config block at the bottom is illustrative — a description of what a BuildEase dataset contains, not something the stock builder executes.
The spine
partner + products → quantity.take.off → .line → .line.calculation
→ convert_to_quotation() → sale.order (draft) → action_confirm() → sale (project)
→ sale.progress (+ lines) → approve → invoice
Build in that order. Each step has a guard that blocks the next if skipped.
| # | Create | Then |
|---|---|---|
| 1 | res.partner (customer), product.product with sale_ok=True |
— |
| 2 | quantity.take.off |
lines |
| 3 | quantity.take.off.line (type="line") |
calculation lines |
| 4 | quantity.take.off.line.calculation |
trigger _compute_unit_price |
| 5 | convert_to_quotation() on the QTO |
SO exists in draft, QTO moves to draft |
| 6 | action_confirm() on the SO |
SO sale; QTO mirrors to sale |
| 7 | sale.progress via the wizard |
progress lines auto-built from SO lines |
| 8 | Set current_quantity / percentage on progress lines |
approve, then invoice |
quantity.take.off
| Field | Note |
|---|---|
partner_id |
Required. |
company_id |
Required, defaults to env.company. |
name |
Required and readonly — create() overwrites it unconditionally from the quantity.take.off.number sequence. Never pass it; whatever you send is discarded. |
date |
Defaults to today. |
state |
Only exists once quantity_take_off_sale is installed: quantity_take_off → draft → sent → sale → cancel. Stored and writable, default quantity_take_off. |
site_address_id, pricelist_id, fiscal_position_id |
Computed from partner_id with store=True, readonly=False — they self-populate on create; only pass them to override. |
amount_subtotal, amount_total, margin, margin_percentage |
Computed from lines. Never write them. |
Inherits mail.thread + mail.activity.mixin — chatter and activities work for free.
One sale order per take-off, enforced by @api.constrains. A second one raises.
quantity.take.off.line
| Field | Note |
|---|---|
quantity_take_off_id |
Required, ondelete="cascade". |
type |
Required. title / text / line / subtitle, default line. |
name |
Required (Text, computed with readonly=False — pass it or give a product to compute from). |
quantity, unit_price, cost |
unit_price and cost are readonly=False, so writable directly. |
sequence |
Default 10. |
product_id |
Required when is_product_id_required — and missing products block conversion, see below. |
Only type="line" rows count toward the totals. title, text and subtitle are
presentation. A demo whose totals read zero is usually a take-off built entirely from the
wrong type.
Pro memorie lines are excluded from every total. If nature_of_agreement_id.pro_memorie
is set, the line is filtered out of amount_subtotal, amount_total, margin and out of
convert_to_quotation. Deliberate for "for information" lines; a silent zero if unintended.
quantity.take.off.line.calculation
| Field | Note |
|---|---|
product_id |
Required, domain sale_ok = True. A non-saleable product simply won't be selectable. |
name |
Required. |
quantity_take_off_line_id |
Required, ondelete="cascade". |
quantity |
Default 1.0. |
markup, cost, unit_price |
cost is computed-store-precompute but readonly=False. |
This is the measurement math beneath a line. After creating calculation lines
programmatically, call _compute_unit_price() on them — add_quantity_take_off_lines()
does exactly that after building from templates, and prices stay stale otherwise.
sale.progress
| Field | Note |
|---|---|
sale_order_id |
Required, readonly. |
start_date, end_date |
Required. Constrained: end may not precede start. |
state |
Required, readonly, default draft: draft / sent / denied / approved / cancelled / invoiced. |
name |
Auto — sale_progress_prefix + zero-padded count of non-cancelled statements + 1, both off res.company. |
project_id, responsible_user_id |
Populated from the sale order by prepare_sale_progress_vals(). |
Create it through the flow, not by hand. sale.order.prepare_sale_progress_vals()
returns the full vals dict including every progress line built from the order lines; the
sale.progress.create wizard (fields start_date, end_date, sale_order_id) adds the
dates and calls create. Building a sale.progress directly means building
sale_progress_line_ids yourself for every order line — there is no auto-fill on create.
sale.progress.line
sale_progress_id is the only required field (ondelete="cascade"); nearly everything else
is related to sale_order_line_id. The demo-relevant writable fields are percentage,
current_quantity and previous_quantity — that is where "we invoice 40% this month" lives.
percentage is a FRACTION, not 0–100. 0.60 means 60%. Writing 60 does not raise —
it sets current_quantity to expected_quantity × 60, so a 340 m² post silently becomes
20 400 m² and the statement reads in the millions. It is also cumulative: on the second
statement it arrives pre-filled with the previous cumulative, and writing 0.85 bills the
delta (85 m², not 289).
The revision formula — where the numbers actually live
sale_progress_revision_formula splits the contractual formula across four models, and
none of them holds a coefficient:
| Model | Holds |
|---|---|
revision.scheme |
A named set of formulas |
revision.formula |
name is a label only. formula_html is generated from the parameter count |
revision.parameter |
One row per index pair: initial_index_parameter (S), current_index_parameter (s) |
index.value.date |
The monthly series per parameter: start_date + index_value |
formula.input |
The numbers: weight_value (a, b), initial_index_value (S, I), current_index_value (s, i) |
formula_html renders symbolically from the parameters — two parameters produce
p = P × (a × s/S + b × i/I + c) whatever you called the formula. c is implicit, the
remainder of the weights.
formula.input hangs off either a take-off or a progress statement, and that split
is the whole feature: the take-off carries the contract reference (S, I at start_date),
and each statement gets its own copy carrying that period's index. This is what answers
"ik moet de index van díe maand nemen, niet van vandaag".
Three traps, each of which cost a debugging round:
- The rows are generated by an
@api.onchange. The list iscreate="0" delete="0"in the form, so there is no button and no create path — andwrite()over XML-RPC does not fire onchanges, so nothing appears. Every field onformula.inputis a plain stored field (readonly in the UI only), so write them directly,weight_parameterincluded. - Set
is_revision = Trueon the take-off first.revision_scheme_idisinvisible="not is_revision", andrevision_formula_idscomputes off the inputs, not off the lines — it reads empty until the inputs exist, whatever the lines say. index_valuestores to 3 decimals. A ratio-style series near1.0xxxsilently loses a digit (1.0221→1.022). Use the absolute published index numbers (a wage cost of43.00, a material index of125.42), which is what Statbel publishes anyway.
And the failure mode with no guard behind it: a statement created before the contract
reference was corrected keeps the stale initial_index_value. The revision then divides by
the wrong denominator and computes a plausible-looking but wildly wrong number — in one
build, a factor of 65 instead of 1.005. Write both sides of every formula.input.
The button chain is action_price_revision (draft → revision) →
action_calculate_price_revision (→ revision_done), then approve and invoice. The
statement's state selection carries revision and revision_done on top of the six states
listed above.
The guards that will stop you
In the order you'll hit them. All raise ValidationError or UserError, all are in Python.
| Guard | Raised by | Fix |
|---|---|---|
Missing product on a line/variant row that isn't pro memorie |
convert_to_quotation → _check_alerts |
Give every non-pro-memorie line a product_id before converting |
| "Convert To Quotation" unavailable | Button is invisible="state != 'quantity_take_off' or sale_order_id" |
Only converts once, from the initial state |
| "You cannot confirm an order if the quantity take off is in the QTO state" | sale.order.action_confirm |
Convert first; confirming is what moves both to sale |
| "You can only create a sale progress when the quantity take-off is in the 'Confirmed' state" | sale.order.action_create_sale_progress |
Confirm the SO before staging any statement |
| "You can only create a new sale progress when all the other sale progresses are in the following states…" | same | Approve or invoice the previous statement first |
| "Choose a start date that is later than or equal to the end date of a previous sale progress" | sale.progress.create wizard |
Stage consecutive, non-overlapping periods |
| "You cannot duplicate an order coming from a quantity take off!" | sale.order.copy |
Duplicate the take-off instead — the SO cannot be copied at all |
State mirrors automatically. sale.order.write propagates draft / sent / sale /
cancel onto the linked take-off. You do not set QTO state by hand once a sale order
exists, and doing so will disagree with the order.
Teardown
Invoices come before statements, not after. An invoiced sale.progress refuses
action_cancel outright — the guard allows only draft / sent / denied / approved,
and invoiced is none of them. Cancelling its invoice is what reverts the statement to
approved, which is the first state it can be cancelled from at all. So the order is:
transient wizard rows → invoices (draft → cancel → unlink)
→ statements (cancel newest-first, then unlink) → timesheet lines → tasks
→ projects → sale orders → take-offs → the partner
Two corrections to the generic chain, both from source:
Deleting the take-off deletes its sale order. quantity.take.off.unlink() calls
self.sale_order_ids.unlink() first. So the take-off is the handle for both; you do not
need to delete the order separately, only to have cancelled it.
A take-off with no sale order can be cancelled and deleted. cancel() writes
state = "cancel" directly and then calls action_cancel() on sale_order_id — an empty
recordset when there is no order, and in Odoo 19 sale.order.action_cancel is
self.write({'state': 'cancel'}) with no ensure_one(), so it is a clean no-op. Over
XML-RPC:
x("quantity.take.off", "cancel", [qto_id]) # works with or without a sale order
x("quantity.take.off", "unlink", [qto_id]) # now passes the state == 'cancel' check
This supersedes the older "convert it to a quotation first, then cancel that" workaround, which is unnecessary on Odoo 19.
Both unlink() overrides are strict about state:
| Model | Refuses unless |
|---|---|
quantity.take.off |
state == "cancel" |
sale.order (QTO-linked) |
state == "cancel" |
sale.progress |
state == "cancelled" |
And sale.progress.action_cancel() refuses unless show_cancel — "You can only cancel the
last sale progress" — while action_draft() refuses if a more recently created statement
exists. That is why statements tear down newest first, and why it is not negotiable.
Guard every teardown step on its own. Wrapping a draft → cancel → unlink sequence in one
try is how a purge reports success over residue: button_draft raises on an
already-draft invoice, and the unlink beneath it never runs. Then run the whole chain in a
loop until a pass keeps nothing — cascades free records that the previous pass could not
touch.
Button methods return an action dict Odoo cannot marshal over XML-RPC.
action_cancel, action_approved, action_price_revision, create_invoices and friends
all raise a Fault whose text mentions Marshal/dumps. The method already ran and
committed — only the response failed. Catch that specific fault and continue; retrying
re-runs the transition and will fail the second time on a state guard.
Illustrative dataset shape
Not a schema. Nothing reads this file. It records what a BuildEase demo dataset contains so the per-engagement staging script covers it:
{
"client": "Vandenberghe Bouw",
"currency": "EUR",
"scope_modules": ["sale_management", "project", "purchase_stock", "sale_timesheet",
"uom", "accountant", "buildease", "quantity_take_off_base",
"quantity_take_off_sale", "sale_progress_base"],
"customers": [{ "name": "Gemeente Deinze", "site_address": "Markt 21, 9800 Deinze" }],
"articles": [
{ "article_nr": "21.32", "name": "Betonvloer gepolierd", "uom": "m²",
"cost": 42.00, "price": 68.50, "sale_ok": true },
{ "article_nr": "31.11", "name": "Metselwerk snelbouwsteen", "uom": "m²",
"cost": 55.00, "price": 89.00, "sale_ok": true }
],
"take_off": {
"customer": "Gemeente Deinze",
"lines": [
{ "type": "title", "name": "Ruwbouw" },
{ "type": "line", "article_nr": "21.32", "quantity": 340,
"calculations": [ { "product": "Betonvloer gepolierd", "name": "Zone A",
"quantity": 340, "markup": 12.0 } ] },
{ "type": "line", "article_nr": "31.11", "quantity": 118 }
]
},
"_comment_progress": "Stage TWO consecutive statements: the first approved+invoiced so the demo has history, the second left in draft for the live click. Periods must not overlap.",
"progress_statements": [
{ "start_date": "2026-05-01", "end_date": "2026-05-31",
"lines_percentage": 35, "final_state": "invoiced" },
{ "start_date": "2026-06-01", "end_date": "2026-06-30",
"lines_percentage": 60, "final_state": "draft" }
]
}
Staging rules that matter on screen, beyond what the guards enforce:
- Two statements, not one. A single draft statement has no
previous_quantityto show, and the cumulative column — the reason clients ask for vorderingsstaten — reads as zero. - Leave the last statement in
draftfor the live click, the same rule as the demo quotation. - Keep cumulative percentage under 100. Over-100 makes the statement look broken even though every guard passed.
- Derive quantities from the ordered quantity, never hard-code, so they can't drift when a markup or formula changes.