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>
303 lines
16 KiB
Markdown
303 lines
16 KiB
Markdown
# 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.
|