diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ffd9b53 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this repository is + +This repo *is* a Claude Code skill, not an application. It contains no source code, tests, or build tooling — only prompt content: + +- `SKILL.md` — the skill body (`name: buildease-demo`), loaded by Claude Code when the skill is invoked. +- `reference/buildease.md` — the BuildEase module map and pain→module routing table, read at Phase A. + +Editing this repo means editing instructions that another Claude instance will follow. There is nothing to compile or lint; the only meaningful verification is running the skill end to end against a real Odoo instance. + +### Installing / testing a change + +```bash +ln -s "$PWD" ~/.claude/skills/buildease-demo +``` + +Claude Code discovers skills from the `name:`/`description:` frontmatter in `SKILL.md`. Changing `name:` changes how the skill is invoked; changing `description:` changes when it triggers — both are the routing surface, so treat them as API. + +## Architecture + +`buildease-demo` is a **thin vertical overlay** on `callista-odoo-demo`. It does not own a pipeline. It supplies exactly five overlays, each applied at a *named* step of the substrate skill: + +| Overlay | Applied at | +|---|---| +| Module routing (`reference/buildease.md`) | *Install modules*, Phase A `## Phase 1 scope` | +| Solution precedence — BuildEase → native Odoo 19 → custom | *Research native-vs-custom* | +| Demo data policy — never `buildease_demo_data` | *Load demo data* | +| Purge chain — take-off / progress-statement teardown order | *Purge test residue* | +| Follow-through framing | Phase C | + +**The boundary is the point of this repo.** Anything needed that isn't one of those five is a gap in `callista-odoo-demo` — fix it *there*, so the next vertical inherits it. Do not work around it here, and never reimplement the substrate's `scripts/`, `templates/`, or `demo_config.json` handling. + +This repo was previously a full three-phase skill. The generic two-thirds (discovery brief from any source, the two-agent parallel build, the follow-through doc set) moved into the substrate on 2026-07-31 because Callista runs it for plain Odoo implementations too, and that content was stranded behind a construction trigger. + +### Substrate and upstream + +| Repo | Role | +|---|---| +| `admin/callista-odoo-demo` (Gitea) | The pipeline this skill overlays. Hard dependency, installed as `~/.claude/skills/callista-odoo-demo/` | +| `github.com/Smetools/odoo-demo-architect` | What the substrate was forked from at `ada2751`. Callista has no commit rights; changes land in the fork. See its `UPSTREAM.md` | +| `git@github.com:callista-tools/mev.git` | BuildEase — 34 Odoo 19 modules, umbrella module `buildease`. Not vendored; `reference/buildease.md` is a distilled map | + +**Never install the substrate under the name `odoo-demo-architect`.** Skills resolve by directory name, so a same-named fork silently shadows upstream depending on install order, and two people build different demos from the same brief. + +### Editing rules that carry weight + +- **Reference substrate steps by name** (*Test heroes*, *Purge test residue*), never by number. Numbered references are how an upstream renumbering silently misroutes this skill with nothing to detect it. +- **The purge ordering** (transient wizard rows → statements → invoices → timesheets → tasks → projects → sale orders → take-offs → partner last) is ordered because Odoo blocks deletion otherwise. Do not reorder or summarise. The take-off-without-a-sale-order trap in particular is non-obvious and cost a debugging round. +- **Not installing `buildease_demo_data`** is the reason this overlay exists at all; the app-level dependencies that bundle used to drag in must still be routed from the pain table. +- `reference/buildease.md` drifts when mev changes. When updating it, keep the two things that make it useful: the pain→module table keyed on *the client's own words*, and the gotchas (missing `custom_product_*` deps, Odoo 19 only, settings that default off). + +## Writing style for this repo + +Match the existing prose: imperative, second person, no hedging, tables for anything enumerable. Rules state what to do *and* the failure that motivates them ("a fabricated quote read back to the client in the demo is not"). Avoid generic agent boilerplate — everything here should be specific to BuildEase or it belongs in the substrate. + +## Related + +The parent workspace `../CLAUDE.md` (Callista BV multi-customer workspace) covers Odoo module conventions — naming, manifests, version numbering — and applies when editing the mev modules themselves, not this repo. diff --git a/SKILL.md b/SKILL.md index 43f1471..23d0b5d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,208 +1,135 @@ --- name: buildease-demo -description: Wraps odoo-demo-architect for BuildEase (callista-tools/mev) construction demos. Builds the discovery brief from any source — transcript file, notes, email thread, or a live interview — instead of requiring Knowcap, routes client pains onto BuildEase modules before considering custom work, and after the demo generates the follow-through set (objection sheet, scoped quote, implementation timeline, SOW). Use for "BuildEase demo", "QTO demo", "construction Odoo demo", "prep a demo from these notes", or "write the follow-up after the demo". +description: BuildEase (callista-tools/mev) construction overlay for the callista-odoo-demo skill. Routes the client's pains onto the 34 BuildEase Odoo 19 modules before any custom work is considered, replaces the generic teardown with the take-off → progress-statement chain, and keeps the per-client dataset instead of the old buildease_demo_data bundle. Use for "BuildEase demo", "QTO demo", "quantity take-off demo", "construction Odoo demo", "vorderingsstaat demo", or a follow-up after one. --- # BuildEase Demo -Three phases. `odoo-demo-architect` does the middle one; this skill owns the ends. +A **vertical overlay** on `callista-odoo-demo`. That skill owns the pipeline — discovery +brief, build, test, purge, sales docs, follow-through. This one supplies the five things +that are specific to construction, and nothing else. -**Requires** `odoo-demo-architect` installed (`~/.claude/skills/odoo-demo-architect/`, -from github.com/Smetools/odoo-demo-architect). If it is missing, say so and stop — -do not reimplement its XML-RPC connector or data builder. +**Requires** `callista-odoo-demo` installed at `~/.claude/skills/callista-odoo-demo/` +(Callista's fork of Smetools/odoo-demo-architect). If it is missing, say so and stop — +do not reimplement its XML-RPC connector, data builder, or pipeline here. + +> Do **not** substitute an `odoo-demo-architect` install for it. That is upstream, without +> Phase A's source-agnostic discovery, without Phase C, and without the extension points +> this file plugs into. + +**Run `callista-odoo-demo` and apply the overlays below at the named steps.** Everything +not listed here is that skill's job — follow it as written. --- -## Phase A — Discovery brief (no Knowcap) +## Overlay 1 — Module routing -Goal: produce `discovery_brief.md` in exactly the shape odoo-demo-architect's step 1 -would have emitted, from whatever the user actually has. +*Applies at:* **Install modules**, and Phase A's `## Phase 1 scope`. -**Source, in order of preference:** -1. A transcript, meeting notes, or email thread — read it. -2. Knowcap MCP, if it happens to be available and the user wants it. -3. Nothing written down → interview the user (see intake below). +Read `reference/buildease.md` and route the client's pains onto BuildEase modules using +its pain → module table. Match on **the client's own words** — the table is keyed on what +they actually say ("we measure in Excel then re-type it into the quote"), not on module +names they will never utter. -### Intake — infer first, ask only the gaps +Install the umbrella module `buildease` plus the routed set. `buildease` depends on +nothing and installs the set. -Before asking anything, **look**: glob the working directory for a transcript, notes, -brief, or `.md`/`.txt`/`.docx` that plausibly is the source, and read it. Then answer -each item below from what you read. Only the items you genuinely cannot answer go to -the user, in **one** `AskUserQuestion` batch — never a serial interrogation, never a -question whose answer is sitting in the file you just read. +## Overlay 2 — Solution precedence -| Item | Infer from | Ask only if | -|---|---|---| -| **Source brief location** | A single obvious candidate file in the working directory | Zero or several candidates — ask which, or paste | -| **Odoo UI language** | Language the brief is written in; client country; `l10n_*` in scope | Brief is English but the client clearly isn't anglophone — ask (nl_BE / fr_BE / en_US / other) | -| **Client website / company info** | URL or company name in the brief — fetch the site, mine it for what they build, size, regions, branding | No name or URL anywhere in the source | -| **Odoo instance** | `demo_config.json`, prior session, the brief | Not found — always ask, never guess a URL or API key | -| **Demo audience** | Attendee list in a transcript header or email thread | Source is notes with no names | -| **Rate card** | A rates file in the project | Only needed at Phase C — don't ask now | +*Applies at:* **Research native-vs-custom**. -Anything still missing after that batch: write it into the brief as `(unknown)` and -carry on. One unknown does not block building the demo. +The precedence becomes **BuildEase → native Odoo 19 → custom**, in that order. Most +construction pains are already solved in mev; custom work is the last resort, and native +Odoo is the middle tier, not the first question. -If nothing written down exists at all, the interview replaces the table — still one -batch: who the client is, what they build, how they quote today, how they invoice, -the top 3 complaints they voiced, who is in the room. +Odoo 19 only — every mev manifest is `19.0.x`. Do not attempt this against 17 or 18. -**Write `discovery_brief.md` with these six sections and no others:** +## Overlay 3 — Demo data policy -```markdown -## Business shape -Entities, product/customer types, volumes, shared financials. -Company name, website, country, what they actually build — from the site if you fetched it. +*Applies at:* **Load demo data**. -## Top pains -3–5 items. Direct client quotes where you have them. Mark anything you inferred as (inferred). +**Do not install `buildease_demo_data` or `buildease_demo_data_sale_progress`.** They are +the previous generation of environment setup: one fixed Belgian construction dataset, the +same for every prospect, dragging in `l10n_be` and `accountant` whether or not the client +is Belgian. The per-client dataset generated from the discovery brief replaces them — the +client's own partners, articles, projects and orders, staged so each hero has a record to +fire on. -## Hero features -1–2. Each names the pain it kills and the decision-maker it lands on. +Install them only if the user explicitly asks for the generic sandbox. The app-level +dependencies that bundle used to drag in (`sale_management`, `project`, `purchase_stock`, +`sale_timesheet`, `industry_fsm`, `accountant`, `uom`) still need installing — route them +from the pain table, not from the demo bundle. -## Phase 1 scope -Odoo apps + BuildEase modules, from reference/buildease.md. +**Read `reference/buildease_demo_data.md` before writing the staging script.** It carries +the QTO → sale order → project → progress statement → invoice object graph from mev source: +required fields per model, creation order, which transitions are button methods rather than +writes, and the seven guards that will otherwise stop the chain mid-build. -## Odoo instance -URL, database, login, API key. Ask if absent — do not guess. -UI language + `l10n_*` localisation to install. +`scripts/build_demo.py` is a per-engagement template, not an engine — it knows nothing about +take-offs, and it ignores config keys it doesn't recognise. Adapt it; do not assume a +BuildEase-shaped config will drive it. -## Demo audience -Who is in the room and what each one needs to hear. -``` +## Overlay 4 — Purge chain -Never invent a pain quote. An inferred pain marked `(inferred)` is fine; a fabricated -quote read back to the client in the demo is not. - -Show the brief to the user and get a yes before Phase B. - ---- - -## Phase B — Build, via odoo-demo-architect - -Hand `discovery_brief.md` to `odoo-demo-architect` and let it run its pipeline -(research → connect → install → data → heroes → test → sales docs). - -### Fork the slow half - -Module installation and client research are independent — the install list comes from the -brief plus the routing table, the research feeds hero *building* which happens after both. -Installing over XML-RPC is minutes of waiting and produces log spew nobody needs in context. -So run them as two parallel subagents, launched in **one** message: - -**Agent 1 — instance prep.** Give it the Odoo credentials, the routed module list, and -odoo-demo-architect's `scripts/odoo_connect.py`. It sets the company, currency, UoM and -UI language, installs the localisation, installs `buildease` + the routed modules, then -walks Settings for the BuildEase toggles that default off. It loads **no data** — that is -the join step's job. Returns: what installed, what failed, the settings it changed. - -**Agent 2 — client research.** Give it the brief. It fetches the client's website, and -researches each hero feature **BuildEase → native Odoo 19 → custom**, in that order. -Returns: per hero, which of the three it lands in, and the click-path if it already exists. - -**Join.** Both done → install any module Agent 2's research turned up that Agent 1 didn't -know about (incremental, cheap), then generate and load the client-specific dataset into -the empty database, then build and test heroes. - -Two agents, not five. Do not fan out further — hero building is sequential against one -database and parallel writes will fight. - -Two things to inject before it starts: - -1. **Read `reference/buildease.md`** and pass the routed module list. The research step's - native-vs-custom question becomes **BuildEase → native Odoo → custom**, in that order. - Most construction pains are already solved in mev; custom work is the last resort. -2. **This skill is the demo data loader.** Do not install `buildease_demo_data` — that is - the old, one-size-fits-all Belgian dataset it replaces. The database starts empty and - gets a dataset generated from the brief: the client's own partners, articles, projects - and orders, staged so each hero feature has a record to fire on. Install it only if the - user explicitly asks for the generic sandbox. - -Its step 7 (test every hero on throwaway records) is not optional here either. A QTO → -sale order → project → progress statement → invoice chain that breaks mid-demo is the -one failure mode that loses the deal. - -### Verify on throwaway records, then delete them - -Test against a partner named `ZZ Test…`, never against the client's own records — a hero -you tested is a hero you consumed, and the demo needs its first click to be the first click. - -**Then purge. A demo environment ships with zero test residue.** Debugging the chain takes -several runs, each leaving a full take-off → order → project → statement → invoice trail; -"it's cancelled and the partner is archived" is not clean, those records still list on -screen. Write the purge as a function in the test script and run it until the counts are -zero — not as a one-off you improvise at the end. - -Odoo blocks most of this unless you go in order: +*Applies at:* **Purge test residue**, replacing the generic chain. ``` transient wizard rows → statements (cancelled only, newest first) → invoices → timesheet lines → tasks → projects → sale orders → take-offs → the partner ``` -The traps, all of which cost a debugging round: -- **A take-off with no sale order cannot be deleted at all.** Its state is derived from the - order, so there is nothing to cancel. Convert it to a quotation, cancel that, then delete. -- `search` will not find the partner once archived — pass `context={"active_test": False}` - or the purge silently no-ops and reports success. -- Statements cancel newest-first only; invoices must go to draft before cancel; leftover - `add.dynamic.article.template.line` rows pin take-offs. -- **Delete the partner last, and only once nothing references it.** Deleting it first nulls - `partner_id` on whatever survived, so a `partner_id`-based purge can no longer see those - records and a `partner_id`-based assertion reports clean over an environment that isn't. - Sweep by name as well, and assert on both. -- Anything the tests **copy** inherits the source record's customer, not the throwaway one. - Repoint copies at the throwaway partner or they escape the purge. -- Make every step best-effort with its own `try`, and **print what it kept** — a silent - purge that skipped nine records reads exactly like one that worked. +The generic traps still apply. These are the BuildEase-specific ones, each of which cost +a debugging round: -Finish by reading back the counts of every model you touched and putting them in the -verification log. "No test residue" is a claim; the counts are the evidence. +- **Cancel before deleting, on all three.** `unlink()` refuses unless `quantity.take.off` is + `cancel`, a QTO-linked `sale.order` is `cancel`, and `sale.progress` is `cancelled`. +- **Deleting the take-off deletes its sale order** — `quantity.take.off.unlink()` unlinks + `sale_order_ids` first. The take-off is the handle for both. +- A take-off with **no** sale order still cancels and deletes normally: call `cancel()`, which + writes the state directly and no-ops on the empty order. No convert-to-quotation dance. +- **Statements cancel newest-first only** — `action_cancel` refuses unless it is the last one, + and `action_draft` refuses if a newer statement exists. +- Leftover `add.dynamic.article.template.line` rows pin take-offs. Clear them first. -### Sanity-check the staged numbers before calling it done +Full field-level detail, including the guards, is in `reference/buildease_demo_data.md`. -A chain that passes every assertion can still look broken on screen. Check the staged data -reads plausibly: **booked hours well under ordered hours** (over 100% makes the progress -statement look wrong), costs under selling prices, dates in the past. Derive staged -quantities from the ordered quantity rather than hard-coding them, so they cannot drift -when a formula changes. +## Overlay 5 — Follow-through framing + +*Applies at:* **Phase C**. + +Where an objection is already covered by BuildEase, name the **module and the screen** on +the objection sheet — "that's `sale_progress_revision_formula`, on the statement's Revision +tab" lands better than "yes, we support that". Where mev does not cover it, say so and give +the workaround or the build estimate. + +Check the scoped quote against `reference/buildease.md`'s gotchas before pricing: +`custom_product_sale`, `custom_product_bom` and `bom_on_sale_order_line` are **not in the +mev repo**, so anything under `buildease_custom_product*` is not a "just install it" line. --- -## Phase C — Follow-through +## BuildEase-specific checks -Runs after the demo happened. Needs: the built demo, the verification log, and what -actually came up in the room. Ask the user for the room notes — objections raised, -who pushed back, what they asked for that wasn't there. - -Generate four documents. Reuse odoo-demo-architect's `templates/` branding and its -self-contained-HTML rule (no external assets, single file, prints to A4). - -| Document | Contents | -|---|---| -| **Objection sheet** | Every objection raised → the honest answer. Where BuildEase already covers it, name the module and the screen. Where it doesn't, say so and give the workaround or the build estimate. | -| **Scoped quote** | Line items from the Phase 1 scope: modules, configuration, data migration, custom work, training. Days and rate per line. Flag assumptions separately from committed lines. | -| **Implementation timeline** | Phased, with the client's own dependencies on it (data extraction, sign-offs, who from their side). Weeks, not dates, unless a start date is agreed. | -| **Statement of work** | Deliverables, acceptance criteria, out-of-scope list, change-request process. The out-of-scope list is the point — write it properly. | - -Rules: -- Every number traces to something. No invented day-rates, no invented licence costs — - ask the user for the rate card. -- Anything the demo *didn't* prove goes in the quote as build work, not as "included". - Cross-check against the verification log from step 7. -- The out-of-scope list is where the follow-through earns its keep. Anything discussed - and not quoted goes in it explicitly. - -Show all four to the user before anything leaves the machine. - ---- +- After installing, **walk Settings once**. `res.config.settings` and `res.company` are + extended by 8 modules each and several BuildEase behaviours default to off. +- At the staged-numbers sanity check: **booked hours well under ordered hours**. Over 100% + makes the progress statement look wrong on screen even when every assertion passed. +- The spine of every BuildEase demo is QTO → sale order → project → progress statement → + invoice. If that chain breaks mid-demo you lose the deal, so it is the chain **Test + heroes** must exercise end to end. +- `quantity.take.off` inherits `mail.thread` + `mail.activity.mixin` — chatter and + activities work for free. Cheap demo beats. ## Guardrails - Never claim a BuildEase feature works without having seen it work in the demo DB. -- Never hand over an environment holding test records. Purge them and read back the counts; - do not settle for "cancelled and archived" and do not write the leftovers off as a note - in the verification log. -- Never send anything client-facing without the user reading it first. -- `discovery_brief.md`, `demo_config.json` and the follow-through docs hold client data - and API keys — keep them in the project directory, never in a scratchpad you'd forget. +- Never invent a pain quote. `(inferred)` is fine; a fabricated quote read back to the + client in the demo is not. +- If something you need is not one of the five overlays above, that is a gap in + `callista-odoo-demo` — fix it there, so the next vertical gets it free. Do not work + around it here. + +## Files + +- `reference/buildease.md` — the module map, the pain → module routing table, and the mev + gotchas. Read it at Phase A. diff --git a/reference/buildease.md b/reference/buildease.md index c6a298e..0d62cda 100644 --- a/reference/buildease.md +++ b/reference/buildease.md @@ -48,8 +48,8 @@ Match the client's own words on the left; install the right. **Do not install `buildease_demo_data` or `buildease_demo_data_sale_progress` by default.** They are the previous generation of environment setup: one fixed Belgian construction dataset, the same for every prospect. This skill replaces them — demo data is generated -per client from the discovery brief and loaded through odoo-demo-architect's -`build_demo.py` / `demo_config.json`. +per client from the discovery brief and loaded through `callista-odoo-demo`'s +`scripts/build_demo.py` / `demo_config.json`. Install them only if the user explicitly asks, or when there is genuinely no client context and a generic sandbox is wanted. If you do, know what comes with them: diff --git a/reference/buildease_demo_data.md b/reference/buildease_demo_data.md new file mode 100644 index 0000000..3b97a42 --- /dev/null +++ b/reference/buildease_demo_data.md @@ -0,0 +1,226 @@ +# 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.