Files
Claude-buildease-demo/reference/buildease_demo_data.md
T
glennandClaude Opus 5 17ebc96bdc Add the BuildEase demo-data reference, read from mev source
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>
2026-07-31 11:10:29 +02:00

227 lines
11 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.
---
## 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:
```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.
---
## 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.