Reseam onto callista odoo demo #1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user