|
| 1 | +//! SMB (small-and-medium-business German office ERP) tenant bridge — |
| 2 | +//! thin type alias over [`crate::bridges::unified::UnifiedBridge`] |
| 3 | +//! parameterised by [`ogar_vocab::ports::SmbPort`]. |
| 4 | +//! |
| 5 | +//! SMB's OGAR-driven port surface (OGAR PR #93, 2026-06-21). The legacy |
| 6 | +//! OGIT-side [`lance_graph_ontology::bridges::OgitBridge`] (pass-through |
| 7 | +//! for raw OGIT URIs) stays in place for tools that don't need codebook |
| 8 | +//! synthesis; this OGAR-side bridge gives smb-office-rs cross-fork |
| 9 | +//! convergence with WoA + OpenProject + Odoo on the canonical class_ids. |
| 10 | +//! |
| 11 | +//! # The convergence pin (operator value statement 2026-06-21) |
| 12 | +//! |
| 13 | +//! SMB's `Stundenzettel` / `TimeEntry` / `Zeiterfassung` resolve to |
| 14 | +//! [`ogar_vocab::class_ids::BILLABLE_WORK_ENTRY`] via this bridge's |
| 15 | +//! `entity()` codebook synthesis path — the SAME id WoA's Stundenzettel, |
| 16 | +//! OpenProject's TimeEntry, and Odoo's HrAttendance / account.move.line |
| 17 | +//! (qty=hours) resolve to. *"Planner times align with billable hours"* |
| 18 | +//! becomes a codebook lookup, not a translation layer. See |
| 19 | +//! `ogar_vocab::ports::tests::time_entry_converges_across_planner_and_erp_ports`. |
| 20 | +
|
| 21 | +use crate::bridges::unified::UnifiedBridge; |
| 22 | +use ogar_vocab::ports::PortSpec; |
| 23 | +pub use ogar_vocab::ports::SmbPort; |
| 24 | + |
| 25 | +/// SMB `NamespaceBridge` — alias over the generic harness, locked to |
| 26 | +/// the `SMB` namespace via [`SmbPort`]. |
| 27 | +pub type SmbBridge = UnifiedBridge<SmbPort>; |
| 28 | + |
| 29 | +/// Canonical namespace name for SMB. Mirrors `SmbPort::NAMESPACE`. |
| 30 | +pub const NAMESPACE: &str = SmbPort::NAMESPACE; |
| 31 | + |
| 32 | +#[cfg(test)] |
| 33 | +mod tests { |
| 34 | + use super::*; |
| 35 | + use lance_graph_ontology::bridge::{BridgeError, NamespaceBridge}; |
| 36 | + use lance_graph_ontology::error::Error; |
| 37 | + use lance_graph_ontology::namespace::NamespaceId; |
| 38 | + use lance_graph_ontology::registry::OntologyRegistry; |
| 39 | + use ogar_vocab::class_ids; |
| 40 | + use ogar_vocab::ports::PortSpec; |
| 41 | + use std::fs; |
| 42 | + use std::sync::Arc; |
| 43 | + |
| 44 | + fn registry_with_smb() -> Arc<OntologyRegistry> { |
| 45 | + let ttl = r#" |
| 46 | +@prefix ogit: <http://www.purl.org/ogit/> . |
| 47 | +@prefix ogit.SMB: <http://www.purl.org/ogit/SMB/> . |
| 48 | +@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> . |
| 49 | +
|
| 50 | +ogit.SMB:Kunde |
| 51 | + a rdfs:Class; |
| 52 | + rdfs:subClassOf ogit:Entity; |
| 53 | + rdfs:label "Kunde"; |
| 54 | + ogit:scope "NTO"; |
| 55 | + ogit:parent ogit:Node; |
| 56 | + ogit:mandatory-attributes ( ogit:id ); |
| 57 | + ogit:optional-attributes ( ) ; |
| 58 | +. |
| 59 | +"#; |
| 60 | + let tmp = tempfile::tempdir().unwrap(); |
| 61 | + fs::create_dir_all(tmp.path().join("SMB")).unwrap(); |
| 62 | + fs::write(tmp.path().join("SMB").join("ents.ttl"), ttl).unwrap(); |
| 63 | + let registry = Arc::new(OntologyRegistry::new_in_memory()); |
| 64 | + registry.hydrate_once_sync(tmp.path(), &["SMB"]).unwrap(); |
| 65 | + registry |
| 66 | + } |
| 67 | + |
| 68 | + #[test] |
| 69 | + fn new_succeeds_when_namespace_registered() { |
| 70 | + let registry = registry_with_smb(); |
| 71 | + let bridge = SmbBridge::new(registry).unwrap(); |
| 72 | + assert_ne!(bridge.g_lock(), NamespaceId::UNKNOWN); |
| 73 | + } |
| 74 | + |
| 75 | + #[test] |
| 76 | + fn new_returns_unknown_namespace_when_not_registered() { |
| 77 | + let registry = Arc::new(OntologyRegistry::new_in_memory()); |
| 78 | + match SmbBridge::new(registry) { |
| 79 | + Ok(_) => panic!("expected UnknownNamespace, got Ok(_)"), |
| 80 | + Err(Error::UnknownNamespace(name)) => assert_eq!(name, "SMB"), |
| 81 | + Err(other) => panic!("expected UnknownNamespace, got {other:?}"), |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + #[test] |
| 86 | + fn bridge_id_is_lowercase_smb() { |
| 87 | + let bridge = SmbBridge::new(registry_with_smb()).unwrap(); |
| 88 | + assert_eq!(bridge.bridge_id(), "smb"); |
| 89 | + } |
| 90 | + |
| 91 | + #[test] |
| 92 | + fn entity_resolves_kunde_to_canonical_billing_party_class_id() { |
| 93 | + let bridge = SmbBridge::new(registry_with_smb()).unwrap(); |
| 94 | + let entity = bridge.entity("Kunde").unwrap(); |
| 95 | + assert_eq!(entity.schema_ptr.entity_type_id(), class_ids::BILLING_PARTY); |
| 96 | + assert_eq!(entity.schema_ptr.entity_type_id(), 0x0204); |
| 97 | + } |
| 98 | + |
| 99 | + #[test] |
| 100 | + fn entity_resolves_stundenzettel_to_billable_work_entry_planner_convergence() { |
| 101 | + let bridge = SmbBridge::new(registry_with_smb()).unwrap(); |
| 102 | + for public_name in ["Stundenzettel", "TimeEntry", "Zeiterfassung"] { |
| 103 | + let entity = bridge |
| 104 | + .entity(public_name) |
| 105 | + .unwrap_or_else(|e| panic!("{public_name}: {e:?}")); |
| 106 | + assert_eq!( |
| 107 | + entity.schema_ptr.entity_type_id(), |
| 108 | + class_ids::BILLABLE_WORK_ENTRY, |
| 109 | + "SMB `{public_name}` must resolve to BILLABLE_WORK_ENTRY (planner-ERP convergence)", |
| 110 | + ); |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + #[test] |
| 115 | + fn entity_for_each_codebook_entry_returns_its_canonical_class_id() { |
| 116 | + let bridge = SmbBridge::new(registry_with_smb()).unwrap(); |
| 117 | + for &(public_name, expected_id) in SmbPort::aliases() { |
| 118 | + let entity = bridge.entity(public_name).unwrap_or_else(|e| { |
| 119 | + panic!("codebook entry `{public_name}` failed to resolve: {e:?}") |
| 120 | + }); |
| 121 | + assert_eq!( |
| 122 | + entity.schema_ptr.entity_type_id(), |
| 123 | + expected_id, |
| 124 | + "codebook entry `{public_name}` should resolve to 0x{expected_id:04X}", |
| 125 | + ); |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + #[test] |
| 130 | + fn entity_for_non_codebook_name_falls_back_to_registry_lookup() { |
| 131 | + let bridge = SmbBridge::new(registry_with_smb()).unwrap(); |
| 132 | + match bridge.entity("Artikel") { |
| 133 | + // Artikel/Product/SKU isn't in the codebook yet (intentional — |
| 134 | + // needs a `0x02XX` codebook extension). Until then it falls |
| 135 | + // through to the registry-resolution path which returns |
| 136 | + // NotInScope because the TTL fixture only hydrates `Kunde`. |
| 137 | + Err(BridgeError::NotInScope { public_name, .. }) => { |
| 138 | + assert_eq!(public_name, "Artikel") |
| 139 | + } |
| 140 | + other => panic!("expected NotInScope, got {other:?}"), |
| 141 | + } |
| 142 | + } |
| 143 | +} |
0 commit comments