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>
This commit is contained in:
glenn
2026-07-31 14:43:15 +02:00
co-authored by Claude Opus 5
parent 8454aa132e
commit 5aee1c61b5
3 changed files with 108 additions and 5 deletions
+21 -4
View File
@@ -31,8 +31,15 @@ its pain → module table. Match on **the client's own words** — the table is
they actually say ("we measure in Excel then re-type it into the quote"), not on module
names they will never utter.
Install the umbrella module `buildease` plus the routed set. `buildease` depends on
nothing and installs the set.
Install the routed set **explicitly**, module by module. `buildease` is a marker whose
manifest depends on `base` alone — it installs none of the other 33, so installing it and
stopping there leaves an empty database and no error to catch it. Install it alongside the
routed set if you want the marker; never instead of it.
Verify afterwards: read back `ir.module.module` state for every routed name and fail loudly
on anything not `installed`. `button_immediate_install` also dies on a transient
`ir_cron` row lock (`LockNotAvailable`) when a cron is mid-run — back off and retry, it is
not a real failure.
## Overlay 2 — Solution precedence
@@ -74,15 +81,25 @@ BuildEase-shaped config will drive it.
*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
transient wizard rows → invoices (draft → cancel → unlink)
statements (cancelled only, newest first) → timesheet lines → tasks
→ projects → sale orders → take-offs → the partner
```
The generic traps still apply. These are the BuildEase-specific ones, each of which cost
a debugging round:
- **Invoices go before 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. Statements-then-invoices deadlocks on the one statement you invoiced.
- **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`.
- **Guard each step separately and loop until a pass keeps nothing.** One `try` around
draft → cancel → unlink lets a raise on an already-draft invoice skip the unlink beneath
it, and the purge then reports success over residue.
- **Button methods return an unmarshallable action dict** over XML-RPC (`Fault` mentioning
`Marshal`/`dumps`). The transition already committed — catch it and move on. Retrying
fails on a state guard and reads like a real error.
- **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
+11 -1
View File
@@ -1,7 +1,12 @@
# BuildEase (callista-tools/mev) — module map & pain routing
Odoo **19.0** vertical for construction / contracting. Repo: `git@github.com:callista-tools/mev.git`.
34 modules, all LGPL-3. Umbrella module `buildease` depends on nothing and installs the set.
34 modules, all LGPL-3.
**`buildease` is a marker, not an installer.** Its manifest depends on `base` and nothing
else, so installing it pulls in none of the other 33. Verified on a live 19.0+e instance:
`buildease` on its own leaves the database empty and every routed feature missing, with no
error to catch it. Always install the routed set explicitly.
## The four core concepts
@@ -66,6 +71,11 @@ installing on their own — route them from the pain table above, not from the d
- **Three dependencies are not in this repo**: `custom_product_sale`, `custom_product_bom`,
`bom_on_sale_order_line`. Anything under `buildease_custom_product*` fails to install
without them. Confirm their source before promising a BOM-pricing hero feature.
**Not in the repo does not mean not on the instance.** On the Callista demo servers all
three are present and installable — they ship with the `demo19-custom` addons path, not
with mev. Check the target instance (`ir.module.module` by name) before pricing
`buildease_custom_product*` as build work; on a server that already has them it is a
install line, and on a client's own Odoo.sh it is not.
- **Odoo 19 only.** Manifests are all `19.0.x`; do not attempt against 17/18.
- `l10n_be` and `accountant` are pulled in by demo data — the demo DB is Belgian-localised.
Wrong-country client → set `demo_config.json` company/fiscal position explicitly, or skip
+76
View File
@@ -113,6 +113,59 @@ dates and calls create. Building a `sale.progress` directly means building
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.0221``1.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
@@ -137,6 +190,17 @@ 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
@@ -169,6 +233,18 @@ And `sale.progress.action_cancel()` refuses unless `show_cancel` — *"You can o
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