diff --git a/.claude/handovers/2026-07-07-odoo-transpile-v3-w1-w2-shipped-w3-handoff.md b/.claude/handovers/2026-07-07-odoo-transpile-v3-w1-w2-shipped-w3-handoff.md new file mode 100644 index 0000000..08b200b --- /dev/null +++ b/.claude/handovers/2026-07-07-odoo-transpile-v3-w1-w2-shipped-w3-handoff.md @@ -0,0 +1,43 @@ +# Handover — odoo→odoo-rs V3 transpile: W1+W2 shipped (OGAR side), W3 handed to an odoo-rs-scoped session + +> **Date:** 2026-07-07 +> **Branch (this repo):** `claude/odoo-rs-v3-ogar-transpile-nwriny` +> **Why this lives in OGAR, not odoo-rs:** the prior handover +> (`odoo-rs/docs/HANDOVER-2026-07-06-odoo-transpile-v3.md`) is in the +> **private `odoo-rs` repo, which is NOT in every session's scope**. This +> session had OGAR/ruff/lance-graph access but `odoo-rs` was hard-blocked at +> the session allowlist (add_repo, git-proxy, PAT-URL, GitHub-MCP, and repo +> search all denied it). So the W3 spec is recorded HERE, in an accessible +> repo, anchored to the shipped commits. + +## Status of the arc + +harvest ✅ → fingerprint quartet ✅ → lift+mint+carry ✅ (#164) → consume ✅ +→ SurrealQL deprecated ✅ → AT-CARRY-2 kausal upstream ✅ (#168) → +**W1 kausal-parity consume (OGAR side) ✅** → **W2 V3 SoA sink ✅** → +**end-to-end capstone ✅**. Remaining: **W3 (odoo-rs-side deletions).** + +## Shipped THIS session (OGAR `claude/odoo-rs-v3-ogar-transpile-nwriny`) + +| Commit | What | Tests | +|---|---|---| +| `8319690` | **W1** — kausal-parity consume pin. Real Odoo 19 source (`account_payment_term.py`, `AdaWorldAPI/odoo` `2c78d5f1`, byte-identical, PROVENANCE.md) drives `ruff_python_spo::extract_from_source → compile_graph_python:: → lift_actions`; pins 8/8 `@api.depends` compute methods → `KausalSpec::Depends` verbatim, 8 plain methods kausal-free. Ledger `D-KAUSAL-CONSUME-PIN-ODOO`. | `tests/odoo_kausal_parity_probe.rs` 1/1 | +| `ae6bb0f` | **W2** — V3 SoA sink for `CompiledClass`. `crates/ogar-from-ruff/src/lance_sink.rs` behind feature `lance-sink` (zero-dep `lance-graph-contract` only, **no `*Bridge`**). `compiled_class_to_facet` (byte-identical reinterpret → `FacetCascade`, L1 rails `G6D2`), `compiled_class_to_noderow` (classid in key, bootstrap tail, `ValueSchema::Bootstrap`), `compiled_classes_to_le_bytes`. Mirrors `contract::network` (shape) + `symbiont::bridge` (idiom). Ledger `D-V3-SINK-COMPILEDCLASS`. | 7/7 incl. field-isolation matrix + `ENVELOPE_LAYOUT_VERSION==2` fuse | +| `dfc644e` | **Capstone** — end-to-end `account_payment_term.py → ruff → OGAR → 2 CANON NodeRow (1024 B)`, no SurrealQL. | `tests/odoo_v3_sink_e2e.rs` 1/1 (feature `lance-sink`) | + +Full crate: `cargo test -p ogar-from-ruff --features lance-sink` = **78 lib + all integration green**; `cargo fmt`/`clippy` clean. Ruff **#51 is MERGED to `main`** (verified via GitHub API: `merged_at 2026-07-06T21:38:44Z`, merge commit `9ef26c1`, base `main`) — downstream `ogar-from-ruff` floats on it green (D-NEVER-PIN-BUMP). + +## W3 — what a session WITH odoo-rs access must do (ordered) + +The W1 witness (`8319690`) is the gate that was blocking these deletions; it is now green, so they are UNBLOCKED. + +1. **Delete the odoo-rs local corpus DO-arm.** The deprecated odoo-rs corpus witness is superseded by the OGAR-side real-source witness (`D-KAUSAL-CONSUME-PIN-ODOO`, fixture `account_payment_term.py`). With W1 green, remove the local corpus DO-arm in odoo-rs and repoint any consumer at the OGAR pipeline (`ruff_python_spo → compile_graph_python:: → lift_actions`). Only Arm A (`compute=`/`@api.depends`) is witnessed; **arms B (`@api.constrains`/`@api.onchange`) + D (`computed.stored`) remain out of scope pending ruff #49** — do NOT delete anything those arms feed until #49 lands and a follow-up OGAR pin covers them. +2. **W3 Stage-C fork delete**, modulo **AT-CARRY-3 (`body_source`)** — the named blocker in the prior odoo-rs handover. Confirm `body_source` carry is resolved before the Stage-C fork is deleted. +3. **(Optional, engine-side) finish the W2 sink's out-of-scope tail:** the actual Lance `Dataset::write` of the `NodeRowPacket::as_le_bytes` output + kanban transition need the lance engine / ractor runtime. `lance_sink` stops at `as_le_bytes()` on purpose (mirrors `contract::network` stopping at the facet, and `symbiont::bridge`'s BLOCKED tombstone write). `[H2]` embedding the 12-byte rail payload into a NodeRow value tenant needs a new append-only `ValueTenant` (v3-envelope-auditor-gated) — deferred, mirror network (added no lane). `[H1]` rail-chain↔key-tail reconciliation stays frozen (tail bootstrapped, test-enforced T-D). `[H3]` L1 rails only; L6 "odoo?" quads unruled — do not implement. + +## Gotchas (carried forward) + +- **D-NEVER-PIN-BUMP:** every cross-repo dep floats on `branch=main`; drift protection is loud breaks + fuse tests, never rev pins. +- **PAT-URL pushes:** OGAR/ruff push via `https://x-access-token:${GH_TOKEN}@github.com/AdaWorldAPI/` — the session `origin` git-proxy prompts for a password and fails. +- **odoo-rs scope:** if `odoo-rs` is again absent from the session allowlist, W3 CANNOT be done — request the session be scoped to include `AdaWorldAPI/odoo-rs`. +- **Cargo.lock gitignored** in these crates; **no PR created in-session** (push branch, open PR out-of-band). diff --git a/crates/ogar-from-ruff/Cargo.toml b/crates/ogar-from-ruff/Cargo.toml index cbd1967..1596a0c 100644 --- a/crates/ogar-from-ruff/Cargo.toml +++ b/crates/ogar-from-ruff/Cargo.toml @@ -11,9 +11,18 @@ description = "Lift `ruff_spo_triplet::Model` (the language-agnostic IR populate [features] default = [] serde = ["dep:serde", "ogar-vocab/serde"] +# W2 — sink a `CompiledClass` onto the lance-graph V3 SoA (the 4+12 facet / +# 512-byte CANON node). Pulls the ZERO-DEP `lance-graph-contract` only — no +# lance engine, no kv-lance, no `*Bridge`. Mirrors `contract::network` + +# `symbiont::bridge`; stops at the `NodeRowPacket::as_le_bytes` byte boundary. +lance-sink = ["dep:lance-graph-contract"] [dependencies] ogar-vocab = { path = "../ogar-vocab" } +# The zero-dep contract carries the V3 byte surface (FacetCascade / NodeGuid / +# NodeRow / NodeRowPacket / ValueTenant). Git-floats on `main` like the two +# `ruff_spo_*` deps + ogar-class-view's own contract dep (D-NEVER-PIN-BUMP). +lance-graph-contract = { git = "https://github.com/AdaWorldAPI/lance-graph", branch = "main", optional = true } # Operator ruling: rev pins on AdaWorldAPI-internal git deps are retired — # they were "out of desperation to lock what hadn't converged in transpiler # sink-in yet." Drift protection is the fuse tests, not pins. Floats on the @@ -25,3 +34,10 @@ ogar-vocab = { path = "../ogar-vocab" } ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" } ruff_spo_address = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" } serde = { workspace = true, optional = true } + +[dev-dependencies] +# Odoo/Python source frontend — dev-only: builds `ModelGraph` straight from +# real `.py` source for the kausal-parity consume probe +# (`tests/odoo_kausal_parity_probe.rs`). Floats on `main` like the two +# `ruff_spo_*` deps above (no rev pin; drift protection is the fuse tests). +ruff_python_spo = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" } diff --git a/crates/ogar-from-ruff/src/lance_sink.rs b/crates/ogar-from-ruff/src/lance_sink.rs new file mode 100644 index 0000000..47dc7a6 --- /dev/null +++ b/crates/ogar-from-ruff/src/lance_sink.rs @@ -0,0 +1,263 @@ +//! W2 — sink a [`CompiledClass`] onto the lance-graph **V3 SoA** substrate. +//! +//! The pull-in half of the transpiler mints a [`CompiledClass`] (THINK arm +//! [`Class`](ogar_vocab::Class) + DO arm [`ActionDef`](ogar_vocab::ActionDef) + +//! the 16-byte rail [`Facet`](ruff_spo_address::Facet)); nothing *stored* it. +//! This module is the storage seam: `CompiledClass → V3 byte surface`, mirroring +//! the two already-shipped sinks and inventing **no `*Bridge`**: +//! +//! - `lance_graph_contract::network` (harvest → `FacetCascade`, content-blind, +//! one concept mint, deferred row embedding) — the *shape* borrowed here. +//! - `symbiont::bridge` (`NodeRow` + `NodeRowPacket::as_le_bytes` zero-copy Lance +//! byte path) — the *embedding idiom* borrowed here. +//! +//! # What this does (and deliberately does NOT) +//! +//! `CompiledClass.facet` is a [`ruff_spo_address::Facet`] whose 16 LE bytes are +//! **byte-identical** to [`lance_graph_contract::facet::FacetCascade`] +//! (`facet_classid(4) | 6×(is_a:lo, part_of:hi)`, the L1 **rails** plane, +//! `CascadeShape::G6D2`). So [`compiled_class_to_facet`] is a reinterpret no-op. +//! [`compiled_class_to_noderow`] embeds one class as ONE 512-byte CANON +//! [`NodeRow`] — the render classid in the key `[0..4)`, a **bootstrap** tail +//! (family=0, HHTL=0, identity=caller-supplied), `ValueSchema::Bootstrap` +//! (all-zero value slab). [`compiled_classes_to_le_bytes`] packs a slice onto the +//! zero-copy storage boundary. +//! +//! Out of scope (named, not hidden), per the W2 plan: +//! - The actual Lance `Dataset::write` I/O — stops at `as_le_bytes()`, exactly +//! as `network::to_facet` stops at the facet and symbiont's tombstone write is +//! BLOCKED. That column write needs the lance engine / ractor runtime. +//! - The 12-byte rail payload → a NodeRow **value tenant** (`[H2]`: needs a new +//! append-only `ValueTenant`, `v3-envelope-auditor`-gated) — mirror network, +//! which added no lane. The rails ride the `FacetCascade` surface only. +//! - The key **tail from the rail tiers** (`[H1]`: le-contract §3 open item — +//! the `6×(8:8)` rail chain vs the key's contiguous `u24++u24` tail are +//! structurally different bit-groupings, "do not unify silently in code"). +//! The tail is bootstrapped; [`compiled_class_to_noderow`] never derives it +//! from the rails (test-enforced). +//! - `CompiledClass.actions` (behavior) — out-of-line, not in this byte payload +//! ("neither half carries behavior"). +//! - Ownership: this is an OFFLINE **bake** (source → artifact, single-writer) = +//! BOOTSTRAP-OK; the envelope owner is 0. NOT an online write — do not route +//! through any batch writer. If it ever goes online, W5 stamp routing applies. + +use lance_graph_contract::canonical_node::{NodeRowPacket, ValueSchema}; +use lance_graph_contract::facet::FacetCascade; +use lance_graph_contract::soa_envelope::SoaEnvelope; +use lance_graph_contract::{EdgeBlock, NodeGuid, NodeRow}; + +use crate::mint::CompiledClass; + +/// The compiled class's 16-byte rail facet as a [`FacetCascade`] — a reinterpret +/// no-op (`Facet::to_bytes` → `FacetCascade::from_bytes`, byte-identical layout). +/// +/// The result is the L1 **rails** reading (`CascadeShape::G6D2`): `hi_chain` = +/// `part_of`, `lo_chain` = `is_a`. `facet_classid` is taken verbatim from the +/// mint (the sanctioned composer) — never recomposed, never `as u16`/`>> 16`. +#[must_use] +pub fn compiled_class_to_facet(cc: &CompiledClass) -> FacetCascade { + FacetCascade::from_bytes(&cc.facet.to_bytes()) +} + +/// Embed one [`CompiledClass`] as ONE 512-byte CANON [`NodeRow`]: the render +/// classid in the key `[0..4)`, a **bootstrap** tail (`family = 0`, HHTL = 0, +/// `identity` = caller-supplied), `ValueSchema::Bootstrap` (all-zero 480-byte +/// value slab), empty edge block. Mirrors `symbiont::bridge::board`. +/// +/// The key tail is bootstrapped, NOT derived from the rail tiers (`[H1]` +/// freeze — the rail chain vs the key `u24++u24` tail reconciliation is an open +/// operator/envelope ruling). `identity` must fit in 24 bits (`NodeGuid::new` +/// asserts this). +#[must_use] +pub fn compiled_class_to_noderow(cc: &CompiledClass, identity: u32) -> NodeRow { + NodeRow { + // classid verbatim from the mint; family/HHTL bootstrap-zero, identity + // caller-supplied → the zero-fallback ladder's bootstrap address while + // the class is unbasined. + key: NodeGuid::new(cc.facet.facet_classid(), 0, 0, 0, 0, identity), + edges: EdgeBlock::default(), + // ValueSchema::Bootstrap == FieldMask::EMPTY == all-zero slab; asserted + // by the constant below so a reader never mistakes it for a live schema. + value: [0u8; 480], + } +} + +/// `ValueSchema` this sink stamps into every row it builds — the all-zero +/// bootstrap slab (no tenant lane written; the rails ride the facet surface). +pub const SINK_VALUE_SCHEMA: ValueSchema = ValueSchema::Bootstrap; + +/// Pack a slice of CANON [`NodeRow`]s onto the zero-copy Lance storage boundary +/// (`NodeRowPacket::as_le_bytes`, the `SoaEnvelope` byte view). `cycle` stamps +/// the packet's write cycle. This is the last step in scope — the actual +/// `Dataset::write` is a separate, engine-side deliverable. +#[must_use] +pub fn compiled_classes_to_le_bytes(rows: &[NodeRow], cycle: u32) -> Vec { + NodeRowPacket::new(rows, cycle).as_le_bytes().to_vec() +} + +#[cfg(test)] +mod tests { + use super::*; + use lance_graph_contract::canonical_node::node_rows_from_le_bytes; + use lance_graph_contract::facet::CascadeShape; + use lance_graph_contract::soa_envelope::ENVELOPE_LAYOUT_VERSION; + use ogar_vocab::ports::OdooPort; + use ruff_spo_triplet::{Field, Model, ModelGraph}; + + /// A representative `account.move` graph, built directly (the + /// source→ModelGraph parse is `ruff_python_spo`'s job) — the aliased model + /// the mint resolves to the worked-example classid `0x0202_0002`. + fn account_move_graph() -> ModelGraph { + let mut m = Model::new("account_move"); + m.fields.push(Field { + name: "partner_id".to_string(), + target: Some("res.partner".to_string()), + relation_kind: Some("many2one".to_string()), + ..Default::default() + }); + let mut g = ModelGraph::new("odoo"); + g.models.push(m); + g + } + + /// The canonical `account.move` fixture, minted through the sanctioned + /// composer so its classid is real (`0x0202_0002`), never hand-rolled hex. + fn account_move_compiled() -> CompiledClass { + let graph = account_move_graph(); + let compiled = crate::mint::compile_graph_python::(&graph); + compiled + .into_iter() + .find(|c| c.class.name == "account_move") + .expect("account_move compiles") + } + + // ── T-A: facet byte-parity (the primary gate) ── + #[test] + fn facet_sink_is_byte_identical_reinterpret() { + let cc = account_move_compiled(); + let fc = compiled_class_to_facet(&cc); + assert_eq!( + fc.to_bytes(), + cc.facet.to_bytes(), + "FacetCascade round-trips the rail facet's 16 bytes verbatim" + ); + assert_eq!( + fc.facet_classid, + cc.facet.facet_classid(), + "classid taken verbatim from the mint (0x0202_0002 for account.move)" + ); + assert_eq!( + cc.facet.facet_classid(), + 0x0202_0002, + "the worked-example classid" + ); + } + + // ── T-B: the facet is L1 rails (G6D2), NOT an L6 quad ── + #[test] + fn facet_is_rails_plane_not_quads() { + let cc = account_move_compiled(); + let fc = compiled_class_to_facet(&cc); + // part_of / is_a chains survive the reinterpret in the operator's + // 6×(8:8) rails reading. hi = part_of, lo = is_a (le-contract L1). + assert_eq!( + fc.hi_chain(), + cc.facet.part_of_chain(), + "part_of == FacetCascade hi" + ); + assert_eq!( + fc.lo_chain(), + cc.facet.is_a_chain(), + "is_a == FacetCascade lo" + ); + // Explicitly the rails shape (6 groups × 2 levels), NOT the L6 quad + // shape (whose odoo-? semantics are unruled — we never emit it here). + assert_eq!(CascadeShape::G6D2.groups(), 6); + } + + // ── T-C: the render classid lands in the key ── + #[test] + fn classid_lands_in_key() { + let cc = account_move_compiled(); + let row = compiled_class_to_noderow(&cc, 7); + assert_eq!( + row.key.classid(), + cc.facet.facet_classid(), + "key[0..4) decodes the render classid" + ); + } + + // ── T-D: bootstrap tail / [H1] guard — rails did NOT leak into the key tail ── + #[test] + fn key_tail_is_bootstrap_not_derived_from_rails() { + let cc = account_move_compiled(); + let row = compiled_class_to_noderow(&cc, 7); + assert_eq!(row.key.family(), 0, "family bootstrap-zero (unbasined)"); + assert_eq!( + row.key.identity(), + 7, + "identity is the caller value, not a rail byte" + ); + assert!( + row.key.is_unbasined(), + "family==0 → unbasined; the rail tiers must not populate the tail ([H1])" + ); + } + + // ── T-E: round-trip through the storage byte boundary ── + #[test] + fn le_bytes_round_trip_preserves_classid() { + let cc = account_move_compiled(); + let rows = vec![ + compiled_class_to_noderow(&cc, 1), + compiled_class_to_noderow(&cc, 2), + ]; + + // The true zero-copy decode reads the packet's OWN bytes (a borrow of the + // 64-aligned `&[NodeRow]`, per canonical_node's align(64) contract) — + // mirrors symbiont::bridge's round-trip. + let packet = NodeRowPacket::new(&rows, 0); + let aligned = packet.as_le_bytes(); + assert_eq!(aligned.len(), 2 * 512, "two 512-byte CANON rows"); + let decoded = node_rows_from_le_bytes(aligned).expect("aligned zero-copy decode"); + assert_eq!(decoded.len(), 2); + assert_eq!(decoded[0].key.classid(), cc.facet.facet_classid()); + assert_eq!(decoded[1].key.identity(), 2); + + // The owned-`Vec` helper carries the identical bytes (a copy off the + // storage boundary — Lance owns re-alignment on read-back). + assert_eq!(compiled_classes_to_le_bytes(&rows, 0), aligned); + } + + // ── T-F: field isolation (I-LEGACY-API-FEATURE-GATED matrix) ── + #[test] + fn field_isolation_classid_and_identity_are_independent() { + let cc = account_move_compiled(); + // Same class, two identities → classid identical, identity distinct, + // value slab + edges untouched (all zero) in both. + let a = compiled_class_to_noderow(&cc, 10); + let b = compiled_class_to_noderow(&cc, 20); + assert_eq!( + a.key.classid(), + b.key.classid(), + "identity write left classid unchanged" + ); + assert_ne!(a.key.identity(), b.key.identity()); + assert_eq!( + a.value, [0u8; 480], + "no value lane written (bootstrap schema)" + ); + assert_eq!(b.value, [0u8; 480]); + assert_eq!(a.edges, EdgeBlock::default(), "edge block reserved-zero"); + } + + // ── T-G: this sink moves ZERO bytes at rest — no layout-version bump ── + #[test] + fn sink_does_not_move_the_layout_version() { + assert_eq!( + ENVELOPE_LAYOUT_VERSION, 2, + "W2 adds no tenant lane; the envelope layout is untouched at v2" + ); + assert_eq!(SINK_VALUE_SCHEMA, ValueSchema::Bootstrap); + } +} diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index 2ab6e63..d81f263 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -68,6 +68,8 @@ #![warn(missing_docs)] pub mod emit; +#[cfg(feature = "lance-sink")] +pub mod lance_sink; // W2 — CompiledClass → lance-graph V3 SoA pub mod mint; pub mod sqlalchemy; // WS-G-D diff --git a/crates/ogar-from-ruff/tests/odoo_kausal_parity_probe.rs b/crates/ogar-from-ruff/tests/odoo_kausal_parity_probe.rs new file mode 100644 index 0000000..49e1c42 --- /dev/null +++ b/crates/ogar-from-ruff/tests/odoo_kausal_parity_probe.rs @@ -0,0 +1,288 @@ +//! `D-KAUSAL-CONSUME-PIN-ODOO` (W1, odoo -> odoo-rs transpile arc) — real-Odoo- +//! source kausal-parity **consume** probe. +//! +//! Unlike `woa_parity_probe.rs` (a hand-transcribed synthetic `ModelGraph`, +//! standing in for a not-yet-built SQLAlchemy frontend), this probe drives +//! the REAL, already-shipped Odoo frontend end to end: +//! +//! ```text +//! real .py source (verbatim, see tests/py/account_payment_term.PROVENANCE.md) +//! -> ruff_python_spo::extract_from_source (AST -> ModelGraph) +//! -> ogar_from_ruff::mint::compile_graph_python:: +//! -> lift_model_graph_python (THINK arm: Class) +//! -> lift_actions (DO arm: ActionDef + KausalSpec) +//! ``` +//! +//! The pin: every `compute='_compute_x'` field with a `@api.depends(...)`- +//! decorated computer method must round-trip, verbatim dotted paths and all, +//! into `ActionDef.kausal == Some(KausalSpec::Depends { paths })` on the +//! `ActionDef` whose `predicate` is that computer method's name — Arm A of +//! `D-ATC2-KAUSAL-AUTARK` (SPEC-ATC2-OGAR), now witnessed against real Odoo +//! 19 source instead of a hand-built fixture. +//! +//! # Scope (named, not hidden) +//! +//! - Arm A (`compute=` + `@api.depends` -> `KausalSpec::Depends`) AND Arm B +//! (`@api.constrains` -> `KausalSpec::Constrains`) are both pinned here — +//! Arm B opened up once ruff #49 landed `Function::constrains` on main +//! (`D-ATC2-KAUSAL-RUFF-GATED`). The fixture has no `@api.onchange`, so the +//! Onchange arm is not exercised by this witness. `computed.stored` (Arm D) +//! is a `Class`-side field, out of scope for this DO-arm/kausal probe. +//! - `account.payment.term` / `account.payment.term.line` are NOT in +//! `ogar_vocab::ports::ODOO_ALIASES`, so both mint the bootstrap facet +//! classid `0` (see `mint.rs`'s +//! `unmapped_model_mints_the_bootstrap_address_not_a_wrong_classid`) — this +//! probe is about the DO-arm (kausal), not classid convergence, so the +//! bootstrap value is asserted as a fact, not treated as a defect. + +use ogar_from_ruff::mint::compile_graph_python; +use ogar_vocab::KausalSpec; +use ogar_vocab::ports::OdooPort; +use ruff_python_spo::extract_from_source; + +/// Verbatim Odoo 19 source, `addons/account/models/account_payment_term.py` +/// at `AdaWorldAPI/odoo` commit `2c78d5f1ff1d4e32b0c480c3b2714f31fb71bef7`. +/// See `tests/py/account_payment_term.PROVENANCE.md`. +const ACCOUNT_PAYMENT_TERM_PY: &str = include_str!("py/account_payment_term.py"); + +/// One pinned `@api.depends(...)` compute-method fact: method name (== +/// `ActionDef::predicate`) and its verbatim dotted dependency paths, read +/// directly off the source above (not derived by running the extractor +/// first and copying its output). +struct ExpectedDepends { + method: &'static str, + paths: &'static [&'static str], +} + +/// `AccountPaymentTerm` (`account.payment.term` -> `account_payment_term`) +/// compute methods carrying `@api.depends(...)`. `_compute_example_preview` +/// is the interesting case: it emits TWO fields (`example_preview` and +/// `example_preview_discount`), both `compute='_compute_example_preview'`, +/// so it must appear exactly once in the compiled `actions` (one `ActionDef` +/// per METHOD, not per field). +const PAYMENT_TERM_DEPENDS: &[ExpectedDepends] = &[ + ExpectedDepends { + method: "_compute_fiscal_country_codes", + paths: &["company_id"], + }, + ExpectedDepends { + method: "_compute_currency_id", + paths: &["company_id"], + }, + ExpectedDepends { + method: "_compute_discount_computation", + paths: &["company_id"], + }, + ExpectedDepends { + method: "_compute_example_invalid", + paths: &["line_ids"], + }, + ExpectedDepends { + method: "_compute_example_preview", + paths: &[ + "currency_id", + "example_amount", + "example_date", + "line_ids.value", + "line_ids.value_amount", + "line_ids.nb_days", + "early_discount", + "discount_percentage", + "discount_days", + ], + }, +]; + +/// `AccountPaymentTermLine` (`account.payment.term.line` -> +/// `account_payment_term_line`) compute methods carrying `@api.depends(...)`. +const PAYMENT_TERM_LINE_DEPENDS: &[ExpectedDepends] = &[ + ExpectedDepends { + method: "_compute_display_days_next_month", + paths: &["delay_type"], + }, + ExpectedDepends { + method: "_compute_days", + paths: &["payment_id"], + }, + ExpectedDepends { + method: "_compute_value_amount", + paths: &["payment_id"], + }, +]; + +/// `AccountPaymentTerm` methods carrying `@api.constrains(...)` — the Arm-B +/// witness that opened up once ruff #49 landed `Function::constrains` on main +/// (`D-ATC2-KAUSAL-RUFF-GATED`). Each becomes +/// `KausalSpec::Constrains { paths }` (validation trigger), Depends still +/// wins when both are present (`kausal.is_none()` gate in `lift_actions`). +const PAYMENT_TERM_CONSTRAINS: &[ExpectedDepends] = &[ExpectedDepends { + method: "_check_lines", + paths: &["line_ids", "early_discount"], +}]; + +/// `AccountPaymentTermLine` methods carrying `@api.constrains(...)`. +const PAYMENT_TERM_LINE_CONSTRAINS: &[ExpectedDepends] = &[ + ExpectedDepends { + method: "_check_valid_char_value", + paths: &["days_next_month"], + }, + ExpectedDepends { + method: "_check_percent", + paths: &["value", "value_amount"], + }, +]; + +/// Plain (non-compute, non-constrains) methods on `AccountPaymentTerm`, read +/// off the same source, that must NOT carry a `kausal` fact — the facts-only +/// guard (`D-ATC2-KAUSAL-AUTARK`: a method's own body `reads` never gets +/// promoted into a reactive `KausalSpec` trigger). +const PAYMENT_TERM_PLAIN_METHODS: &[&str] = &[ + "_get_amount_due_after_discount", + "_get_amount_by_date", + "_compute_terms", // named like a compute target but has no `compute=` field pointing at it + "_unlink_except_referenced_terms", + "_get_last_discount_date", + "_get_last_discount_date_formatted", + "copy_data", +]; + +fn assert_kausal_depends( + actions: &[ogar_vocab::ActionDef], + expected: &ExpectedDepends, + model: &str, +) { + let action = actions + .iter() + .find(|a| a.predicate == expected.method) + .unwrap_or_else(|| panic!("{model}: no ActionDef for method {:?}", expected.method)); + let expected_paths: Vec = expected.paths.iter().map(|s| s.to_string()).collect(); + assert_eq!( + action.kausal, + Some(KausalSpec::depends(expected_paths)), + "{model}::{} kausal depends-paths mismatch", + expected.method + ); +} + +fn assert_kausal_constrains( + actions: &[ogar_vocab::ActionDef], + expected: &ExpectedDepends, + model: &str, +) { + let action = actions + .iter() + .find(|a| a.predicate == expected.method) + .unwrap_or_else(|| panic!("{model}: no ActionDef for method {:?}", expected.method)); + let expected_paths: Vec = expected.paths.iter().map(|s| s.to_string()).collect(); + assert_eq!( + action.kausal, + Some(KausalSpec::constrains(expected_paths)), + "{model}::{} kausal constrains-paths mismatch", + expected.method + ); +} + +#[test] +fn odoo_kausal_parity_pin_account_payment_term() { + let graph = extract_from_source(ACCOUNT_PAYMENT_TERM_PY); + assert_eq!(graph.namespace, "odoo"); + // Two models declared in the source: AccountPaymentTerm (_name = + // 'account.payment.term') and AccountPaymentTermLine (_name = + // 'account.payment.term.line'). + assert_eq!(graph.models.len(), 2, "two models in the fixture"); + + let compiled = compile_graph_python::(&graph); + assert_eq!(compiled.len(), 2); + + let payment_term = compiled + .iter() + .find(|c| c.class.name == "account_payment_term") + .expect("account_payment_term compiles"); + let payment_term_line = compiled + .iter() + .find(|c| c.class.name == "account_payment_term_line") + .expect("account_payment_term_line compiles"); + + // Neither model is in ODOO_ALIASES -> bootstrap classid 0 (named in the + // module doc as a fact, not a defect of this probe). + assert_eq!(payment_term.facet.facet_classid(), 0); + assert_eq!(payment_term_line.facet.facet_classid(), 0); + + // ── Arm A pin: every @api.depends compute method round-trips verbatim ── + for expected in PAYMENT_TERM_DEPENDS { + assert_kausal_depends(&payment_term.actions, expected, "account_payment_term"); + } + for expected in PAYMENT_TERM_LINE_DEPENDS { + assert_kausal_depends( + &payment_term_line.actions, + expected, + "account_payment_term_line", + ); + } + + // ── Arm B pin: every @api.constrains method → KausalSpec::Constrains ── + // (opened up by ruff #49 / D-ATC2-KAUSAL-RUFF-GATED; Depends still wins + // when both are present, so these are the pure-constrains methods.) + for expected in PAYMENT_TERM_CONSTRAINS { + assert_kausal_constrains(&payment_term.actions, expected, "account_payment_term"); + } + for expected in PAYMENT_TERM_LINE_CONSTRAINS { + assert_kausal_constrains( + &payment_term_line.actions, + expected, + "account_payment_term_line", + ); + } + + // Exactly one kausal-bearing ActionDef per @api.depends compute METHOD + // (not per field — _compute_example_preview emits two fields, appears once) + // PLUS one per @api.constrains method. + let kausal_bearing = payment_term + .actions + .iter() + .filter(|a| a.kausal.is_some()) + .count(); + assert_eq!( + kausal_bearing, + PAYMENT_TERM_DEPENDS.len() + PAYMENT_TERM_CONSTRAINS.len(), + "one kausal-bearing ActionDef per @api.depends + @api.constrains method (account_payment_term)" + ); + let kausal_bearing_line = payment_term_line + .actions + .iter() + .filter(|a| a.kausal.is_some()) + .count(); + assert_eq!( + kausal_bearing_line, + PAYMENT_TERM_LINE_DEPENDS.len() + PAYMENT_TERM_LINE_CONSTRAINS.len(), + "one kausal-bearing ActionDef per @api.depends + @api.constrains method (account_payment_term_line)" + ); + + // ── facts-only guard: plain methods carry no kausal fact ── + for name in PAYMENT_TERM_PLAIN_METHODS { + let action = payment_term + .actions + .iter() + .find(|a| a.predicate == *name) + .unwrap_or_else(|| panic!("no ActionDef for plain method {name:?}")); + assert_eq!( + action.kausal, None, + "plain method {name:?} must not carry a kausal fact" + ); + } + + let expected_kausal = PAYMENT_TERM_DEPENDS.len() + + PAYMENT_TERM_LINE_DEPENDS.len() + + PAYMENT_TERM_CONSTRAINS.len() + + PAYMENT_TERM_LINE_CONSTRAINS.len(); + println!( + "OK: account_payment_term(+line) kausal_actions={}/{} (depends={} constrains={}) plain_checked={} total_actions={}/{}", + kausal_bearing + kausal_bearing_line, + expected_kausal, + PAYMENT_TERM_DEPENDS.len() + PAYMENT_TERM_LINE_DEPENDS.len(), + PAYMENT_TERM_CONSTRAINS.len() + PAYMENT_TERM_LINE_CONSTRAINS.len(), + PAYMENT_TERM_PLAIN_METHODS.len(), + payment_term.actions.len(), + payment_term_line.actions.len(), + ); +} diff --git a/crates/ogar-from-ruff/tests/odoo_v3_sink_e2e.rs b/crates/ogar-from-ruff/tests/odoo_v3_sink_e2e.rs new file mode 100644 index 0000000..ca7d7fd --- /dev/null +++ b/crates/ogar-from-ruff/tests/odoo_v3_sink_e2e.rs @@ -0,0 +1,90 @@ +//! End-to-end capstone of the odoo→odoo-rs V3 transpile (W1 + W2 joined): +//! **real Odoo source → V3 SoA CANON node bytes**, no SurrealQL anywhere. +//! +//! ```text +//! account_payment_term.py (verbatim Odoo 19, tests/py/ + PROVENANCE.md) +//! -> ruff_python_spo::extract_from_source (AST -> ModelGraph) +//! -> ogar_from_ruff::mint::compile_graph_python:: +//! -> Vec (W1 arm) +//! -> ogar_from_ruff::lance_sink::compiled_class_to_noderow (W2 arm) +//! -> NodeRowPacket::as_le_bytes (storage boundary) +//! -> node_rows_from_le_bytes (zero-copy decode round-trip) +//! ``` +//! +//! This is the pull-in → sink half of `OGAR-TRANSPILE-SUBSTRATE.md` proven on a +//! single real file end to end. It joins `D-KAUSAL-CONSUME-PIN-ODOO` (the DO-arm +//! kausal witness) with `D-V3-SINK-COMPILEDCLASS` (the V3 SoA sink): the SAME +//! `CompiledClass`es that carry the pinned `KausalSpec::Depends` facts are the +//! ones embedded as CANON `NodeRow`s. +//! +//! Gated on the `lance-sink` feature (the sink's only new dependency is the +//! zero-dep `lance-graph-contract`). +#![cfg(feature = "lance-sink")] + +use lance_graph_contract::canonical_node::{NodeRowPacket, node_rows_from_le_bytes}; +use lance_graph_contract::soa_envelope::SoaEnvelope; +use ogar_from_ruff::lance_sink::{compiled_class_to_facet, compiled_class_to_noderow}; +use ogar_from_ruff::mint::compile_graph_python; +use ogar_vocab::ports::OdooPort; +use ruff_python_spo::extract_from_source; + +/// Same verbatim fixture the W1 kausal probe uses (Odoo 19 `2c78d5f1`). +const ACCOUNT_PAYMENT_TERM_PY: &str = include_str!("py/account_payment_term.py"); + +#[test] +fn odoo_source_transpiles_to_v3_soa_noderows() { + // ── pull-in (W1 arm): real source → CompiledClasses ── + let graph = extract_from_source(ACCOUNT_PAYMENT_TERM_PY); + let compiled = compile_graph_python::(&graph); + assert_eq!(compiled.len(), 2, "two models transpile end to end"); + + // ── sink (W2 arm): each CompiledClass → one CANON NodeRow ── + let rows: Vec<_> = compiled + .iter() + .enumerate() + .map(|(i, cc)| compiled_class_to_noderow(cc, i as u32)) + .collect(); + + // Each row's key carries the class's render classid verbatim; the facet + // reinterpret preserves the rail (part_of/is_a) chains. + for (i, (cc, row)) in compiled.iter().zip(&rows).enumerate() { + assert_eq!( + row.key.classid(), + cc.facet.facet_classid(), + "row {i}: key classid == mint classid" + ); + assert_eq!(row.key.identity(), i as u32, "row {i}: bootstrap identity"); + assert!( + row.key.is_unbasined(), + "row {i}: bootstrap tail (no rail leak)" + ); + let fc = compiled_class_to_facet(cc); + assert_eq!( + fc.lo_chain(), + cc.facet.is_a_chain(), + "row {i}: is_a rails survive" + ); + assert_eq!( + fc.hi_chain(), + cc.facet.part_of_chain(), + "row {i}: part_of rails survive" + ); + } + + // ── storage boundary: pack → zero-copy decode round-trip ── + let packet = NodeRowPacket::new(&rows, 0); + let bytes = packet.as_le_bytes(); + assert_eq!(bytes.len(), rows.len() * 512, "512-byte CANON rows"); + let decoded = node_rows_from_le_bytes(bytes).expect("aligned zero-copy decode"); + assert_eq!(decoded.len(), rows.len()); + for (cc, row) in compiled.iter().zip(decoded) { + assert_eq!(row.key.classid(), cc.facet.facet_classid()); + } + + println!( + "OK: account_payment_term.py -> {} CompiledClass -> {} CANON NodeRow ({} bytes), no SurrealQL", + compiled.len(), + rows.len(), + bytes.len(), + ); +} diff --git a/crates/ogar-from-ruff/tests/py/account_payment_term.PROVENANCE.md b/crates/ogar-from-ruff/tests/py/account_payment_term.PROVENANCE.md new file mode 100644 index 0000000..4dd7954 --- /dev/null +++ b/crates/ogar-from-ruff/tests/py/account_payment_term.PROVENANCE.md @@ -0,0 +1,33 @@ +# Provenance — `account_payment_term.py` + +- **Source repo:** `AdaWorldAPI/odoo` (LGPLv3, mirrored via the local read-only + clone at `/home/user/odoo`) +- **Commit:** `2c78d5f1ff1d4e32b0c480c3b2714f31fb71bef7` +- **Original path:** `addons/account/models/account_payment_term.py` +- **Copied:** 2026-07-06 +- **Copy method:** byte-for-byte (`diff` verified identical against source) + +## Purpose + +Kausal-parity **consume** witness (W1 of the odoo→odoo-rs transpile arc, +`D-KAUSAL-CONSUME-PIN-ODOO`). This is real, unmodified Odoo 19 source — +it replaces the deprecated odoo-rs corpus witness as the fixture that +proves `ruff_python_spo::extract_from_source` → `ogar-from-ruff::lift_actions` +reproduces the file's `compute=` / `@api.depends(...)` reactive wiring +(`ActionDef.kausal = Some(KausalSpec::Depends { paths })`) verbatim, dotted +paths and all, off REAL production source rather than a synthetic or +hand-authored stand-in. + +The file declares two Odoo models (`AccountPaymentTerm` / +`account.payment.term` and `AccountPaymentTermLine` / +`account.payment.term.line`), giving the probe both a multi-model graph +and, within `AccountPaymentTerm._compute_example_preview`, a single compute +method that emits two distinct fields (`example_preview` + +`example_preview_discount`) from the same `@api.depends(...)` set — an +edge case the synthetic WoA/`account_move` fixtures used elsewhere in this +crate do not exercise. + +See `crates/ogar-from-ruff/tests/odoo_kausal_parity_probe.rs` for the pinned +assertions and `docs/DISCOVERY-MAP.md`'s `D-KAUSAL-CONSUME-PIN-ODOO` entry +for the full writeup (including scope: constrains/onchange arms and +computed.stored parity remain out of scope, pending ruff #49). diff --git a/crates/ogar-from-ruff/tests/py/account_payment_term.py b/crates/ogar-from-ruff/tests/py/account_payment_term.py new file mode 100644 index 0000000..91fc23d --- /dev/null +++ b/crates/ogar-from-ruff/tests/py/account_payment_term.py @@ -0,0 +1,367 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _, Command +from odoo.exceptions import UserError, ValidationError +from odoo.tools import format_date, formatLang, frozendict, date_utils +from odoo.tools.float_utils import float_round + +from dateutil.relativedelta import relativedelta + + +class AccountPaymentTerm(models.Model): + _name = 'account.payment.term' + _description = "Payment Terms" + _order = "sequence, id" + _check_company_domain = models.check_company_domain_parent_of + + def _default_line_ids(self): + return [Command.create({'value': 'percent', 'value_amount': 100.0, 'nb_days': 0})] + + def _default_example_date(self): + return self.env.context.get('example_date') or fields.Date.today() + + name = fields.Char(string='Payment Terms', translate=True, required=True) + active = fields.Boolean(default=True, help="If the active field is set to False, it will allow you to hide the payment terms without removing it.") + note = fields.Html(string='Description on the Invoice', translate=True) + line_ids = fields.One2many('account.payment.term.line', 'payment_id', string='Terms', copy=True, default=_default_line_ids) + company_id = fields.Many2one('res.company', string='Company') + fiscal_country_codes = fields.Char(compute='_compute_fiscal_country_codes') + sequence = fields.Integer(required=True, default=10) + currency_id = fields.Many2one('res.currency', compute="_compute_currency_id") + + display_on_invoice = fields.Boolean(string='Show installment dates', default=True) + example_amount = fields.Monetary(currency_field='currency_id', default=1000, store=False, readonly=True) + example_date = fields.Date(string='Date example', default=_default_example_date, store=False) + example_invalid = fields.Boolean(compute='_compute_example_invalid') + example_preview = fields.Html(compute='_compute_example_preview') + example_preview_discount = fields.Html(compute='_compute_example_preview') + + discount_percentage = fields.Float(string='Discount %', help='Early Payment Discount granted for this payment term', default=2.0) + discount_days = fields.Integer(string='Discount Days', help='Number of days before the early payment proposition expires', default=10) + early_pay_discount_computation = fields.Selection([ + ('included', 'On early payment'), + ('excluded', 'Never'), + ('mixed', 'Always (upon invoice)'), + ], string='Cash Discount Tax Reduction', readonly=False, store=True, compute='_compute_discount_computation') + early_discount = fields.Boolean(string='Early Discount') + + @api.depends('company_id') + @api.depends_context('allowed_company_ids') + def _compute_fiscal_country_codes(self): + for record in self: + allowed_companies = record.company_id or self.env.companies + record.fiscal_country_codes = ",".join(allowed_companies.mapped('account_fiscal_country_id.code')) + + @api.depends_context('company') + @api.depends('company_id') + def _compute_currency_id(self): + for payment_term in self: + payment_term.currency_id = payment_term.company_id.currency_id or self.env.company.currency_id + + def _get_amount_due_after_discount(self, total_amount, untaxed_amount): + self.ensure_one() + if self.early_discount: + percentage = self.discount_percentage / 100.0 + if self.early_pay_discount_computation in ('excluded', 'mixed'): + discount_amount_currency = (total_amount - untaxed_amount) * percentage + else: + discount_amount_currency = total_amount * percentage + amount_due = self.currency_id.round(total_amount - discount_amount_currency) + if self.env.context.get('active_model') == 'account.move' and (active_id := self.env.context.get('active_id')): + move = self.env['account.move'].browse(active_id) + cash_rounding = move.invoice_cash_rounding_id + currency = move.currency_id + if cash_rounding: + cash_rounding_difference = cash_rounding.compute_difference(currency, amount_due) + if not currency.is_zero(cash_rounding_difference): + amount_due = self.currency_id.round(amount_due + cash_rounding_difference) + return amount_due + return total_amount + + @api.depends('company_id') + def _compute_discount_computation(self): + for pay_term in self: + country_code = pay_term.company_id.country_code or self.env.company.country_code + if country_code == 'BE': + pay_term.early_pay_discount_computation = 'mixed' + elif country_code == 'NL': + pay_term.early_pay_discount_computation = 'excluded' + else: + pay_term.early_pay_discount_computation = 'included' + + @api.depends('line_ids') + def _compute_example_invalid(self): + for payment_term in self: + payment_term.example_invalid = not payment_term.line_ids + + @api.depends('currency_id', 'example_amount', 'example_date', 'line_ids.value', 'line_ids.value_amount', 'line_ids.nb_days', 'early_discount', 'discount_percentage', 'discount_days') + def _compute_example_preview(self): + for record in self: + example_preview = "" + record.example_preview_discount = "" + currency = record.currency_id + if record.early_discount: + date = record._get_last_discount_date_formatted(record.example_date or fields.Date.context_today(record)) + discount_amount = record._get_amount_due_after_discount(record.example_amount, 0.0) + record.example_preview_discount = _( + "Early Payment Discount: %(amount)s if paid before %(date)s", + amount=formatLang(self.env, discount_amount, currency_obj=currency), + date=date, + ) + + if not record.example_invalid: + terms = record._compute_terms( + date_ref=record.example_date or fields.Date.context_today(record), + currency=currency, + company=self.env.company, + tax_amount=0, + tax_amount_currency=0, + untaxed_amount=record.example_amount, + untaxed_amount_currency=record.example_amount, + sign=1) + for i, info_by_dates in enumerate(record._get_amount_by_date(terms).values()): + date = info_by_dates['date'] + amount = info_by_dates['amount'] + example_preview += "
" + example_preview += _( + "%(count)s# Installment of %(amount)s due on %(date)s", + count=i+1, + amount=formatLang(self.env, amount, currency_obj=currency), + date=date, + ) + example_preview += "
" + + record.example_preview = example_preview + + @api.model + def _get_amount_by_date(self, terms): + """ + Returns a dictionary with the amount for each date of the payment term + (grouped by date, discounted percentage and discount last date, + sorted by date and ignoring null amounts). + """ + terms_lines = sorted(terms["line_ids"], key=lambda t: t.get('date')) + amount_by_date = {} + for term in terms_lines: + key = frozendict({ + 'date': term['date'], + }) + results = amount_by_date.setdefault(key, { + 'date': format_date(self.env, term['date']), + 'amount': 0.0, + }) + results['amount'] += term['foreign_amount'] + return amount_by_date + + @api.constrains('line_ids', 'early_discount') + def _check_lines(self): + round_precision = self.env['decimal.precision'].precision_get('Payment Terms') + for terms in self: + total_percent = sum(line.value_amount for line in terms.line_ids if line.value == 'percent') + if float_round(total_percent, precision_digits=round_precision) != 100: + raise ValidationError(_('The Payment Term must have at least one percent line and the sum of the percent must be 100%.')) + if len(terms.line_ids) > 1 and terms.early_discount: + raise ValidationError( + _("The Early Payment Discount functionality can only be used with payment terms using a single 100% line. ")) + if terms.early_discount and terms.discount_percentage <= 0.0: + raise ValidationError(_("The Early Payment Discount must be strictly positive.")) + if terms.early_discount and terms.discount_days <= 0: + raise ValidationError(_("The Early Payment Discount days must be strictly positive.")) + + def _compute_terms(self, date_ref, currency, company, tax_amount, tax_amount_currency, sign, untaxed_amount, untaxed_amount_currency, cash_rounding=None): + """Get the distribution of this payment term. + :param date_ref: The move date to take into account + :param currency: the move's currency + :param company: the company issuing the move + :param tax_amount: the signed tax amount for the move + :param tax_amount_currency: the signed tax amount for the move in the move's currency + :param untaxed_amount: the signed untaxed amount for the move + :param untaxed_amount_currency: the signed untaxed amount for the move in the move's currency + :param sign: the sign of the move + :param cash_rounding: the cash rounding that should be applied (or None). + We assume that the input total in move currency (tax_amount_currency + untaxed_amount_currency) is already cash rounded. + The cash rounding does not change the totals: Consider the sum of all the computed payment term amounts in move / company currency. + It is the same as the input total in move / company currency. + """ + self.ensure_one() + company_currency = company.currency_id + total_amount = tax_amount + untaxed_amount + total_amount_currency = tax_amount_currency + untaxed_amount_currency + rate = abs(total_amount_currency / total_amount) if total_amount else 0.0 + + pay_term = { + 'total_amount': total_amount, + 'discount_percentage': self.discount_percentage if self.early_discount else 0.0, + 'discount_date': date_ref + relativedelta(days=(self.discount_days or 0)) if self.early_discount else False, + 'discount_balance': 0, + 'line_ids': [], + } + + if self.early_discount: + # Early discount is only available on single line, 100% payment terms. + discount_percentage = self.discount_percentage / 100.0 + if self.early_pay_discount_computation in ('excluded', 'mixed'): + pay_term['discount_balance'] = company_currency.round(total_amount - untaxed_amount * discount_percentage) + pay_term['discount_amount_currency'] = currency.round(total_amount_currency - untaxed_amount_currency * discount_percentage) + else: + pay_term['discount_balance'] = company_currency.round(total_amount * (1 - discount_percentage)) + pay_term['discount_amount_currency'] = currency.round(total_amount_currency * (1 - discount_percentage)) + + if cash_rounding: + cash_rounding_difference_currency = cash_rounding.compute_difference(currency, pay_term['discount_amount_currency']) + if not currency.is_zero(cash_rounding_difference_currency): + pay_term['discount_amount_currency'] += cash_rounding_difference_currency + pay_term['discount_balance'] = company_currency.round(pay_term['discount_amount_currency'] / rate) if rate else 0.0 + + residual_amount = total_amount + residual_amount_currency = total_amount_currency + + for i, line in enumerate(self.line_ids): + term_vals = { + 'date': line._get_due_date(date_ref), + 'company_amount': 0, + 'foreign_amount': 0, + } + + # The last line is always the balance, no matter the type + on_balance_line = i == len(self.line_ids) - 1 + if on_balance_line: + term_vals['company_amount'] = residual_amount + term_vals['foreign_amount'] = residual_amount_currency + elif line.value == 'fixed': + # Fixed amounts + term_vals['company_amount'] = sign * company_currency.round(line.value_amount / rate) if rate else 0.0 + term_vals['foreign_amount'] = sign * currency.round(line.value_amount) + else: + # Percentage amounts + line_amount = company_currency.round(total_amount * (line.value_amount / 100.0)) + line_amount_currency = currency.round(total_amount_currency * (line.value_amount / 100.0)) + term_vals['company_amount'] = line_amount + term_vals['foreign_amount'] = line_amount_currency + + if cash_rounding and not on_balance_line: + # The value `residual_amount_currency` is always cash rounded (in case of cash rounding). + # * We assume `total_amount_currency` is cash rounded. + # * We only subtract cash rounded amounts. + # Thus the balance line is cash rounded. + cash_rounding_difference_currency = cash_rounding.compute_difference(currency, term_vals['foreign_amount']) + if not currency.is_zero(cash_rounding_difference_currency): + term_vals['foreign_amount'] += cash_rounding_difference_currency + term_vals['company_amount'] = company_currency.round(term_vals['foreign_amount'] / rate) if rate else 0.0 + + residual_amount -= term_vals['company_amount'] + residual_amount_currency -= term_vals['foreign_amount'] + pay_term['line_ids'].append(term_vals) + + return pay_term + + @api.ondelete(at_uninstall=False) + def _unlink_except_referenced_terms(self): + if self.env['account.move'].search_count([('invoice_payment_term_id', 'in', self.ids)], limit=1): + raise UserError(_("Uh-oh! Those payment terms are quite popular and can't be deleted since there are still some records referencing them. How about archiving them instead?")) + + def _get_last_discount_date(self, date_ref): + self.ensure_one() + if not date_ref: + return None + return date_ref + relativedelta(days=self.discount_days or 0) if self.early_discount else False + + def _get_last_discount_date_formatted(self, date_ref): + self.ensure_one() + if not date_ref: + return None + return format_date(self.env, self._get_last_discount_date(date_ref)) + + def copy_data(self, default=None): + default = dict(default or {}) + vals_list = super().copy_data(default=default) + return [dict(vals, name=_("%s (copy)", line.name)) for line, vals in zip(self, vals_list)] + + +class AccountPaymentTermLine(models.Model): + _name = 'account.payment.term.line' + _description = "Payment Terms Line" + _order = "id" + + value = fields.Selection([ + ('percent', 'Percent'), + ('fixed', 'Fixed') + ], required=True, default='percent', + help="Select here the kind of valuation related to this payment terms line.") + value_amount = fields.Float(string='Due', digits='Payment Terms', + help="For percent enter a ratio between 0-100.", + compute='_compute_value_amount', store=True, readonly=False) + delay_type = fields.Selection([ + ('days_after', 'Days after invoice date'), + ('days_after_end_of_month', 'Days after end of month'), + ('days_after_end_of_next_month', 'Days after end of next month'), + ('days_end_of_month_on_the', 'Days end of month on the'), + ], required=True, default='days_after') + display_days_next_month = fields.Boolean(compute='_compute_display_days_next_month') + days_next_month = fields.Char( + string='Days on the next month', + readonly=False, + default='10', + size=2, + ) + nb_days = fields.Integer(string='Days', readonly=False, store=True, compute='_compute_days') + payment_id = fields.Many2one('account.payment.term', string='Payment Terms', required=True, index=True, ondelete='cascade') + + def _get_due_date(self, date_ref): + self.ensure_one() + due_date = fields.Date.from_string(date_ref) or fields.Date.today() + if self.delay_type == 'days_after_end_of_month': + return date_utils.end_of(due_date, 'month') + relativedelta(days=self.nb_days) + elif self.delay_type == 'days_after_end_of_next_month': + return date_utils.end_of(due_date + relativedelta(months=1), 'month') + relativedelta(days=self.nb_days) + elif self.delay_type == 'days_end_of_month_on_the': + try: + days_next_month = int(self.days_next_month) + except ValueError: + days_next_month = 1 + + if not days_next_month: + return date_utils.end_of(due_date + relativedelta(days=self.nb_days), 'month') + + return due_date + relativedelta(days=self.nb_days) + relativedelta(months=1, day=days_next_month) + return due_date + relativedelta(days=self.nb_days) + + @api.constrains('days_next_month') + def _check_valid_char_value(self): + for record in self: + if record.days_next_month and record.days_next_month.isnumeric(): + if not (0 <= int(record.days_next_month) <= 31): + raise ValidationError(_('The days added must be between 0 and 31.')) + else: + raise ValidationError(_('The days added must be a number and has to be between 0 and 31.')) + + @api.depends('delay_type') + def _compute_display_days_next_month(self): + for record in self: + record.display_days_next_month = record.delay_type == 'days_end_of_month_on_the' + + @api.constrains('value', 'value_amount') + def _check_percent(self): + for term_line in self: + if term_line.value == 'percent' and (term_line.value_amount < 0.0 or term_line.value_amount > 100.0): + raise ValidationError(_('Percentages on the Payment Terms lines must be between 0 and 100.')) + + @api.depends('payment_id') + def _compute_days(self): + for line in self: + #Line.payment_id.line_ids[-1] is the new line that has been just added when clicking "add a new line" + if not line.nb_days and len(line.payment_id.line_ids) > 1: + line.nb_days = line.payment_id.line_ids[-2].nb_days + 30 + else: + line.nb_days = line.nb_days + + @api.depends('payment_id') + def _compute_value_amount(self): + for line in self: + if line.value == 'fixed': + line.value_amount = 0 + else: + amount = 0 + for i in line.payment_id.line_ids.filtered(lambda r: r.value == 'percent'): + amount += i['value_amount'] + line.value_amount = 100 - amount diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index c295d64..19f99a6 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -1213,3 +1213,66 @@ isolation. The map's job is to keep them visible. IR-shape tests (effect-annotations-first-class, typed-signature, semantic- preservation) pass; the change is a declared-capability growth, not an IR reshape. + +- **D-KAUSAL-CONSUME-PIN-ODOO** — 2026-07-06 `[G]`: the OGAR-side + realization of the odoo-rs **AT-CONSUME** extension (W1 of the + odoo→odoo-rs transpile arc). The deprecated odoo-rs corpus witness is + replaced by **real, unmodified Odoo 19 source as the witness**: + `addons/account/models/account_payment_term.py` (AdaWorldAPI/odoo + `2c78d5f1`, byte-identical copy at + `crates/ogar-from-ruff/tests/py/account_payment_term.py` + PROVENANCE.md) + drives the already-shipped frontend end to end — + `ruff_python_spo::extract_from_source` → `compile_graph_python::` + → `lift_actions` — and `tests/odoo_kausal_parity_probe.rs` pins the + Arm-A kausal reproduction VERBATIM. Result: **8/8 `@api.depends` + compute methods** across the two declared models + (`account.payment.term` 5, `account.payment.term.line` 3) round-trip + to `ActionDef.kausal == Some(KausalSpec::Depends { paths })` with + exact dotted paths (incl. the 9-path `_compute_example_preview` and + its two co-computed fields collapsing to ONE ActionDef — one per + METHOD, not per field); **8 plain methods** assert `kausal == None` + (the facts-only guard, real-source witnessed). Pinned values are + hand-derived by READING the source, not by copying extractor output. + `ruff_python_spo` added as a **dev-dependency** floating on `main` + (D-NEVER-PIN-BUMP). **Scope (named, not hidden):** only Arm A + (`compute=` + `@api.depends`); arms B (`@api.constrains`/`@api.onchange`) + and D (`computed.stored`) stay out of scope pending ruff #49 — no + `ActionDef`/`Class` slot carries them yet, so a consume pin for them + would fail by design today. Both models are unaliased → bootstrap + facet classid `0` (asserted as fact; this pin is the DO-arm/kausal, + not classid convergence). Cross-ref D-ATC2-KAUSAL-AUTARK (the Arm-A + machinery this witnesses), D-PARITY-PROBE-WOA-1 (the synthetic-graph + sibling this upgrades to real source). + +- **D-V3-SINK-COMPILEDCLASS** — 2026-07-06 `[G]`: W2 of the odoo→odoo-rs + transpile arc — the storage seam that `D-EXEC-ONE-ACTION` named as the + gap ("Lance tombstone needs the lance-graph runtime"). A `CompiledClass` + now sinks onto the lance-graph **V3 SoA** byte surface via new module + `crates/ogar-from-ruff/src/lance_sink.rs`, behind feature `lance-sink` + (pulls the ZERO-DEP `lance-graph-contract` only — no lance engine, no + kv-lance, **no `*Bridge`**). Mirrors two shipped sinks — + `contract::network` (harvest → `FacetCascade`, content-blind, deferred + embedding) for shape, `symbiont::bridge` (`NodeRow` + + `NodeRowPacket::as_le_bytes`) for the zero-copy idiom. Three functions: + `compiled_class_to_facet` (a reinterpret no-op — `Facet::to_bytes` → + `FacetCascade::from_bytes`, byte-identical L1 **rails** `G6D2`), + `compiled_class_to_noderow` (render classid in key `[0..4)`, **bootstrap** + tail, `ValueSchema::Bootstrap` all-zero slab), `compiled_classes_to_le_bytes` + (the storage byte boundary). 7 tests incl. the field-isolation matrix + (T-F) + the layout-version fuse (T-G: `ENVELOPE_LAYOUT_VERSION == 2`, + W2 moves ZERO bytes at rest). Worked example `account.move → 0x0202_0002` + taken verbatim from the mint (never recomposed). **Dependency direction:** + the sink is OGAR-owned because `CompiledClass` is OGAR-owned (lance-graph + cannot dep OGAR); it needs only the zero-dep contract, so it is NOT the + `ogar-proposal` Sprint-5b `lance-bind` boundary (that heavier feature is + untouched). **Scope (named, not hidden):** stops at `as_le_bytes()` — the + actual `Dataset::write` I/O + kanban transition need the engine/ractor + runtime (out of scope). `[H1]` the rail-chain↔key-tail reconciliation is + frozen — the tail is bootstrapped, never derived from the rails + (test-enforced T-D). `[H2]` embedding the 12-byte rail payload into a + NodeRow value tenant needs a new append-only `ValueTenant` (envelope- + auditor-gated) — deferred, mirror network which added no lane. `[H3]` the + facet stays on the L1 rails plane; L6 "odoo ?" quads are NOT implemented + (semantics unruled). This is an offline BAKE (BOOTSTRAP-OK, envelope + owner 0), not an online write. Cross-ref D-EXEC-ONE-ACTION, + D-KAUSAL-CONSUME-PIN-ODOO, OGAR-TRANSPILE-SUBSTRATE (pull-back contract).