Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DywdYrFTrWa4h1T3ZykPBX
305 lines
16 KiB
Markdown
305 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 the external API
|
||
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. Through
|
||
the substrate's `x()` helper:
|
||
|
||
```python
|
||
x("quantity.take.off", "cancel", qto_ids) # works with or without a sale order
|
||
x("quantity.take.off", "unlink", qto_ids) # 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, not `True`.** `action_cancel`, `action_approved`,
|
||
`action_price_revision`, `create_invoices` and friends all hand back a window action. Over
|
||
JSON-2 that serialises fine, so the dict is neither an error nor proof the transition
|
||
landed — read the state back. On a legacy XML-RPC connection the same dict raises a `Fault`
|
||
whose text mentions `Marshal`/`dumps`: **the method already ran and committed**, only the
|
||
response failed, so catch that specific fault and continue. Never retry — the second run
|
||
hits a state guard and reads like a real error.
|
||
|
||
---
|
||
|
||
## 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.
|