Skip to content

Commit 90b2b56

Browse files
committed
feat(ogar-vocab): add OdooPort commerce-arm curator + ERP time bridge
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.
1 parent b6a12a6 commit 90b2b56

1 file changed

Lines changed: 138 additions & 0 deletions

File tree

‎crates/ogar-vocab/src/ports.rs‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,65 @@ pub const HEALTHCARE_ALIASES: &[(&str, u16)] = &[
239239
("VitalSign", class_ids::VITAL_SIGN),
240240
];
241241

242+
// ── Odoo (odoo-rs) port ─────────────────────────────────────────────
243+
244+
/// Odoo's `PortSpec` — maps Odoo model names (`account.move`,
245+
/// `account.move.line`, `account.tax`, …) onto the canonical OGAR
246+
/// **commerce** codebook (`0x02XX`), plus the one cross-arm bridge to
247+
/// the project domain (`account.analytic.line` → `billable_work_entry`,
248+
/// `0x0103`).
249+
///
250+
/// This is the commerce-arm sibling of [`OpenProjectPort`] /
251+
/// [`RedminePort`] — Northstar plan §7 T10 ("convergence proof for the
252+
/// commerce arm … mirroring the project-mgmt arm, same shape, different
253+
/// domain"). Odoo's `account.*` ERP models are the first commerce
254+
/// curator on the codebook; a second (OSB) maps onto these same
255+
/// `class_ids::*` to complete the apple-meets-apple pin for commerce.
256+
///
257+
/// **Why this matters (severity).** `odoo-rs` currently lowers Odoo's
258+
/// ontology through a *bespoke* SurrealQL AST + triple pipeline that
259+
/// forks `op-surreal-ast` / `ogar-adapter-surrealql` and never touches
260+
/// `ogar-vocab`. OGAR exists precisely to own the AR-shaped Class / AST
261+
/// / ClassView surface; this port is the beachhead that lets `odoo-rs`
262+
/// converge onto the canonical layer (lower onto `ogar_vocab::Class`,
263+
/// emit via `ogar-adapter-surrealql`) instead of re-deriving it.
264+
pub struct OdooPort;
265+
266+
impl PortSpec for OdooPort {
267+
const NAMESPACE: &'static str = "Odoo";
268+
const BRIDGE_ID: &'static str = "odoo";
269+
fn aliases() -> &'static [(&'static str, u16)] {
270+
ODOO_ALIASES
271+
}
272+
}
273+
274+
/// Odoo model name → canonical class_id. Commerce-arm core
275+
/// (`account.*` + `res.*`) plus the `account.analytic.line` cross-arm
276+
/// bridge. `pub` for symmetry with the other `*_ALIASES`.
277+
///
278+
/// `account.move` is Odoo's journal-entry / invoice model — the posted
279+
/// commercial document (`od-ontology`'s "Slice 1" target). `sale.order`
280+
/// is the upstream quote/order shape; it also converges on
281+
/// `commercial_document` (many curator models → one canonical concept,
282+
/// same as OpenProject `Status` / Redmine `IssueStatus` → `project_status`).
283+
pub const ODOO_ALIASES: &[(&str, u16)] = &[
284+
// Commerce arm (0x02XX).
285+
("account.move", class_ids::COMMERCIAL_DOCUMENT),
286+
("sale.order", class_ids::COMMERCIAL_DOCUMENT),
287+
("account.move.line", class_ids::COMMERCIAL_LINE_ITEM),
288+
("sale.order.line", class_ids::COMMERCIAL_LINE_ITEM),
289+
("account.tax", class_ids::TAX_POLICY),
290+
("res.partner", class_ids::BILLING_PARTY),
291+
("account.payment", class_ids::PAYMENT_RECORD),
292+
("res.currency", class_ids::CURRENCY_POLICY),
293+
// Cross-arm bridge: the timesheet / cost line converges on the
294+
// project-arm `billable_work_entry` (0x0103) — the SAME id
295+
// OpenProject `TimeEntry` and Redmine `TimeEntry` resolve to.
296+
// See `billable_work_entry`'s doc: "OpenProject TimeEntry, Redmine
297+
// TimeEntry, Odoo account.analytic.line all converge here."
298+
("account.analytic.line", class_ids::BILLABLE_WORK_ENTRY),
299+
];
300+
242301
#[cfg(test)]
243302
mod tests {
244303
use super::*;
@@ -464,4 +523,83 @@ mod tests {
464523
Some(class_ids::PRIORITY)
465524
);
466525
}
526+
527+
// ── Odoo (commerce arm + the planning↔ERP bridge) ───────────────
528+
529+
#[test]
530+
fn odoo_namespace_and_bridge_id_match_canonical_strings() {
531+
assert_eq!(OdooPort::NAMESPACE, "Odoo");
532+
assert_eq!(OdooPort::BRIDGE_ID, "odoo");
533+
}
534+
535+
#[test]
536+
fn odoo_account_move_maps_to_commercial_document() {
537+
assert_eq!(
538+
OdooPort::class_id("account.move"),
539+
Some(class_ids::COMMERCIAL_DOCUMENT)
540+
);
541+
assert_eq!(OdooPort::class_id("account.move"), Some(0x0202));
542+
}
543+
544+
#[test]
545+
fn odoo_commerce_models_resolve_into_the_commerce_domain() {
546+
use crate::{canonical_concept_domain, ConceptDomain};
547+
// Every commerce-arm alias lands in the Commerce (0x02XX) domain.
548+
// `account.analytic.line` is the deliberate exception — it's the
549+
// cross-arm bridge into the project domain (asserted separately).
550+
for &(name, _) in OdooPort::aliases() {
551+
if name == "account.analytic.line" {
552+
continue;
553+
}
554+
let id = OdooPort::class_id(name)
555+
.unwrap_or_else(|| panic!("`{name}` must resolve"));
556+
assert_eq!(
557+
canonical_concept_domain(id),
558+
ConceptDomain::Commerce,
559+
"`{name}` -> 0x{id:04X} must live in the Commerce (0x02XX) domain",
560+
);
561+
}
562+
}
563+
564+
/// **The planning ↔ ERP convergence pin.** A logged unit of work is
565+
/// one canonical concept — `billable_work_entry` (0x0103) — whether
566+
/// it arrives as an OpenProject `TimeEntry`, a Redmine `TimeEntry`,
567+
/// or an Odoo `account.analytic.line` (the ERP timesheet/cost line).
568+
/// This is the codebook's named "first cross-domain bridge": the
569+
/// planning arm (work performed) and the commerce arm (work billed)
570+
/// meet here. Drift on any side splits a hour-logged-in-planning from
571+
/// the-same-hour-billed-in-ERP.
572+
#[test]
573+
fn planning_and_erp_converge_on_billable_work_entry() {
574+
let op = OpenProjectPort::class_id("TimeEntry");
575+
let rm = RedminePort::class_id("TimeEntry");
576+
let odoo = OdooPort::class_id("account.analytic.line");
577+
assert_eq!(op, Some(class_ids::BILLABLE_WORK_ENTRY));
578+
assert_eq!(rm, Some(class_ids::BILLABLE_WORK_ENTRY));
579+
assert_eq!(odoo, Some(class_ids::BILLABLE_WORK_ENTRY));
580+
assert_eq!(op, odoo, "OpenProject TimeEntry ↔ Odoo analytic line must converge");
581+
assert_eq!(rm, odoo, "Redmine TimeEntry ↔ Odoo analytic line must converge");
582+
assert_eq!(odoo, Some(0x0103));
583+
}
584+
585+
#[test]
586+
fn odoo_alias_count_is_stable() {
587+
// 9 Odoo model aliases = 8 commerce-arm (account.move,
588+
// sale.order, account.move.line, sale.order.line, account.tax,
589+
// res.partner, account.payment, res.currency) + 1 cross-arm
590+
// bridge (account.analytic.line → billable_work_entry).
591+
// Re-count on drift.
592+
assert_eq!(
593+
OdooPort::aliases().len(),
594+
9,
595+
"Odoo alias count drift — re-count the ODOO_ALIASES table",
596+
);
597+
}
598+
599+
#[test]
600+
fn odoo_unknown_model_names_resolve_to_none() {
601+
assert_eq!(OdooPort::class_id("ir.cron"), None);
602+
assert_eq!(OdooPort::class_id("WorkPackage"), None);
603+
assert_eq!(OdooPort::class_id(""), None);
604+
}
467605
}

0 commit comments

Comments
 (0)