# 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 is `create="0" delete="0"` in the form, so there is no button and no create path — and `write()` over XML-RPC does not fire onchanges, so nothing appears. Every field on `formula.input` is a plain **stored** field (readonly in the UI only), so write them directly, `weight_parameter` included. - **Set `is_revision = True` on the take-off first.** `revision_scheme_id` is `invisible="not is_revision"`, and `revision_formula_ids` computes off the *inputs*, not off the lines — it reads empty until the inputs exist, whatever the lines say. - **`index_value` stores to 3 decimals.** A ratio-style series near `1.0xxx` silently loses a digit (`1.0221` → `1.022`). Use the absolute published index numbers (a wage cost of `43.00`, a material index of `125.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: ```python 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: ```jsonc { "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_quantity` to show, and the cumulative column — the reason clients ask for *vorderingsstaten* — reads as zero. - **Leave the last statement in `draft`** for 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.