diff --git a/SKILL.md b/SKILL.md index 113ada8..23d0b5d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -60,6 +60,15 @@ dependencies that bundle used to drag in (`sale_management`, `project`, `purchas `sale_timesheet`, `industry_fsm`, `accountant`, `uom`) still need installing — route them from the pain table, not from the demo bundle. +**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. + +`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. + ## Overlay 4 — Purge chain *Applies at:* **Purge test residue**, replacing the generic chain. @@ -72,10 +81,17 @@ transient wizard rows → statements (cancelled only, newest first) → invoices The generic traps still apply. These are the BuildEase-specific ones, each 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. +- **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. -- Statements cancel newest-first only. + +Full field-level detail, including the guards, is in `reference/buildease_demo_data.md`. ## Overlay 5 — Follow-through framing 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.