Overlay 3 said what not to install and gave no shape for what to build instead, so the staging script was being written from scratch each time against models nobody had the field list for. reference/buildease_demo_data.md carries the QTO -> sale order -> project -> progress statement -> invoice graph: required fields per model, creation order, which transitions are button methods rather than writes, and the seven guards that stop the chain mid-build (missing product blocks conversion; the SO will not confirm while the QTO is in QTO state; no statement until the QTO is Confirmed; statement periods may not overlap; a QTO-linked order cannot be copied at all). All of it read from callista-tools/mev @ 43c2e19 and Odoo 19 core, nothing inferred. The config block is labelled illustrative because build_demo.py cannot execute it — it is a per-engagement template that silently ignores keys it does not implement, which is now an error upstream in callista-odoo-demo. Two purge corrections fall out of the source, both loosening rules that were stricter than reality: - Deleting the take-off already deletes its sale order; unlink() cascades. - A take-off with no sale order cancels and deletes normally. cancel() writes the state directly and no-ops on the empty order, since Odoo 19's action_cancel has no ensure_one(). The convert-to-quotation-first workaround was unnecessary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 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.
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
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.
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.