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

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_offdraftsentsalecancel. 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_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.