From 2fc96e5cdcbcef6a4427da8642bf165798637f3a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 10:03:30 +0000 Subject: [PATCH] feat(ogar-vocab): add OdooPort commerce-arm curator + ERP time bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OdooPort: PortSpec (NAMESPACE="Odoo", BRIDGE_ID="odoo") + ODOO_ALIASES map Odoo model names onto the canonical OGAR codebook, making Odoo a first-class codebook curator -- the commerce-arm sibling of OpenProjectPort / RedminePort (Northstar §7 T10, "same shape, different domain"). Commerce arm (0x02XX): account.move, sale.order -> commercial_document (0x0202) account.move.line, sale.order.line -> commercial_line_item (0x0201) account.tax -> tax_policy (0x0203) res.partner -> billing_party (0x0204) account.payment -> payment_record (0x0205) res.currency -> currency_policy (0x0206) Cross-arm bridge (the headline): account.analytic.line -> billable_work_entry (0x0103) That last row is the planning<->ERP convergence pin: an Odoo analytic (timesheet/cost) line resolves to the SAME canonical concept as an OpenProject TimeEntry and a Redmine TimeEntry. A logged planning hour and the billed ERP line are one concept, so the planner's hours and the ERP's billable hours align by construction. 6 tests: namespace/bridge_id, account.move -> commercial_document, every commerce alias in the Commerce (0x02XX) domain, the planning<->ERP convergence (op == rm == odoo == 0x0103), alias-count stability, unknown name -> None. Beachhead for converging odoo-rs onto the canonical layer: odoo-rs today lowers its ontology through a bespoke SurrealQL AST + triple pipeline that never touches ogar-vocab. OdooPort lets it adopt ogar_vocab::Class + ogar-adapter-surrealql instead of re-deriving the AR-shaped surface. cargo check --workspace --all-targets + cargo test --workspace clean. --- crates/ogar-vocab/src/ports.rs | 144 +++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/crates/ogar-vocab/src/ports.rs b/crates/ogar-vocab/src/ports.rs index 9473166..5572b6b 100644 --- a/crates/ogar-vocab/src/ports.rs +++ b/crates/ogar-vocab/src/ports.rs @@ -393,6 +393,65 @@ pub const SMB_ALIASES: &[(&str, u16)] = &[ ("Zeiterfassung", class_ids::BILLABLE_WORK_ENTRY), ]; +// ── Odoo (odoo-rs) port ───────────────────────────────────────────── + +/// Odoo's `PortSpec` — maps Odoo model names (`account.move`, +/// `account.move.line`, `account.tax`, …) onto the canonical OGAR +/// **commerce** codebook (`0x02XX`), plus the one cross-arm bridge to +/// the project domain (`account.analytic.line` → `billable_work_entry`, +/// `0x0103`). +/// +/// This is the commerce-arm sibling of [`OpenProjectPort`] / +/// [`RedminePort`] — Northstar plan §7 T10 ("convergence proof for the +/// commerce arm … mirroring the project-mgmt arm, same shape, different +/// domain"). Odoo's `account.*` ERP models are the first commerce +/// curator on the codebook; a second (OSB) maps onto these same +/// `class_ids::*` to complete the apple-meets-apple pin for commerce. +/// +/// **Why this matters (severity).** `odoo-rs` currently lowers Odoo's +/// ontology through a *bespoke* SurrealQL AST + triple pipeline that +/// forks `op-surreal-ast` / `ogar-adapter-surrealql` and never touches +/// `ogar-vocab`. OGAR exists precisely to own the AR-shaped Class / AST +/// / ClassView surface; this port is the beachhead that lets `odoo-rs` +/// converge onto the canonical layer (lower onto `ogar_vocab::Class`, +/// emit via `ogar-adapter-surrealql`) instead of re-deriving it. +pub struct OdooPort; + +impl PortSpec for OdooPort { + const NAMESPACE: &'static str = "Odoo"; + const BRIDGE_ID: &'static str = "odoo"; + fn aliases() -> &'static [(&'static str, u16)] { + ODOO_ALIASES + } +} + +/// Odoo model name → canonical class_id. Commerce-arm core +/// (`account.*` + `res.*`) plus the `account.analytic.line` cross-arm +/// bridge. `pub` for symmetry with the other `*_ALIASES`. +/// +/// `account.move` is Odoo's journal-entry / invoice model — the posted +/// commercial document (`od-ontology`'s "Slice 1" target). `sale.order` +/// is the upstream quote/order shape; it also converges on +/// `commercial_document` (many curator models → one canonical concept, +/// same as OpenProject `Status` / Redmine `IssueStatus` → `project_status`). +pub const ODOO_ALIASES: &[(&str, u16)] = &[ + // Commerce arm (0x02XX). + ("account.move", class_ids::COMMERCIAL_DOCUMENT), + ("sale.order", class_ids::COMMERCIAL_DOCUMENT), + ("account.move.line", class_ids::COMMERCIAL_LINE_ITEM), + ("sale.order.line", class_ids::COMMERCIAL_LINE_ITEM), + ("account.tax", class_ids::TAX_POLICY), + ("res.partner", class_ids::BILLING_PARTY), + ("account.payment", class_ids::PAYMENT_RECORD), + ("res.currency", class_ids::CURRENCY_POLICY), + // Cross-arm bridge: the timesheet / cost line converges on the + // project-arm `billable_work_entry` (0x0103) — the SAME id + // OpenProject `TimeEntry` and Redmine `TimeEntry` resolve to. + // See `billable_work_entry`'s doc: "OpenProject TimeEntry, Redmine + // TimeEntry, Odoo account.analytic.line all converge here." + ("account.analytic.line", class_ids::BILLABLE_WORK_ENTRY), +]; + #[cfg(test)] mod tests { use super::*; @@ -583,6 +642,12 @@ mod tests { "SmbPort alias `{name}` -> 0x{id:04X} not in class_ids::ALL" ); } + for &(name, id) in OdooPort::aliases() { + assert!( + codebook_ids.contains(&id), + "OdooPort alias `{name}` -> 0x{id:04X} not in class_ids::ALL" + ); + } } #[test] @@ -780,4 +845,83 @@ mod tests { Some(class_ids::PRIORITY) ); } + + // ── Odoo (commerce arm + the planning↔ERP bridge) ─────────────── + + #[test] + fn odoo_namespace_and_bridge_id_match_canonical_strings() { + assert_eq!(OdooPort::NAMESPACE, "Odoo"); + assert_eq!(OdooPort::BRIDGE_ID, "odoo"); + } + + #[test] + fn odoo_account_move_maps_to_commercial_document() { + assert_eq!( + OdooPort::class_id("account.move"), + Some(class_ids::COMMERCIAL_DOCUMENT) + ); + assert_eq!(OdooPort::class_id("account.move"), Some(0x0202)); + } + + #[test] + fn odoo_commerce_models_resolve_into_the_commerce_domain() { + use crate::{canonical_concept_domain, ConceptDomain}; + // Every commerce-arm alias lands in the Commerce (0x02XX) domain. + // `account.analytic.line` is the deliberate exception — it's the + // cross-arm bridge into the project domain (asserted separately). + for &(name, _) in OdooPort::aliases() { + if name == "account.analytic.line" { + continue; + } + let id = OdooPort::class_id(name) + .unwrap_or_else(|| panic!("`{name}` must resolve")); + assert_eq!( + canonical_concept_domain(id), + ConceptDomain::Commerce, + "`{name}` -> 0x{id:04X} must live in the Commerce (0x02XX) domain", + ); + } + } + + /// **The planning ↔ ERP convergence pin.** A logged unit of work is + /// one canonical concept — `billable_work_entry` (0x0103) — whether + /// it arrives as an OpenProject `TimeEntry`, a Redmine `TimeEntry`, + /// or an Odoo `account.analytic.line` (the ERP timesheet/cost line). + /// This is the codebook's named "first cross-domain bridge": the + /// planning arm (work performed) and the commerce arm (work billed) + /// meet here. Drift on any side splits a hour-logged-in-planning from + /// the-same-hour-billed-in-ERP. + #[test] + fn planning_and_erp_converge_on_billable_work_entry() { + let op = OpenProjectPort::class_id("TimeEntry"); + let rm = RedminePort::class_id("TimeEntry"); + let odoo = OdooPort::class_id("account.analytic.line"); + assert_eq!(op, Some(class_ids::BILLABLE_WORK_ENTRY)); + assert_eq!(rm, Some(class_ids::BILLABLE_WORK_ENTRY)); + assert_eq!(odoo, Some(class_ids::BILLABLE_WORK_ENTRY)); + assert_eq!(op, odoo, "OpenProject TimeEntry ↔ Odoo analytic line must converge"); + assert_eq!(rm, odoo, "Redmine TimeEntry ↔ Odoo analytic line must converge"); + assert_eq!(odoo, Some(0x0103)); + } + + #[test] + fn odoo_alias_count_is_stable() { + // 9 Odoo model aliases = 8 commerce-arm (account.move, + // sale.order, account.move.line, sale.order.line, account.tax, + // res.partner, account.payment, res.currency) + 1 cross-arm + // bridge (account.analytic.line → billable_work_entry). + // Re-count on drift. + assert_eq!( + OdooPort::aliases().len(), + 9, + "Odoo alias count drift — re-count the ODOO_ALIASES table", + ); + } + + #[test] + fn odoo_unknown_model_names_resolve_to_none() { + assert_eq!(OdooPort::class_id("ir.cron"), None); + assert_eq!(OdooPort::class_id("WorkPackage"), None); + assert_eq!(OdooPort::class_id(""), None); + } }