Files
Claude-buildease-demo/reference/buildease_demo_data.md
T
glennandClaude Opus 5 5aee1c61b5 Correct three claims that a live 19.0+e build disproved
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>
2026-07-31 14:43:15 +02:00

16 KiB
Raw Blame History

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.

percentage is a FRACTION, not 0100. 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.02211.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:

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:

{
  "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.