Reseam onto callista odoo demo #1

Merged
admin merged 2 commits from reseam-onto-callista-odoo-demo into main 2026-07-31 11:13:02 +02:00
2 changed files with 245 additions and 3 deletions
Showing only changes of commit 17ebc96bdc - Show all commits
+19 -3
View File
@@ -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 `sale_timesheet`, `industry_fsm`, `accountant`, `uom`) still need installing — route them
from the pain table, not from the demo bundle. 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 ## Overlay 4 — Purge chain
*Applies at:* **Purge test residue**, replacing the generic 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 The generic traps still apply. These are the BuildEase-specific ones, each of which cost
a debugging round: a debugging round:
- **A take-off with no sale order cannot be deleted at all.** Its state is derived from the - **Cancel before deleting, on all three.** `unlink()` refuses unless `quantity.take.off` is
order, so there is nothing to cancel. Convert it to a quotation, cancel that, then delete. `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. - 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 ## Overlay 5 — Follow-through framing
+226
View File
@@ -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.