diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 81c4c4d..178686d 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -7,6 +7,27 @@ --- +## 2026-07-06 — E-BEHAVIOR-AT-COMPILE-TIME — behavior facts are substrate, not runtime beiwerk + +**Status:** FRAMING (`[G]` for the effect-annotation facts now wired; `[H]` for source-body lowering). + +Operator §3: the sink-in GUID substrate is compile-time-available INCLUDING behavior. Realised minimally in commit `789c7ed` — ruff's `Function.reads/writes/calls` now ride `ActionDef` at lift time, the IR surface signed off against OGAR-AS-IR §3 (test 2). Provenance is kept honest rather than flattened: **writes/calls are authoritative** (the extractor sees assignment and call targets directly), **reads are inferred** (name references, an over-approximation); the annotation records which is which, so no downstream consumer mistakes an inferred read for a proven effect, and no reactive `kausal` is fabricated from a plain read. The remaining `[H]` is source-body lowering (`body_source`, params, value-carrying `on_enter`), gated on a named `ruff_spo_triplet::Function` extension (SPEC-1 Part A — a follow-up). Cross-ref D-BEHAVIOR-ACTION-EDGES, E-OGAR-CONVERGENCE-SHAPE behavior layer ("[G] facts / [H] lowering"). + +--- + +## 2026-07-06 — E-ONE-MASK-TWO-ENGINES — one ClassView×FieldMask projection, askama and jinja agree + +**Status:** `[G]` on the pinned round-trip; `[H]`→retires the render layer's falsifier #2 for the ≤64-field case. + +Falsifier #2 (the classview-mask round-trip named in E-OGAR-CONVERGENCE-SHAPE) RAN and is GREEN: a single fixture's `(fields, mask)` at **mask=45** yields the identical field-name set through askama (Rust) and jinja2 (Python) — the dual-target render Operator §5 asks for, one projection driving both engines rather than two hand-kept templates. Two honesties are pinned alongside the green: (1) the `>64`-field case is **loud-failed** (`RenderError::TooManyFieldsForMask`, D-FIELDMASK-LOUD-FAIL), never silently round-tripped — a loud-fail guard replaced the old field-64+ silent drop; (2) the FULL sentinel **aliases** any genuine all-ones mask at the u64 tier, so "render everything" and "explicitly select all 64" are indistinguishable there — documented as a caveat, and the reason the widening (D-FIELDMASK-WIDENING) carries a canonical form. Companion to E-ONE-MASK-THREE-PORTS; realises Operator §5 (dual-target render) + §6 (identical data model both languages). Cross-ref D-FIELDMASK-LOUD-FAIL, D-FIELDMASK-WIDENING. + +--- + +## 2026-07-06 — E-PYTHON-SUBSTRATE-MIRROR — the emit_python @dataclass IS the Python mirror of CompiledClass + +**Status:** FRAMING (`[G]` for the py_compile+instantiate gate on the synthetic WoA class; `[H]` for the persistence adapter). + +Operator §6: identical data model in both languages via Classes/ClassView, never a parallel schema truth. Realised: `emit_python` renders the same `CompiledClass` the Rust emitters render, and the emitted `TimesheetActivity` `@dataclass` both **py_compiles and instantiates** (D-PARITY-PROBE-WOA-1). Two findings sharpen the mirror rather than decorate it: the class does not sit on bootstrap classid 0 as the spec assumed — it converges through `WOA_ALIASES` onto `BILLABLE_WORK_ENTRY` (`0x0103_0003`), pinned by test, so the "Python mirror" is an *addressed* mirror, not an anonymous one; and `emit_python` still omits `options.required`, a named emitter follow-up (the mirror is structurally 1:1 on fields / types / associations, honestly short one nullability annotation). The DB format (Operator §4) stays a documented decision — Option C now (dataclass mirror), Option B (SQLAlchemy/SQLite reusable adapter) and the PG facet table (D-PY-PERSIST-PG-FACET) next, Option A (lancedb bindings) as the native substrate. Cross-ref D-PARITY-PROBE-WOA-1, D-PY-PERSIST-PG-FACET, E-ONE-MASK-TWO-ENGINES. ## 2026-07-05 — E-RECIPE-FAMILIES-MINT-ON-EMIT — Scope-kind / Concern-kind are RESERVED (bytes 0x05 / 0x06) but minted ON EMIT, not speculatively **Status:** FINDING (operator rule, 2026-07-05 — verbatim: *"add scope-kind concern-kind when you see the code wants to emit it (eg ruff dto ast for duplicated routes, or god object split)"*). Recorded in `recipe.rs` module doc (§ Reserved-but-unminted families); no `RecipeFamily` variant, no concept, no codebook row minted — bytes `0x05`/`0x06` resolve to `RecipeFamily::Unassigned` until the emit seam exists. diff --git a/.claude/harvest/woa-rs/HARVEST.md b/.claude/harvest/woa-rs/HARVEST.md new file mode 100644 index 0000000..1e3b073 --- /dev/null +++ b/.claude/harvest/woa-rs/HARVEST.md @@ -0,0 +1,186 @@ +# HARVEST — WoA sink-in substrate v1 + +`WoA` (Flask-SQLAlchemy, `/home/user/WoA/models.py`, READ-ONLY corpus) transcoded +through the ruff → OGAR pipeline into the V3 transpile substrate. Produced for +MISSION "OGAR V3: Criticals testen + Python-Arm real machen (WoA 1:1)" as the +concrete SPEC-5 Part B/C/D reality-check artifact set. + +## Pipeline (real code path, nothing hand-written) + +``` +/home/user/WoA/models.py + │ ruff_sqlalchemy_spo::extract_file (frontend, /tmp/wt-gr crates/ruff_sqlalchemy_spo, + │ branch claude/spo-python-main) + ▼ +ruff_spo_triplet::ModelGraph + │ ogar-from-ruff::sqlalchemy::lift_model_graph_sqlalchemy + ▼ +Vec (schema stratum: attributes / associations) + │ ogar-from-ruff::mint::compile_graph_sqlalchemy:: + ▼ +Vec (Class + 16-byte rail Facet, classid via WoaPort) + │ ogar-from-ruff::emit::emit_python -> models.py + │ (association walk, classid-annotated) -> woa_graph.spo + │ ogar-adapter-postgres-ddl::emit_facet_table_ddl + emit_postgres_ddl -> woa_facet.sql + ▼ +this directory +``` + +Driver: a scratch Cargo binary at `/tmp/harvest-woa` (NOT committed anywhere — +path-deps + a local `[patch]` block only, per the mission's leitplanken). See +"Reproduction" below for the exact commands to regenerate. + +## Metrics + +| Metric | Value | +|---|---| +| `db.Model` classes harvested | **139** (`ruff_sqlalchemy_spo` corpus-gate test asserts `>= 139`; WoA's own `grep -c 'class.*db.Model'` on `models.py` also gives 139 — exact match, no discrepancy) | +| Attributes (`Attribute` after FK-dedup) | **1961** | +| Associations (`db.relationship`, `BelongsTo`/`HasMany`) | **107** | +| Aliased (resolve through `WOA_ALIASES` convergence pins, concept ≠ 0) | **6** | +| Bootstrap (unmapped model name → classid `0x0000_0003`, concept = 0) | **133** | + +### The 6 aliased classes (WOA_ALIASES convergence pins, `ogar-vocab/src/ports.rs`) + +| Class | classid | concept | Same concept converges with | +|---|---|---|---| +| `TimesheetActivity` | `0x0103_0003` | `0x0103` (`BILLABLE_WORK_ENTRY`) | `OpenProjectPort::TimeEntry`, `RedminePort::TimeEntry` | +| `Customer` | `0x0204_0003` | `0x0204` | (CRM-concept alias) | +| `WorkOrder` | `0x0202_0003` | `0x0202` | (matches Odoo's `account.move`-adjacent concept family — same concept id Odoo's `sale.order`/`account.move` land in) | +| `Position` | `0x0201_0003` | `0x0201` | | +| `RecurringInvoice` | `0x0202_0003` | `0x0202` | same concept as `WorkOrder` — both are "billing document" shaped | +| `TaxRate` | `0x0203_0003` | `0x0203` | | + +The remaining 133 models mint the **bootstrap** classid `0x0000_0003` +(concept `0x0000`, WoA app prefix `0x0003`) — `WOA_ALIASES` is a small, +deliberately curated table (per `ports.rs` docs, a handful of named +convergence pins), not a name-for-name mirror of all 139 WoA models. This is +the honest, measured state (matches `sqlalchemy.rs`'s own doc comment on +`compile_graph_sqlalchemy`, which names this exact caveat for +`TimesheetActivity`) — no attempt was made to mint new aliases (mint +governance: no new Codebook concepts without an Operator ruling). + +### Convergence spot-check (mission-mandated) + +``` +TimesheetActivity -> classid 0x01030003 (concept 0x0103, app 0x0003) +``` + +Matches the fixture in `ogar-from-ruff/src/mint.rs`'s own doc comment on +`compile_graph_sqlalchemy` and its test +`compile_graph_sqlalchemy_timesheet_activity_converges_via_alias_not_bootstrap` +byte-for-byte. `TimesheetActivity`'s harvested shape also matches WoA source +`models.py:1746-1753` exactly: `id` (required), `beschreibung` (required), +`created_at` (nullable — no `nullable=False` in source), `timesheet_id` +FK-shadowed by the `timesheet: ToOne["TimeSheet"]` association (not +double-projected). + +## Gates run + +- `python3 -m py_compile models.py` → **PASS** (module parses; see "Known + gaps" below for what this gate does NOT prove). +- `cargo test -p ruff_sqlalchemy_spo` corpus gate (`WOA_SRC=/home/user/WoA/models.py`) + already asserts `>= 139` models upstream in the frontend crate itself (not + re-run here — this harvest's own driver run independently reproduces the + same 139). + +## Reproduction + +```sh +# 1. Scratch driver (path deps + local-only [patch], never committed): +mkdir -p /tmp/harvest-woa/src +cat > /tmp/harvest-woa/Cargo.toml <<'TOML' +[package] +name = "harvest-woa" +version = "0.1.0" +edition = "2021" +publish = false +[[bin]] +name = "harvest-woa" +path = "src/main.rs" +[dependencies] +ruff_sqlalchemy_spo = { path = "/tmp/wt-gr/crates/ruff_sqlalchemy_spo" } +ogar-from-ruff = { path = "/workspace/ogar/crates/ogar-from-ruff" } +ogar-vocab = { path = "/workspace/ogar/crates/ogar-vocab" } +ogar-adapter-postgres-ddl = { path = "/workspace/ogar/crates/ogar-adapter-postgres-ddl" } +[patch."https://github.com/AdaWorldAPI/ruff"] +ruff_spo_triplet = { path = "/tmp/wt-gr/crates/ruff_spo_triplet" } +ruff_spo_address = { path = "/tmp/wt-gr/crates/ruff_spo_address" } +TOML +cp /workspace/ogar/rust-toolchain.toml /tmp/harvest-woa/rust-toolchain.toml # pins 1.95.0 +# (src/main.rs: extract_file -> compile_graph_sqlalchemy:: -> emit_python +# + a classid-keyed association walk for woa_graph.spo + the postgres-ddl adapter +# calls for woa_facet.sql — see this harvest's provenance pins below for the +# exact commit this was run against.) + +cd /tmp/harvest-woa +cargo run --quiet -- /home/user/WoA/models.py /tmp/harvest-woa/out +python3 -m py_compile /tmp/harvest-woa/out/models.py +``` + +## Provenance + +| Input | Pin | +|---|---| +| WoA (`AdaWorldAPI/WoA`, `models.py`, READ-ONLY) | `4427b3d715d841290ea9108e3bf94b22d0cb72c2` (last commit touching `models.py`) | +| ruff (`AdaWorldAPI/ruff`, `ruff_sqlalchemy_spo`) | `/tmp/wt-gr`, branch `claude/spo-python-main`, HEAD `66db5c417eddf6017e924706031a23b019c17e81` | +| OGAR (`ogar-from-ruff`, `ogar-vocab`, `ogar-adapter-postgres-ddl`) | `/workspace/ogar`, branch `claude/v3-criticals-woa-parity`, HEAD `d5479ce382136b3aedad3c122e13d3aab6f9695e` | + +## Known gaps (honest, not papered over) + +1. **Behavior = names only.** `Model::functions` (methods) are harvested + name-only by `ruff_sqlalchemy_spo::functions` (v0, per its module doc) — + no params, no `reads`/`writes`, no `body_source`. `emit_python` therefore + emits **zero** methods; the generated dataclasses are schema-only mirrors + of WoA's `db.Model` classes, not behavior-complete ports. This is the same + gap the mission's Critical #1 (`behavior`) names for `lift_actions` + generally (`ogar-from-ruff/src/lib.rs:495`) — this SQLAlchemy harvest + doesn't lift `ActionDef`/`KausalSpec` at all (v0 doesn't wire it), so the + gap is total rather than partial here. +2. **`woa/models_shop.py` (12 more `db.Model` classes) is out of scope for + this v1.** Only the monolithic `/home/user/WoA/models.py` (139 classes) + was harvested, per the mission's explicit instruction. Follow-up: run + `ruff_sqlalchemy_spo::extract_file` a second time against + `woa/models_shop.py` (or `extract_with` over the `woa/` tree) and merge + the two `ModelGraph`s before minting, so shop-domain FKs into the core 139 + resolve rather than dangling. +3. **`emit_python`'s import list is incomplete — `models.py` does not + `import` (nor is `ogar_sdk.py` shipped alongside).** The header only + imports `OgScalar, ToOne, ToMany` from `ogar_sdk` (mirroring the PR #154 + Python-emit precedent's own header), but `emit_python`'s + `og_scalar_type()` mapping emits bare names — `OgInt`, `OgStr`, `OgBool`, + `OgDateTime`, `OgFloat`, `OgMoney`, `OgDate`, `OgBytes`, `OgSelection`, + `OgJson` — that are **never imported**. `python3 -m py_compile` still + passes because compiling to bytecode does not evaluate the class-body + annotation expressions at import time in a way that raises — it is a + syntax-level gate only, not proof the module is importable/runnable. + Actually running `import models` would raise `NameError` on the first + annotated attribute. This is `ogar-from-ruff::emit_python`'s existing gap + (not introduced by this harvest), reported per the mission's instruction + to name the "emit_python required-Lücke" honestly rather than + hand-patching the generated output. +4. **`woa_graph.spo` target-classid resolution: measured, not assumed.** An + association's target (`Association::class_name`, e.g. `TimeSheet` for + `TimesheetActivity.timesheet`) is looked up by name against the 139 + harvested+minted classes; a miss (renamed target, target lives in + `models_shop.py`, or a `class_name` string that doesn't match any + harvested class) is written as `[--------]` rather than silently guessed. + Measured over the actual 107-edge output: **0 of 107 edges are + `[--------]`** — every association target in `models.py` resolves within + the same 139-class corpus (the file is self-contained; nothing points + into the excluded `models_shop.py`). This is a stronger result than + expected going in and is called out explicitly so it isn't mistaken for + an unverified assumption. +5. **`PostgreSQL` DDL is the relational (`emit_postgres_ddl`) shape, not yet + wired to the facet table.** `woa_facet.sql` emits the two pieces + side-by-side (facet table via `emit_facet_table_ddl("facet")`, then one + `CREATE TABLE` per class via `emit_postgres_ddl`) but does **not** attempt + the dual-write / parity-checker wiring the Operator's ruling (b, refined) + describes — that is explicitly a "follow-up, NOT built here" per + `ogar-adapter-postgres-ddl`'s own module doc. +6. **Falsifizierer #1 (WP-Parity)-style field-by-field diff against the WoA + source was not run for all 139 classes** — only the `TimesheetActivity` + fixture (§ "Convergence spot-check" above) was checked field-for-field + against source. A full N/N-columns-typed parity metric (mission's + D-PARITY-PROBE-style measure) across all 139 classes is a reasonable + next step but out of scope for this harvest pass. diff --git a/.claude/harvest/woa-rs/PARKED.md b/.claude/harvest/woa-rs/PARKED.md new file mode 100644 index 0000000..80e1187 --- /dev/null +++ b/.claude/harvest/woa-rs/PARKED.md @@ -0,0 +1,49 @@ +# PARKED — belongs in `AdaWorldAPI/woa-rs` + +This directory is the ruff → OGAR transcode of `AdaWorldAPI/WoA`'s +Flask-SQLAlchemy `models.py` (139 `db.Model` classes) into the OGAR V3 +sink-in substrate. Its real home is `AdaWorldAPI/woa-rs` (the Rust port +repo this session's WoA-side context — `/home/user/WoA/.claude/CLAUDE.md` +§4 "Zwei-Welten-Pattern" — names as the sibling repo, read-only from the +WoA checkout: `../woa-rs/`). + +It is parked here **only** because `woa-rs` is not in this session's write +scope — this OGAR session has no clone/checkout of `AdaWorldAPI/woa-rs` and +no registered write access to it (same shape of gap `openstreetmap-website-rs` +hit in OGAR PR #152: the target repo isn't reachable/writable from this +session, so the artifacts are committed here — an in-scope, writable repo — +as the durable save, rather than lost to an ephemeral scratch directory). + +## Relocation (when `woa-rs` is in scope) + +```sh +git clone /AdaWorldAPI/woa-rs /tmp/woa-rs +cp -r .claude/harvest/woa-rs/. /tmp/woa-rs// # excluding this PARKED.md +cd /tmp/woa-rs && git add -A && git commit && git push +``` + +The natural landing spot is a `python/` (or `generated/`) subtree mirroring +the `osm-website-rs` precedent's `python/osm/models.py` placement — consult +`woa-rs`'s own `CLAUDE.md`/`board/Stand.md` (per `WoA`'s `CLAUDE.md` §2 +"Im Rust-Port arbeitest" row) for its actual layout convention before +copying in, since this session cannot read that repo to confirm. + +## What's in this harvest + +See `HARVEST.md` (same directory) for the full metrics, provenance, gates, +and — importantly — the honestly-documented gaps (behavior is names-only, +`models_shop.py`'s 12 classes are out of scope, `emit_python`'s import list +is incomplete, and others). Read `HARVEST.md` before relying on `models.py` +here as anything more than a structural (schema-stratum) reality check. + +## Provenance + +| Input | Pin | +|---|---| +| WoA (`AdaWorldAPI/WoA`, `models.py`, READ-ONLY) | `4427b3d715d841290ea9108e3bf94b22d0cb72c2` | +| ruff (`AdaWorldAPI/ruff`, `ruff_sqlalchemy_spo`) | `/tmp/wt-gr`, branch `claude/spo-python-main`, `66db5c417eddf6017e924706031a23b019c17e81` | +| OGAR (`ogar-from-ruff` / `ogar-vocab` / `ogar-adapter-postgres-ddl`) | `/workspace/ogar`, branch `claude/v3-criticals-woa-parity`, `d5479ce382136b3aedad3c122e13d3aab6f9695e` | + +Regenerate with the scratch driver documented in `HARVEST.md`'s +"Reproduction" section (not itself committed anywhere, per the mission's +leitplanken — path-deps + a local-only `[patch]` block). diff --git a/.claude/harvest/woa-rs/models.py b/.claude/harvest/woa-rs/models.py new file mode 100644 index 0000000..5a179ad --- /dev/null +++ b/.claude/harvest/woa-rs/models.py @@ -0,0 +1,2779 @@ +"""WoA sink-in substrate v1 — generated, do not hand-edit. + +Source: /home/user/WoA/models.py (READ-ONLY corpus, 139 `db.Model` classes harvested) +Pipeline: ruff_sqlalchemy_spo::extract_file -> ogar-from-ruff::lift_model_graph_sqlalchemy + -> ogar-from-ruff::mint::compile_graph_sqlalchemy:: + -> ogar-from-ruff::emit::emit_python +Metrics: 139 classes, 1961 attributes, 107 associations, 6 aliased (WOA_ALIASES convergence pin) / 133 bootstrap (classid 0x0000_0003). +TimesheetActivity -> classid 0x01030003 (concept 0x0103, app 0x0003) +""" + +from dataclasses import dataclass +from typing import ClassVar + +from ogar_sdk import OgScalar, ToOne, ToMany + + +@dataclass +class Tenant: + """Rail class `Tenant` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + name: OgStr + slug: OgStr + max_users: OgInt + aktiv: OgBool + logo_path: OgStr + logo_mail_path: OgStr + created_at: OgDateTime + branche: OgStr + is_test: OgBool + is_anbieter: OgBool + mod_tresor: OgBool + mod_stundenzettel: OgBool + mod_multitimer: OgBool + mod_fahrtenbuch: OgBool + mod_wiedervorlage: OgBool + mod_notizbuch: OgBool + mod_wartung: OgBool + mod_abo: OgBool + mod_inventar: OgBool + mod_abnahme: OgBool + mod_gobd: OgBool + mod_referral: OgBool + mod_kaltakquise: OgBool + mod_woa_service: OgBool + mod_erp: OgBool + mod_dms: OgBool + mod_rustdesk: OgBool + mod_rustdesk_server: OgBool + erp_gobd_festschreibung: OgBool + erp_mod_stammdaten: OgBool + erp_mod_fibu: OgBool + erp_mod_bank: OgBool + erp_mod_steuer: OgBool + erp_mod_lager: OgBool + erp_mod_dms: OgBool + erp_mod_reporting: OgBool + erp_mod_pos: OgBool + erp_mod_zugferd: OgBool + erp_mod_lohn: OgBool + erp_mod_shop: OgBool + erp_mod_crm: OgBool + erp_mod_stammdaten_gesperrt: OgBool + erp_mod_fibu_gesperrt: OgBool + erp_mod_bank_gesperrt: OgBool + erp_mod_steuer_gesperrt: OgBool + erp_mod_lager_gesperrt: OgBool + erp_mod_dms_gesperrt: OgBool + erp_mod_reporting_gesperrt: OgBool + erp_mod_pos_gesperrt: OgBool + erp_mod_zugferd_gesperrt: OgBool + erp_mod_crm_gesperrt: OgBool + erp_mod_lohn_gesperrt: OgBool + erp_mod_shop_gesperrt: OgBool + +@dataclass +class User: + """Rail class `User` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + username: OgStr + password_hash: OgStr + firstname: OgStr + lastname: OgStr + email: OgStr + phone: OgStr + ma_rabatt: OgFloat + is_admin: OgBool + is_superadmin: OgBool + perm_dashboard_umsaetze: OgBool + perm_buchhaltung: OgBool + perm_statistik: OgBool + perm_einstellungen: OgBool + perm_dms: OgBool + perm_serverschutz: OgBool + perm_erp_stammdaten: OgBool + perm_erp_buchen: OgBool + perm_erp_debitoren: OgBool + perm_erp_kreditoren: OgBool + perm_erp_bank: OgBool + perm_erp_kasse: OgBool + perm_erp_steuer: OgBool + perm_erp_abschluss: OgBool + perm_erp_anlagen: OgBool + perm_erp_lager: OgBool + perm_erp_einkauf: OgBool + perm_erp_dms: OgBool + perm_erp_compliance: OgBool + perm_erp_lohn: OgBool + failed_attempts: OgInt + locked_until: OgDateTime + scan_import_mode: OgStr + scan_import_username: OgStr + scan_import_pw_set_at: OgDateTime + scan_import_host: OgStr + scan_import_port: OgInt + vpn_enabled: OgBool + vpn_ip: OgStr + vpn_pubkey: OgStr + vpn_created_at: OgDateTime + samba_enabled: OgBool + samba_pw_set_at: OgDateTime + created_at: OgDateTime + tenant: ToOne["Tenant"] + tresor_customer: ToOne["Customer"] + +@dataclass +class Customer: + """Rail class `Customer` — classid 0x02040003 (concept 0x0204, app 0x0003).""" + CLASSID: ClassVar[int] = 0x02040003 + id: OgInt + tenant_id: OgInt + kdnr: OgStr + quick_token: OgStr + quick_token_ts: OgStr + firma: OgStr + anrede: OgStr + vorname: OgStr + nachname: OgStr + mail_anrede: OgStr + strasse: OgStr + adresszusatz: OgStr + plz: OgStr + ort: OgStr + email: OgStr + telefon: OgStr + tresor_pw_hash: OgStr + tresor_pw_set_at: OgDateTime + tresor_pw_failed: OgInt + tresor_pw_locked_until: OgDateTime + zahlungsziel: OgInt + skonto_prozent: OgFloat + skonto_tage: OgInt + stundensatz: OgFloat + fahrt_km: OgFloat + fahrt_kosten: OgFloat + notizen: OgStr + aktiv: OgBool + kundentyp: OgStr + referral_code: OgStr + sepa_iban: OgStr + sepa_bic: OgStr + sepa_kontoinhaber: OgStr + sepa_mandat_ref: OgStr + sepa_mandat_datum: OgDate + sepa_mandat_typ: OgStr + sepa_mandat_status: OgStr + sepa_letzte_lastschrift: OgDate + sepa_pre_notification_tage: OgInt + preis_gruppe_id: OgInt + created_at: OgDateTime + workorders: ToMany["WorkOrder"] + +@dataclass +class Project: + """Rail class `Project` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + projekt_nr: OgStr + beschreibung: OgStr + status: OgStr + sort_order: OgInt + erstellt_am: OgDateTime + erstellt_von_id: OgInt + abgeschlossen_am: OgDateTime + customer: ToOne["Customer"] + notes: ToOne["ProjectNote"] + +@dataclass +class ProjectNote: + """Rail class `ProjectNote` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + titel: OgStr + inhalt_text: OgScalar + inhalt_zeichnung: OgScalar + erstellt_am: OgDateTime + erstellt_von_id: OgInt + project: ToOne["Project"] + +@dataclass +class ErpArticleCategory: + """Rail class `ErpArticleCategory` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + icon: OgStr + sort_order: OgInt + created_at: OgDateTime + parent: ToMany["ErpArticleCategory"] + +@dataclass +class ErpStorageLocation: + """Rail class `ErpStorageLocation` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + kuerzel: OgStr + icon: OgStr + beschreibung: OgStr + sort_order: OgInt + aktiv: OgBool + created_at: OgDateTime + parent: ToMany["ErpStorageLocation"] + +@dataclass +class Article: + """Rail class `Article` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + artikelnr: OgStr + ean: OgStr + beschreibung: OgStr + kategorie: OgStr + category_id: OgInt + storage_location_id: OgInt + einheit: OgStr + hersteller: OgStr + hersteller_anr: OgStr + bild_url: OgStr + bestand: OgFloat + mindestbestand: OgFloat + preis_netto: OgMoney + ek_preis: OgMoney + mwst_satz: OgFloat + tax_rate_id: OgInt + lieferant: OgStr + lieferant_anr: OgStr + notizen: OgStr + typ: OgStr + aktiv: OgBool + dauer_minuten: OgInt + preis_schema_id: OgInt + vk_preis_manuell: OgBool + listenpreis: OgMoney + uvp: OgMoney + gewicht_kg: OgMoney + herkunftsland: OgStr + zolltarifnr: OgStr + langbeschreibung: OgStr + matchcode: OgStr + warengruppe: OgStr + warengruppe_nr: OgStr + gefahrgut: OgBool + gefahrgut_un_nr: OgStr + gefahrgut_klasse: OgStr + auslaufartikel: OgBool + auslaufdatum: OgDate + deeplink: OgStr + shop_active: OgBool + shop_product_type: OgStr + shop_category_id: OgInt + shop_long_desc_html: OgStr + shop_saas_meta_json: OgScalar + shop_saas_package_id: OgInt + shop_digital_path: OgStr + shop_meta_title: OgStr + shop_meta_description: OgStr + shop_slug: OgStr + shop_payment_methods_csv: OgStr + shop_free_shipping: OgBool + shop_extra_user_price_cents: OgInt + shop_addon_article_ids: OgScalar + shop_included_users: OgInt + omd_tax_code: OgInt + +@dataclass +class WorkOrder: + """Rail class `WorkOrder` — classid 0x02020003 (concept 0x0202, app 0x0003).""" + CLASSID: ClassVar[int] = 0x02020003 + id: OgInt + tenant_id: OgInt + customer_id: OgInt + created_by: OgInt + doc_type: OgStr + status: OgStr + angebot_nr: OgStr + auftrags_nr: OgStr + workorder_nr: OgStr + rechnung_nr: OgStr + gutschrift_nr: OgStr + sammelrechnung_id: OgInt + datum: OgDate + zeit_start: OgStr + zeit_ende: OgStr + anfahrten: OgFloat + mitarbeiter: OgFloat + pause_h: OgFloat + zusatz_h: OgFloat + betreff: OgStr + notizen: OgStr + intern_notizen: OgStr + bezahlt: OgBool + bezahlt_am: OgDate + bezahlt_betrag: OgMoney + mahnstufe: OgInt + letzte_mahnung: OgDate + erfuellung_bis: OgDate + unterschrift: OgStr + signed_at: OgDateTime + signed_ip: OgStr + signed_user_agent: OgStr + zahlungsart: OgStr + anzahlung_prozent: OgFloat + anzahlung_betrag: OgFloat + anzahlung_bezahlt: OgBool + anzahlung_bezahlt_am: OgDate + kleinunternehmer_snapshot: OgBool + zahlungsziel_tage_snapshot: OgInt + gesamt_rabatt_prozent: OgFloat + gesamt_rabatt_betrag: OgFloat + skonto_prozent_snapshot: OgFloat + skonto_tage_snapshot: OgInt + skonto_ausweisen: OgBool + skonto_aufschlag: OgBool + skonto_aufschlag_faktor: OgFloat + created_at: OgDateTime + updated_at: OgDateTime + project: ToOne["Project"] + positionen: ToMany["Position"] + activities: ToMany["Activity"] + pictures: ToMany["Picture"] + history: ToMany["HistoryEntry"] + acceptance_protocols: ToOne["AcceptanceProtocol"] + child_workorders: ToMany["WorkOrder"] + +@dataclass +class Position: + """Rail class `Position` — classid 0x02010003 (concept 0x0201, app 0x0003).""" + CLASSID: ClassVar[int] = 0x02010003 + id: OgInt + workorder_id: OgInt + article_id: OgInt + sort_order: OgInt + pos_typ: OgStr + beschreibung: OgStr + menge: OgFloat + einheit: OgStr + einzelpreis: OgMoney + mwst_satz: OgFloat + tax_rate_id: OgInt + versteckt: OgBool + is_optional: OgBool + customer_accepted_at: OgDateTime + rabatt_prozent: OgFloat + einzelpreis_vor_skonto: OgMoney + +@dataclass +class Activity: + """Rail class `Activity` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + workorder_id: OgInt + geraet: OgStr + beschreibung: OgStr + logbuch: OgBool + intern: OgBool + created_at: OgDateTime + acceptance_items: ToOne["AcceptanceItem"] + +@dataclass +class Picture: + """Rail class `Picture` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + workorder_id: OgInt + dateiname: OgStr + beschreibung: OgStr + logbuch: OgBool + an_kunde: OgBool + created_at: OgDateTime + +@dataclass +class Document: + """Rail class `Document` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + dateiname: OgStr + original_name: OgStr + beschreibung: OgStr + mime_type: OgStr + size_bytes: OgInt + an_kunde: OgBool + uploaded_by: OgInt + uploaded_at: OgDateTime + customer: ToOne["Customer"] + workorder: ToOne["WorkOrder"] + +@dataclass +class AcceptanceProtocol: + """Rail class `AcceptanceProtocol` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + sequenz_nr: OgInt + vorgaenger_id: OgInt + aktiv: OgBool + gesamt_abgenommen: OgBool + abnahme_datum: OgDate + abnahme_ort: OgStr + bemerkungen: OgStr + nachbesserungstermin: OgDate + unterschrift_kunde: OgStr + unterschrieben_am: OgDateTime + unterschrieben_von: OgStr + erstellt_am: OgDateTime + erstellt_von_id: OgInt + workorder: ToOne["WorkOrder"] + items: ToOne["AcceptanceItem"] + defects: ToOne["AcceptanceDefect"] + +@dataclass +class AcceptanceItem: + """Rail class `AcceptanceItem` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + abgenommen: OgBool + bemerkung: OgStr + bezeichnung: OgStr + status: OgStr + sort_order: OgInt + protocol: ToOne["AcceptanceProtocol"] + activity: ToOne["Activity"] + +@dataclass +class AcceptanceDefect: + """Rail class `AcceptanceDefect` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + beschreibung: OgStr + erfasst_am: OgDateTime + nachbesserung_bis: OgDate + behoben: OgBool + behoben_am: OgDateTime + intern_status: OgStr + intern_status_am: OgDateTime + protocol: ToOne["AcceptanceProtocol"] + +@dataclass +class AcceptanceTemplate: + """Rail class `AcceptanceTemplate` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + is_default: OgBool + aktiv: OgBool + erstellt_am: OgDateTime + items: ToOne["AcceptanceTemplateItem"] + +@dataclass +class AcceptanceTemplateItem: + """Rail class `AcceptanceTemplateItem` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + bezeichnung: OgStr + sort_order: OgInt + template: ToOne["AcceptanceTemplate"] + +@dataclass +class HistoryEntry: + """Rail class `HistoryEntry` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + workorder_id: OgInt + aktion: OgStr + details: OgStr + created_at: OgDateTime + user: ToOne["User"] + +@dataclass +class LogbookEntry: + """Rail class `LogbookEntry` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + workorder_id: OgInt + datum: OgDate + abfahrt: OgStr + ankunft: OgStr + rueckfahrt: OgStr + zurueck: OgStr + start_km: OgFloat + ende_km: OgFloat + route: OgStr + zweck: OgStr + fahrzeug: OgStr + privat_anteil: OgFloat + created_at: OgDateTime + user: ToOne["User"] + customer: ToOne["Customer"] + +@dataclass +class NumberSequence: + """Rail class `NumberSequence` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + prefix: OgStr + current: OgInt + padding: OgInt + +@dataclass +class Setting: + """Rail class `Setting` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + key: OgStr + value: OgStr + label: OgStr + override_master: OgInt + +@dataclass +class CustomerPortalUser: + """Rail class `CustomerPortalUser` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + username: OgStr + password_hash: OgStr + aktiv: OgBool + must_change_pw: OgBool + last_login: OgDateTime + created_at: OgDateTime + failed_attempts: OgInt + locked_until: OgDateTime + customer: ToOne["Customer"] + +@dataclass +class PasswordEntry: + """Rail class `PasswordEntry` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + created_by: OgInt + gruppe: OgStr + titel: OgStr + benutzername: OgStr + passwort_enc: OgStr + url: OgStr + notizen_enc: OgStr + icon: OgStr + aktiv: OgBool + keepass_uid: OgStr + created_at: OgDateTime + updated_at: OgDateTime + customer: ToOne["Customer"] + creator: ToOne["User"] + +@dataclass +class TimeSheet: + """Rail class `TimeSheet` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + source: OgStr + datum: OgDate + minuten: OgInt + erfasst_von: OgStr + startzeit: OgScalar + endzeit: OgScalar + beschreibung: OgStr + timer_start: OgDateTime + timer_paused_at: OgDateTime + abgerechnet: OgBool + created_at: OgDateTime + updated_at: OgDateTime + customer: ToOne["Customer"] + user: ToOne["User"] + +@dataclass +class TaxReserve: + """Rail class `TaxReserve` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + jahr: OgInt + monat: OgInt + quartal: OgInt + typ: OgStr + erledigt: OgBool + +@dataclass +class TimesheetActivity: + """Rail class `TimesheetActivity` — classid 0x01030003 (concept 0x0103, app 0x0003).""" + CLASSID: ClassVar[int] = 0x01030003 + id: OgInt + beschreibung: OgStr + created_at: OgDateTime + timesheet: ToOne["TimeSheet"] + +@dataclass +class Reminder: + """Rail class `Reminder` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + titel: OgStr + beschreibung: OgStr + faellig_am: OgDate + prioritaet: OgStr + erledigt: OgBool + erledigt_am: OgDateTime + erstellt_am: OgDateTime + zeit_von: OgStr + zeit_bis: OgStr + termin_typ: OgStr + wiederkehrend: OgBool + intervall: OgStr + intervall_tage: OgStr + intervall_tag: OgInt + intervall_monat: OgInt + intervall_alle: OgInt + token_cancel: OgStr + cancelled_at: OgDateTime + cancelled_ip: OgStr + crm_lead_id: OgInt + crm_contact_id: OgInt + crm_company_id: OgInt + crm_task_id: OgInt + project: ToOne["Project"] + user: ToOne["User"] + customer: ToOne["Customer"] + workorder: ToOne["WorkOrder"] + +@dataclass +class MaintenanceContract: + """Rail class `MaintenanceContract` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + titel: OgStr + beschreibung: OgStr + intervall: OgStr + preis_netto: OgFloat + mwst_satz: OgFloat + beginn: OgDate + ende: OgDate + letzte_wartung: OgDate + naechste_wartung: OgDate + auto_rechnung: OgBool + aktiv: OgBool + notizen: OgStr + created_at: OgDateTime + customer: ToMany["Customer"] + +@dataclass +class RecurringInvoice: + """Rail class `RecurringInvoice` — classid 0x02020003 (concept 0x0202, app 0x0003).""" + CLASSID: ClassVar[int] = 0x02020003 + id: OgInt + tenant_id: OgInt + titel: OgStr + beschreibung: OgStr + intervall: OgStr + preis_netto: OgFloat + mwst_satz: OgFloat + naechste_ausfuehrung: OgDate + letzte_ausfuehrung: OgDate + auto_versand: OgBool + aktiv: OgBool + notizen: OgStr + created_at: OgDateTime + customer: ToOne["Customer"] + contract: ToOne["MaintenanceContract"] + +@dataclass +class Device: + """Rail class `Device` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + kategorie: OgStr + hersteller: OgStr + modell: OgStr + seriennummer: OgStr + hostname: OgStr + ip_adresse: OgStr + mac_adresse: OgStr + standort: OgStr + kaufdatum: OgDate + garantie_bis: OgDate + firmware: OgStr + zugangsdaten: OgStr + notizen: OgStr + letzte_wartung: OgDate + status: OgStr + aktiv: OgBool + created_at: OgDateTime + updated_at: OgDateTime + customer: ToMany["Customer"] + +@dataclass +class KummerkastenEntry: + """Rail class `KummerkastenEntry` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + source: OgStr + typ: OgStr + titel: OgStr + beschreibung: OgStr + prioritaet: OgStr + status: OgStr + admin_kommentar: OgStr + erstellt_am: OgDateTime + aktualisiert_am: OgDateTime + user: ToOne["User"] + tenant: ToOne["Tenant"] + customer: ToOne["Customer"] + +@dataclass +class PortalViewState: + """Rail class `PortalViewState` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + customer_id: OgInt + kind: OgStr + last_seen_at: OgDateTime + +@dataclass +class ReferralLog: + """Rail class `ReferralLog` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + referral_code: OgStr + empfaenger_email: OgStr + empfaenger_name: OgStr + versendet_am: OgDateTime + versendet_von: OgInt + status: OgStr + converted_at: OgDateTime + converted_manually: OgBool + converted_note: OgStr + proposed_by: OgInt + proposed_at: OgDateTime + converted_tenant_id: OgInt + customer: ToOne["Customer"] + tenant: ToOne["Tenant"] + +@dataclass +class ColdLead: + """Rail class `ColdLead` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + firma: OgStr + ansprechpartner: OgStr + branche: OgStr + strasse: OgStr + plz: OgStr + ort: OgStr + land: OgStr + telefon: OgStr + mobil: OgStr + email: OgStr + webseite: OgStr + quelle: OgStr + briefanrede: OgStr + mitarbeiterzahl: OgStr + notiz_kurz: OgStr + newsletter_sperre: OgBool + newsletter_sperre_grund: OgStr + newsletter_sperre_am: OgDateTime + source_customer_id: OgInt + status: OgStr + wiedervorlage_am: OgDate + notizen: OgStr + converted_customer_id: OgInt + converted_at: OgDateTime + created_at: OgDateTime + created_by: OgInt + updated_at: OgDateTime + activities: ToMany["ColdLeadActivity"] + +@dataclass +class ColdLeadActivity: + """Rail class `ColdLeadActivity` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + lead_id: OgInt + typ: OgStr + text: OgStr + mail_subject: OgStr + mail_to: OgStr + status_from: OgStr + status_to: OgStr + created_at: OgDateTime + created_by: OgInt + +@dataclass +class ColdCampaign: + """Rail class `ColdCampaign` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + beschreibung: OgStr + status: OgStr + mail_subject: OgStr + mail_template_html: OgStr + created_at: OgDateTime + created_by: OgInt + updated_at: OgDateTime + leads: ToMany["ColdCampaignLead"] + +@dataclass +class ColdCampaignLead: + """Rail class `ColdCampaignLead` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + campaign_id: OgInt + sent_at: OgDateTime + sent_status: OgStr + sent_error: OgStr + added_at: OgDateTime + added_by: OgInt + lead: ToOne["ColdLead"] + +@dataclass +class ServicePackage: + """Rail class `ServicePackage` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + beschreibung: OgStr + rechnungs_titel: OgStr + rechnungs_text: OgStr + preis_monthly: OgFloat + preis_quarterly: OgFloat + preis_half_yearly: OgFloat + preis_yearly: OgFloat + mwst_satz: OgFloat + free_months_default: OgInt + aktiv: OgBool + sort_order: OgInt + created_at: OgDateTime + with_mail_templates: OgBool + with_demo_data: OgBool + required_server_type: OgStr + +@dataclass +class RentedServer: + """Rail class `RentedServer` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + provider: OgStr + hostname: OgStr + ip_address: OgStr + woa_url: OgStr + dns_eintrag: OgStr + ssh_user: OgStr + ssh_port: OgInt + miete_netto: OgFloat + miete_intervall: OgStr + ssh_password_enc: OgStr + root_password_enc: OgStr + notizen_enc: OgStr + master_api_url: OgStr + master_api_token: OgStr + verify_ssl: OgBool + erp_ocr_token: OgStr + last_sync_at: OgDateTime + last_sync_status: OgStr + last_sync_message: OgStr + last_software_version: OgStr + last_health_at: OgDateTime + last_health_payload: OgStr + sa_username: OgStr + sa_password_enc: OgStr + installed_at: OgDateTime + tenant_slug_remote: OgStr + is_master: OgBool + aktiv: OgBool + created_at: OgDateTime + server_type: OgStr + max_tenants: OgInt + tenant_count: OgInt + tenant_count_at: OgDateTime + push_failure_count: OgInt + push_last_failure_at: OgDateTime + push_last_error: OgStr + push_disabled: OgBool + push_disabled_at: OgDateTime + +@dataclass +class ServiceContract: + """Rail class `ServiceContract` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + intervall: OgStr + vertrag_start: OgDate + rechnung_ab: OgDate + naechste_rechnung: OgDate + individualpreis_netto: OgFloat + rabatt_prozent: OgFloat + free_months_remaining: OgInt + credit_eur: OgFloat + bonus_pending_eur: OgFloat + sales_partner_id: OgInt + provision_pct: OgFloat + commission_until: OgDate + auto_versand: OgBool + aktiv: OgBool + gekuendigt_am: OgDate + geloescht_am: OgDateTime + notizen: OgStr + created_at: OgDateTime + last_invoice_at: OgDateTime + last_invoice_workorder_id: OgInt + shop_saas_config_id: OgInt + prorata_pending: OgBool + customer: ToOne["Customer"] + package: ToOne["ServicePackage"] + server: ToOne["RentedServer"] + +@dataclass +class SalesPartner: + """Rail class `SalesPartner` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + tier: OgStr + provision_pct: OgFloat + aktiv: OgBool + firma: OgStr + ansprechpartner: OgStr + email: OgStr + telefon: OgStr + is_service_techniker: OgBool + strasse: OgStr + plz: OgStr + ort: OgStr + ustid: OgStr + steuer_status: OgStr + iban: OgStr + bic: OgStr + bank_name: OgStr + commission_months: OgInt + aktiv_ab: OgDate + aktiv_bis: OgDate + notizen: OgStr + vertrag_pdf_path: OgStr + vertrag_versendet_am: OgDateTime + vertrag_versand_method: OgStr + vertrag_signatur_token: OgStr + vertrag_signiertes_pdf_path: OgStr + vertrag_signiert_am: OgDateTime + vertrag_signatur_data: OgStr + vertrag_signatur_ip: OgStr + vertrag_signatur_user_agent: OgStr + created_at: OgDateTime + updated_at: OgDateTime + +@dataclass +class PartnerCommission: + """Rail class `PartnerCommission` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + payout_id: OgInt + tenant_id: OgInt + basis_netto: OgFloat + provision_pct: OgFloat + betrag_netto: OgFloat + status: OgStr + pending_at: OgDateTime + earned_at: OgDateTime + paid_at: OgDate + paid_workorder_id: OgInt + cancelled_at: OgDateTime + cancelled_reason: OgStr + notes: OgStr + created_at: OgDateTime + partner: ToOne["SalesPartner"] + contract: ToOne["ServiceContract"] + source_workorder: ToOne["WorkOrder"] + +@dataclass +class PartnerPayout: + """Rail class `PartnerPayout` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + payout_nr: OgStr + tenant_id: OgInt + paid_at: OgDate + summe_netto: OgFloat + summe_ust: OgFloat + summe_brutto: OgFloat + ust_satz: OgFloat + steuer_status_snapshot: OgStr + note: OgStr + pdf_path: OgStr + pdf_generated_at: OgDateTime + mail_sent_at: OgDateTime + mail_sent_to: OgStr + created_by: OgInt + created_at: OgDateTime + partner: ToOne["SalesPartner"] + commissions: ToMany["PartnerCommission"] + +@dataclass +class ContractSetup: + """Rail class `ContractSetup` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + setup_phase: OgStr + partner_decision: OgStr + progress_pct: OgInt + subdomain: OgStr + server_ip: OgStr + dns_recipient: OgStr + dns_bcc: OgStr + dns_mail_sent_at: OgDateTime + dns_confirmed_at: OgDateTime + dns_confirmed_by: OgInt + server_ready_at: OgDateTime + server_ready_by: OgInt + server_notes: OgStr + ssh_host: OgStr + ssh_port: OgInt + ssh_user: OgStr + package_uploaded_at: OgDateTime + package_path_remote: OgStr + package_filename: OgStr + target_tenant_id: OgInt + target_tenant_name: OgStr + target_tenant_slug: OgStr + target_branche: OgStr + welcome_recipient: OgStr + welcome_sent_at: OgDateTime + admin_username: OgStr + onboarding_recipient: OgStr + onboarding_bcc: OgStr + onboarding_sent_at: OgDateTime + onboarding_csv_attached: OgBool + created_at: OgDateTime + updated_at: OgDateTime + contract: ToMany["ServiceContract"] + +@dataclass +class ContractSetupHistory: + """Rail class `ContractSetupHistory` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + contract_id: OgInt + action: OgStr + phase_from: OgStr + phase_to: OgStr + user_id: OgInt + notes: OgStr + created_at: OgDateTime + +@dataclass +class AppVersion: + """Rail class `AppVersion` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + version: OgStr + notes: OgStr + set_by: OgInt + set_at: OgDateTime + is_current: OgBool + change_pdf_path: OgStr + change_pdf_sha256: OgStr + change_pdf_size: OgInt + audit_detail_pdf_path: OgStr + audit_detail_pdf_sha256: OgStr + audit_detail_pdf_size: OgInt + audit_layperson_pdf_path: OgStr + audit_layperson_pdf_sha256: OgStr + audit_layperson_pdf_size: OgInt + audit_run_id: OgInt + +@dataclass +class IpBlacklist: + """Rail class `IpBlacklist` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + ip: OgStr + grund: OgStr + aktiv: OgBool + auto: OgBool + expires_at: OgDateTime + created_at: OgDateTime + created_by: OgInt + last_hit_at: OgDateTime + hit_count: OgInt + safe_marked_by: OgInt + safe_marked_at: OgDateTime + safe_reason: OgStr + origin_server: OgStr + creator: ToOne["User"] + safe_marker: ToOne["User"] + +@dataclass +class LoginAudit: + """Rail class `LoginAudit` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + username: OgStr + ip: OgStr + user_agent: OgStr + success: OgBool + reason: OgStr + created_at: OgDateTime + +@dataclass +class ScopeAuditBlock: + """Rail class `ScopeAuditBlock` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + created_at: OgDateTime + actor_user_id: OgInt + actor_username: OgStr + actor_tenant_id: OgInt + actor_is_admin: OgBool + actor_is_superadmin: OgBool + target_model: OgStr + target_id: OgInt + target_tenant_id: OgInt + route: OgStr + method: OgStr + reason: OgStr + ip: OgStr + user_agent: OgStr + +@dataclass +class SecurityAudit: + """Rail class `SecurityAudit` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + app_version: OgStr + status: OgStr + report_mode: OgStr + audited_by: OgInt + audited_by_name: OgStr + audited_at: OgDateTime + approved_by: OgInt + approved_by_name: OgStr + approved_at: OgDateTime + results_json: OgStr + overall_status: OgStr + pass_count: OgInt + fail_count: OgInt + warn_count: OgInt + skip_count: OgInt + auditor_notes: OgStr + version_history_json: OgStr + signature_hash: OgStr + signed_at: OgDateTime + pdf_path: OgStr + pdf_sha256: OgStr + pdf_size: OgInt + sa_notified_at: OgDateTime + created_at: OgDateTime + auditor: ToOne["User"] + approver: ToOne["User"] + +@dataclass +class SyncHistory: + """Rail class `SyncHistory` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + rented_server_id: OgInt + started_at: OgDateTime + finished_at: OgDateTime + action: OgStr + status: OgStr + files_changed: OgInt + backup_path: OgStr + error_message: OgStr + log_excerpt: OgStr + triggered_by: OgInt + server: ToOne["RentedServer"] + +@dataclass +class UpdateJob: + """Rail class `UpdateJob` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + status: OgStr + started_at: OgDateTime + finished_at: OgDateTime + last_heartbeat: OgDateTime + current_step: OgStr + progress_percent: OgInt + log_excerpt: OgStr + error_message: OgStr + triggered_by: OgInt + cancel_requested: OgBool + server: ToOne["RentedServer"] + +@dataclass +class UpdateSnapshot: + """Rail class `UpdateSnapshot` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + snapshot_file: OgStr + snapshot_size: OgInt + version_before: OgStr + version_after: OgStr + status: OgStr + log_excerpt: OgStr + created_at: OgDateTime + restored_at: OgDateTime + rollback_note: OgStr + +@dataclass +class TerminVorschlag: + """Rail class `TerminVorschlag` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + project_id: OgInt + defect_id: OgInt + titel: OgStr + beschreibung: OgStr + termin_typ: OgStr + vorschlaege_json: OgStr + status: OgStr + accepted_index: OgInt + accepted_reminder_id: OgInt + accepted_at: OgDateTime + accepted_ip: OgStr + token_slot1: OgStr + token_slot2: OgStr + token_slot3: OgStr + token_decline: OgStr + token_cancel: OgStr + cancelled_at: OgDateTime + cancelled_ip: OgStr + expires_at: OgDateTime + erstellt_am: OgDateTime + updated_at: OgDateTime + customer: ToOne["Customer"] + user: ToOne["User"] + workorder: ToOne["WorkOrder"] + reminder: ToOne["Reminder"] + +@dataclass +class TerminVorschlagBlocker: + """Rail class `TerminVorschlagBlocker` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + slot_index: OgInt + datum: OgDate + zeit_von: OgStr + zeit_bis: OgStr + caldav_uid: OgStr + pushed_at: OgDateTime + deleted_at: OgDateTime + erstellt_am: OgDateTime + tv: ToOne["TerminVorschlag"] + +@dataclass +class HandbookFeature: + """Rail class `HandbookFeature` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + titel: OgStr + beschreibung: OgStr + kategorie: OgStr + version: OgStr + reihenfolge: OgInt + aktiv: OgBool + datum: OgDate + created_at: OgDateTime + updated_at: OgDateTime + +@dataclass +class BrancheTemplate: + """Rail class `BrancheTemplate` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + branche: OgStr + label: OgStr + html_content: OgStr + aktiv: OgBool + erstellt_am: OgDateTime + aktualisiert_am: OgDateTime + +@dataclass +class ServiceContractItem: + """Rail class `ServiceContractItem` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + sort_order: OgInt + pos_typ: OgStr + titel: OgStr + beschreibung: OgStr + intervall: OgStr + preis_netto: OgFloat + menge: OgFloat + mwst_satz: OgFloat + rabatt_typ: OgStr + rabatt_wert: OgFloat + rabatt_bis: OgDate + rabatt_grund: OgStr + aktiv_ab: OgDate + aktiv_bis: OgDate + abgerechnet_bis: OgDate + sofort_rechnung: OgBool + aktiv: OgBool + created_at: OgDateTime + contract: ToOne["ServiceContract"] + package: ToOne["ServicePackage"] + +@dataclass +class ContractBonus: + """Rail class `ContractBonus` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + bonus_typ: OgStr + wert: OgFloat + grund: OgStr + aktiv_ab: OgDate + aktiv_bis: OgDate + verbraucht: OgBool + verbraucht_am: OgDateTime + verbraucht_in_workorder_id: OgInt + promo_code_id: OgInt + aktiv: OgBool + created_at: OgDateTime + created_by: OgInt + contract: ToMany["ServiceContract"] + +@dataclass +class PromoCode: + """Rail class `PromoCode` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + code: OgStr + titel: OgStr + beschreibung: OgStr + code_typ: OgStr + wert: OgFloat + gueltig_ab: OgDate + gueltig_bis: OgDate + max_einloesungen: OgInt + aktuelle_einloesungen: OgInt + nur_neukunden: OgBool + min_vertragswert_netto: OgFloat + bonus_aktiv_monate: OgInt + aktiv: OgBool + created_at: OgDateTime + created_by: OgInt + +@dataclass +class OnboardingTemplate: + """Rail class `OnboardingTemplate` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + kind: OgStr + label: OgStr + subject: OgStr + html_content: OgStr + plain_body: OgStr + aktiv: OgBool + erstellt_am: OgDateTime + aktualisiert_am: OgDateTime + +@dataclass +class PartnerContractTemplate: + """Rail class `PartnerContractTemplate` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + label: OgStr + html_content: OgStr + aktiv: OgBool + erstellt_am: OgDateTime + aktualisiert_am: OgDateTime + +@dataclass +class PortalAutoLoginToken: + """Rail class `PortalAutoLoginToken` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + customer_portal_user_id: OgInt + workorder_id: OgInt + token: OgStr + created_at: OgDateTime + expires_at: OgDateTime + last_used_at: OgDateTime + revoked_at: OgDateTime + use_count: OgInt + created_by: OgStr + scope: OgStr + permanent: OgBool + portal_user: ToOne["CustomerPortalUser"] + +@dataclass +class MailTemplate: + """Rail class `MailTemplate` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + kind: OgStr + subject: OgStr + html_body: OgStr + plain_body: OgStr + updated_at: OgDateTime + updated_by_user_id: OgInt + +@dataclass +class TresorPwResetToken: + """Rail class `TresorPwResetToken` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + customer_id: OgInt + token: OgStr + created_at: OgDateTime + expires_at: OgDateTime + used_at: OgDateTime + created_by: OgStr + +@dataclass +class LegacyRouteKey: + """Rail class `LegacyRouteKey` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + key_data: OgStr + retired_at: OgDateTime + expires_at: OgDateTime + note: OgStr + +@dataclass +class RevokedToken: + """Rail class `RevokedToken` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + token_hash: OgStr + revoked_at: OgDateTime + revoked_by_uid: OgInt + reason: OgStr + +@dataclass +class GeoblockSetting: + """Rail class `GeoblockSetting` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + country_code: OgStr + country_name: OgStr + continent: OgStr + blocked: OgBool + updated_at: OgDateTime + updated_by: OgInt + +@dataclass +class GeoblockAllowIP: + """Rail class `GeoblockAllowIP` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + cidr: OgStr + note: OgStr + created_at: OgDateTime + created_by: OgInt + +@dataclass +class GeoblockStat: + """Rail class `GeoblockStat` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + country_code: OgStr + stat_date: OgDate + block_count: OgInt + +@dataclass +class ClaudeLesson: + """Rail class `ClaudeLesson` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + session_date: OgDate + category: OgStr + severity: OgStr + title: OgStr + body: OgStr + tags: OgStr + related_patches: OgStr + is_active: OgBool + created_at: OgDateTime + created_by: OgStr + +@dataclass +class ClaudeStaticSection: + """Rail class `ClaudeStaticSection` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + position: OgInt + section_key: OgStr + title: OgStr + content: OgStr + has_timestamp_placeholder: OgBool + is_active: OgBool + created_at: OgDateTime + updated_at: OgDateTime + +@dataclass +class TaxRate: + """Rail class `TaxRate` — classid 0x02030003 (concept 0x0203, app 0x0003).""" + CLASSID: ClassVar[int] = 0x02030003 + id: OgInt + tenant_id: OgInt + name: OgStr + prozent: OgFloat + aktiv: OgBool + is_default: OgBool + sort_order: OgInt + created_at: OgDateTime + +@dataclass +class ShiftTicket: + """Rail class `ShiftTicket` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + token: OgStr + valid_until: OgDateTime + created_at: OgDateTime + created_by_id: OgInt + used_at: OgDateTime + customer: ToOne["Customer"] + project: ToOne["Project"] + assigned_user: ToOne["User"] + reminder: ToOne["Reminder"] + used_workorder: ToOne["WorkOrder"] + +@dataclass +class ErpLegalProfile: + """Rail class `ErpLegalProfile` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + rechtsform: OgStr + festschreib_periode: OgStr + kleinunternehmer: OgBool + besteuerungsart: OgStr + buchen_belegpflicht: OgBool + lager_fibu_methode: OgStr + lager_wareneinsatz_konto: OgStr + bank_sachkonto_auto_pi: OgBool + customer_bank_autosync: OgStr + handelsregister: OgStr + steuernummer: OgStr + ust_id: OgStr + default_currency: OgStr + currency_custom_code: OgStr + currency_custom_symbol: OgStr + datev_beraternr: OgStr + datev_mandantnr: OgStr + datev_wj_beginn: OgInt + erp_startdatum: OgDate + legacy_source: OgStr + legacy_object_id: OgStr + created_at: OgDateTime + updated_at: OgDateTime + imap_server: OgStr + imap_port: OgInt + imap_user: OgStr + imap_password: OgStr + imap_folder: OgStr + imap_ssl: OgBool + imap_aktiv: OgBool + imap_forward_to: OgStr + imap_interval: OgInt + gewerbesteuer_hebesatz: OgInt + erechnung_format: OgStr + +@dataclass +class ErpSupplierIban: + """Rail class `ErpSupplierIban` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + supplier_id: OgInt + iban: OgStr + bic: OgStr + notiz: OgStr + created_at: OgDateTime + +@dataclass +class ErpFintsInstitute: + """Rail class `ErpFintsInstitute` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + blz: OgStr + bic: OgStr + institut: OgStr + ort: OgStr + rz: OgStr + organisation: OgStr + hbci_dns: OgStr + hbci_version: OgStr + pintan_url: OgStr + fints_version: OgStr + updated_at_src: OgDate + imported_at: OgDateTime + +@dataclass +class ErpExchangeRate: + """Rail class `ErpExchangeRate` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + from_currency: OgStr + to_currency: OgStr + rate: OgMoney + valid_from: OgDate + valid_until: OgDate + source: OgStr + created_at: OgDateTime + +@dataclass +class ErpEstTarifParams: + """Rail class `ErpEstTarifParams` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + jahr: OgInt + grundfreibetrag: OgInt + zone2_bis: OgInt + zone3_bis: OgInt + zone4_bis: OgInt + z2a: OgStr + z2b: OgStr + z3a: OgStr + z3b: OgStr + z3c: OgStr + z4_abzug: OgStr + z5_abzug: OgStr + soli_freigrenze: OgInt + created_at: OgDateTime + updated_at: OgDateTime + +@dataclass +class ErpKuGrenzen: + """Rail class `ErpKuGrenzen` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + jahr: OgInt + grenze_vorjahr: OgInt + grenze_laufend: OgInt + created_at: OgDateTime + updated_at: OgDateTime + +@dataclass +class ErpLedgerLock: + """Rail class `ErpLedgerLock` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + buchungsperiode: OgStr + status: OgStr + locked_at: OgDateTime + locked_by_user_id: OgInt + hash_snapshot: OgStr + created_at: OgDateTime + +@dataclass +class ErpAuditTrail: + """Rail class `ErpAuditTrail` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + seq_no: OgInt + entity: OgStr + entity_id: OgInt + aktion: OgStr + user_id: OgInt + ip: OgStr + user_agent: OgStr + freitext: OgStr + before_hash: OgStr + after_hash: OgStr + created_at: OgDateTime + +@dataclass +class ErpChartOfAccounts: + """Rail class `ErpChartOfAccounts` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + rahmen: OgStr + aktiv: OgBool + gesperrt_ab: OgDateTime + created_at: OgDateTime + +@dataclass +class ErpAccount: + """Rail class `ErpAccount` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + rahmen: OgStr + kontonummer: OgStr + bezeichnung: OgStr + kontoart: OgStr + kontenklasse: OgInt + steuer_relevant: OgBool + ust_kennzeichen: OgStr + automatik_konto: OgBool + gesperrt: OgBool + sort_order: OgInt + currency: OgStr + legacy_source: OgStr + legacy_object_id: OgStr + created_at: OgDateTime + +@dataclass +class ErpCostCenter: + """Rail class `ErpCostCenter` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + nummer: OgStr + bezeichnung: OgStr + aktiv: OgBool + parent_id: OgInt + created_at: OgDateTime + +@dataclass +class ErpFiscalYear: + """Rail class `ErpFiscalYear` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + jahr: OgInt + beginn: OgDate + ende: OgDate + status: OgStr + created_at: OgDateTime + +@dataclass +class ErpPeriod: + """Rail class `ErpPeriod` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + fiscal_year_id: OgInt + periode_key: OgStr + bezeichnung: OgStr + status: OgStr + created_at: OgDateTime + +@dataclass +class ErpTaxAccountMap: + """Rail class `ErpTaxAccountMap` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + rahmen: OgStr + ust_kennzeichen: OgStr + beschreibung: OgStr + ust_konto: OgStr + vst_konto: OgStr + steuer_konto: OgStr + prozent: OgMoney + gueltig_ab: OgDate + gueltig_bis: OgDate + created_at: OgDateTime + +@dataclass +class ErpJournalEntry: + """Rail class `ErpJournalEntry` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + belegnummer: OgStr + belegdatum: OgDate + buchungsdatum: OgDate + buchungstext: OgStr + erfasst_von_user_id: OgInt + festgeschrieben: OgBool + festgeschrieben_am: OgDateTime + storno_of_id: OgInt + herkunft: OgStr + herkunft_ref_id: OgInt + currency: OgStr + created_at: OgDateTime + lines: ToMany["ErpJournalLine"] + +@dataclass +class ErpJournalLine: + """Rail class `ErpJournalLine` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + entry_id: OgInt + konto: OgStr + gegenkonto: OgStr + soll_betrag: OgMoney + haben_betrag: OgMoney + steuer_betrag: OgMoney + ust_kennzeichen: OgStr + kostenstelle_id: OgInt + zeilentext: OgStr + +@dataclass +class ErpDebitorAccount: + """Rail class `ErpDebitorAccount` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + customer_id: OgInt + kontonummer: OgStr + created_at: OgDateTime + +@dataclass +class ErpSupplier: + """Rail class `ErpSupplier` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + anschrift: OgStr + ust_id: OgStr + steuernummer: OgStr + iban: OgStr + bic: OgStr + zahlungsziel: OgInt + skonto_tage: OgInt + skonto_prozent: OgFloat + kreditorenkonto: OgStr + aufwandskonto_default: OgStr + ansprechpartner: OgStr + ap_position: OgStr + telefon: OgStr + email: OgStr + website: OgStr + strasse: OgStr + plz: OgStr + ort: OgStr + land: OgStr + unsere_kundennr: OgStr + notizen: OgStr + lieferzeit_tage: OgInt + mindestbestellwert: OgMoney + aktiv: OgBool + created_at: OgDateTime + +@dataclass +class ErpPurchaseInvoice: + """Rail class `ErpPurchaseInvoice` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + rechnungsnr: OgStr + belegdatum: OgDate + eingangsdatum: OgDate + faellig_am: OgDate + netto: OgMoney + steuer: OgMoney + brutto: OgMoney + currency: OgStr + aufwandskonto: OgStr + buchungstext: OgStr + split_buchung: OgStr + geprueft: OgBool + geprueft_von: OgInt + geprueft_am: OgDateTime + dokument_pfad: OgStr + status: OgStr + bezahlt: OgBool + bezahlt_am: OgDate + journal_entry_id: OgInt + created_at: OgDateTime + skontofrist: OgInt + skontosatz: OgMoney + supplier: ToOne["ErpSupplier"] + +@dataclass +class ErpSupplierArticle: + """Rail class `ErpSupplierArticle` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + lieferanten_artikelnr: OgStr + ek_preis: OgMoney + currency: OgStr + mindestmenge: OgInt + staffelpreise: OgStr + lieferzeit_tage: OgInt + ist_hauptlieferant: OgBool + aktiv: OgBool + created_at: OgDateTime + supplier: ToOne["ErpSupplier"] + article: ToOne["Article"] + +@dataclass +class ErpPurchaseOrder: + """Rail class `ErpPurchaseOrder` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + bestellnummer: OgStr + status: OgStr + bestelldatum: OgDate + liefertermin: OgDate + netto_summe: OgMoney + steuer_summe: OgMoney + brutto_summe: OgMoney + currency: OgStr + notizen: OgStr + purchase_invoice_id: OgInt + erstellt_von: OgInt + created_at: OgDateTime + updated_at: OgDateTime + supplier: ToOne["ErpSupplier"] + lines: ToMany["ErpPurchaseOrderLine"] + +@dataclass +class ErpPurchaseOrderLine: + """Rail class `ErpPurchaseOrderLine` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + purchase_order_id: OgInt + artikelnr: OgStr + bezeichnung: OgStr + menge: OgFloat + ek_preis: OgMoney + steuersatz: OgMoney + currency: OgStr + geliefert_menge: OgFloat + notiz: OgStr + article: ToOne["Article"] + +@dataclass +class ErpMaterialBedarf: + """Rail class `ErpMaterialBedarf` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + menge_position: OgFloat + menge_bestand: OgFloat + menge_bedarf: OgFloat + menge_bestellt: OgFloat + menge_geliefert: OgFloat + einheit: OgStr + best_ek: OgMoney + status: OgStr + workorder_nr: OgStr + artikel_bez: OgStr + created_at: OgDateTime + created_by: OgInt + notiz: OgStr + workorder: ToOne["WorkOrder"] + article: ToOne["Article"] + best_supplier: ToOne["ErpSupplier"] + position: ToOne["Position"] + purchase_order: ToOne["ErpPurchaseOrder"] + +@dataclass +class ErpCashRegister: + """Rail class `ErpCashRegister` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + sachkonto: OgStr + currency: OgStr + aktiv: OgBool + anfangssaldo: OgMoney + created_at: OgDateTime + +@dataclass +class ErpCashEntry: + """Rail class `ErpCashEntry` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + lfd_nr: OgInt + datum: OgDate + vorgang: OgStr + betrag: OgMoney + gegenkonto: OgStr + zweck: OgStr + erfasst_von_user_id: OgInt + festgeschrieben: OgBool + storno_of_id: OgInt + journal_entry_id: OgInt + created_at: OgDateTime + register: ToOne["ErpCashRegister"] + +@dataclass +class ErpDocument: + """Rail class `ErpDocument` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + original_name: OgStr + stored_name: OgStr + mime_type: OgStr + size_bytes: OgInt + integrity_hash: OgStr + belegtyp: OgStr + beleg_status: OgStr + steuerberater_relevant: OgBool + beschreibung: OgStr + customer_id: OgInt + purchase_invoice_id: OgInt + asset_id: OgInt + journal_entry_id: OgInt + legacy_source: OgStr + legacy_object_id: OgStr + legacy_tree_id: OgStr + legacy_filename: OgStr + legacy_imported_at: OgDateTime + uploaded_by: OgInt + uploaded_at: OgDateTime + kategorie_id: OgInt + periode: OgStr + tags: OgStr + beleg_datum: OgDate + lieferant_id: OgInt + workorder_id: OgInt + bank_tx_id: OgInt + ocr_status: OgStr + +@dataclass +class ErpDocumentFulltext: + """Rail class `ErpDocumentFulltext` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + quelle: OgStr + text: OgStr + indexed_at: OgDateTime + document: ToOne["ErpDocument"] + +@dataclass +class ErpDmsKategorie: + """Rail class `ErpDmsKategorie` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + parent_id: OgInt + name: OgStr + beschreibung: OgStr + icon: OgStr + sort_order: OgInt + aktiv: OgBool + created_at: OgDateTime + children: ToMany["ErpDmsKategorie"] + +@dataclass +class ErpDmsAudit: + """Rail class `ErpDmsAudit` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + document_id: OgInt + user_id: OgInt + aktion: OgStr + von_status: OgStr + zu_status: OgStr + kommentar: OgStr + created_at: OgDateTime + +@dataclass +class SambaShare: + """Rail class `SambaShare` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + path: OgStr + comment: OgStr + browseable: OgBool + aktiv: OgBool + created_at: OgDateTime + +@dataclass +class SambaShareAcl: + """Rail class `SambaShareAcl` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + can_read: OgBool + can_write: OgBool + share: ToOne["SambaShare"] + user: ToOne["User"] + +@dataclass +class ScanRequest: + """Rail class `ScanRequest` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + user_id: OgInt + ziel_typ: OgStr + ziel_id: OgInt + status: OgStr + document_id: OgInt + created_at: OgDateTime + completed_at: OgDateTime + expires_at: OgDateTime + hinweis: OgStr + +@dataclass +class EinsatzSession: + """Rail class `EinsatzSession` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + pairing_code: OgStr + qr_token: OgStr + code_expires_at: OgDateTime + session_token: OgStr + user_id: OgInt + customer_id: OgInt + reminder_id: OgInt + workorder_id: OgInt + scope: OgStr + status: OgStr + paired_at: OgDateTime + expires_at: OgDateTime + last_activity: OgDateTime + ended_at: OgDateTime + ended_by: OgStr + browser_fp: OgStr + ip_address: OgStr + user_agent: OgStr + created_at: OgDateTime + +@dataclass +class ErpSerialCharge: + """Rail class `ErpSerialCharge` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + typ: OgStr + nummer: OgStr + mhd: OgDate + status: OgStr + workorder_id: OgInt + movement_id: OgInt + created_at: OgDateTime + article: ToOne["Article"] + +@dataclass +class ErpEinvoiceImport: + """Rail class `ErpEinvoiceImport` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + status: OgStr + filename: OgStr + dateityp: OgStr + raw_xml: OgStr + profil: OgStr + format_typ: OgStr + lieferant_name: OgStr + lieferant_ustid: OgStr + lieferant_iban: OgStr + rechnungsnr: OgStr + rechnungsdatum: OgDate + faelligkeit: OgDate + waehrung: OgStr + netto_betrag: OgMoney + steuer_betrag: OgMoney + brutto_betrag: OgMoney + steuer_prozent: OgMoney + verwendungszweck: OgStr + positionen_json: OgStr + purchase_invoice_id: OgInt + document_id: OgInt + fehler: OgStr + warnungen: OgStr + hash_dedup: OgStr + created_at: OgDateTime + created_by: OgInt + confirmed_at: OgDateTime + confirmed_by: OgInt + supplier: ToOne["ErpSupplier"] + +@dataclass +class ErpSupplierCsvMapping: + """Rail class `ErpSupplierCsvMapping` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + trennzeichen: OgStr + encoding: OgStr + header_zeile: OgInt + skip_zeilen: OgInt + col_ean: OgStr + col_artikelnr: OgStr + col_bezeichnung: OgStr + col_ek_preis: OgStr + col_mindestmenge: OgStr + col_einheit: OgStr + col_hersteller: OgStr + col_hersteller_anr: OgStr + col_bild_url: OgStr + col_kategorie: OgStr + col_unterkategorie: OgStr + fuzzy_schwelle: OgInt + auto_anlegen: OgBool + auto_kategorie: OgStr + auto_update_url: OgStr + auto_update_aktiv: OgBool + auto_update_auth_type: OgStr + auto_update_auth_user: OgStr + auto_update_auth_pass: OgStr + auto_update_auth_header: OgStr + auto_update_omd_token_url: OgStr + auto_update_omd_client_id: OgStr + auto_update_omd_client_secret: OgStr + auto_update_omd_cred_location: OgStr + auto_update_omd_customer_id: OgStr + auto_update_intervall: OgInt + auto_update_typ: OgStr + auto_update_uhrzeit: OgStr + auto_update_wochentage: OgStr + auto_update_next_run: OgDateTime + auto_inaktiv_setzen: OgBool + auto_update_last_status: OgStr + auto_update_last_error: OgStr + auto_update_last_run: OgDateTime + last_import_at: OgDateTime + last_import_count: OgInt + created_at: OgDateTime + supplier: ToOne["ErpSupplier"] + +@dataclass +class ErpStockMovement: + """Rail class `ErpStockMovement` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + datum: OgDate + bewegungsart: OgStr + menge: OgFloat + ek_preis: OgFloat + currency: OgStr + herkunft: OgStr + herkunft_ref_id: OgInt + notiz: OgStr + erfasst_von: OgInt + erfasst_am: OgDateTime + mitarbeiter_id: OgInt + supplier_id: OgInt + gutschrift_nr: OgStr + schadensgrund: OgStr + article: ToOne["Article"] + +@dataclass +class ErpAsset: + """Rail class `ErpAsset` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + inventarnr: OgStr + bezeichnung: OgStr + anlagenklasse: OgStr + anschaffungsdatum: OgDate + anschaffungskosten: OgMoney + currency: OgStr + nutzungsdauer_jahre: OgInt + afa_methode: OgStr + anlagenkonto: OgStr + afa_konto: OgStr + kum_afa: OgMoney + letztes_afa_jahr: OgInt + aktiv: OgBool + abgang_datum: OgDate + abgang_art: OgStr + device_id: OgInt + kostenstelle_id: OgInt + created_at: OgDateTime + +@dataclass +class ErpBankAccount: + """Rail class `ErpBankAccount` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + iban: OgStr + bic: OgStr + sachkonto: OgStr + aktiv: OgBool + kontotyp: OgStr + ist_hauptkonto: OgBool + anfangssaldo: OgMoney + fints_aktiv: OgBool + fints_blz: OgStr + fints_endpoint_url: OgStr + fints_login: OgStr + fints_pin_encrypted: OgStr + fints_tan_methode: OgStr + fints_letzter_abruf: OgDateTime + fints_status: OgStr + fints_letzter_fehler: OgStr + fints_system_id: OgStr + created_at: OgDateTime + +@dataclass +class ErpBankTransaction: + """Rail class `ErpBankTransaction` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + bank_account_id: OgInt + buchungsdatum: OgDate + betrag: OgMoney + verwendungszweck: OgStr + gegenname: OgStr + gegen_iban: OgStr + status: OgStr + match_wo_id: OgInt + match_pi_id: OgInt + match_type: OgStr + match_transfer_tx_id: OgInt + match_sachkonto: OgStr + match_steuersatz: OgInt + import_hash: OgStr + created_at: OgDateTime + document_id: OgInt + match_darlehen_id: OgInt + match_split_data: OgStr + match_abschlag_id: OgInt + match_konto_override: OgStr + hinweis_text: OgStr + +@dataclass +class ErpDarlehen: + """Rail class `ErpDarlehen` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + darlehensnr: OgStr + bezeichnung: OgStr + glaeubiger: OgStr + glaeubiger_iban: OgStr + darlehensbetrag_brutto: OgMoney + restschuld: OgMoney + zinssatz: OgMoney + startdatum: OgDate + enddatum_geplant: OgDate + monatsrate_soll: OgMoney + konto_darlehen: OgStr + konto_zinsen: OgStr + aktiv: OgBool + notiz: OgStr + created_at: OgDateTime + +@dataclass +class ErpAbschlagskonto: + """Rail class `ErpAbschlagskonto` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + bezeichnung: OgStr + anbieter: OgStr + anbieter_iban: OgStr + vertragsnummer: OgStr + art: OgStr + monatlicher_abschlag: OgMoney + konto_aufwand: OgStr + ust_satz: OgInt + konto_aufwand_2: OgStr + anteil_aufwand_2_proz: OgInt + aktiv: OgBool + notiz: OgStr + created_at: OgDateTime + +@dataclass +class ErpPaymentRun: + """Rail class `ErpPaymentRun` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + ausfuehrungsdatum: OgDate + status: OgStr + summe: OgMoney + anzahl: OgInt + currency: OgStr + xml_exported: OgBool + created_at: OgDateTime + created_by_id: OgInt + bank_account: ToOne["ErpBankAccount"] + items: ToMany["ErpPaymentRunItem"] + +@dataclass +class ErpPaymentRunItem: + """Rail class `ErpPaymentRunItem` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + run_id: OgInt + purchase_invoice_id: OgInt + empfaenger: OgStr + iban: OgStr + bic: OgStr + betrag: OgMoney + currency: OgStr + verwendungszweck: OgStr + +@dataclass +class ErpVatReturn: + """Rail class `ErpVatReturn` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + jahr: OgInt + monat: OgInt + kz81: OgMoney + kz86: OgMoney + kz66: OgMoney + ust_19: OgMoney + ust_7: OgMoney + zahllast: OgMoney + status: OgStr + berechnet_am: OgDateTime + created_at: OgDateTime + +@dataclass +class ErpPreisSchema: + """Rail class `ErpPreisSchema` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + beschreibung: OgStr + fix_aufschlag: OgMoney + rundung_typ: OgStr + mindestmarge_pct: OgMoney + auto_neuberechnung: OgBool + ist_default: OgBool + aktiv: OgBool + created_at: OgDateTime + updated_at: OgDateTime + spannen: ToMany["ErpPreisSpanne"] + +@dataclass +class ErpPreisSpanne: + """Rail class `ErpPreisSpanne` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + schema_id: OgInt + preis_von: OgMoney + preis_bis: OgMoney + aufschlag_pct: OgMoney + sort_order: OgInt + +@dataclass +class ErpPreisGruppe: + """Rail class `ErpPreisGruppe` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + rabatt_pct: OgMoney + schema_id: OgInt + ist_default: OgBool + sort_order: OgInt + aktiv: OgBool + created_at: OgDateTime + kunden: ToMany["Customer"] + +@dataclass +class ErpLohnPeriode: + """Rail class `ErpLohnPeriode` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + jahr: OgInt + monat: OgInt + brutto_loehne: OgMoney + ag_sv_anteile: OgMoney + an_sv_anteile: OgMoney + lohnsteuer_gesamt: OgMoney + currency: OgStr + notiz: OgStr + gebucht_am: OgDateTime + gebucht_von: OgInt + erfasst_am: OgDateTime + erfasst_von: OgInt + +@dataclass +class DashboardDismissal: + """Rail class `DashboardDismissal` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + user_id: OgInt + item_key: OgStr + created_at: OgDateTime + +@dataclass +class CustomerRemoteDevice: + """Rail class `CustomerRemoteDevice` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + provider: OgStr + label: OgStr + device_id: OgStr + notizen: OgStr + created_at: OgDateTime + customer: ToOne["Customer"] + +@dataclass +class CrmCompany: + """Rail class `CrmCompany` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + rechtsform: OgStr + branche: OgStr + website: OgStr + telefon: OgStr + email: OgStr + strasse: OgStr + plz: OgStr + ort: OgStr + land: OgStr + ust_id: OgStr + customer_id: OgInt + inhaber_user_id: OgInt + notizen: OgStr + tags: OgStr + aktiv: OgBool + geloescht_am: OgDateTime + erstellt_am: OgDateTime + geaendert_am: OgDateTime + +@dataclass +class CrmContact: + """Rail class `CrmContact` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + company_id: OgInt + customer_id: OgInt + anrede: OgStr + vorname: OgStr + nachname: OgStr + position: OgStr + abteilung: OgStr + email: OgStr + telefon: OgStr + mobil: OgStr + ist_hauptkontakt: OgBool + quelle: OgStr + notizen: OgStr + tags: OgStr + aktiv: OgBool + geloescht_am: OgDateTime + erstellt_am: OgDateTime + geaendert_am: OgDateTime + carddav_uid: OgStr + carddav_href: OgStr + carddav_etag: OgStr + carddav_synced_at: OgDateTime + +@dataclass +class CrmPipeline: + """Rail class `CrmPipeline` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + ist_default: OgBool + sort_order: OgInt + aktiv: OgBool + geloescht_am: OgDateTime + erstellt_am: OgDateTime + +@dataclass +class CrmStage: + """Rail class `CrmStage` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + pipeline_id: OgInt + name: OgStr + sort_order: OgInt + wahrscheinlichkeit: OgInt + ist_gewonnen: OgBool + ist_verloren: OgBool + farbe: OgStr + aktiv: OgBool + geloescht_am: OgDateTime + +@dataclass +class CrmLead: + """Rail class `CrmLead` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + titel: OgStr + pipeline_id: OgInt + stage_id: OgInt + contact_id: OgInt + company_id: OgInt + customer_id: OgInt + wert_netto: OgMoney + waehrung: OgStr + wahrscheinlichkeit: OgInt + quelle: OgStr + zustaendig_user_id: OgInt + erwartetes_datum: OgDate + status: OgStr + verloren_grund: OgStr + source_cold_lead_id: OgInt + notizen: OgStr + tags: OgStr + category_id: OgInt + aktiv: OgBool + geloescht_am: OgDateTime + erstellt_am: OgDateTime + geaendert_am: OgDateTime + +@dataclass +class CrmCategory: + """Rail class `CrmCategory` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + farbe: OgStr + sort_order: OgInt + aktiv: OgBool + geloescht_am: OgDateTime + erstellt_am: OgDateTime + +@dataclass +class CrmActivity: + """Rail class `CrmActivity` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + entity_type: OgStr + entity_id: OgInt + typ: OgStr + betreff: OgStr + inhalt: OgStr + richtung: OgStr + ref_table: OgStr + ref_id: OgInt + user_id: OgInt + zeitpunkt: OgDateTime + geloescht_am: OgDateTime + erstellt_am: OgDateTime + +@dataclass +class CrmTask: + """Rail class `CrmTask` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + titel: OgStr + beschreibung: OgStr + entity_type: OgStr + entity_id: OgInt + faellig_am: OgDateTime + erinnerung_am: OgDateTime + prioritaet: OgStr + status: OgStr + zustaendig_user_id: OgInt + erledigt_am: OgDateTime + geloescht_am: OgDateTime + erstellt_am: OgDateTime + geaendert_am: OgDateTime + +@dataclass +class CrmDocument: + """Rail class `CrmDocument` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + titel: OgStr + vorlage_id: OgInt + entity_type: OgStr + entity_id: OgInt + customer_id: OgInt + storage_key: OgStr + mime_type: OgStr + format: OgStr + version: OgInt + erp_document_id: OgInt + status: OgStr + erstellt_von: OgInt + folder_id: OgInt + aktiv: OgBool + geloescht_am: OgDateTime + erstellt_am: OgDateTime + geaendert_am: OgDateTime + +@dataclass +class CrmDocumentFolder: + """Rail class `CrmDocumentFolder` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + entity_type: OgStr + entity_id: OgInt + parent_id: OgInt + name: OgStr + sort_order: OgInt + geloescht_am: OgDateTime + erstellt_am: OgDateTime + +@dataclass +class CrmDocTemplate: + """Rail class `CrmDocTemplate` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + name: OgStr + format: OgStr + storage_key: OgStr + beschreibung: OgStr + kategorie: OgStr + aktiv: OgBool + geloescht_am: OgDateTime + erstellt_am: OgDateTime + +@dataclass +class CrmDocumentLink: + """Rail class `CrmDocumentLink` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + tenant_id: OgInt + erp_document_id: OgInt + entity_type: OgStr + entity_id: OgInt + zugeordnet_von_user_id: OgInt + zugeordnet_am: OgDateTime + quelle: OgStr + geloescht_am: OgDateTime + erstellt_am: OgDateTime + +@dataclass +class RentedServerEvent: + """Rail class `RentedServerEvent` — classid 0x00000000 (concept 0x0000, app 0x0000).""" + CLASSID: ClassVar[int] = 0x00000000 + id: OgInt + ts: OgDateTime + kind: OgStr + level: OgStr + message: OgStr + payload: OgStr + server: ToOne["RentedServer"] + diff --git a/.claude/harvest/woa-rs/woa_facet.sql b/.claude/harvest/woa-rs/woa_facet.sql new file mode 100644 index 0000000..303e217 --- /dev/null +++ b/.claude/harvest/woa-rs/woa_facet.sql @@ -0,0 +1,2412 @@ +-- WoA sink-in substrate v1 — PostgreSQL DDL, generated, do not hand-edit. +-- facet table: classid(4) + 12-byte payload (V3 sink-in System-of-Record) + +CREATE TABLE facet ( + classid INTEGER NOT NULL, + p0 SMALLINT, + p1 SMALLINT, + p2 SMALLINT, + p3 SMALLINT, + p4 SMALLINT, + p5 SMALLINT, + p6 SMALLINT, + p7 SMALLINT, + p8 SMALLINT, + p9 SMALLINT, + p10 SMALLINT, + p11 SMALLINT +); +CREATE INDEX facet_classid_idx ON facet (classid); +CREATE INDEX facet_p0_idx ON facet (p0); +CREATE INDEX facet_p1_idx ON facet (p1); +CREATE INDEX facet_p2_idx ON facet (p2); +CREATE INDEX facet_p3_idx ON facet (p3); +CREATE INDEX facet_p4_idx ON facet (p4); +CREATE INDEX facet_p5_idx ON facet (p5); +CREATE INDEX facet_p6_idx ON facet (p6); +CREATE INDEX facet_p7_idx ON facet (p7); +CREATE INDEX facet_p8_idx ON facet (p8); +CREATE INDEX facet_p9_idx ON facet (p9); +CREATE INDEX facet_p10_idx ON facet (p10); +CREATE INDEX facet_p11_idx ON facet (p11); + +-- per-class relational DDL (ClassView-projected, typed + nullable) + +CREATE TABLE Tenant ( + id INTEGER NOT NULL, + name TEXT NOT NULL, + slug TEXT NOT NULL, + max_users INTEGER, + aktiv BOOLEAN, + logo_path TEXT, + logo_mail_path TEXT, + created_at TIMESTAMP, + branche TEXT, + is_test BOOLEAN, + is_anbieter BOOLEAN, + mod_tresor BOOLEAN, + mod_stundenzettel BOOLEAN, + mod_multitimer BOOLEAN, + mod_fahrtenbuch BOOLEAN, + mod_wiedervorlage BOOLEAN, + mod_notizbuch BOOLEAN, + mod_wartung BOOLEAN, + mod_abo BOOLEAN, + mod_inventar BOOLEAN, + mod_abnahme BOOLEAN, + mod_gobd BOOLEAN, + mod_referral BOOLEAN, + mod_kaltakquise BOOLEAN, + mod_woa_service BOOLEAN, + mod_erp BOOLEAN, + mod_dms BOOLEAN, + mod_rustdesk BOOLEAN, + mod_rustdesk_server BOOLEAN, + erp_gobd_festschreibung BOOLEAN, + erp_mod_stammdaten BOOLEAN, + erp_mod_fibu BOOLEAN, + erp_mod_bank BOOLEAN, + erp_mod_steuer BOOLEAN, + erp_mod_lager BOOLEAN, + erp_mod_dms BOOLEAN, + erp_mod_reporting BOOLEAN, + erp_mod_pos BOOLEAN, + erp_mod_zugferd BOOLEAN, + erp_mod_lohn BOOLEAN, + erp_mod_shop BOOLEAN, + erp_mod_crm BOOLEAN, + erp_mod_stammdaten_gesperrt BOOLEAN, + erp_mod_fibu_gesperrt BOOLEAN, + erp_mod_bank_gesperrt BOOLEAN, + erp_mod_steuer_gesperrt BOOLEAN, + erp_mod_lager_gesperrt BOOLEAN, + erp_mod_dms_gesperrt BOOLEAN, + erp_mod_reporting_gesperrt BOOLEAN, + erp_mod_pos_gesperrt BOOLEAN, + erp_mod_zugferd_gesperrt BOOLEAN, + erp_mod_crm_gesperrt BOOLEAN, + erp_mod_lohn_gesperrt BOOLEAN, + erp_mod_shop_gesperrt BOOLEAN +); + +CREATE TABLE User ( + id INTEGER NOT NULL, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + firstname TEXT, + lastname TEXT, + email TEXT, + phone TEXT, + ma_rabatt DOUBLE PRECISION, + is_admin BOOLEAN, + is_superadmin BOOLEAN, + perm_dashboard_umsaetze BOOLEAN NOT NULL, + perm_buchhaltung BOOLEAN NOT NULL, + perm_statistik BOOLEAN NOT NULL, + perm_einstellungen BOOLEAN NOT NULL, + perm_dms BOOLEAN NOT NULL, + perm_serverschutz BOOLEAN NOT NULL, + perm_erp_stammdaten BOOLEAN NOT NULL, + perm_erp_buchen BOOLEAN NOT NULL, + perm_erp_debitoren BOOLEAN NOT NULL, + perm_erp_kreditoren BOOLEAN NOT NULL, + perm_erp_bank BOOLEAN NOT NULL, + perm_erp_kasse BOOLEAN NOT NULL, + perm_erp_steuer BOOLEAN NOT NULL, + perm_erp_abschluss BOOLEAN NOT NULL, + perm_erp_anlagen BOOLEAN NOT NULL, + perm_erp_lager BOOLEAN NOT NULL, + perm_erp_einkauf BOOLEAN NOT NULL, + perm_erp_dms BOOLEAN NOT NULL, + perm_erp_compliance BOOLEAN NOT NULL, + perm_erp_lohn BOOLEAN NOT NULL, + failed_attempts INTEGER, + locked_until TIMESTAMP, + scan_import_mode TEXT, + scan_import_username TEXT, + scan_import_pw_set_at TIMESTAMP, + scan_import_host TEXT, + scan_import_port INTEGER, + vpn_enabled BOOLEAN NOT NULL, + vpn_ip TEXT, + vpn_pubkey TEXT, + vpn_created_at TIMESTAMP, + samba_enabled BOOLEAN NOT NULL, + samba_pw_set_at TIMESTAMP, + created_at TIMESTAMP +); + +CREATE TABLE Customer ( + id INTEGER NOT NULL, + tenant_id INTEGER, + kdnr TEXT, + quick_token TEXT, + quick_token_ts TEXT, + firma TEXT, + anrede TEXT, + vorname TEXT, + nachname TEXT, + mail_anrede TEXT, + strasse TEXT, + adresszusatz TEXT, + plz TEXT, + ort TEXT, + email TEXT, + telefon TEXT, + tresor_pw_hash TEXT, + tresor_pw_set_at TIMESTAMP, + tresor_pw_failed INTEGER, + tresor_pw_locked_until TIMESTAMP, + zahlungsziel INTEGER, + skonto_prozent DOUBLE PRECISION, + skonto_tage INTEGER, + stundensatz DOUBLE PRECISION, + fahrt_km DOUBLE PRECISION, + fahrt_kosten DOUBLE PRECISION, + notizen TEXT, + aktiv BOOLEAN, + kundentyp TEXT, + referral_code TEXT, + sepa_iban TEXT, + sepa_bic TEXT, + sepa_kontoinhaber TEXT, + sepa_mandat_ref TEXT, + sepa_mandat_datum DATE, + sepa_mandat_typ TEXT, + sepa_mandat_status TEXT, + sepa_letzte_lastschrift DATE, + sepa_pre_notification_tage INTEGER, + preis_gruppe_id INTEGER, + created_at TIMESTAMP +); + +CREATE TABLE Project ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT NOT NULL, + projekt_nr TEXT, + beschreibung TEXT, + status TEXT, + sort_order INTEGER, + erstellt_am TIMESTAMP, + erstellt_von_id INTEGER, + abgeschlossen_am TIMESTAMP +); + +CREATE TABLE ProjectNote ( + id INTEGER NOT NULL, + titel TEXT, + inhalt_text TEXT, + inhalt_zeichnung TEXT, + erstellt_am TIMESTAMP, + erstellt_von_id INTEGER +); + +CREATE TABLE ErpArticleCategory ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT NOT NULL, + icon TEXT, + sort_order INTEGER, + created_at TIMESTAMP +); + +CREATE TABLE ErpStorageLocation ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT NOT NULL, + kuerzel TEXT, + icon TEXT, + beschreibung TEXT, + sort_order INTEGER, + aktiv BOOLEAN, + created_at TIMESTAMP +); + +CREATE TABLE Article ( + id INTEGER NOT NULL, + tenant_id INTEGER, + artikelnr TEXT, + ean TEXT, + beschreibung TEXT NOT NULL, + kategorie TEXT, + category_id INTEGER, + storage_location_id INTEGER, + einheit TEXT, + hersteller TEXT, + hersteller_anr TEXT, + bild_url TEXT, + bestand DOUBLE PRECISION, + mindestbestand DOUBLE PRECISION, + preis_netto NUMERIC, + ek_preis NUMERIC, + mwst_satz DOUBLE PRECISION, + tax_rate_id INTEGER, + lieferant TEXT, + lieferant_anr TEXT, + notizen TEXT, + typ TEXT, + aktiv BOOLEAN, + dauer_minuten INTEGER, + preis_schema_id INTEGER, + vk_preis_manuell BOOLEAN, + listenpreis NUMERIC, + uvp NUMERIC, + gewicht_kg NUMERIC, + herkunftsland TEXT, + zolltarifnr TEXT, + langbeschreibung TEXT, + matchcode TEXT, + warengruppe TEXT, + warengruppe_nr TEXT, + gefahrgut BOOLEAN, + gefahrgut_un_nr TEXT, + gefahrgut_klasse TEXT, + auslaufartikel BOOLEAN, + auslaufdatum DATE, + deeplink TEXT, + shop_active BOOLEAN, + shop_product_type TEXT, + shop_category_id INTEGER, + shop_long_desc_html TEXT, + shop_saas_meta_json TEXT, + shop_saas_package_id INTEGER, + shop_digital_path TEXT, + shop_meta_title TEXT, + shop_meta_description TEXT, + shop_slug TEXT, + shop_payment_methods_csv TEXT, + shop_free_shipping BOOLEAN NOT NULL, + shop_extra_user_price_cents INTEGER, + shop_addon_article_ids TEXT, + shop_included_users INTEGER NOT NULL, + omd_tax_code INTEGER +); + +CREATE TABLE WorkOrder ( + id INTEGER NOT NULL, + tenant_id INTEGER, + customer_id INTEGER NOT NULL, + created_by INTEGER, + doc_type TEXT, + status TEXT, + angebot_nr TEXT, + auftrags_nr TEXT, + workorder_nr TEXT, + rechnung_nr TEXT, + gutschrift_nr TEXT, + sammelrechnung_id INTEGER, + datum DATE, + zeit_start TEXT, + zeit_ende TEXT, + anfahrten DOUBLE PRECISION, + mitarbeiter DOUBLE PRECISION, + pause_h DOUBLE PRECISION, + zusatz_h DOUBLE PRECISION, + betreff TEXT, + notizen TEXT, + intern_notizen TEXT, + bezahlt BOOLEAN, + bezahlt_am DATE, + bezahlt_betrag NUMERIC, + mahnstufe INTEGER, + letzte_mahnung DATE, + erfuellung_bis DATE, + unterschrift TEXT, + signed_at TIMESTAMP, + signed_ip TEXT, + signed_user_agent TEXT, + zahlungsart TEXT, + anzahlung_prozent DOUBLE PRECISION, + anzahlung_betrag DOUBLE PRECISION, + anzahlung_bezahlt BOOLEAN, + anzahlung_bezahlt_am DATE, + kleinunternehmer_snapshot BOOLEAN, + zahlungsziel_tage_snapshot INTEGER, + gesamt_rabatt_prozent DOUBLE PRECISION, + gesamt_rabatt_betrag DOUBLE PRECISION, + skonto_prozent_snapshot DOUBLE PRECISION, + skonto_tage_snapshot INTEGER, + skonto_ausweisen BOOLEAN, + skonto_aufschlag BOOLEAN, + skonto_aufschlag_faktor DOUBLE PRECISION, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE Position ( + id INTEGER NOT NULL, + workorder_id INTEGER NOT NULL, + article_id INTEGER, + sort_order INTEGER, + pos_typ TEXT, + beschreibung TEXT, + menge DOUBLE PRECISION, + einheit TEXT, + einzelpreis NUMERIC, + mwst_satz DOUBLE PRECISION, + tax_rate_id INTEGER, + versteckt BOOLEAN, + is_optional BOOLEAN, + customer_accepted_at TIMESTAMP, + rabatt_prozent DOUBLE PRECISION, + einzelpreis_vor_skonto NUMERIC +); + +CREATE TABLE Activity ( + id INTEGER NOT NULL, + workorder_id INTEGER NOT NULL, + geraet TEXT, + beschreibung TEXT, + logbuch BOOLEAN, + intern BOOLEAN, + created_at TIMESTAMP +); + +CREATE TABLE Picture ( + id INTEGER NOT NULL, + workorder_id INTEGER NOT NULL, + dateiname TEXT, + beschreibung TEXT, + logbuch BOOLEAN, + an_kunde BOOLEAN, + created_at TIMESTAMP +); + +CREATE TABLE Document ( + id INTEGER NOT NULL, + tenant_id INTEGER, + dateiname TEXT NOT NULL, + original_name TEXT NOT NULL, + beschreibung TEXT, + mime_type TEXT, + size_bytes BIGINT, + an_kunde BOOLEAN, + uploaded_by INTEGER, + uploaded_at TIMESTAMP +); + +CREATE TABLE AcceptanceProtocol ( + id INTEGER NOT NULL, + tenant_id INTEGER, + sequenz_nr INTEGER NOT NULL, + vorgaenger_id INTEGER, + aktiv BOOLEAN, + gesamt_abgenommen BOOLEAN, + abnahme_datum DATE, + abnahme_ort TEXT, + bemerkungen TEXT, + nachbesserungstermin DATE, + unterschrift_kunde TEXT, + unterschrieben_am TIMESTAMP, + unterschrieben_von TEXT, + erstellt_am TIMESTAMP, + erstellt_von_id INTEGER +); + +CREATE TABLE AcceptanceItem ( + id INTEGER NOT NULL, + abgenommen BOOLEAN, + bemerkung TEXT, + bezeichnung TEXT, + status TEXT, + sort_order INTEGER +); + +CREATE TABLE AcceptanceDefect ( + id INTEGER NOT NULL, + beschreibung TEXT NOT NULL, + erfasst_am TIMESTAMP, + nachbesserung_bis DATE, + behoben BOOLEAN, + behoben_am TIMESTAMP, + intern_status TEXT NOT NULL, + intern_status_am TIMESTAMP +); + +CREATE TABLE AcceptanceTemplate ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT NOT NULL, + is_default BOOLEAN, + aktiv BOOLEAN, + erstellt_am TIMESTAMP +); + +CREATE TABLE AcceptanceTemplateItem ( + id INTEGER NOT NULL, + bezeichnung TEXT NOT NULL, + sort_order INTEGER +); + +CREATE TABLE HistoryEntry ( + id INTEGER NOT NULL, + workorder_id INTEGER NOT NULL, + aktion TEXT, + details TEXT, + created_at TIMESTAMP +); + +CREATE TABLE LogbookEntry ( + id INTEGER NOT NULL, + workorder_id INTEGER, + datum DATE NOT NULL, + abfahrt TEXT, + ankunft TEXT, + rueckfahrt TEXT, + zurueck TEXT, + start_km DOUBLE PRECISION, + ende_km DOUBLE PRECISION, + route TEXT, + zweck TEXT, + fahrzeug TEXT, + privat_anteil DOUBLE PRECISION, + created_at TIMESTAMP +); + +CREATE TABLE NumberSequence ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT NOT NULL, + prefix TEXT, + current INTEGER, + padding INTEGER +); + +CREATE TABLE Setting ( + id INTEGER NOT NULL, + tenant_id INTEGER, + key TEXT NOT NULL, + value TEXT, + label TEXT, + override_master INTEGER NOT NULL +); + +CREATE TABLE CustomerPortalUser ( + id INTEGER NOT NULL, + username TEXT NOT NULL, + password_hash TEXT NOT NULL, + aktiv BOOLEAN, + must_change_pw BOOLEAN, + last_login TIMESTAMP, + created_at TIMESTAMP, + failed_attempts INTEGER, + locked_until TIMESTAMP +); + +CREATE TABLE PasswordEntry ( + id INTEGER NOT NULL, + tenant_id INTEGER, + created_by INTEGER, + gruppe TEXT, + titel TEXT NOT NULL, + benutzername TEXT, + passwort_enc TEXT, + url TEXT, + notizen_enc TEXT, + icon TEXT, + aktiv BOOLEAN, + keepass_uid TEXT, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE TimeSheet ( + id INTEGER NOT NULL, + tenant_id INTEGER, + source TEXT, + datum DATE NOT NULL, + minuten INTEGER, + erfasst_von TEXT, + startzeit TEXT, + endzeit TEXT, + beschreibung TEXT, + timer_start TIMESTAMP, + timer_paused_at TIMESTAMP, + abgerechnet BOOLEAN, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE TaxReserve ( + id INTEGER NOT NULL, + tenant_id INTEGER, + jahr INTEGER NOT NULL, + monat INTEGER NOT NULL, + quartal INTEGER, + typ TEXT, + erledigt BOOLEAN +); + +CREATE TABLE TimesheetActivity ( + id INTEGER NOT NULL, + beschreibung TEXT NOT NULL, + created_at TIMESTAMP +); + +CREATE TABLE Reminder ( + id INTEGER NOT NULL, + tenant_id INTEGER, + titel TEXT NOT NULL, + beschreibung TEXT, + faellig_am DATE NOT NULL, + prioritaet TEXT, + erledigt BOOLEAN, + erledigt_am TIMESTAMP, + erstellt_am TIMESTAMP, + zeit_von TEXT, + zeit_bis TEXT, + termin_typ TEXT, + wiederkehrend BOOLEAN, + intervall TEXT, + intervall_tage TEXT, + intervall_tag INTEGER, + intervall_monat INTEGER, + intervall_alle INTEGER, + token_cancel TEXT, + cancelled_at TIMESTAMP, + cancelled_ip TEXT, + crm_lead_id INTEGER, + crm_contact_id INTEGER, + crm_company_id INTEGER, + crm_task_id INTEGER +); + +CREATE TABLE MaintenanceContract ( + id INTEGER NOT NULL, + tenant_id INTEGER, + titel TEXT NOT NULL, + beschreibung TEXT, + intervall TEXT, + preis_netto DOUBLE PRECISION, + mwst_satz DOUBLE PRECISION, + beginn DATE NOT NULL, + ende DATE, + letzte_wartung DATE, + naechste_wartung DATE, + auto_rechnung BOOLEAN, + aktiv BOOLEAN, + notizen TEXT, + created_at TIMESTAMP +); + +CREATE TABLE RecurringInvoice ( + id INTEGER NOT NULL, + tenant_id INTEGER, + titel TEXT NOT NULL, + beschreibung TEXT, + intervall TEXT, + preis_netto DOUBLE PRECISION, + mwst_satz DOUBLE PRECISION, + naechste_ausfuehrung DATE NOT NULL, + letzte_ausfuehrung DATE, + auto_versand BOOLEAN, + aktiv BOOLEAN, + notizen TEXT, + created_at TIMESTAMP +); + +CREATE TABLE Device ( + id INTEGER NOT NULL, + tenant_id INTEGER, + kategorie TEXT, + hersteller TEXT, + modell TEXT, + seriennummer TEXT, + hostname TEXT, + ip_adresse TEXT, + mac_adresse TEXT, + standort TEXT, + kaufdatum DATE, + garantie_bis DATE, + firmware TEXT, + zugangsdaten TEXT, + notizen TEXT, + letzte_wartung DATE, + status TEXT, + aktiv BOOLEAN, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE KummerkastenEntry ( + id INTEGER NOT NULL, + source TEXT, + typ TEXT, + titel TEXT NOT NULL, + beschreibung TEXT, + prioritaet TEXT, + status TEXT, + admin_kommentar TEXT, + erstellt_am TIMESTAMP, + aktualisiert_am TIMESTAMP +); + +CREATE TABLE PortalViewState ( + id INTEGER NOT NULL, + customer_id INTEGER NOT NULL, + kind TEXT NOT NULL, + last_seen_at TIMESTAMP +); + +CREATE TABLE ReferralLog ( + id INTEGER NOT NULL, + referral_code TEXT NOT NULL, + empfaenger_email TEXT, + empfaenger_name TEXT, + versendet_am TIMESTAMP, + versendet_von INTEGER, + status TEXT, + converted_at TIMESTAMP, + converted_manually BOOLEAN, + converted_note TEXT, + proposed_by INTEGER, + proposed_at TIMESTAMP, + converted_tenant_id INTEGER +); + +CREATE TABLE ColdLead ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + firma TEXT NOT NULL, + ansprechpartner TEXT, + branche TEXT, + strasse TEXT, + plz TEXT, + ort TEXT, + land TEXT, + telefon TEXT, + mobil TEXT, + email TEXT, + webseite TEXT, + quelle TEXT, + briefanrede TEXT, + mitarbeiterzahl TEXT, + notiz_kurz TEXT, + newsletter_sperre BOOLEAN, + newsletter_sperre_grund TEXT, + newsletter_sperre_am TIMESTAMP, + source_customer_id INTEGER, + status TEXT, + wiedervorlage_am DATE, + notizen TEXT, + converted_customer_id INTEGER, + converted_at TIMESTAMP, + created_at TIMESTAMP, + created_by INTEGER, + updated_at TIMESTAMP +); + +CREATE TABLE ColdLeadActivity ( + id INTEGER NOT NULL, + lead_id INTEGER NOT NULL, + typ TEXT, + text TEXT, + mail_subject TEXT, + mail_to TEXT, + status_from TEXT, + status_to TEXT, + created_at TIMESTAMP, + created_by INTEGER +); + +CREATE TABLE ColdCampaign ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + beschreibung TEXT, + status TEXT, + mail_subject TEXT, + mail_template_html TEXT, + created_at TIMESTAMP, + created_by INTEGER, + updated_at TIMESTAMP +); + +CREATE TABLE ColdCampaignLead ( + id INTEGER NOT NULL, + campaign_id INTEGER NOT NULL, + sent_at TIMESTAMP, + sent_status TEXT, + sent_error TEXT, + added_at TIMESTAMP, + added_by INTEGER +); + +CREATE TABLE ServicePackage ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + beschreibung TEXT, + rechnungs_titel TEXT, + rechnungs_text TEXT, + preis_monthly DOUBLE PRECISION, + preis_quarterly DOUBLE PRECISION, + preis_half_yearly DOUBLE PRECISION, + preis_yearly DOUBLE PRECISION, + mwst_satz DOUBLE PRECISION, + free_months_default INTEGER, + aktiv BOOLEAN, + sort_order INTEGER, + created_at TIMESTAMP, + with_mail_templates BOOLEAN, + with_demo_data BOOLEAN, + required_server_type TEXT +); + +CREATE TABLE RentedServer ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + provider TEXT, + hostname TEXT, + ip_address TEXT, + woa_url TEXT, + dns_eintrag TEXT, + ssh_user TEXT, + ssh_port INTEGER, + miete_netto DOUBLE PRECISION, + miete_intervall TEXT, + ssh_password_enc TEXT, + root_password_enc TEXT, + notizen_enc TEXT, + master_api_url TEXT, + master_api_token TEXT, + verify_ssl BOOLEAN NOT NULL, + erp_ocr_token TEXT, + last_sync_at TIMESTAMP, + last_sync_status TEXT, + last_sync_message TEXT, + last_software_version TEXT, + last_health_at TIMESTAMP, + last_health_payload TEXT, + sa_username TEXT, + sa_password_enc TEXT, + installed_at TIMESTAMP, + tenant_slug_remote TEXT, + is_master BOOLEAN, + aktiv BOOLEAN, + created_at TIMESTAMP, + server_type TEXT, + max_tenants INTEGER, + tenant_count INTEGER, + tenant_count_at TIMESTAMP, + push_failure_count INTEGER NOT NULL, + push_last_failure_at TIMESTAMP, + push_last_error TEXT, + push_disabled BOOLEAN NOT NULL, + push_disabled_at TIMESTAMP +); + +CREATE TABLE ServiceContract ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + intervall TEXT, + vertrag_start DATE NOT NULL, + rechnung_ab DATE NOT NULL, + naechste_rechnung DATE NOT NULL, + individualpreis_netto DOUBLE PRECISION, + rabatt_prozent DOUBLE PRECISION, + free_months_remaining INTEGER, + credit_eur DOUBLE PRECISION, + bonus_pending_eur DOUBLE PRECISION, + sales_partner_id INTEGER, + provision_pct DOUBLE PRECISION, + commission_until DATE, + auto_versand BOOLEAN, + aktiv BOOLEAN, + gekuendigt_am DATE, + geloescht_am TIMESTAMP, + notizen TEXT, + created_at TIMESTAMP, + last_invoice_at TIMESTAMP, + last_invoice_workorder_id INTEGER, + shop_saas_config_id INTEGER, + prorata_pending BOOLEAN NOT NULL +); + +CREATE TABLE SalesPartner ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + tier TEXT NOT NULL, + provision_pct DOUBLE PRECISION NOT NULL, + aktiv BOOLEAN NOT NULL, + firma TEXT, + ansprechpartner TEXT, + email TEXT, + telefon TEXT, + is_service_techniker BOOLEAN NOT NULL, + strasse TEXT, + plz TEXT, + ort TEXT, + ustid TEXT, + steuer_status TEXT, + iban TEXT, + bic TEXT, + bank_name TEXT, + commission_months INTEGER, + aktiv_ab DATE, + aktiv_bis DATE, + notizen TEXT, + vertrag_pdf_path TEXT, + vertrag_versendet_am TIMESTAMP, + vertrag_versand_method TEXT, + vertrag_signatur_token TEXT, + vertrag_signiertes_pdf_path TEXT, + vertrag_signiert_am TIMESTAMP, + vertrag_signatur_data TEXT, + vertrag_signatur_ip TEXT, + vertrag_signatur_user_agent TEXT, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE PartnerCommission ( + id INTEGER NOT NULL, + payout_id INTEGER, + tenant_id INTEGER NOT NULL, + basis_netto DOUBLE PRECISION NOT NULL, + provision_pct DOUBLE PRECISION NOT NULL, + betrag_netto DOUBLE PRECISION NOT NULL, + status TEXT NOT NULL, + pending_at TIMESTAMP, + earned_at TIMESTAMP, + paid_at DATE, + paid_workorder_id INTEGER, + cancelled_at TIMESTAMP, + cancelled_reason TEXT, + notes TEXT, + created_at TIMESTAMP +); + +CREATE TABLE PartnerPayout ( + id INTEGER NOT NULL, + payout_nr TEXT, + tenant_id INTEGER NOT NULL, + paid_at DATE NOT NULL, + summe_netto DOUBLE PRECISION NOT NULL, + summe_ust DOUBLE PRECISION, + summe_brutto DOUBLE PRECISION, + ust_satz DOUBLE PRECISION, + steuer_status_snapshot TEXT, + note TEXT, + pdf_path TEXT, + pdf_generated_at TIMESTAMP, + mail_sent_at TIMESTAMP, + mail_sent_to TEXT, + created_by INTEGER, + created_at TIMESTAMP +); + +CREATE TABLE ContractSetup ( + id INTEGER NOT NULL, + setup_phase TEXT NOT NULL, + partner_decision TEXT, + progress_pct INTEGER, + subdomain TEXT, + server_ip TEXT, + dns_recipient TEXT, + dns_bcc TEXT, + dns_mail_sent_at TIMESTAMP, + dns_confirmed_at TIMESTAMP, + dns_confirmed_by INTEGER, + server_ready_at TIMESTAMP, + server_ready_by INTEGER, + server_notes TEXT, + ssh_host TEXT, + ssh_port INTEGER, + ssh_user TEXT, + package_uploaded_at TIMESTAMP, + package_path_remote TEXT, + package_filename TEXT, + target_tenant_id INTEGER, + target_tenant_name TEXT, + target_tenant_slug TEXT, + target_branche TEXT, + welcome_recipient TEXT, + welcome_sent_at TIMESTAMP, + admin_username TEXT, + onboarding_recipient TEXT, + onboarding_bcc TEXT, + onboarding_sent_at TIMESTAMP, + onboarding_csv_attached BOOLEAN, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE ContractSetupHistory ( + id INTEGER NOT NULL, + contract_id INTEGER NOT NULL, + action TEXT NOT NULL, + phase_from TEXT, + phase_to TEXT, + user_id INTEGER, + notes TEXT, + created_at TIMESTAMP +); + +CREATE TABLE AppVersion ( + id INTEGER NOT NULL, + version TEXT NOT NULL, + notes TEXT, + set_by INTEGER, + set_at TIMESTAMP, + is_current BOOLEAN, + change_pdf_path TEXT, + change_pdf_sha256 TEXT, + change_pdf_size INTEGER, + audit_detail_pdf_path TEXT, + audit_detail_pdf_sha256 TEXT, + audit_detail_pdf_size INTEGER, + audit_layperson_pdf_path TEXT, + audit_layperson_pdf_sha256 TEXT, + audit_layperson_pdf_size INTEGER, + audit_run_id INTEGER +); + +CREATE TABLE IpBlacklist ( + id INTEGER NOT NULL, + ip TEXT NOT NULL, + grund TEXT, + aktiv BOOLEAN, + auto BOOLEAN, + expires_at TIMESTAMP, + created_at TIMESTAMP, + created_by INTEGER, + last_hit_at TIMESTAMP, + hit_count INTEGER, + safe_marked_by INTEGER, + safe_marked_at TIMESTAMP, + safe_reason TEXT, + origin_server TEXT +); + +CREATE TABLE LoginAudit ( + id INTEGER NOT NULL, + username TEXT, + ip TEXT, + user_agent TEXT, + success BOOLEAN, + reason TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ScopeAuditBlock ( + id INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL, + actor_user_id INTEGER, + actor_username TEXT, + actor_tenant_id INTEGER, + actor_is_admin BOOLEAN, + actor_is_superadmin BOOLEAN, + target_model TEXT, + target_id INTEGER, + target_tenant_id INTEGER, + route TEXT, + method TEXT, + reason TEXT, + ip TEXT, + user_agent TEXT +); + +CREATE TABLE SecurityAudit ( + id INTEGER NOT NULL, + app_version TEXT, + status TEXT, + report_mode TEXT, + audited_by INTEGER, + audited_by_name TEXT, + audited_at TIMESTAMP, + approved_by INTEGER, + approved_by_name TEXT, + approved_at TIMESTAMP, + results_json TEXT, + overall_status TEXT, + pass_count INTEGER, + fail_count INTEGER, + warn_count INTEGER, + skip_count INTEGER, + auditor_notes TEXT, + version_history_json TEXT, + signature_hash TEXT, + signed_at TIMESTAMP, + pdf_path TEXT, + pdf_sha256 TEXT, + pdf_size INTEGER, + sa_notified_at TIMESTAMP, + created_at TIMESTAMP +); + +CREATE TABLE SyncHistory ( + id INTEGER NOT NULL, + rented_server_id INTEGER NOT NULL, + started_at TIMESTAMP NOT NULL, + finished_at TIMESTAMP, + action TEXT, + status TEXT, + files_changed INTEGER, + backup_path TEXT, + error_message TEXT, + log_excerpt TEXT, + triggered_by INTEGER +); + +CREATE TABLE UpdateJob ( + id INTEGER NOT NULL, + status TEXT, + started_at TIMESTAMP NOT NULL, + finished_at TIMESTAMP, + last_heartbeat TIMESTAMP NOT NULL, + current_step TEXT, + progress_percent INTEGER, + log_excerpt TEXT, + error_message TEXT, + triggered_by INTEGER, + cancel_requested BOOLEAN NOT NULL +); + +CREATE TABLE UpdateSnapshot ( + id INTEGER NOT NULL, + snapshot_file TEXT NOT NULL, + snapshot_size BIGINT, + version_before TEXT, + version_after TEXT, + status TEXT, + log_excerpt TEXT, + created_at TIMESTAMP NOT NULL, + restored_at TIMESTAMP, + rollback_note TEXT +); + +CREATE TABLE TerminVorschlag ( + id INTEGER NOT NULL, + tenant_id INTEGER, + project_id INTEGER, + defect_id INTEGER, + titel TEXT NOT NULL, + beschreibung TEXT, + termin_typ TEXT, + vorschlaege_json TEXT, + status TEXT, + accepted_index INTEGER, + accepted_reminder_id INTEGER, + accepted_at TIMESTAMP, + accepted_ip TEXT, + token_slot1 TEXT, + token_slot2 TEXT, + token_slot3 TEXT, + token_decline TEXT NOT NULL, + token_cancel TEXT, + cancelled_at TIMESTAMP, + cancelled_ip TEXT, + expires_at TIMESTAMP NOT NULL, + erstellt_am TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE TerminVorschlagBlocker ( + id INTEGER NOT NULL, + slot_index INTEGER NOT NULL, + datum DATE NOT NULL, + zeit_von TEXT, + zeit_bis TEXT, + caldav_uid TEXT NOT NULL, + pushed_at TIMESTAMP, + deleted_at TIMESTAMP, + erstellt_am TIMESTAMP +); + +CREATE TABLE HandbookFeature ( + id INTEGER NOT NULL, + titel TEXT NOT NULL, + beschreibung TEXT, + kategorie TEXT, + version TEXT, + reihenfolge INTEGER, + aktiv BOOLEAN, + datum DATE, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE BrancheTemplate ( + id INTEGER NOT NULL, + branche TEXT NOT NULL, + label TEXT, + html_content TEXT, + aktiv BOOLEAN, + erstellt_am TIMESTAMP, + aktualisiert_am TIMESTAMP +); + +CREATE TABLE ServiceContractItem ( + id INTEGER NOT NULL, + sort_order INTEGER, + pos_typ TEXT, + titel TEXT, + beschreibung TEXT, + intervall TEXT, + preis_netto DOUBLE PRECISION, + menge DOUBLE PRECISION, + mwst_satz DOUBLE PRECISION, + rabatt_typ TEXT, + rabatt_wert DOUBLE PRECISION, + rabatt_bis DATE, + rabatt_grund TEXT, + aktiv_ab DATE, + aktiv_bis DATE, + abgerechnet_bis DATE, + sofort_rechnung BOOLEAN, + aktiv BOOLEAN, + created_at TIMESTAMP +); + +CREATE TABLE ContractBonus ( + id INTEGER NOT NULL, + bonus_typ TEXT NOT NULL, + wert DOUBLE PRECISION, + grund TEXT, + aktiv_ab DATE, + aktiv_bis DATE, + verbraucht BOOLEAN, + verbraucht_am TIMESTAMP, + verbraucht_in_workorder_id INTEGER, + promo_code_id INTEGER, + aktiv BOOLEAN, + created_at TIMESTAMP, + created_by INTEGER +); + +CREATE TABLE PromoCode ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + code TEXT NOT NULL, + titel TEXT, + beschreibung TEXT, + code_typ TEXT, + wert DOUBLE PRECISION, + gueltig_ab DATE, + gueltig_bis DATE, + max_einloesungen INTEGER, + aktuelle_einloesungen INTEGER, + nur_neukunden BOOLEAN, + min_vertragswert_netto DOUBLE PRECISION, + bonus_aktiv_monate INTEGER, + aktiv BOOLEAN, + created_at TIMESTAMP, + created_by INTEGER +); + +CREATE TABLE OnboardingTemplate ( + id INTEGER NOT NULL, + kind TEXT NOT NULL, + label TEXT, + subject TEXT, + html_content TEXT, + plain_body TEXT, + aktiv BOOLEAN, + erstellt_am TIMESTAMP, + aktualisiert_am TIMESTAMP +); + +CREATE TABLE PartnerContractTemplate ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + label TEXT, + html_content TEXT, + aktiv BOOLEAN, + erstellt_am TIMESTAMP, + aktualisiert_am TIMESTAMP +); + +CREATE TABLE PortalAutoLoginToken ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + customer_portal_user_id INTEGER NOT NULL, + workorder_id INTEGER, + token TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + last_used_at TIMESTAMP, + revoked_at TIMESTAMP, + use_count INTEGER NOT NULL, + created_by TEXT, + scope TEXT, + permanent BOOLEAN +); + +CREATE TABLE MailTemplate ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + kind TEXT NOT NULL, + subject TEXT NOT NULL, + html_body TEXT NOT NULL, + plain_body TEXT, + updated_at TIMESTAMP NOT NULL, + updated_by_user_id INTEGER +); + +CREATE TABLE TresorPwResetToken ( + id INTEGER NOT NULL, + customer_id INTEGER NOT NULL, + token TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + used_at TIMESTAMP, + created_by TEXT +); + +CREATE TABLE LegacyRouteKey ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + key_data TEXT NOT NULL, + retired_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + note TEXT +); + +CREATE TABLE RevokedToken ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + token_hash TEXT NOT NULL, + revoked_at TIMESTAMP NOT NULL, + revoked_by_uid INTEGER, + reason TEXT +); + +CREATE TABLE GeoblockSetting ( + id INTEGER NOT NULL, + country_code TEXT NOT NULL, + country_name TEXT NOT NULL, + continent TEXT, + blocked BOOLEAN NOT NULL, + updated_at TIMESTAMP, + updated_by INTEGER +); + +CREATE TABLE GeoblockAllowIP ( + id INTEGER NOT NULL, + cidr TEXT NOT NULL, + note TEXT, + created_at TIMESTAMP, + created_by INTEGER +); + +CREATE TABLE GeoblockStat ( + id INTEGER NOT NULL, + country_code TEXT NOT NULL, + stat_date DATE NOT NULL, + block_count INTEGER NOT NULL +); + +CREATE TABLE ClaudeLesson ( + id INTEGER NOT NULL, + session_date DATE NOT NULL, + category TEXT NOT NULL, + severity TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + tags TEXT, + related_patches TEXT, + is_active BOOLEAN, + created_at TIMESTAMP, + created_by TEXT +); + +CREATE TABLE ClaudeStaticSection ( + id INTEGER NOT NULL, + position INTEGER NOT NULL, + section_key TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + has_timestamp_placeholder BOOLEAN, + is_active BOOLEAN, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE TaxRate ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + prozent DOUBLE PRECISION NOT NULL, + aktiv BOOLEAN NOT NULL, + is_default BOOLEAN NOT NULL, + sort_order INTEGER NOT NULL, + created_at TIMESTAMP +); + +CREATE TABLE ShiftTicket ( + id INTEGER NOT NULL, + tenant_id INTEGER, + token TEXT NOT NULL, + valid_until TIMESTAMP NOT NULL, + created_at TIMESTAMP, + created_by_id INTEGER, + used_at TIMESTAMP +); + +CREATE TABLE ErpLegalProfile ( + id INTEGER NOT NULL, + tenant_id INTEGER, + rechtsform TEXT, + festschreib_periode TEXT, + kleinunternehmer BOOLEAN, + besteuerungsart TEXT, + buchen_belegpflicht BOOLEAN, + lager_fibu_methode TEXT, + lager_wareneinsatz_konto TEXT, + bank_sachkonto_auto_pi BOOLEAN, + customer_bank_autosync TEXT, + handelsregister TEXT, + steuernummer TEXT, + ust_id TEXT, + default_currency TEXT, + currency_custom_code TEXT, + currency_custom_symbol TEXT, + datev_beraternr TEXT, + datev_mandantnr TEXT, + datev_wj_beginn INTEGER, + erp_startdatum DATE, + legacy_source TEXT, + legacy_object_id TEXT, + created_at TIMESTAMP, + updated_at TIMESTAMP, + imap_server TEXT, + imap_port INTEGER, + imap_user TEXT, + imap_password TEXT, + imap_folder TEXT, + imap_ssl BOOLEAN, + imap_aktiv BOOLEAN, + imap_forward_to TEXT, + imap_interval INTEGER, + gewerbesteuer_hebesatz INTEGER, + erechnung_format TEXT +); + +CREATE TABLE ErpSupplierIban ( + id INTEGER NOT NULL, + supplier_id INTEGER NOT NULL, + iban TEXT NOT NULL, + bic TEXT, + notiz TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ErpFintsInstitute ( + blz TEXT NOT NULL, + bic TEXT, + institut TEXT, + ort TEXT, + rz TEXT, + organisation TEXT, + hbci_dns TEXT, + hbci_version TEXT, + pintan_url TEXT, + fints_version TEXT, + updated_at_src DATE, + imported_at TIMESTAMP +); + +CREATE TABLE ErpExchangeRate ( + id INTEGER NOT NULL, + tenant_id INTEGER, + from_currency TEXT, + to_currency TEXT, + rate NUMERIC, + valid_from DATE, + valid_until DATE, + source TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ErpEstTarifParams ( + id INTEGER NOT NULL, + tenant_id INTEGER, + jahr INTEGER NOT NULL, + grundfreibetrag INTEGER, + zone2_bis INTEGER, + zone3_bis INTEGER, + zone4_bis INTEGER, + z2a TEXT, + z2b TEXT, + z3a TEXT, + z3b TEXT, + z3c TEXT, + z4_abzug TEXT, + z5_abzug TEXT, + soli_freigrenze INTEGER, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE ErpKuGrenzen ( + id INTEGER NOT NULL, + tenant_id INTEGER, + jahr INTEGER NOT NULL, + grenze_vorjahr INTEGER, + grenze_laufend INTEGER, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE ErpLedgerLock ( + id INTEGER NOT NULL, + tenant_id INTEGER, + buchungsperiode TEXT NOT NULL, + status TEXT, + locked_at TIMESTAMP, + locked_by_user_id INTEGER, + hash_snapshot TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ErpAuditTrail ( + id INTEGER NOT NULL, + tenant_id INTEGER, + seq_no INTEGER NOT NULL, + entity TEXT NOT NULL, + entity_id INTEGER, + aktion TEXT NOT NULL, + user_id INTEGER, + ip TEXT, + user_agent TEXT, + freitext TEXT, + before_hash TEXT, + after_hash TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ErpChartOfAccounts ( + id INTEGER NOT NULL, + tenant_id INTEGER, + rahmen TEXT NOT NULL, + aktiv BOOLEAN, + gesperrt_ab TIMESTAMP, + created_at TIMESTAMP +); + +CREATE TABLE ErpAccount ( + id INTEGER NOT NULL, + tenant_id INTEGER, + rahmen TEXT NOT NULL, + kontonummer TEXT NOT NULL, + bezeichnung TEXT NOT NULL, + kontoart TEXT, + kontenklasse INTEGER, + steuer_relevant BOOLEAN, + ust_kennzeichen TEXT, + automatik_konto BOOLEAN, + gesperrt BOOLEAN, + sort_order INTEGER, + currency TEXT, + legacy_source TEXT, + legacy_object_id TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ErpCostCenter ( + id INTEGER NOT NULL, + tenant_id INTEGER, + nummer TEXT NOT NULL, + bezeichnung TEXT, + aktiv BOOLEAN, + parent_id INTEGER, + created_at TIMESTAMP +); + +CREATE TABLE ErpFiscalYear ( + id INTEGER NOT NULL, + tenant_id INTEGER, + jahr INTEGER NOT NULL, + beginn DATE NOT NULL, + ende DATE NOT NULL, + status TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ErpPeriod ( + id INTEGER NOT NULL, + tenant_id INTEGER, + fiscal_year_id INTEGER, + periode_key TEXT NOT NULL, + bezeichnung TEXT, + status TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ErpTaxAccountMap ( + id INTEGER NOT NULL, + tenant_id INTEGER, + rahmen TEXT NOT NULL, + ust_kennzeichen TEXT NOT NULL, + beschreibung TEXT, + ust_konto TEXT, + vst_konto TEXT, + steuer_konto TEXT, + prozent NUMERIC, + gueltig_ab DATE, + gueltig_bis DATE, + created_at TIMESTAMP +); + +CREATE TABLE ErpJournalEntry ( + id INTEGER NOT NULL, + tenant_id INTEGER, + belegnummer TEXT NOT NULL, + belegdatum DATE NOT NULL, + buchungsdatum DATE NOT NULL, + buchungstext TEXT, + erfasst_von_user_id INTEGER, + festgeschrieben BOOLEAN, + festgeschrieben_am TIMESTAMP, + storno_of_id INTEGER, + herkunft TEXT, + herkunft_ref_id INTEGER, + currency TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ErpJournalLine ( + id INTEGER NOT NULL, + entry_id INTEGER NOT NULL, + konto TEXT NOT NULL, + gegenkonto TEXT, + soll_betrag NUMERIC, + haben_betrag NUMERIC, + steuer_betrag NUMERIC, + ust_kennzeichen TEXT, + kostenstelle_id INTEGER, + zeilentext TEXT +); + +CREATE TABLE ErpDebitorAccount ( + id INTEGER NOT NULL, + tenant_id INTEGER, + customer_id INTEGER NOT NULL, + kontonummer TEXT NOT NULL, + created_at TIMESTAMP +); + +CREATE TABLE ErpSupplier ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT NOT NULL, + anschrift TEXT, + ust_id TEXT, + steuernummer TEXT, + iban TEXT, + bic TEXT, + zahlungsziel INTEGER, + skonto_tage INTEGER, + skonto_prozent DOUBLE PRECISION, + kreditorenkonto TEXT, + aufwandskonto_default TEXT, + ansprechpartner TEXT, + ap_position TEXT, + telefon TEXT, + email TEXT, + website TEXT, + strasse TEXT, + plz TEXT, + ort TEXT, + land TEXT, + unsere_kundennr TEXT, + notizen TEXT, + lieferzeit_tage INTEGER, + mindestbestellwert NUMERIC, + aktiv BOOLEAN, + created_at TIMESTAMP +); + +CREATE TABLE ErpPurchaseInvoice ( + id INTEGER NOT NULL, + tenant_id INTEGER, + rechnungsnr TEXT NOT NULL, + belegdatum DATE NOT NULL, + eingangsdatum DATE, + faellig_am DATE, + netto NUMERIC, + steuer NUMERIC, + brutto NUMERIC, + currency TEXT, + aufwandskonto TEXT, + buchungstext TEXT, + split_buchung TEXT, + geprueft BOOLEAN, + geprueft_von INTEGER, + geprueft_am TIMESTAMP, + dokument_pfad TEXT, + status TEXT, + bezahlt BOOLEAN, + bezahlt_am DATE, + journal_entry_id INTEGER, + created_at TIMESTAMP, + skontofrist INTEGER, + skontosatz NUMERIC +); + +CREATE TABLE ErpSupplierArticle ( + id INTEGER NOT NULL, + tenant_id INTEGER, + lieferanten_artikelnr TEXT, + ek_preis NUMERIC, + currency TEXT, + mindestmenge INTEGER, + staffelpreise TEXT, + lieferzeit_tage INTEGER, + ist_hauptlieferant BOOLEAN, + aktiv BOOLEAN, + created_at TIMESTAMP +); + +CREATE TABLE ErpPurchaseOrder ( + id INTEGER NOT NULL, + tenant_id INTEGER, + bestellnummer TEXT NOT NULL, + status TEXT, + bestelldatum DATE, + liefertermin DATE, + netto_summe NUMERIC, + steuer_summe NUMERIC, + brutto_summe NUMERIC, + currency TEXT, + notizen TEXT, + purchase_invoice_id INTEGER, + erstellt_von INTEGER, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE ErpPurchaseOrderLine ( + id INTEGER NOT NULL, + purchase_order_id INTEGER NOT NULL, + artikelnr TEXT, + bezeichnung TEXT, + menge DOUBLE PRECISION NOT NULL, + ek_preis NUMERIC, + steuersatz NUMERIC, + currency TEXT, + geliefert_menge DOUBLE PRECISION, + notiz TEXT +); + +CREATE TABLE ErpMaterialBedarf ( + id INTEGER NOT NULL, + tenant_id INTEGER, + menge_position DOUBLE PRECISION, + menge_bestand DOUBLE PRECISION, + menge_bedarf DOUBLE PRECISION, + menge_bestellt DOUBLE PRECISION, + menge_geliefert DOUBLE PRECISION, + einheit TEXT, + best_ek NUMERIC, + status TEXT, + workorder_nr TEXT, + artikel_bez TEXT, + created_at TIMESTAMP, + created_by INTEGER, + notiz TEXT +); + +CREATE TABLE ErpCashRegister ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT, + sachkonto TEXT, + currency TEXT, + aktiv BOOLEAN, + anfangssaldo NUMERIC, + created_at TIMESTAMP +); + +CREATE TABLE ErpCashEntry ( + id INTEGER NOT NULL, + tenant_id INTEGER, + lfd_nr INTEGER NOT NULL, + datum DATE NOT NULL, + vorgang TEXT NOT NULL, + betrag NUMERIC NOT NULL, + gegenkonto TEXT, + zweck TEXT, + erfasst_von_user_id INTEGER, + festgeschrieben BOOLEAN, + storno_of_id INTEGER, + journal_entry_id INTEGER, + created_at TIMESTAMP +); + +CREATE TABLE ErpDocument ( + id INTEGER NOT NULL, + tenant_id INTEGER, + original_name TEXT NOT NULL, + stored_name TEXT NOT NULL, + mime_type TEXT, + size_bytes BIGINT, + integrity_hash TEXT, + belegtyp TEXT, + beleg_status TEXT, + steuerberater_relevant BOOLEAN, + beschreibung TEXT, + customer_id INTEGER, + purchase_invoice_id INTEGER, + asset_id INTEGER, + journal_entry_id INTEGER, + legacy_source TEXT, + legacy_object_id TEXT, + legacy_tree_id TEXT, + legacy_filename TEXT, + legacy_imported_at TIMESTAMP, + uploaded_by INTEGER, + uploaded_at TIMESTAMP, + kategorie_id INTEGER, + periode TEXT, + tags TEXT, + beleg_datum DATE, + lieferant_id INTEGER, + workorder_id INTEGER, + bank_tx_id INTEGER, + ocr_status TEXT +); + +CREATE TABLE ErpDocumentFulltext ( + id INTEGER NOT NULL, + quelle TEXT, + text TEXT, + indexed_at TIMESTAMP +); + +CREATE TABLE ErpDmsKategorie ( + id INTEGER NOT NULL, + tenant_id INTEGER, + parent_id INTEGER, + name TEXT NOT NULL, + beschreibung TEXT, + icon TEXT, + sort_order INTEGER, + aktiv BOOLEAN, + created_at TIMESTAMP +); + +CREATE TABLE ErpDmsAudit ( + id INTEGER NOT NULL, + tenant_id INTEGER, + document_id INTEGER NOT NULL, + user_id INTEGER, + aktion TEXT NOT NULL, + von_status TEXT, + zu_status TEXT, + kommentar TEXT, + created_at TIMESTAMP +); + +CREATE TABLE SambaShare ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT NOT NULL, + path TEXT NOT NULL, + comment TEXT, + browseable BOOLEAN, + aktiv BOOLEAN, + created_at TIMESTAMP +); + +CREATE TABLE SambaShareAcl ( + id INTEGER NOT NULL, + can_read BOOLEAN, + can_write BOOLEAN +); + +CREATE TABLE ScanRequest ( + id INTEGER NOT NULL, + tenant_id INTEGER, + user_id INTEGER NOT NULL, + ziel_typ TEXT NOT NULL, + ziel_id INTEGER, + status TEXT, + document_id INTEGER, + created_at TIMESTAMP, + completed_at TIMESTAMP, + expires_at TIMESTAMP, + hinweis TEXT +); + +CREATE TABLE EinsatzSession ( + id INTEGER NOT NULL, + tenant_id INTEGER, + pairing_code TEXT NOT NULL, + qr_token TEXT, + code_expires_at TIMESTAMP NOT NULL, + session_token TEXT, + user_id INTEGER, + customer_id INTEGER, + reminder_id INTEGER, + workorder_id INTEGER, + scope TEXT, + status TEXT, + paired_at TIMESTAMP, + expires_at TIMESTAMP, + last_activity TIMESTAMP, + ended_at TIMESTAMP, + ended_by TEXT, + browser_fp TEXT, + ip_address TEXT, + user_agent TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ErpSerialCharge ( + id INTEGER NOT NULL, + tenant_id INTEGER, + typ TEXT, + nummer TEXT NOT NULL, + mhd DATE, + status TEXT, + workorder_id INTEGER, + movement_id INTEGER, + created_at TIMESTAMP +); + +CREATE TABLE ErpEinvoiceImport ( + id INTEGER NOT NULL, + tenant_id INTEGER, + status TEXT, + filename TEXT, + dateityp TEXT, + raw_xml TEXT, + profil TEXT, + format_typ TEXT, + lieferant_name TEXT, + lieferant_ustid TEXT, + lieferant_iban TEXT, + rechnungsnr TEXT, + rechnungsdatum DATE, + faelligkeit DATE, + waehrung TEXT, + netto_betrag NUMERIC, + steuer_betrag NUMERIC, + brutto_betrag NUMERIC, + steuer_prozent NUMERIC, + verwendungszweck TEXT, + positionen_json TEXT, + purchase_invoice_id INTEGER, + document_id INTEGER, + fehler TEXT, + warnungen TEXT, + hash_dedup TEXT, + created_at TIMESTAMP, + created_by INTEGER, + confirmed_at TIMESTAMP, + confirmed_by INTEGER +); + +CREATE TABLE ErpSupplierCsvMapping ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT, + trennzeichen TEXT, + encoding TEXT, + header_zeile INTEGER, + skip_zeilen INTEGER, + col_ean TEXT, + col_artikelnr TEXT, + col_bezeichnung TEXT, + col_ek_preis TEXT, + col_mindestmenge TEXT, + col_einheit TEXT, + col_hersteller TEXT, + col_hersteller_anr TEXT, + col_bild_url TEXT, + col_kategorie TEXT, + col_unterkategorie TEXT, + fuzzy_schwelle INTEGER, + auto_anlegen BOOLEAN, + auto_kategorie TEXT, + auto_update_url TEXT, + auto_update_aktiv BOOLEAN, + auto_update_auth_type TEXT, + auto_update_auth_user TEXT, + auto_update_auth_pass TEXT, + auto_update_auth_header TEXT, + auto_update_omd_token_url TEXT, + auto_update_omd_client_id TEXT, + auto_update_omd_client_secret TEXT, + auto_update_omd_cred_location TEXT, + auto_update_omd_customer_id TEXT, + auto_update_intervall INTEGER, + auto_update_typ TEXT, + auto_update_uhrzeit TEXT, + auto_update_wochentage TEXT, + auto_update_next_run TIMESTAMP, + auto_inaktiv_setzen BOOLEAN, + auto_update_last_status TEXT, + auto_update_last_error TEXT, + auto_update_last_run TIMESTAMP, + last_import_at TIMESTAMP, + last_import_count INTEGER, + created_at TIMESTAMP +); + +CREATE TABLE ErpStockMovement ( + id INTEGER NOT NULL, + tenant_id INTEGER, + datum DATE NOT NULL, + bewegungsart TEXT, + menge DOUBLE PRECISION NOT NULL, + ek_preis DOUBLE PRECISION, + currency TEXT, + herkunft TEXT, + herkunft_ref_id INTEGER, + notiz TEXT, + erfasst_von INTEGER, + erfasst_am TIMESTAMP, + mitarbeiter_id INTEGER, + supplier_id INTEGER, + gutschrift_nr TEXT, + schadensgrund TEXT +); + +CREATE TABLE ErpAsset ( + id INTEGER NOT NULL, + tenant_id INTEGER, + inventarnr TEXT, + bezeichnung TEXT NOT NULL, + anlagenklasse TEXT, + anschaffungsdatum DATE NOT NULL, + anschaffungskosten NUMERIC NOT NULL, + currency TEXT, + nutzungsdauer_jahre INTEGER, + afa_methode TEXT, + anlagenkonto TEXT, + afa_konto TEXT, + kum_afa NUMERIC, + letztes_afa_jahr INTEGER, + aktiv BOOLEAN, + abgang_datum DATE, + abgang_art TEXT, + device_id INTEGER, + kostenstelle_id INTEGER, + created_at TIMESTAMP +); + +CREATE TABLE ErpBankAccount ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT, + iban TEXT, + bic TEXT, + sachkonto TEXT, + aktiv BOOLEAN, + kontotyp TEXT, + ist_hauptkonto BOOLEAN, + anfangssaldo NUMERIC, + fints_aktiv BOOLEAN, + fints_blz TEXT, + fints_endpoint_url TEXT, + fints_login TEXT, + fints_pin_encrypted TEXT, + fints_tan_methode TEXT, + fints_letzter_abruf TIMESTAMP, + fints_status TEXT, + fints_letzter_fehler TEXT, + fints_system_id TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ErpBankTransaction ( + id INTEGER NOT NULL, + tenant_id INTEGER, + bank_account_id INTEGER, + buchungsdatum DATE, + betrag NUMERIC NOT NULL, + verwendungszweck TEXT, + gegenname TEXT, + gegen_iban TEXT, + status TEXT, + match_wo_id INTEGER, + match_pi_id INTEGER, + match_type TEXT, + match_transfer_tx_id INTEGER, + match_sachkonto TEXT, + match_steuersatz INTEGER, + import_hash TEXT, + created_at TIMESTAMP, + document_id INTEGER, + match_darlehen_id INTEGER, + match_split_data TEXT, + match_abschlag_id INTEGER, + match_konto_override TEXT, + hinweis_text TEXT +); + +CREATE TABLE ErpDarlehen ( + id INTEGER NOT NULL, + tenant_id INTEGER, + darlehensnr TEXT, + bezeichnung TEXT, + glaeubiger TEXT, + glaeubiger_iban TEXT, + darlehensbetrag_brutto NUMERIC, + restschuld NUMERIC, + zinssatz NUMERIC, + startdatum DATE, + enddatum_geplant DATE, + monatsrate_soll NUMERIC, + konto_darlehen TEXT, + konto_zinsen TEXT, + aktiv BOOLEAN, + notiz TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ErpAbschlagskonto ( + id INTEGER NOT NULL, + tenant_id INTEGER, + bezeichnung TEXT, + anbieter TEXT, + anbieter_iban TEXT, + vertragsnummer TEXT, + art TEXT, + monatlicher_abschlag NUMERIC, + konto_aufwand TEXT, + ust_satz INTEGER, + konto_aufwand_2 TEXT, + anteil_aufwand_2_proz INTEGER, + aktiv BOOLEAN, + notiz TEXT, + created_at TIMESTAMP +); + +CREATE TABLE ErpPaymentRun ( + id INTEGER NOT NULL, + tenant_id INTEGER, + ausfuehrungsdatum DATE NOT NULL, + status TEXT, + summe NUMERIC, + anzahl INTEGER, + currency TEXT, + xml_exported BOOLEAN, + created_at TIMESTAMP, + created_by_id INTEGER +); + +CREATE TABLE ErpPaymentRunItem ( + id INTEGER NOT NULL, + run_id INTEGER NOT NULL, + purchase_invoice_id INTEGER, + empfaenger TEXT NOT NULL, + iban TEXT NOT NULL, + bic TEXT, + betrag NUMERIC NOT NULL, + currency TEXT, + verwendungszweck TEXT +); + +CREATE TABLE ErpVatReturn ( + id INTEGER NOT NULL, + tenant_id INTEGER, + jahr INTEGER NOT NULL, + monat INTEGER NOT NULL, + kz81 NUMERIC, + kz86 NUMERIC, + kz66 NUMERIC, + ust_19 NUMERIC, + ust_7 NUMERIC, + zahllast NUMERIC, + status TEXT, + berechnet_am TIMESTAMP, + created_at TIMESTAMP +); + +CREATE TABLE ErpPreisSchema ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT NOT NULL, + beschreibung TEXT, + fix_aufschlag NUMERIC, + rundung_typ TEXT, + mindestmarge_pct NUMERIC, + auto_neuberechnung BOOLEAN, + ist_default BOOLEAN, + aktiv BOOLEAN, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE ErpPreisSpanne ( + id INTEGER NOT NULL, + tenant_id INTEGER, + schema_id INTEGER NOT NULL, + preis_von NUMERIC NOT NULL, + preis_bis NUMERIC, + aufschlag_pct NUMERIC NOT NULL, + sort_order INTEGER +); + +CREATE TABLE ErpPreisGruppe ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT NOT NULL, + rabatt_pct NUMERIC, + schema_id INTEGER, + ist_default BOOLEAN, + sort_order INTEGER, + aktiv BOOLEAN, + created_at TIMESTAMP +); + +CREATE TABLE ErpLohnPeriode ( + id INTEGER NOT NULL, + tenant_id INTEGER, + jahr INTEGER NOT NULL, + monat INTEGER NOT NULL, + brutto_loehne NUMERIC, + ag_sv_anteile NUMERIC, + an_sv_anteile NUMERIC, + lohnsteuer_gesamt NUMERIC, + currency TEXT, + notiz TEXT, + gebucht_am TIMESTAMP, + gebucht_von INTEGER, + erfasst_am TIMESTAMP, + erfasst_von INTEGER +); + +CREATE TABLE DashboardDismissal ( + id INTEGER NOT NULL, + tenant_id INTEGER, + user_id INTEGER NOT NULL, + item_key TEXT NOT NULL, + created_at TIMESTAMP +); + +CREATE TABLE CustomerRemoteDevice ( + id INTEGER NOT NULL, + tenant_id INTEGER, + provider TEXT NOT NULL, + label TEXT, + device_id TEXT NOT NULL, + notizen TEXT, + created_at TIMESTAMP +); + +CREATE TABLE CrmCompany ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + rechtsform TEXT, + branche TEXT, + website TEXT, + telefon TEXT, + email TEXT, + strasse TEXT, + plz TEXT, + ort TEXT, + land TEXT, + ust_id TEXT, + customer_id INTEGER, + inhaber_user_id INTEGER, + notizen TEXT, + tags TEXT, + aktiv BOOLEAN, + geloescht_am TIMESTAMP, + erstellt_am TIMESTAMP, + geaendert_am TIMESTAMP +); + +CREATE TABLE CrmContact ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + company_id INTEGER, + customer_id INTEGER, + anrede TEXT, + vorname TEXT, + nachname TEXT NOT NULL, + position TEXT, + abteilung TEXT, + email TEXT, + telefon TEXT, + mobil TEXT, + ist_hauptkontakt BOOLEAN, + quelle TEXT, + notizen TEXT, + tags TEXT, + aktiv BOOLEAN, + geloescht_am TIMESTAMP, + erstellt_am TIMESTAMP, + geaendert_am TIMESTAMP, + carddav_uid TEXT, + carddav_href TEXT, + carddav_etag TEXT, + carddav_synced_at TIMESTAMP +); + +CREATE TABLE CrmPipeline ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + ist_default BOOLEAN, + sort_order INTEGER, + aktiv BOOLEAN, + geloescht_am TIMESTAMP, + erstellt_am TIMESTAMP +); + +CREATE TABLE CrmStage ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + pipeline_id INTEGER NOT NULL, + name TEXT NOT NULL, + sort_order INTEGER, + wahrscheinlichkeit INTEGER, + ist_gewonnen BOOLEAN, + ist_verloren BOOLEAN, + farbe TEXT, + aktiv BOOLEAN, + geloescht_am TIMESTAMP +); + +CREATE TABLE CrmLead ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + titel TEXT NOT NULL, + pipeline_id INTEGER NOT NULL, + stage_id INTEGER NOT NULL, + contact_id INTEGER, + company_id INTEGER, + customer_id INTEGER, + wert_netto NUMERIC, + waehrung TEXT, + wahrscheinlichkeit INTEGER, + quelle TEXT, + zustaendig_user_id INTEGER, + erwartetes_datum DATE, + status TEXT, + verloren_grund TEXT, + source_cold_lead_id INTEGER, + notizen TEXT, + tags TEXT, + category_id INTEGER, + aktiv BOOLEAN, + geloescht_am TIMESTAMP, + erstellt_am TIMESTAMP, + geaendert_am TIMESTAMP +); + +CREATE TABLE CrmCategory ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + farbe TEXT, + sort_order INTEGER, + aktiv BOOLEAN, + geloescht_am TIMESTAMP, + erstellt_am TIMESTAMP +); + +CREATE TABLE CrmActivity ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + typ TEXT NOT NULL, + betreff TEXT, + inhalt TEXT, + richtung TEXT, + ref_table TEXT, + ref_id INTEGER, + user_id INTEGER, + zeitpunkt TIMESTAMP, + geloescht_am TIMESTAMP, + erstellt_am TIMESTAMP +); + +CREATE TABLE CrmTask ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + titel TEXT NOT NULL, + beschreibung TEXT, + entity_type TEXT, + entity_id INTEGER, + faellig_am TIMESTAMP, + erinnerung_am TIMESTAMP, + prioritaet TEXT, + status TEXT, + zustaendig_user_id INTEGER, + erledigt_am TIMESTAMP, + geloescht_am TIMESTAMP, + erstellt_am TIMESTAMP, + geaendert_am TIMESTAMP +); + +CREATE TABLE CrmDocument ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + titel TEXT NOT NULL, + vorlage_id INTEGER, + entity_type TEXT, + entity_id INTEGER, + customer_id INTEGER, + storage_key TEXT, + mime_type TEXT, + format TEXT, + version INTEGER, + erp_document_id INTEGER, + status TEXT, + erstellt_von INTEGER, + folder_id INTEGER, + aktiv BOOLEAN, + geloescht_am TIMESTAMP, + erstellt_am TIMESTAMP, + geaendert_am TIMESTAMP +); + +CREATE TABLE CrmDocumentFolder ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + parent_id INTEGER, + name TEXT NOT NULL, + sort_order INTEGER, + geloescht_am TIMESTAMP, + erstellt_am TIMESTAMP +); + +CREATE TABLE CrmDocTemplate ( + id INTEGER NOT NULL, + tenant_id INTEGER, + name TEXT NOT NULL, + format TEXT, + storage_key TEXT, + beschreibung TEXT, + kategorie TEXT, + aktiv BOOLEAN, + geloescht_am TIMESTAMP, + erstellt_am TIMESTAMP +); + +CREATE TABLE CrmDocumentLink ( + id INTEGER NOT NULL, + tenant_id INTEGER NOT NULL, + erp_document_id INTEGER NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + zugeordnet_von_user_id INTEGER, + zugeordnet_am TIMESTAMP, + quelle TEXT, + geloescht_am TIMESTAMP, + erstellt_am TIMESTAMP +); + +CREATE TABLE RentedServerEvent ( + id INTEGER NOT NULL, + ts TIMESTAMP NOT NULL, + kind TEXT NOT NULL, + level TEXT, + message TEXT, + payload TEXT +); + diff --git a/.claude/harvest/woa-rs/woa_graph.spo b/.claude/harvest/woa-rs/woa_graph.spo new file mode 100644 index 0000000..aba7f66 --- /dev/null +++ b/.claude/harvest/woa-rs/woa_graph.spo @@ -0,0 +1,110 @@ +# WoA association graph — SPO edges (ruff_sqlalchemy_spo -> OGAR), classid-keyed +# subject[classid] --assoc_kind:name--> object[classid] + +User[0x00000000] --BelongsTo:tenant--> Tenant[0x00000000] +User[0x00000000] --BelongsTo:tresor_customer--> Customer[0x02040003] +Customer[0x02040003] --HasMany:workorders--> WorkOrder[0x02020003] +Project[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +Project[0x00000000] --BelongsTo:notes--> ProjectNote[0x00000000] +ProjectNote[0x00000000] --BelongsTo:project--> Project[0x00000000] +ErpArticleCategory[0x00000000] --HasMany:parent--> ErpArticleCategory[0x00000000] +ErpStorageLocation[0x00000000] --HasMany:parent--> ErpStorageLocation[0x00000000] +WorkOrder[0x02020003] --BelongsTo:project--> Project[0x00000000] +WorkOrder[0x02020003] --HasMany:positionen--> Position[0x02010003] +WorkOrder[0x02020003] --HasMany:activities--> Activity[0x00000000] +WorkOrder[0x02020003] --HasMany:pictures--> Picture[0x00000000] +WorkOrder[0x02020003] --HasMany:history--> HistoryEntry[0x00000000] +WorkOrder[0x02020003] --BelongsTo:acceptance_protocols--> AcceptanceProtocol[0x00000000] +WorkOrder[0x02020003] --HasMany:child_workorders--> WorkOrder[0x02020003] +Activity[0x00000000] --BelongsTo:acceptance_items--> AcceptanceItem[0x00000000] +Document[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +Document[0x00000000] --BelongsTo:workorder--> WorkOrder[0x02020003] +AcceptanceProtocol[0x00000000] --BelongsTo:workorder--> WorkOrder[0x02020003] +AcceptanceProtocol[0x00000000] --BelongsTo:items--> AcceptanceItem[0x00000000] +AcceptanceProtocol[0x00000000] --BelongsTo:defects--> AcceptanceDefect[0x00000000] +AcceptanceItem[0x00000000] --BelongsTo:protocol--> AcceptanceProtocol[0x00000000] +AcceptanceItem[0x00000000] --BelongsTo:activity--> Activity[0x00000000] +AcceptanceDefect[0x00000000] --BelongsTo:protocol--> AcceptanceProtocol[0x00000000] +AcceptanceTemplate[0x00000000] --BelongsTo:items--> AcceptanceTemplateItem[0x00000000] +AcceptanceTemplateItem[0x00000000] --BelongsTo:template--> AcceptanceTemplate[0x00000000] +HistoryEntry[0x00000000] --BelongsTo:user--> User[0x00000000] +LogbookEntry[0x00000000] --BelongsTo:user--> User[0x00000000] +LogbookEntry[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +CustomerPortalUser[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +PasswordEntry[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +PasswordEntry[0x00000000] --BelongsTo:creator--> User[0x00000000] +TimeSheet[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +TimeSheet[0x00000000] --BelongsTo:user--> User[0x00000000] +TimesheetActivity[0x01030003] --BelongsTo:timesheet--> TimeSheet[0x00000000] +Reminder[0x00000000] --BelongsTo:project--> Project[0x00000000] +Reminder[0x00000000] --BelongsTo:user--> User[0x00000000] +Reminder[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +Reminder[0x00000000] --BelongsTo:workorder--> WorkOrder[0x02020003] +MaintenanceContract[0x00000000] --HasMany:customer--> Customer[0x02040003] +RecurringInvoice[0x02020003] --BelongsTo:customer--> Customer[0x02040003] +RecurringInvoice[0x02020003] --BelongsTo:contract--> MaintenanceContract[0x00000000] +Device[0x00000000] --HasMany:customer--> Customer[0x02040003] +KummerkastenEntry[0x00000000] --BelongsTo:user--> User[0x00000000] +KummerkastenEntry[0x00000000] --BelongsTo:tenant--> Tenant[0x00000000] +KummerkastenEntry[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +ReferralLog[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +ReferralLog[0x00000000] --BelongsTo:tenant--> Tenant[0x00000000] +ColdLead[0x00000000] --HasMany:activities--> ColdLeadActivity[0x00000000] +ColdCampaign[0x00000000] --HasMany:leads--> ColdCampaignLead[0x00000000] +ColdCampaignLead[0x00000000] --BelongsTo:lead--> ColdLead[0x00000000] +ServiceContract[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +ServiceContract[0x00000000] --BelongsTo:package--> ServicePackage[0x00000000] +ServiceContract[0x00000000] --BelongsTo:server--> RentedServer[0x00000000] +PartnerCommission[0x00000000] --BelongsTo:partner--> SalesPartner[0x00000000] +PartnerCommission[0x00000000] --BelongsTo:contract--> ServiceContract[0x00000000] +PartnerCommission[0x00000000] --BelongsTo:source_workorder--> WorkOrder[0x02020003] +PartnerPayout[0x00000000] --BelongsTo:partner--> SalesPartner[0x00000000] +PartnerPayout[0x00000000] --HasMany:commissions--> PartnerCommission[0x00000000] +ContractSetup[0x00000000] --HasMany:contract--> ServiceContract[0x00000000] +IpBlacklist[0x00000000] --BelongsTo:creator--> User[0x00000000] +IpBlacklist[0x00000000] --BelongsTo:safe_marker--> User[0x00000000] +SecurityAudit[0x00000000] --BelongsTo:auditor--> User[0x00000000] +SecurityAudit[0x00000000] --BelongsTo:approver--> User[0x00000000] +SyncHistory[0x00000000] --BelongsTo:server--> RentedServer[0x00000000] +UpdateJob[0x00000000] --BelongsTo:server--> RentedServer[0x00000000] +TerminVorschlag[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +TerminVorschlag[0x00000000] --BelongsTo:user--> User[0x00000000] +TerminVorschlag[0x00000000] --BelongsTo:workorder--> WorkOrder[0x02020003] +TerminVorschlag[0x00000000] --BelongsTo:reminder--> Reminder[0x00000000] +TerminVorschlagBlocker[0x00000000] --BelongsTo:tv--> TerminVorschlag[0x00000000] +ServiceContractItem[0x00000000] --BelongsTo:contract--> ServiceContract[0x00000000] +ServiceContractItem[0x00000000] --BelongsTo:package--> ServicePackage[0x00000000] +ContractBonus[0x00000000] --HasMany:contract--> ServiceContract[0x00000000] +PortalAutoLoginToken[0x00000000] --BelongsTo:portal_user--> CustomerPortalUser[0x00000000] +ShiftTicket[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +ShiftTicket[0x00000000] --BelongsTo:project--> Project[0x00000000] +ShiftTicket[0x00000000] --BelongsTo:assigned_user--> User[0x00000000] +ShiftTicket[0x00000000] --BelongsTo:reminder--> Reminder[0x00000000] +ShiftTicket[0x00000000] --BelongsTo:used_workorder--> WorkOrder[0x02020003] +ErpJournalEntry[0x00000000] --HasMany:lines--> ErpJournalLine[0x00000000] +ErpPurchaseInvoice[0x00000000] --BelongsTo:supplier--> ErpSupplier[0x00000000] +ErpSupplierArticle[0x00000000] --BelongsTo:supplier--> ErpSupplier[0x00000000] +ErpSupplierArticle[0x00000000] --BelongsTo:article--> Article[0x00000000] +ErpPurchaseOrder[0x00000000] --BelongsTo:supplier--> ErpSupplier[0x00000000] +ErpPurchaseOrder[0x00000000] --HasMany:lines--> ErpPurchaseOrderLine[0x00000000] +ErpPurchaseOrderLine[0x00000000] --BelongsTo:article--> Article[0x00000000] +ErpMaterialBedarf[0x00000000] --BelongsTo:workorder--> WorkOrder[0x02020003] +ErpMaterialBedarf[0x00000000] --BelongsTo:article--> Article[0x00000000] +ErpMaterialBedarf[0x00000000] --BelongsTo:best_supplier--> ErpSupplier[0x00000000] +ErpMaterialBedarf[0x00000000] --BelongsTo:position--> Position[0x02010003] +ErpMaterialBedarf[0x00000000] --BelongsTo:purchase_order--> ErpPurchaseOrder[0x00000000] +ErpCashEntry[0x00000000] --BelongsTo:register--> ErpCashRegister[0x00000000] +ErpDocumentFulltext[0x00000000] --BelongsTo:document--> ErpDocument[0x00000000] +ErpDmsKategorie[0x00000000] --HasMany:children--> ErpDmsKategorie[0x00000000] +SambaShareAcl[0x00000000] --BelongsTo:share--> SambaShare[0x00000000] +SambaShareAcl[0x00000000] --BelongsTo:user--> User[0x00000000] +ErpSerialCharge[0x00000000] --BelongsTo:article--> Article[0x00000000] +ErpEinvoiceImport[0x00000000] --BelongsTo:supplier--> ErpSupplier[0x00000000] +ErpSupplierCsvMapping[0x00000000] --BelongsTo:supplier--> ErpSupplier[0x00000000] +ErpStockMovement[0x00000000] --BelongsTo:article--> Article[0x00000000] +ErpPaymentRun[0x00000000] --BelongsTo:bank_account--> ErpBankAccount[0x00000000] +ErpPaymentRun[0x00000000] --HasMany:items--> ErpPaymentRunItem[0x00000000] +ErpPreisSchema[0x00000000] --HasMany:spannen--> ErpPreisSpanne[0x00000000] +ErpPreisGruppe[0x00000000] --HasMany:kunden--> Customer[0x02040003] +CustomerRemoteDevice[0x00000000] --BelongsTo:customer--> Customer[0x02040003] +RentedServerEvent[0x00000000] --BelongsTo:server--> RentedServer[0x00000000] diff --git a/Cargo.toml b/Cargo.toml index 8dd7d5d..c9da03d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/ogar-adapter-surrealql", "crates/ogar-adapter-ttl", "crates/ogar-adapter-clickhouse-ddl", + "crates/ogar-adapter-postgres-ddl", # WS-G-E "crates/ogar-knowable-from", "crates/ogar-from-elixir", "crates/ogar-from-ruff", diff --git a/crates/ogar-action-handler/Cargo.toml b/crates/ogar-action-handler/Cargo.toml index 7c862a4..8042009 100644 --- a/crates/ogar-action-handler/Cargo.toml +++ b/crates/ogar-action-handler/Cargo.toml @@ -19,3 +19,11 @@ ogar-vocab = { path = "../ogar-vocab" } # (`ogar-from-schema`) stays parser-free — it defines the `Deserialize` DTOs + # the pure lift; this crate does the `from_str`. (B2-lift.) serde_json = "1.0" + +[dev-dependencies] +# Execution probe only: drives the executor from a *lifted* ActionDef +# (harvest arm) rather than a hand-written capability, so the probe +# witnesses lift -> dispatch. Not a runtime dependency of this crate. +ogar-from-ruff = { path = "../ogar-from-ruff" } +# Flips to branch = "main" with the other ruff pins (O-2 de-pin); keep in lockstep with ogar-from-ruff. +ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", branch = "claude/odoo-rs-transcode-lf8ya5" } diff --git a/crates/ogar-action-handler/tests/lifted_action_dispatch.rs b/crates/ogar-action-handler/tests/lifted_action_dispatch.rs new file mode 100644 index 0000000..c6e7c61 --- /dev/null +++ b/crates/ogar-action-handler/tests/lifted_action_dispatch.rs @@ -0,0 +1,64 @@ +//! Execution probe: a `ruff_spo_triplet::Model` method, lifted to an +//! `ogar_vocab::ActionDef` via `ogar_from_ruff::lift_actions`, dispatched +//! through the reference `NativeCommandExecutor` — "lifted action -> +//! ActionHandler -> result", the executable half of Falsifier #3 +//! (E-OGAR-CONVERGENCE-SHAPE). +//! +//! **Falsifier #3 gap (documented, not implemented here):** the full +//! falsifier is `op::work_package::update(shape) -> ActionHandler -> kanban +//! transition -> Lance tombstone`. This probe covers `lifted action -> +//! ActionHandler -> result` only. The **kanban transition** (an action +//! executing IS a mailbox-owned board transition) and the **Lance +//! tombstone** require the lance-graph `ractor` runtime + a mailbox/board — +//! **not present in `/workspace/ogar`** (this repo has no `ractor` / +//! `lance-graph` runtime dependency at all). That remainder is the `[H]` +//! ERP-wiring half of the execution layer; it lands in a lance-graph-side +//! probe, not here. + +use ogar_action_handler::NativeCommandExecutor; +use ogar_from_ruff::lift_actions; +use ogar_from_schema::action_ws::CapabilityExecutor; +use ruff_spo_triplet::Model; + +/// A single method lifted to an ActionDef, then dispatched through the +/// reference ActionHandler as an `ExecuteCommand` capability. This is the +/// minimal "lifted action -> execution -> result" end-to-end — the tested +/// machine (`NativeCommandExecutor`), driven from the harvest arm. +#[test] +fn lifted_action_dispatches_through_native_executor() { + // 1. Lift a trivial model with one method. + let mut m = Model::new("Invoice"); + m.functions.push(ruff_spo_triplet::Function { + name: "post".to_string(), + reads: vec![], + writes: vec!["state".to_string()], + calls: vec![], + raises: vec![], + traverses: vec![], + }); + let actions = lift_actions(&m); + let action = actions + .iter() + .find(|a| a.predicate == "post") + .expect("post action lifted"); + + // 2. Bridge: the ActionDef's predicate becomes the command payload. (This + // is the reference bridge — production routing binds real capability + // params via the schema; here we prove the seam mechanically.) + let bound = vec![( + "command".to_owned(), + format!("echo dispatched:{}", action.predicate), + )]; + + // 3. Dispatch through the tested executor. + let result = NativeCommandExecutor + .execute(NativeCommandExecutor::CAPABILITY, &bound) + .expect("dispatch runs"); + + // 4. Assert the end-to-end result carries the lifted predicate. + assert_eq!( + result[0], + ("output".to_owned(), "dispatched:post".to_owned()) + ); + assert_eq!(result[2], ("exitcode".to_owned(), "0".to_owned())); +} diff --git a/crates/ogar-adapter-postgres-ddl/Cargo.toml b/crates/ogar-adapter-postgres-ddl/Cargo.toml new file mode 100644 index 0000000..1dea796 --- /dev/null +++ b/crates/ogar-adapter-postgres-ddl/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ogar-adapter-postgres-ddl" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true +description = "PostgreSQL DDL adapter for OGAR — realizes Operator Ruling (b, refined): PostgreSQL is the transactional System-of-Record (writes, GoBD/audit, ACID); lance-graph is the read-optimized hot-path (zero-copy ClassView x FieldMask projections), never the sole booking store. emit_postgres_ddl(&[Class]) renders per-class CREATE TABLE (typed + nullable from the schema stratum); emit_facet_table_ddl renders the V3 facet table (classid + 12 axis-indexable payload columns) that the sink-in writes land in. Emit-only: no parser, no DB driver, no network. Precedent: ogar-adapter-clickhouse-ddl. Also carries the parity-checker skeleton (`parity` module) — the legacy-parity revival drift-fuse (COUNT_FUSE pattern) that screams when the sink-in projected shape diverges from the legacy ORM shape." + +[features] +default = [] +serde = ["dep:serde", "ogar-vocab/serde"] + +[dependencies] +ogar-vocab = { path = "../ogar-vocab" } +serde = { workspace = true, optional = true } diff --git a/crates/ogar-adapter-postgres-ddl/src/lib.rs b/crates/ogar-adapter-postgres-ddl/src/lib.rs new file mode 100644 index 0000000..15fdc63 --- /dev/null +++ b/crates/ogar-adapter-postgres-ddl/src/lib.rs @@ -0,0 +1,295 @@ +//! `ogar-adapter-postgres-ddl` — PostgreSQL DDL adapter for OGAR. +//! +//! Realizes Operator **Ruling (b, refined)**: the persistence role split is +//! **binding** and must not be violated by any future change to this crate. +//! +//! # The binding role split +//! +//! - **PostgreSQL = transactional System-of-Record.** Writes, GoBD/audit, +//! ACID. The V3 **facet table** ([`emit_facet_table_ddl`]) — `classid` + +//! the 12 axis-indexable payload columns — lives here; it is the table the +//! sink-in writes land in. +//! - **lance-graph = read-optimized hot-path** (zero-copy `ClassView × +//! FieldMask` projections, Arrow/batch). Lance is **never the sole +//! booking store** — append+compaction is not OLTP. This crate emits only +//! the **PG** side (the SoR schema); the lance-graph hot-path read side is +//! a documented follow-up (bindings probe, not built here). +//! - **Cache placement (BINDING guardrail, not a preference):** the +//! `moka` (Rust) <-> `moka-py` (Python) cache tier is **PG-side ONLY**. +//! **Never put a cache in front of lance-graph** — copying Arrow buffers +//! into owned cache entries breaks zero-copy. The lance hot-path reads +//! directly off the buffers, uncached. +//! +//! # Position in the data flow +//! +//! ```text +//! ClassView (schema stratum) ──▶ this adapter ──▶ PostgreSQL DDL (SoR) +//! │ +//! sink-in write (facet bytes) ──▶ facet table (classid + 12 payload cols) +//! │ +//! [follow-up, NOT built here] transactional-outbox row ──▶ async worker +//! ├─▶ projects into lance-graph hot-path (zero-copy read side) +//! └─▶ runs `parity::check_parity` against the legacy ORM read +//! ``` +//! +//! See the [`parity`] module for the legacy-parity drift-fuse skeleton and +//! the full non-built-runtime writeup (transactional outbox, CDC). +//! +//! # Precedent +//! +//! Sibling of [`ogar-adapter-clickhouse-ddl`]; mirrors its shape (emit +//! function, per-attribute column emission, identifier quoting) but targets +//! PostgreSQL and additionally emits the V3 facet table, which has no +//! ClickHouse analog. +//! +//! # Emit-only +//! +//! No parser, no DB driver, no network — this crate renders DDL **text**; +//! tests assert on the text (same discipline as the ClickHouse adapter's +//! emit half). + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +use ogar_vocab::{Attribute, Class}; + +pub mod parity; + +// ───────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────── + +/// Render a slice of OGAR `Class`es as PostgreSQL DDL — one `CREATE TABLE` +/// per class, columns derived from the class's attributes (the +/// ClassView-projected relational view: typed + nullable from the schema +/// stratum). Mirrors `emit_clickhouse_ddl` in the sibling crate +/// `ogar-adapter-clickhouse-ddl`. +/// +/// # Determinism +/// +/// Emit is deterministic — same input always produces the same DDL string. +#[must_use] +pub fn emit_postgres_ddl(classes: &[Class]) -> String { + let mut out = String::new(); + for class in classes { + emit_class(class, &mut out); + out.push('\n'); + } + out +} + +/// Emit the V3 **facet table** DDL — the substrate System-of-Record shape: +/// `classid` + the 12 payload byte-columns (axis-grouped), each column +/// independently B-tree indexable. One flat table is the KV/facet model +/// (OGAR `CLAUDE.md` P0: `classid(4) + 12-byte payload`, `6x(u8:u8)` / +/// `4x(u8:u8:u8)` / `3x(u8:u8:u8:u8)` axis groupings — 12 bytes either way). +/// This is the store the sink-in writes land in. +/// +/// The 12 columns are named `p0..p11` — a generic axis-indexed naming +/// scheme independent of which factorization (`6x(8:8)` / `4x(8:8:8)` / +/// `3x(8:8:8:8)`) a given facet uses; all three factorizations pack into +/// the same 12 bytes, so one column layout serves all of them. +/// +/// # Determinism +/// +/// Emit is deterministic — same `table_name` always produces the same DDL +/// string. +#[must_use] +pub fn emit_facet_table_ddl(table_name: &str) -> String { + const PAYLOAD_COLUMNS: usize = 12; + let table = quote_pg_ident(table_name); + let mut out = String::new(); + + out.push_str(&format!("CREATE TABLE {table} (\n")); + out.push_str(" classid INTEGER NOT NULL,\n"); + let payload_lines: Vec = (0..PAYLOAD_COLUMNS) + .map(|i| format!(" p{i} SMALLINT")) + .collect(); + out.push_str(&payload_lines.join(",\n")); + out.push('\n'); + out.push_str(");\n"); + + // Index names must stay bare identifiers even for a dotted/quoted + // table_name (`sale.order_p0_idx` would be invalid DDL) — derive a + // sanitized stem instead of interpolating the raw name. + let stem: String = table_name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + out.push_str(&format!( + "CREATE INDEX {stem}_classid_idx ON {table} (classid);\n" + )); + for i in 0..PAYLOAD_COLUMNS { + out.push_str(&format!( + "CREATE INDEX {stem}_p{i}_idx ON {table} (p{i});\n" + )); + } + out +} + +// ───────────────────────────────────────────────────────────────────── +// Emit — per-class relational DDL +// ───────────────────────────────────────────────────────────────────── + +fn emit_class(class: &Class, out: &mut String) { + let table = quote_pg_ident(&class.name); + out.push_str(&format!("CREATE TABLE {table} (\n")); + + let mut col_lines = Vec::new(); + for attr in &class.attributes { + col_lines.push(format!(" {}", emit_column(attr))); + } + out.push_str(&col_lines.join(",\n")); + if !col_lines.is_empty() { + out.push('\n'); + } + out.push_str(");\n"); +} + +fn emit_column(attr: &Attribute) -> String { + let pg_type = ogar_type_to_postgres(attr.type_name.as_deref()); + // IR-canonical: `options.required == Some(true)` is the NOT NULL + // marker. `None` ("unset") and `Some(false)` both mean "no NOT NULL + // constraint emitted" — nullable is the safe default absent an + // explicit `required=True`. + if attr.options.required == Some(true) { + format!("{} {pg_type} NOT NULL", quote_pg_ident(&attr.name)) + } else { + format!("{} {pg_type}", quote_pg_ident(&attr.name)) + } +} + +/// Map an OGAR canonical `type_name` (`"string"`, `"integer"`, …) to a +/// PostgreSQL type. Lengths / precision-scale are not carried by the IR, +/// so `TEXT` / `NUMERIC` are the safe unparameterized fallbacks (mirrors +/// the ClickHouse adapter's untyped-fallback discipline). +fn ogar_type_to_postgres(t: Option<&str>) -> String { + match t.map(|s| s.to_ascii_lowercase()).as_deref() { + Some("string") | Some("char") | Some("html") | Some("binary") => "TEXT".to_string(), + Some("text") => "TEXT".to_string(), + Some("integer") | Some("int") => "INTEGER".to_string(), + Some("bigint") => "BIGINT".to_string(), + Some("boolean") | Some("bool") => "BOOLEAN".to_string(), + Some("datetime") | Some("timestamp") => "TIMESTAMP".to_string(), + Some("date") => "DATE".to_string(), + Some("decimal") | Some("monetary") | Some("numeric") => "NUMERIC".to_string(), + Some("float") | Some("double") | Some("real") => "DOUBLE PRECISION".to_string(), + None => "TEXT".to_string(), + Some(_other) => "TEXT".to_string(), + } +} + +/// Quote a PostgreSQL identifier with double-quotes when it isn't a safe +/// bare identifier. PostgreSQL bare identifiers match +/// `[a-zA-Z_][a-zA-Z0-9_]*` (case-folded); everything else — notably +/// Odoo's dotted names like `sale.order` — needs `"..."` quoting. Same +/// discipline as `ogar-adapter-clickhouse-ddl`'s `quote_ch_ident`, adapted +/// to PostgreSQL's double-quote identifier syntax. +fn quote_pg_ident(name: &str) -> String { + let bare = !name.is_empty() + && name + .chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + if bare { + name.to_string() + } else { + format!("\"{}\"", name.replace('"', "\"\"")) + } +} + +// ───────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use ogar_vocab::{Attribute, Class}; + + #[test] + fn emit_minimal_class_produces_create_table() { + let c = Class::new("users"); + let ddl = emit_postgres_ddl(&[c]); + assert!(ddl.contains("CREATE TABLE users (\n"), "got: {ddl}"); + } + + #[test] + fn emit_class_typed_and_nullable_columns() { + let mut c = Class::new("notes"); + let mut subject = Attribute::new("subject"); + subject.type_name = Some("string".into()); + subject.options.required = Some(true); + c.attributes.push(subject); + let mut note = Attribute::new("note"); + note.type_name = Some("text".into()); + c.attributes.push(note); + + let ddl = emit_postgres_ddl(&[c]); + assert!(ddl.contains("subject TEXT NOT NULL"), "got: {ddl}"); + // Nullable: no explicit `required=true` -> no NOT NULL suffix, and + // no incidental match against the NOT NULL variant of the same + // column name. + assert!(ddl.contains("note TEXT") && !ddl.contains("note TEXT NOT NULL"), "got: {ddl}"); + } + + #[test] + fn emit_quotes_dotted_table_name() { + let c = Class::new("sale.order"); + let ddl = emit_postgres_ddl(&[c]); + assert!(ddl.contains("CREATE TABLE \"sale.order\" ("), "got: {ddl}"); + } + + #[test] + fn emit_facet_table_has_classid_and_twelve_payload_columns() { + let ddl = emit_facet_table_ddl("facet"); + assert!(ddl.contains("classid INTEGER NOT NULL"), "got: {ddl}"); + for i in 0..12 { + assert!(ddl.contains(&format!("p{i} SMALLINT")), "missing p{i}: {ddl}"); + assert!( + ddl.contains(&format!("CREATE INDEX facet_p{i}_idx ON facet (p{i});")), + "missing index for p{i}: {ddl}" + ); + } + assert!( + ddl.contains("CREATE INDEX facet_classid_idx ON facet (classid);"), + "got: {ddl}" + ); + } + + /// Ties SPEC-7 to the SPEC-5 parity fixture: `TimesheetActivity` + /// (`WoA/models.py:1746`, read-only source, transcribed faithfully). + /// + /// NOTE (Sonnet grind, SPEC-7): SPEC-5's synthetic `ModelGraph` -> + /// `CompiledClass` lift for `TimesheetActivity` lives on a sibling + /// worktree/branch (`claude/v3c-woa-parity`) that had not merged into + /// this branch (`claude/v3c-postgres-ddl`, forked from `origin/main` + /// at the PR #156 merge commit) at the time this crate was built. Per + /// mission rule ("never invent"), this fixture is hand-transcribed + /// directly from `WoA/models.py:1746` (read in full) rather than + /// invented or borrowed from an unmerged branch, and is a local + /// stand-in for SPEC-5's real lift output. It exercises the same + /// column set: `id` (PK, omitted here — the lift's ID handling is + /// SPEC-5's concern), `timesheet_id` deduped as an FK upstream + /// (omitted, matching the lift's FK-dedup contract), `beschreibung` + /// (`String(500)`, `nullable=False`), `created_at` (`DateTime`, no + /// explicit `nullable=False` -> nullable). + #[test] + fn woa_timesheet_activity_ddl_roundtrips_field_set() { + let mut c = Class::new("TimesheetActivity"); + let mut beschreibung = Attribute::new("beschreibung"); + beschreibung.type_name = Some("string".into()); + beschreibung.options.required = Some(true); + c.attributes.push(beschreibung); + let mut created_at = Attribute::new("created_at"); + created_at.type_name = Some("datetime".into()); + c.attributes.push(created_at); + + let ddl = emit_postgres_ddl(&[c]); + assert!(ddl.contains("beschreibung TEXT NOT NULL"), "got: {ddl}"); + assert!(ddl.contains("created_at TIMESTAMP"), "got: {ddl}"); + assert!(!ddl.contains("created_at TIMESTAMP NOT NULL"), "got: {ddl}"); + assert!(!ddl.contains("timesheet_id"), "FK must be deduped upstream: {ddl}"); + } +} diff --git a/crates/ogar-adapter-postgres-ddl/src/parity.rs b/crates/ogar-adapter-postgres-ddl/src/parity.rs new file mode 100644 index 0000000..ff87b2a --- /dev/null +++ b/crates/ogar-adapter-postgres-ddl/src/parity.rs @@ -0,0 +1,255 @@ +//! Parity-checker skeleton — the legacy-parity revival drift-fuse seed. +//! +//! # The legacy-parity revival mode +//! +//! Operator Ruling (b, refined) revives the existing WoA/SQLAlchemy ORM +//! path as a **parity oracle**, not a schema of record: the sink-in write +//! path (this crate's facet table + per-class DDL) is the new +//! System-of-Record, but the legacy ORM's read shape is kept around to +//! catch drift between what the lift *thinks* the schema is and what the +//! legacy ORM actually persists. +//! +//! [`check_parity`] compares the sink-in projected [`Class`] shape against +//! a legacy [`Class`] shape (a description of the ORM's actual columns) +//! field-by-field and reports every divergence as a [`ParityDrift`]. An +//! empty [`ParityReport`] is the `COUNT_FUSE`-style green state; a +//! non-empty report is the fuse blowing. +//! +//! This module is **pure, no I/O** — it compares two already-constructed +//! `Class` values. Associations are out of scope for this skeleton (scalar +//! attributes only); a follow-up can extend the comparison once the +//! skeleton is load-bearing. +//! +//! # The runtime this seeds (documented, NOT built this session) +//! +//! The full legacy-parity-revival runtime is a **transactional-outbox +//! dual-write**, not a naive parallel write (Ruling b explicitly rejects +//! naive parallel-write — a crash between two independent writes must +//! never cause silent drift): +//! +//! 1. A write transaction inserts into PostgreSQL (the System-of-Record) +//! **and** inserts an outbox row in the *same* transaction. The outbox +//! row is the single committed intent — either both land, or neither +//! does. +//! 2. An async worker drains the outbox and: +//! - projects the row into the lance-graph hot-path (the zero-copy read +//! side), and +//! - runs [`check_parity`] against the legacy ORM's read of the same +//! logical record, screaming (alert / refuse-to-advance) on any +//! [`ParityDrift`]. +//! +//! Further follow-ups (also documented, not built): CDC as an eventual +//! replacement for the outbox-worker dual-write; a full PostgreSQL-ORM +//! adapter (this crate only emits DDL, it does not read/write rows). + +use ogar_vocab::Class; + +/// One divergence between the sink-in shape and the legacy ORM shape. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ParityDrift { + /// The legacy ORM has this field; the sink-in lift dropped it. + MissingInSinkIn { + /// The attribute name present only in the legacy shape. + field: String, + }, + /// The sink-in lift has this field; the legacy ORM shape lacks it. + MissingInLegacy { + /// The attribute name present only in the sink-in shape. + field: String, + }, + /// Both shapes have the field, but its `type_name` disagrees. + TypeMismatch { + /// The shared attribute name. + field: String, + /// The sink-in side's `type_name`. + sink_in: Option, + /// The legacy side's `type_name`. + legacy: Option, + }, + /// Both shapes have the field, but `options.required` disagrees. + NullabilityMismatch { + /// The shared attribute name. + field: String, + /// The sink-in side's `options.required`. + sink_in: Option, + /// The legacy side's `options.required`. + legacy: Option, + }, +} + +/// Drift-fuse report. Empty `drifts` == parity holds (the `COUNT_FUSE` +/// green state). A non-empty report is the fuse blowing — the caller (the +/// dual-write outbox worker, in the full design) refuses to commit / +/// alerts rather than silently tolerating drift. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ParityReport { + /// Every divergence found, in legacy-declaration order (missing-in- + /// sink-in / type / nullability first), then sink-in-only fields + /// (missing-in-legacy) last. Iteration order, not sorted by name. + pub drifts: Vec, +} + +impl ParityReport { + /// `true` when no divergence was found — the fuse has not blown. + #[must_use] + pub fn is_clean(&self) -> bool { + self.drifts.is_empty() + } +} + +/// Compare two `Class` shapes (sink-in vs legacy) field-by-field. Pure, no +/// I/O. Compares the attribute name sets, then per shared name the +/// `type_name` and `options.required`. Associations can be added in a +/// follow-up; this skeleton covers scalar columns — enough for the +/// drift-fuse seed. +#[must_use] +pub fn check_parity(sink_in: &Class, legacy: &Class) -> ParityReport { + let mut drifts = Vec::new(); + + for legacy_attr in &legacy.attributes { + let Some(sink_in_attr) = sink_in + .attributes + .iter() + .find(|a| a.name == legacy_attr.name) + else { + drifts.push(ParityDrift::MissingInSinkIn { + field: legacy_attr.name.clone(), + }); + continue; + }; + + if sink_in_attr.type_name != legacy_attr.type_name { + drifts.push(ParityDrift::TypeMismatch { + field: legacy_attr.name.clone(), + sink_in: sink_in_attr.type_name.clone(), + legacy: legacy_attr.type_name.clone(), + }); + } + + if sink_in_attr.options.required != legacy_attr.options.required { + drifts.push(ParityDrift::NullabilityMismatch { + field: legacy_attr.name.clone(), + sink_in: sink_in_attr.options.required, + legacy: legacy_attr.options.required, + }); + } + } + + for sink_in_attr in &sink_in.attributes { + if !legacy.attributes.iter().any(|a| a.name == sink_in_attr.name) { + drifts.push(ParityDrift::MissingInLegacy { + field: sink_in_attr.name.clone(), + }); + } + } + + ParityReport { drifts } +} + +// ───────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use ogar_vocab::Attribute; + + fn attr(name: &str, type_name: &str, required: Option) -> Attribute { + let mut a = Attribute::new(name); + a.type_name = Some(type_name.into()); + a.options.required = required; + a + } + + #[test] + fn identical_shapes_have_clean_parity() { + let mut c = Class::new("timesheet_activities"); + c.attributes.push(attr("beschreibung", "string", Some(true))); + c.attributes.push(attr("created_at", "datetime", None)); + + let report = check_parity(&c, &c.clone()); + assert!(report.is_clean(), "expected clean, got: {report:?}"); + } + + #[test] + fn dropped_column_blows_the_fuse() { + let mut legacy = Class::new("t"); + legacy.attributes.push(attr("foo", "string", None)); + let sink_in = Class::new("t"); + + let report = check_parity(&sink_in, &legacy); + assert_eq!( + report.drifts, + vec![ParityDrift::MissingInSinkIn { field: "foo".into() }] + ); + } + + #[test] + fn type_mismatch_blows_the_fuse() { + let mut sink_in = Class::new("t"); + sink_in.attributes.push(attr("foo", "string", None)); + let mut legacy = Class::new("t"); + legacy.attributes.push(attr("foo", "text", None)); + + let report = check_parity(&sink_in, &legacy); + assert_eq!( + report.drifts, + vec![ParityDrift::TypeMismatch { + field: "foo".into(), + sink_in: Some("string".into()), + legacy: Some("text".into()), + }] + ); + } + + #[test] + fn nullability_mismatch_blows_the_fuse() { + let mut sink_in = Class::new("t"); + sink_in.attributes.push(attr("foo", "string", Some(true))); + let mut legacy = Class::new("t"); + legacy.attributes.push(attr("foo", "string", Some(false))); + + let report = check_parity(&sink_in, &legacy); + assert_eq!( + report.drifts, + vec![ParityDrift::NullabilityMismatch { + field: "foo".into(), + sink_in: Some(true), + legacy: Some(false), + }] + ); + } + + /// Ties SPEC-7's parity oracle to a real WoA class: `TimesheetActivity` + /// (`WoA/models.py:1746`, read-only source, transcribed faithfully). + /// + /// NOTE (Sonnet grind, SPEC-7): as in `lib.rs`'s + /// `woa_timesheet_activity_ddl_roundtrips_field_set`, SPEC-5's real + /// `CompiledClass` lift output was not available on this branch at + /// build time (parallel, unmerged sibling worktree). Both "sink_in" + /// and "legacy" sides here are hand-transcribed directly from + /// `WoA/models.py:1746` (read in full) as a local stand-in — this + /// specific test therefore does not yet exercise the real + /// lift-vs-ORM disagreement surface the full design targets; it only + /// proves the oracle agrees with itself on a real WoA shape. Re-wire + /// to the actual SPEC-5 fixture once merged (tracked as a Ledger + /// follow-up in the sign-off report, not invented here). + #[test] + fn woa_timesheet_activity_sink_in_matches_hand_legacy() { + let mut sink_in = Class::new("TimesheetActivity"); + sink_in + .attributes + .push(attr("beschreibung", "string", Some(true))); + sink_in.attributes.push(attr("created_at", "datetime", None)); + + let mut legacy = Class::new("TimesheetActivity"); + legacy + .attributes + .push(attr("beschreibung", "string", Some(true))); + legacy.attributes.push(attr("created_at", "datetime", None)); + + let report = check_parity(&sink_in, &legacy); + assert!(report.is_clean(), "expected clean, got: {report:?}"); + } +} diff --git a/crates/ogar-class-view/Cargo.toml b/crates/ogar-class-view/Cargo.toml index acd7bd1..7619f3d 100644 --- a/crates/ogar-class-view/Cargo.toml +++ b/crates/ogar-class-view/Cargo.toml @@ -3,7 +3,7 @@ name = "ogar-class-view" version.workspace = true edition.workspace = true license.workspace = true -description = "Bridge crate: lifts `ogar_vocab::Class` (canonical AR shape, codebook-keyed) onto `lance_graph_contract::ClassView` (presence-bitmask + render-row resolver). The seam between the calcified inner shape and the classview-parameterized outer materialization (askama/jinja). One trait impl over the 68 promoted canonical concepts; no I/O." +description = "Bridge crate: lifts `ogar_vocab::Class` (canonical AR shape, codebook-keyed) onto `lance_graph_contract::ClassView` (presence-bitmask + render-row resolver). The seam between the calcified inner shape and the classview-parameterized outer materialization (askama/jinja). One trait impl over the 79 promoted canonical concepts; no I/O." [dependencies] ogar-vocab = { path = "../ogar-vocab" } diff --git a/crates/ogar-class-view/tests/cognition_step_mapping.rs b/crates/ogar-class-view/tests/cognition_step_mapping.rs new file mode 100644 index 0000000..6ff8ce0 --- /dev/null +++ b/crates/ogar-class-view/tests/cognition_step_mapping.rs @@ -0,0 +1,90 @@ +//! Cognition probe: a type-level witness that an OGAR `ActionDef` lowers onto +//! `lance-graph-contract`'s `UnifiedStep` orchestration surface — +//! "reasoning vs controller methods as ONE invocation surface" +//! (E-OGAR-CONVERGENCE-SHAPE cognition layer). +//! +//! This is a shape-compatibility witness, not a wiring: the mapping below is +//! total on the fields `UnifiedStep` needs from an action, and it does NOT +//! invent a `StepDomain::Erp` variant (that arm does not exist yet on the +//! pinned `lance-graph-contract` branch=main — the named `[H]` seam). +//! +//! **Confirmed against the pinned source** (`lance-graph` +//! `598f8727a7869d6468c631d2977731b573b98e7b`, +//! `crates/lance-graph-contract/src/orchestration.rs`): +//! - `UnifiedStep` is a plain `pub struct` with all-`pub` fields (`step_id: +//! String`, `step_type: String`, `status: StepStatus`, `thinking: +//! Option`, `reasoning: Option`, `confidence: +//! Option`, `depends_on: Vec`) — it is NOT `#[non_exhaustive]` +//! and has no constructor, so it is built here via a plain struct literal. +//! - `StepStatus` is a plain `pub enum` (`Pending`/`Running`/`Completed`/ +//! `Failed`/`Skipped`), also not `#[non_exhaustive]`. +//! - `StepDomain`'s variant set matches RECON R5 exactly: +//! `Crew | Ladybug | N8n | LanceGraph | Ndarray | Smb | Medcare | Kanban` — +//! no drift, no ERP/controller arm. +//! +//! Because both types are fully public and constructible, the *full* +//! `action_def_maps_onto_unified_step_shape` mapping is exercised directly; +//! the leitplanke's documented-gap downgrade is not needed here. + +use lance_graph_contract::orchestration::{StepDomain, StepStatus, UnifiedStep}; +use ogar_vocab::ActionDef; + +/// Lower an `ActionDef` onto the `UnifiedStep` shape. Total on the fields +/// `UnifiedStep` needs from an action: +/// - `step_id` <- `a.identity` (the numeric `StepId` is FNV-1a-derived +/// downstream via `UnifiedStep::id`, not stored here). +/// - `step_type` <- `a.predicate`. +/// - `status` <- `StepStatus::Pending` (the real initial variant; a +/// freshly lifted action has not started executing). +/// - `depends_on` <- `[]` (a.reads could seed this in a follow-up; out of +/// scope for this probe). +fn action_to_step(a: &ActionDef) -> UnifiedStep { + UnifiedStep { + step_id: a.identity.clone(), + step_type: a.predicate.clone(), + status: StepStatus::Pending, + thinking: None, + reasoning: None, + confidence: None, + depends_on: vec![], + } +} + +#[test] +fn action_def_maps_onto_unified_step_shape() { + let a = ActionDef::new( + "ogit-erp/invoice::action_def::post", + "post", + "ogit-erp/invoice", + ); + let step = action_to_step(&a); + assert_eq!(step.step_type, "post"); + // step_id carries the action identity (numeric StepId is derived, not stored). + assert!(step.step_id.contains("invoice::action_def::post")); + assert_eq!(step.status, StepStatus::Pending); + assert!(step.depends_on.is_empty()); +} + +#[test] +fn step_domain_has_no_erp_variant_yet() { + // Pins the named [H] seam: the reasoning/orchestration union already carries + // Crew/Ladybug/N8n/LanceGraph/Ndarray/Smb/Medcare/Kanban — but NOT an ERP / + // controller-method arm. When the ActionDef<->UnifiedStep contract PR lands, + // this test flips (a new variant is added) and documents the change. + // Exhaustive match forces a compile error if a variant is added silently: + fn assert_known(d: StepDomain) { + match d { + StepDomain::Crew + | StepDomain::Ladybug + | StepDomain::N8n + | StepDomain::LanceGraph + | StepDomain::Ndarray + | StepDomain::Smb + | StepDomain::Medcare + | StepDomain::Kanban => {} + // no ERP arm today — when one is added, this match fails to + // compile, forcing an intentional update (the seam-closing PR). + } + } + let _ = assert_known; +} diff --git a/crates/ogar-from-rails/src/lib.rs b/crates/ogar-from-rails/src/lib.rs index b932521..7852ac8 100644 --- a/crates/ogar-from-rails/src/lib.rs +++ b/crates/ogar-from-rails/src/lib.rs @@ -84,26 +84,42 @@ pub fn extract_with(source_tree: &Path, curator: &str) -> Vec { /// core `app/models` *plus* every mounted engine's `app/models` /// (`modules/*/app/models`, `engines/*/app/models`). /// -/// Threads through [`ruff_ruby_spo::extract_app_with`] (added in -/// AdaWorldAPI/ruff#28). OpenProject keeps a large share of its domain in -/// `modules/*` engines (e.g. `TimeEntry` lives in -/// `modules/costs/app/models`), invisible to the core-only [`extract`] / -/// [`extract_with`]; this entry point closes that gap so the canonical -/// layer sees the full corpus. +/// Threads through [`ruff_ruby_spo::extract_app_with_schema`] — the +/// schema-aware extractor (PR #156 finding (a)). Before this fix this +/// function called the schema-*blind* [`ruff_ruby_spo::extract_app_with`], +/// so `Model::fields` (the physical `db/migrate/tables/*.rb` column stratum) +/// never reached the shipped API — `project_rails_fields` had nothing to +/// project, and the "18/18" field parity only held on a direct `lift_model` +/// call with a hand-built `fields` vector (as the unit tests do), never on +/// this public entry point. `extract_app_with_schema` still walks core + +/// engines exactly as `extract_app_with` did; the `SchemaReport` (match +/// counts, skipped files) is discarded here — callers who need it should go +/// straight to `ruff_ruby_spo::extract_app_with_schema`. /// -/// Plain [`extract`] / [`extract_with`] stay core-only for backward -/// compatibility; engines are opt-in via this entry point. +/// OpenProject keeps a large share of its domain in `modules/*` engines +/// (e.g. `TimeEntry` lives in `modules/costs/app/models`), invisible to the +/// core-only [`extract`] / [`extract_with`]; this entry point closes that +/// gap so the canonical layer sees the full corpus. +/// +/// Plain [`extract`] / [`extract_with`] stay core-only (and schema-blind) +/// for backward compatibility; engines + schema are opt-in via this entry +/// point. #[must_use] pub fn extract_app_with(source_tree: &Path, curator: &str) -> Vec { - let graph = ruff_ruby_spo::extract_app_with(source_tree, curator); + let (graph, _report) = ruff_ruby_spo::extract_app_with_schema(source_tree, curator); ogar_from_ruff::lift_model_graph(&graph) } -/// Whole-application extraction (core + engines), defaulting the curator -/// namespace to `"openproject"`. Thin wrapper over [`extract_app_with`]. +/// Whole-application extraction (core + engines, schema-aware), defaulting +/// the curator namespace to [`ruff_ruby_spo::NAMESPACE`] (`"openproject"`). +/// Routes through the same schema-aware extractor as [`extract_app_with`] +/// (PR #156 finding (a)) rather than delegating to it, mirroring how +/// [`ruff_ruby_spo::extract_app`] itself is a thin default-namespace wrapper +/// over `extract_app_with` on the ruff side. #[must_use] pub fn extract_app(source_tree: &Path) -> Vec { - let graph = ruff_ruby_spo::extract_app(source_tree); + let (graph, _report) = + ruff_ruby_spo::extract_app_with_schema(source_tree, ruff_ruby_spo::NAMESPACE); ogar_from_ruff::lift_model_graph(&graph) } @@ -381,6 +397,55 @@ mod tests { ); } + /// PR #156 finding (a): the mainline `extract_app` / `extract_app_with` + /// must route through `ruff_ruby_spo::extract_app_with_schema`, so the + /// physical `db/migrate/tables/*.rb` schema stratum reaches + /// `Class.attributes` on the SHIPPED API — not only via a direct + /// `lift_model` call with a hand-built `fields` vector (as the + /// `ogar-from-ruff` unit tests do). Before this fix, `extract_app_with` + /// called the schema-*blind* `ruff_ruby_spo::extract_app_with`, so + /// `model.fields` was always empty and `project_rails_fields` had + /// nothing to project on this path. + /// + /// Gated behind `#[ignore]` on a real Rails checkout (mirrors the + /// `REDMINE_SRC` / `/home/user/openproject` pattern used by the other + /// real-corpus tests in this module) — set `REDMINE_SRC` or place an + /// OpenProject checkout at `/home/user/openproject` to run it locally. + /// Being `#[ignore]`, it documents the schema-aware wiring without + /// gating CI on a checkout being present. + #[test] + #[ignore = "requires a Rails source tree with db/migrate/tables (REDMINE_SRC or /home/user/openproject)"] + fn mainline_extract_app_routes_through_schema_aware_extractor() { + let src = std::env::var("REDMINE_SRC").unwrap_or_else(|_| "/home/user/openproject".to_string()); + let path = PathBuf::from(&src); + if !path.exists() { + eprintln!("skipping: {} not present", path.display()); + return; + } + let classes = extract_app_with(&path, "openproject"); + assert!( + classes.iter().any(|c| { + c.attributes + .iter() + .any(|a| !a.name.is_empty() && a.type_name.is_some()) + }), + "at least one lifted Class must carry a physical-schema-typed \ + attribute via the mainline extract_app_with — proves \ + model.fields reached the Class, not just a direct lift_model call", + ); + } + + /// **Compile-level pin for PR #156 finding (a).** If the `ruff_ruby_spo` + /// dependency ever drops (or renames) `extract_app_with_schema`, this + /// reference fails to compile — CI fails loudly at build time instead of + /// silently reverting `extract_app` / `extract_app_with` to the + /// schema-blind extractor. Not `#[ignore]`d: it needs no source tree, + /// only the symbol to exist. + #[test] + fn extract_app_uses_schema_aware_symbol() { + let _pin = ruff_ruby_spo::extract_app_with_schema; + } + /// **Per-curator namespace tagging** on real source (the ruff#27 /// `extract_with` thread, end to end). Redmine and OpenProject are /// both `project`-domain but distinct curators: `extract_with` tags diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index 55515cd..82d9720 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -68,6 +68,7 @@ pub mod emit; pub mod mint; +pub mod sqlalchemy; // WS-G-D use ogar_vocab::{ canonical_concept, ActionDef, Association, AssociationKind, Attribute, Callback, Class, @@ -305,7 +306,20 @@ fn project_rails_fields(class: &mut Class, model: &Model) { assoc.class_name = Some(comodel.clone()); assoc.inverse_of = field.inverse_name.clone(); class.associations.push(assoc); - } else if !is_fk_shadowed_by_association(&field.name, class) { + } else if !is_fk_shadowed_by_association(&field.name, class) + && !class.attributes.iter().any(|a| a.name == field.name) + { + // The second guard (no already-lifted attribute of the same + // name) closes PR #156 finding (c): a model that both declares + // `attribute :foo, :string` (AR-DSL, lifted earlier in + // `lift_model_with_language`) AND has a physical `foo` column + // must not end up with two `foo` struct fields. On collision, + // the AR-DSL declaration wins (it is the declared truth; the + // physical column is the same field seen from the migration) — + // we skip the physical duplicate rather than merge/overwrite, + // since overwriting would change `type_name` provenance and + // risk drift. Type reconciliation on collision is a follow-up, + // not done here. let mut attr = Attribute::new(&field.name); attr.type_name = field.field_type.clone(); // Schema stratum is total: null:false -> required; the Rails @@ -321,13 +335,31 @@ fn project_rails_fields(class: &mut Class, model: &Model) { } } -/// FK-dedup predicate for [`project_rails_fields`]: true when `field_name` -/// has the shape `_id` for a non-empty ``, AND `class` already -/// carries an [`Association`] named `` (from the AR-DSL vector, lifted -/// before this function runs in [`lift_model_with_language`]). The bare `id` -/// primary key column never matches — `"id"` has no `"_id"`-suffixed prefix -/// of its own — and is always kept as a scalar attribute. +/// FK-dedup predicate for [`project_rails_fields`]. Two independent shadow +/// rules, checked in order: +/// +/// 1. **Explicit `foreign_key:`** — `class` carries an [`Association`] whose +/// declared `foreign_key` equals `field_name` verbatim (e.g. +/// `belongs_to :author, foreign_key: "user_id"` shadows the physical +/// `user_id` column even though the association's own name is `author`, +/// not `user`). This is the authoritative signal (PR #156 finding (b)) — +/// checked first. +/// 2. **`_id` naming convention** — `field_name` has the shape +/// `_id` for a non-empty ``, AND `class` already carries an +/// [`Association`] named `` (from the AR-DSL vector, lifted before +/// this function runs in [`lift_model_with_language`]). The bare `id` +/// primary key column never matches either rule — `"id"` has no +/// `"_id"`-suffixed prefix of its own, and no real corpus association +/// declares `foreign_key: "id"` — and is always kept as a scalar +/// attribute. fn is_fk_shadowed_by_association(field_name: &str, class: &Class) -> bool { + if class + .associations + .iter() + .any(|a| a.foreign_key.as_deref() == Some(field_name)) + { + return true; + } field_name .strip_suffix("_id") .filter(|prefix| !prefix.is_empty()) @@ -477,7 +509,11 @@ pub fn project_canonical_roles(class: &Class) -> std::collections::HashSet<&'sta /// /// This is a **facts-only** lift: it sets the action's `identity`, /// `predicate` (the method name — already snake_case on the Rails side), -/// and `object_class` (the model name). It deliberately does **not**: +/// `object_class` (the model name), and the `reads` / `writes` / `calls` +/// effect annotations verbatim from [`ruff_spo_triplet::Function`] (OGAR-AS-IR +/// §3 test 2 — "each `ActionDef` declares what it reads, what it writes, what +/// side effects it has… pure-vs-effectful is a type, not a comment"). It +/// deliberately does **not**: /// /// - set any execution policy (the vocab [`ActionDef`] has no `exec` slot; /// backend routing is consumer-private — see the OP-arm plan §5.2), @@ -486,8 +522,11 @@ pub fn project_canonical_roles(class: &Class) -> std::collections::HashSet<&'sta /// - populate `kausal` from [`ruff_spo_triplet::Function`]`::reads` — a /// plain Rails method reading a field is **not** a reactive /// `@api.depends`-style trigger, so claiming one would leak method-body -/// description into causal semantics. Those `reads` / `raises` / -/// `traverses` edges already ride the narrow / SPO arm as triples. +/// description into causal semantics. `reads` (and `writes` / `calls`) now +/// ride `ActionDef` as **effect annotations** (see above) — that is a +/// distinct, weaker claim than a `KausalSpec::Depends` reactive trigger, +/// which stays `None` here. `raises` / `traverses` have no `ActionDef` +/// slot yet and still ride the narrow / SPO arm as triples only. /// /// The result is registry-ready: guard / RBAC / `exec` enrichment happens /// downstream at registration, not in the producer. @@ -497,11 +536,15 @@ pub fn lift_actions(model: &Model) -> Vec { .functions .iter() .map(|f| { - ActionDef::new( + let mut a = ActionDef::new( format!("{}::action_def::{}", model.name, f.name), &f.name, &model.name, - ) + ); + a.reads = f.reads.clone(); + a.writes = f.writes.clone(); + a.calls = f.calls.clone(); + a }) .collect() } @@ -1065,6 +1108,73 @@ mod tests { assert!(lifted.associations.iter().any(|a| a.name == "project")); } + /// PR #156 finding (b): `is_fk_shadowed_by_association` must also honour + /// an association's *explicit* `foreign_key:` option, not just the + /// `_id` naming convention. `belongs_to :author, foreign_key: + /// "user_id"` names the association `author` (not `user`), so the + /// naming-convention rule alone would miss it and double-project + /// `user_id: OgInt` alongside `author: ToOne<...>`. + /// + /// **Red-before-fix:** on unpatched `main`, `user_id` survives as an + /// attribute (the naming-convention check only strips `_id` and compares + /// the stem `"user"` to association names, never finding `"author"`) — + /// this assertion fails until `is_fk_shadowed_by_association` also + /// checks `Association::foreign_key`. + #[test] + fn lift_model_ruby_fk_deduped_by_explicit_foreign_key() { + let mut m = Model::new("Comment"); + m.associations.push(AssocDecl { + kind: AssocKind::BelongsTo, + name: "author".to_string(), + options: vec![("foreign_key".to_string(), "\"user_id\"".to_string())], + }); + m.fields.push(Field { + name: "user_id".to_string(), + field_type: Some("bigint".to_string()), + not_null: Some(true), + ..Default::default() + }); + let lifted = lift_model(&m); + assert!( + !lifted.attributes.iter().any(|a| a.name == "user_id"), + "user_id must be shadowed by author's explicit foreign_key: {:?}", + lifted.attributes, + ); + assert!(lifted.associations.iter().any(|a| a.name == "author")); + } + + /// PR #156 finding (c): a physical column whose name already matches an + /// AR-DSL-declared attribute (`attribute :foo, :string`) must not be + /// projected a second time — `project_rails_fields` previously pushed + /// unconditionally, producing two `foo` entries in `class.attributes` + /// (an invalid duplicate struct field in the generated code). + /// + /// **Red-before-fix:** on unpatched `main`, the count is 2 (the AR-DSL + /// lift populates one `foo` attribute earlier in + /// `lift_model_with_language`, and `project_rails_fields` pushes a + /// second, unguarded, for the physical `foo` column). + #[test] + fn lift_model_ruby_physical_column_does_not_duplicate_declared_attribute() { + let mut m = Model::new("Widget"); + m.attributes.push(AttrDecl { + kind: AttrKind::Attribute, + name: "foo".to_string(), + options: vec![("type".to_string(), "string".to_string())], + }); + m.fields.push(Field { + name: "foo".to_string(), + field_type: Some("string".to_string()), + ..Default::default() + }); + let lifted = lift_model(&m); + assert_eq!( + lifted.attributes.iter().filter(|a| a.name == "foo").count(), + 1, + "foo must appear exactly once, not once per schema stratum: {:?}", + lifted.attributes, + ); + } + /// **(c)** GAP-1 required wiring: `Field::not_null` maps onto /// `AttributeOptions::required` — `Some(true)` (`null: false`) stays /// `Some(true)`; `None` (Rails' nullable default) becomes the EXPLICIT @@ -1624,6 +1734,73 @@ mod tests { assert!(lift_actions(&Model::new("Empty")).is_empty()); } + /// SPEC-1(i): `reads` / `writes` / `calls` now ride `ActionDef` as + /// first-class effect annotations (OGAR-AS-IR §3 test 2), sourced + /// verbatim from `ruff_spo_triplet::Function`. Crucially, this must NOT + /// fabricate a reactive `kausal` trigger from a plain method read — a + /// Rails method reading a field is not an `@api.depends`-style + /// recomputation trigger, so `kausal` stays `None`. + #[test] + fn lift_actions_carries_read_write_call_effect_facts() { + let mut m = Model::new("SaleOrder"); + m.functions.push(Function { + name: "recompute_total".to_string(), + reads: vec!["quantity".to_string(), "price".to_string()], + writes: vec!["total".to_string()], + calls: vec!["self.touch".to_string()], + raises: Vec::new(), + traverses: Vec::new(), + }); + let acts = lift_actions(&m); + assert_eq!(acts.len(), 1); + let a = &acts[0]; + assert_eq!(a.reads, vec!["quantity".to_string(), "price".to_string()]); + assert_eq!(a.writes, vec!["total".to_string()]); + assert_eq!(a.calls, vec!["self.touch".to_string()]); + assert!( + a.kausal.is_none(), + "reads/writes/calls are effect annotations, not a fabricated reactive trigger" + ); + } + + /// A `Function` with all-empty fact vectors must lift to empty `Vec`s on + /// `ActionDef` — not `None`-like phantom entries. `reads` / `writes` / + /// `calls` are `Vec`, so "empty" and "absent" are the same zero + /// value; this pins that the lift never invents entries when ruff saw + /// none. + #[test] + fn lift_actions_empty_facts_stay_empty_not_none() { + let mut m = Model::new("Bare"); + m.functions.push(Function { + name: "noop".to_string(), + reads: Vec::new(), + writes: Vec::new(), + calls: Vec::new(), + raises: Vec::new(), + traverses: Vec::new(), + }); + let acts = lift_actions(&m); + assert_eq!(acts.len(), 1); + assert!(acts[0].reads.is_empty()); + assert!(acts[0].writes.is_empty()); + assert!(acts[0].calls.is_empty()); + } + + /// Regression: adding the effect-annotation fields must not disturb the + /// pre-existing identity / predicate / object_class shape — same + /// assertions as `lift_actions_predicate_object_class_and_identity`, + /// kept as an explicit named regression per SPEC-1(i). + #[test] + fn lift_actions_identity_predicate_object_unchanged() { + let acts = lift_actions(&mk_model_with_functions()); + assert_eq!(acts[0].predicate, "set_status"); + assert_eq!(acts[0].object_class, "WorkPackage"); + assert_eq!(acts[0].identity, "WorkPackage::action_def::set_status"); + assert_eq!(acts[1].predicate, "close!"); + assert_eq!(acts[1].object_class, "WorkPackage"); + assert_eq!(acts[1].identity, "WorkPackage::action_def::close!"); + } + #[test] fn strip_ruby_literal_markers_handles_all_source_forms() { assert_eq!(strip_ruby_literal_markers("\"User\""), "User"); diff --git a/crates/ogar-from-ruff/src/mint.rs b/crates/ogar-from-ruff/src/mint.rs index bef15af..d914cf4 100644 --- a/crates/ogar-from-ruff/src/mint.rs +++ b/crates/ogar-from-ruff/src/mint.rs @@ -35,6 +35,7 @@ use ogar_vocab::ports::PortSpec; use ruff_spo_address::{Facet, Mint, mint_with_classid}; use ruff_spo_triplet::{ModelGraph, expand}; +use crate::sqlalchemy::lift_model_graph_sqlalchemy; use crate::{lift_model_graph, lift_model_graph_python}; /// A class compiled to its rail-shaped, language-agnostic form: the lifted @@ -84,6 +85,52 @@ pub fn compile_graph_python(graph: &ModelGraph) -> Vec` on a `TimesheetActivity` model +/// yields facet classid `0x0103_0003` (concept `0x0103` | WoA app prefix +/// `0x0003`), NOT `0x0000_0003`. This is reported as a SPEC-5 vs. codebase- +/// reality deviation (verified independently against `ports.rs` and +/// `RECON.md` R8, which already named this alias) rather than silently +/// "fixed" by editing the spec's expectation — see the mission report. +/// +/// A second, independent inaccuracy in the same spec passage: even for a +/// genuinely *unmapped* model, [`classid_for_node`] resolves to the literal +/// `0` (its `.map_or(0, render_classid_for::

)` short-circuits on `None` +/// without composing the app prefix — see the pre-existing +/// `unmapped_model_mints_the_bootstrap_address_not_a_wrong_classid` test in +/// this file), not the composed `0x0000_0003` SPEC-5 describes. +#[must_use] +pub fn compile_graph_sqlalchemy(graph: &ModelGraph) -> Vec { + let mint = mint_graph::

(graph); + lift_model_graph_sqlalchemy(graph) + .into_iter() + .map(|class| { + let node = format!("{}:{}", graph.namespace, class.name); + let facet = mint + .facet(&node) + .unwrap_or_else(|| Facet::from_parts(classid_for_node::

(&node), [0; 6], [0; 6])); + CompiledClass { class, facet } + }) + .collect() +} + /// Compile a Ruby/Rails [`ModelGraph`] into rail-shaped [`CompiledClass`]es: /// lift each model's schema ([`Language::Ruby`](ogar_vocab::Language), via /// [`lift_model_graph`] — **not** [`lift_model_graph_python`], which would @@ -353,4 +400,113 @@ mod tests { assert_eq!(app_of(op_id), OpenProjectPort::APP_PREFIX); assert_eq!(app_of(rm_id), RedminePort::APP_PREFIX); } + + // ───── compile_graph_sqlalchemy (SPEC-5 Part B: WoA convergence path) ───── + + fn timesheet_activity_graph() -> ModelGraph { + let mut m = Model::new("TimesheetActivity"); + m.associations.push(AssocDecl { + kind: AssocKind::BelongsTo, + name: "timesheet".to_string(), + options: vec![("class_name".to_string(), "TimeSheet".to_string())], + }); + m.fields.push(Field { + name: "id".to_string(), + field_type: Some("integer".to_string()), + not_null: Some(true), + ..Default::default() + }); + m.fields.push(Field { + name: "timesheet_id".to_string(), + field_type: Some("integer".to_string()), + not_null: Some(true), + ..Default::default() + }); + m.fields.push(Field { + name: "beschreibung".to_string(), + field_type: Some("string".to_string()), + not_null: Some(true), + ..Default::default() + }); + m.fields.push(Field { + name: "created_at".to_string(), + field_type: Some("datetime".to_string()), + not_null: None, + ..Default::default() + }); + let mut g = ModelGraph::new("woa"); + g.models.push(m); + g + } + + #[test] + fn compile_graph_sqlalchemy_stamps_python_language_and_projects_schema() { + use ogar_vocab::ports::WoaPort; + + let compiled = compile_graph_sqlalchemy::(×heet_activity_graph()); + assert_eq!(compiled.len(), 1); + let cc = &compiled[0]; + assert_eq!(cc.class.name, "TimesheetActivity"); + assert!(matches!(cc.class.language, Language::Python)); + assert!(!cc.class.attributes.iter().any(|a| a.name == "timesheet_id")); + assert!(cc.class.associations.iter().any(|a| a.name == "timesheet")); + } + + /// **Named SPEC-5 deviation (verified, not invented):** SPEC-5 expected + /// an *unmapped* WoA model name to resolve to the bootstrap classid + /// `0x0000_0003` through `WoaPort` (premise: `WOA_ALIASES` is a coarse + /// CRM-concept table that doesn't know WoA's actual model names). That + /// premise is FALSE for `TimesheetActivity` specifically: + /// `ogar-vocab/src/ports.rs`'s `WOA_ALIASES` (and RECON.md R8, written + /// before this spec) already lists `("TimesheetActivity", + /// class_ids::BILLABLE_WORK_ENTRY)` as the deliberate planner/ERP + /// convergence pin. So the REAL classid is `0x0103_0003` (concept + /// `0x0103` = `BILLABLE_WORK_ENTRY`, app `0x0003` = WoA), not + /// `0x0000_0003`. This test pins the TRUE behaviour rather than a + /// fabricated bootstrap value; see the module doc on + /// `compile_graph_sqlalchemy` and the mission report for the full + /// writeup. + #[test] + fn compile_graph_sqlalchemy_timesheet_activity_converges_via_alias_not_bootstrap() { + use ogar_vocab::ports::WoaPort; + + let compiled = compile_graph_sqlalchemy::(×heet_activity_graph()); + let cc = &compiled[0]; + assert_eq!( + cc.facet.facet_classid(), + 0x0103_0003, + "TimesheetActivity is an aliased convergence pin (BILLABLE_WORK_ENTRY | WoA), \ + not the bootstrap address SPEC-5 assumed" + ); + assert_eq!(ogar_vocab::app::concept_of(cc.facet.facet_classid()), 0x0103); + assert_eq!( + ogar_vocab::app::app_of(cc.facet.facet_classid()), + WoaPort::APP_PREFIX + ); + } + + /// The bootstrap mechanism itself works for a genuinely unaliased WoA + /// model name (e.g. `Reminder`, not in `WOA_ALIASES`) — but the actual + /// bootstrap value is the literal `0` (`classid_for_node`'s + /// `.map_or(0, render_classid_for::

)` short-circuits to plain `0` + /// on `None`, never composing the app prefix), matching the SAME-file + /// `unmapped_model_mints_the_bootstrap_address_not_a_wrong_classid` + /// test above for `OdooPort`/`odoo:ir_cron`. SPEC-5's stated bootstrap + /// value `0x0000_0003` (concept `0`, app `0x0003` composed) does NOT + /// match this code path either — a second, independent inaccuracy in + /// the same fixture description, reported alongside the alias-pin + /// finding above. + #[test] + fn compile_graph_sqlalchemy_unaliased_model_mints_the_bootstrap_address() { + use ogar_vocab::ports::WoaPort; + + let mut g = ModelGraph::new("woa"); + g.models.push(Model::new("Reminder")); + let compiled = compile_graph_sqlalchemy::(&g); + assert_eq!( + compiled[0].facet.facet_classid(), + 0, + "unmapped WoA model -> literal bootstrap 0, not 0x0000_0003" + ); + } } diff --git a/crates/ogar-from-ruff/src/sqlalchemy.rs b/crates/ogar-from-ruff/src/sqlalchemy.rs new file mode 100644 index 0000000..9164bab --- /dev/null +++ b/crates/ogar-from-ruff/src/sqlalchemy.rs @@ -0,0 +1,275 @@ +//! SQLAlchemy / WoA field projection — SPEC-5 Part B. +//! +//! `lift_model_with_language` (in `lib.rs`) routes `Language::Python` through +//! [`super::project_odoo_fields`], which never wires `not_null -> required` +//! (only the Rails path, `super::project_rails_fields`, does that). WoA's +//! Flask-SQLAlchemy schema is **total** — every column's nullability is a +//! declared fact (`nullable=True/False`), exactly like a Rails migration +//! column — so it needs the Rails-style wiring (FK-dedup + +//! `not_null -> required` + the attribute-dup guard), NOT the Odoo wiring, +//! on a path that still stamps [`Language::Python`] (WoA is Python source). +//! +//! # Why this module does not share code with `project_rails_fields` +//! +//! SPEC-5 Part B (design note 1) prefers ONE shared +//! `project_total_schema_fields(class, model)` helper called by both +//! `project_rails_fields` and this module's [`project_sqlalchemy_fields`], +//! with `lib.rs` threading an internal `FieldProjection` selector through +//! `lift_model_with_language`. +//! +//! **This session's operator constraint overrides that design note:** a +//! sibling grind agent (G-A) is editing `lib.rs` concurrently on unrelated +//! functions, and the merge contract for this workstream is "new module +//! file(s) + at most one `mod` line in `lib.rs`, otherwise `lib.rs` +//! untouched." So the FK-dedup predicate and the total-nullability wiring +//! are **duplicated** here rather than threaded through `lib.rs`'s private +//! `is_fk_shadowed_by_association` / `project_rails_fields`. This is a named, +//! reported trade-off (see the mission report), not a silent divergence — +//! once G-A's `lib.rs` changes land, a follow-up should consolidate onto the +//! spec's preferred `project_total_schema_fields` shared helper. +//! +//! Consequently [`lift_model_sqlalchemy`] / [`lift_model_graph_sqlalchemy`] +//! are deliberately narrower than [`super::lift_model`] / +//! [`super::lift_model_python`]: they populate `name`, `language`, +//! `associations` (via the already-`pub` [`super::lift_association`]), +//! `source_domain` / `source_curator` / `canonical_concept`, and the +//! schema-stratum `attributes` / `associations` / `computed_fields` (this +//! module's job). Rails-DSL-only slots (`mixins`, `scopes`, `callbacks`, +//! `validations`, `enums`, `inheritance`) stay at their `Class::default()` +//! empty state — a Flask-SQLAlchemy model has no such DSL to lift, so this +//! is the honest empty state for this producer, not a dropped fact. + +use ogar_vocab::{Association, AssociationKind, Attribute, Class, ComputedField, Language}; +use ruff_spo_triplet::{Model, ModelGraph}; + +use crate::lift_association; + +/// Project a WoA/Flask-SQLAlchemy model's schema stratum +/// (`Model::fields` — the `db.Column(...)` declarations, the stand-in for +/// the not-yet-built `ruff_sqlalchemy_spo` frontend's harvest) onto the +/// schema-carrying [`Class`] columns. +/// +/// Mirrors `ogar-from-ruff::project_rails_fields` (SPEC-5 Part B, design +/// note 1) field-for-field: +/// - a relational field (`target` set) -> [`Association`]; dormant for the +/// WoA fixture (associations arrive via `Model::associations` — see +/// [`lift_model_sqlalchemy`] — not via a relational `Model::fields` +/// entry), kept for shape-parity with the Odoo/Rails paths. +/// - a scalar field named `_id` whose model ALSO declares an +/// association named `` (SQLAlchemy `db.relationship`, already +/// lifted onto `class.associations` before this function runs) -> +/// **skipped** ([`is_fk_shadowed_by_association`]). The physical FK +/// column and the declared `db.relationship` are the SAME relation seen +/// twice; the ORM spelling (`timesheet_id: OgInt`) is dropped in favour +/// of the AR-ish spelling (`timesheet: ToOne`). +/// - any other scalar field -> [`Attribute`], `type_name` from +/// `Field::field_type`, `AttributeOptions::required` wired from +/// `Field::not_null`: `Some(true)` (`nullable=False`) stays `Some(true)`; +/// `None` (`nullable=True` or absent — SQLAlchemy's column default, per +/// SPEC-5 Part A's mapping table) becomes the EXPLICIT `Some(false)` — the +/// schema stratum is total knowledge, so absence of `nullable=False` IS a +/// positive "nullable" fact, not "unknown". +/// - a compute field (`emitted_by` set) -> [`ComputedField`] — dormant for +/// WoA today (no `@property`/compute-linkage pass exists yet), kept for +/// shape-parity. +fn project_sqlalchemy_fields(class: &mut Class, model: &Model) { + for field in &model.fields { + if let Some(comodel) = &field.target { + let kind = if field.relation_kind.as_deref() == Some("one2many") || field.inverse_name.is_some() + { + AssociationKind::HasMany + } else { + AssociationKind::BelongsTo + }; + let mut assoc = Association::new(kind, &field.name); + assoc.class_name = Some(comodel.clone()); + assoc.inverse_of = field.inverse_name.clone(); + class.associations.push(assoc); + } else if !is_fk_shadowed_by_association(&field.name, class) + && !class.attributes.iter().any(|a| a.name == field.name) + { + let mut attr = Attribute::new(&field.name); + attr.type_name = field.field_type.clone(); + attr.options.required = Some(field.not_null.unwrap_or(false)); + class.attributes.push(attr); + } + if let Some(compute_method) = &field.emitted_by { + let mut computed = ComputedField::new(&field.name, compute_method); + computed.depends = field.depends_on.clone(); + class.computed_fields.push(computed); + } + } +} + +/// FK-dedup predicate for [`project_sqlalchemy_fields`] — duplicated from +/// `ogar-from-ruff::is_fk_shadowed_by_association` (kept `lib.rs`-private, +/// see the module doc for why this file cannot call it directly). Same +/// contract: `_id` is shadowed when `class` already carries an +/// [`Association`] named ``; the bare `id` primary key never matches +/// (no `_id`-suffixed prefix of its own). +fn is_fk_shadowed_by_association(field_name: &str, class: &Class) -> bool { + // Kept in lockstep with lib.rs::is_fk_shadowed_by_association (SPEC-0(b)): + // an explicit `foreign_key` on the association is authoritative and is + // checked FIRST; the `_id` convention is the fallback. Follow-up: + // consolidate both copies onto a shared project_total_schema_fields helper + // (named trade-off in the module doc) — until then, any semantic change + // to one copy MUST land in the other. + if class + .associations + .iter() + .any(|a| a.foreign_key.as_deref() == Some(field_name)) + { + return true; + } + field_name + .strip_suffix("_id") + .filter(|prefix| !prefix.is_empty()) + .is_some_and(|prefix| class.associations.iter().any(|a| a.name == prefix)) +} + +/// Lift one WoA/SQLAlchemy [`Model`] to an OGAR [`Class`] stamped +/// [`Language::Python`] — the SQLAlchemy producer path (SPEC-5 Part B). +/// Deliberately narrower than [`super::lift_model_python`]; see the module +/// doc for exactly which `Class` slots this populates. +#[must_use] +pub fn lift_model_sqlalchemy(model: &Model) -> Class { + let mut class = Class::new(&model.name); + class.language = Language::Python; + // AR-DSL-shaped associations (`db.relationship(...)`) lift the same way + // Rails' `belongs_to`/`has_many` do — `lift_association` is producer + // agnostic and already `pub` in `lib.rs`. + class.associations = model.associations.iter().filter_map(lift_association).collect(); + project_sqlalchemy_fields(&mut class, model); + class +} + +/// Lift every model in a [`ModelGraph`] via [`lift_model_sqlalchemy`] — +/// the whole-graph SQLAlchemy entry point SPEC-5 Part B names. Declaration +/// order preserved, mirroring `lift_model_graph_python` / +/// `lift_model_graph`. +#[must_use] +pub fn lift_model_graph_sqlalchemy(graph: &ModelGraph) -> Vec { + let domain = classify_woa_domain(&graph.namespace); + let concept_domain = domain.as_deref().and_then(ogar_vocab::source_domain_concept); + let curator = if graph.namespace.is_empty() { + None + } else { + Some(graph.namespace.clone()) + }; + graph + .models + .iter() + .map(|m| { + let mut class = lift_model_sqlalchemy(m); + class.source_domain = domain.clone(); + class.source_curator = curator.clone(); + class.canonical_concept = + Some(ogar_vocab::canonical_concept_in_domain(&m.name, concept_domain)); + class + }) + .collect() +} + +/// The WoA/SMB slice of `lib.rs`'s private `classify_domain` — duplicated +/// here for the same merge-constraint reason as +/// [`is_fk_shadowed_by_association`]. Only the branch relevant to a +/// SQLAlchemy producer (WoA / SMB, both Flask-SQLAlchemy German-ERP +/// consumers) is reproduced; the OpenProject/Redmine/Odoo branches don't +/// apply to this producer. +fn classify_woa_domain(namespace: &str) -> Option { + let ns = namespace.to_ascii_lowercase(); + if ns.contains("woa") || ns.contains("smb") { + Some("german-erp".to_string()) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ogar_vocab::AssociationKind; + use ruff_spo_triplet::{AssocDecl, AssocKind, Field}; + + /// The synthetic `TimesheetActivity` fixture (WoA `models.py:1746-1753`, + /// read-only — see SPEC-5's "concrete parity fixture" and + /// `tests/woa_parity_probe.rs` for the full transcription with source + /// quote). A smaller, self-contained copy for this module's unit tests. + fn timesheet_activity_model() -> Model { + let mut m = Model::new("TimesheetActivity"); + m.associations.push(AssocDecl { + kind: AssocKind::BelongsTo, + name: "timesheet".to_string(), + options: vec![("class_name".to_string(), "TimeSheet".to_string())], + }); + m.fields.push(Field { + name: "id".to_string(), + field_type: Some("integer".to_string()), + not_null: Some(true), + ..Default::default() + }); + // FK column duplicating the `timesheet` association above — must be + // shadowed, not double-projected. + m.fields.push(Field { + name: "timesheet_id".to_string(), + field_type: Some("integer".to_string()), + not_null: Some(true), + ..Default::default() + }); + m.fields.push(Field { + name: "beschreibung".to_string(), + field_type: Some("string".to_string()), + not_null: Some(true), + ..Default::default() + }); + m.fields.push(Field { + name: "created_at".to_string(), + field_type: Some("datetime".to_string()), + not_null: None, // no `nullable=False` in the source -> nullable. + ..Default::default() + }); + m + } + + #[test] + fn lift_sqlalchemy_wires_nullability_and_fk_dedup() { + let class = lift_model_sqlalchemy(×heet_activity_model()); + let by_name = |n: &str| class.attributes.iter().find(|a| a.name == n).unwrap(); + + assert_eq!(by_name("id").options.required, Some(true)); + assert_eq!(by_name("beschreibung").options.required, Some(true)); + assert_eq!(by_name("created_at").options.required, Some(false)); + + assert!( + !class.attributes.iter().any(|a| a.name == "timesheet_id"), + "timesheet_id must be FK-shadowed by the `timesheet` association: {:?}", + class.attributes, + ); + let timesheet = class + .associations + .iter() + .find(|a| a.name == "timesheet") + .expect("timesheet association present"); + assert_eq!(timesheet.kind, AssociationKind::BelongsTo); + assert_eq!(timesheet.class_name.as_deref(), Some("TimeSheet")); + } + + #[test] + fn sqlalchemy_path_stamps_python_language() { + let class = lift_model_sqlalchemy(×heet_activity_model()); + assert!(matches!(class.language, Language::Python)); + } + + #[test] + fn lift_model_graph_sqlalchemy_preserves_order_and_stamps_curator() { + let mut graph = ModelGraph::new("woa"); + graph.models.push(timesheet_activity_model()); + graph.models.push(Model::new("Reminder")); + let classes = lift_model_graph_sqlalchemy(&graph); + assert_eq!(classes.len(), 2); + assert_eq!(classes[0].name, "TimesheetActivity"); + assert_eq!(classes[1].name, "Reminder"); + assert_eq!(classes[0].source_domain.as_deref(), Some("german-erp")); + assert_eq!(classes[0].source_curator.as_deref(), Some("woa")); + } +} diff --git a/crates/ogar-from-ruff/tests/py/instantiate_check.py b/crates/ogar-from-ruff/tests/py/instantiate_check.py new file mode 100644 index 0000000..ceb4712 --- /dev/null +++ b/crates/ogar-from-ruff/tests/py/instantiate_check.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""SPEC-5 Part D — usable-dataclass gate. + +Imports the `emit_python`-generated module (path given as argv[1]) and +instantiates its `TimesheetActivity` dataclass with dummy field values, +asserting the CLASSID const and field names — proving it's a usable data +model, not just syntactically valid text (py_compile only proves the +latter; this proves the former). + +Exit 0 = usable dataclass. Any assertion failure / exception = non-zero +exit, surfaced by the calling Rust test. +""" +import dataclasses +import importlib.util +import sys + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: instantiate_check.py ", file=sys.stderr) + return 2 + module_path = sys.argv[1] + + spec = importlib.util.spec_from_file_location("woa_timesheet_activity", module_path) + assert spec is not None and spec.loader is not None, "module spec must load" + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + cls = module.TimesheetActivity + + # The rail classid travels as a ClassVar — the WoaPort convergence pin + # (BILLABLE_WORK_ENTRY 0x0103 | WoA app 0x0003), per the mission report's + # named SPEC-5 classid deviation (see woa_parity_probe.rs's module doc). + assert cls.CLASSID == 0x0103_0003, f"CLASSID mismatch: 0x{cls.CLASSID:08X}" + + # Instantiate with dummy values for every declared field (proves it's a + # real, usable @dataclass — not just syntactically valid text). + instance = cls( + id=1, + beschreibung="dummy timesheet activity", + created_at=None, + timesheet=None, + ) + + assert instance.id == 1 + assert instance.beschreibung == "dummy timesheet activity" + assert instance.created_at is None + assert instance.timesheet is None + + # Field-name inventory (dataclass field order == emit order: scalars + # then associations) — proves the shape, not just that *a* instance works. + field_names = [f.name for f in dataclasses.fields(cls)] + assert field_names == ["id", "beschreibung", "created_at", "timesheet"], field_names + assert "timesheet_id" not in field_names, "FK column must stay deduped" + + print(f"OK: {cls.__name__} CLASSID=0x{cls.CLASSID:08X} fields={field_names}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/crates/ogar-from-ruff/tests/woa_parity_probe.rs b/crates/ogar-from-ruff/tests/woa_parity_probe.rs new file mode 100644 index 0000000..4d8fa67 --- /dev/null +++ b/crates/ogar-from-ruff/tests/woa_parity_probe.rs @@ -0,0 +1,296 @@ +//! SPEC-5 Part C — WoA parity probe: synthetic `ModelGraph` -> lift -> +//! `emit_python` -> structural 1:1 diff against the real WoA +//! `TimesheetActivity` class. +//! +//! ruff has **no SQLAlchemy frontend** yet (RECON R4 / SPEC-5 Part A is the +//! not-yet-built follow-up), so this probe hand-builds the `ModelGraph` a +//! future `ruff_sqlalchemy_spo` would produce, transcribed faithfully from +//! the real, read-only WoA source (never written to). The transcription +//! itself stands in for the ruff frontend and is the auditable artifact — +//! hence the verbatim source quote below. +//! +//! # Source (READ-ONLY, quoted verbatim for audit — `/home/user/WoA/models.py:1746-1753`) +//! ```python +//! class TimesheetActivity(db.Model): +//! __tablename__ = 'timesheet_activities' +//! id = db.Column(db.Integer, primary_key=True) +//! timesheet_id = db.Column(db.Integer, db.ForeignKey('timesheets.id', ondelete='CASCADE'), nullable=False) +//! beschreibung = db.Column(db.String(500), nullable=False) +//! created_at = db.Column(db.DateTime, default=datetime.now) +//! +//! timesheet = db.relationship('TimeSheet', backref=db.backref('activities', lazy='select', order_by='TimesheetActivity.created_at')) +//! ``` +//! +//! Verified in-repo against `/home/user/WoA/models.py` on 2026-07-06 (WoA +//! stayed read-only; only read for this transcription check). +//! +//! # Named SPEC-5 deviation — classid (see `src/mint.rs`'s +//! `compile_graph_sqlalchemy` doc for the full writeup) +//! +//! SPEC-5 expected `TimesheetActivity` to be *unaliased* in `WoaPort` and +//! resolve to a bootstrap classid `0x0000_0003`. That premise is false: +//! `ogar-vocab/src/ports.rs`'s `WOA_ALIASES` (and `RECON.md` R8, written +//! *before* this spec) already pins `"TimesheetActivity"` to +//! `class_ids::BILLABLE_WORK_ENTRY` (`0x0103`) as the deliberate +//! planner/ERP convergence concept. The REAL emitted classid is therefore +//! `0x0103_0003`, not `0x0000_0003` — reflected in the `expected_py` fixture +//! below and called out explicitly in the printed DRIFT list. This is a +//! verified, reported deviation, not an invented workaround. + +use ogar_from_ruff::mint::compile_graph_sqlalchemy; +use ogar_from_ruff::emit::emit_python; +use ogar_vocab::ports::WoaPort; +use ruff_spo_triplet::{AssocDecl, AssocKind, Field, Model, ModelGraph}; + +/// Build the synthetic `ModelGraph` transcribed from the source quoted +/// above. Namespace `"woa"` (routes `WoaPort` / the `german-erp` domain). +fn timesheet_activity_graph() -> ModelGraph { + let mut m = Model::new("TimesheetActivity"); + + // `timesheet = db.relationship('TimeSheet', backref=db.backref('activities', ...))` + // — backref `'activities'` is plural, so per SPEC-5 Part A's heuristic + // this side (`TimesheetActivity.timesheet`) is `BelongsTo` `TimeSheet`. + m.associations.push(AssocDecl { + kind: AssocKind::BelongsTo, + name: "timesheet".to_string(), + options: vec![("class_name".to_string(), "TimeSheet".to_string())], + }); + + // `id = db.Column(db.Integer, primary_key=True)` — PKs are not-null. + m.fields.push(Field { + name: "id".to_string(), + field_type: Some("integer".to_string()), + not_null: Some(true), + ..Default::default() + }); + // `timesheet_id = db.Column(db.Integer, db.ForeignKey(...), nullable=False)` + // — physical FK column, shadowed by the `timesheet` association above. + m.fields.push(Field { + name: "timesheet_id".to_string(), + field_type: Some("integer".to_string()), + not_null: Some(true), + ..Default::default() + }); + // `beschreibung = db.Column(db.String(500), nullable=False)`. + m.fields.push(Field { + name: "beschreibung".to_string(), + field_type: Some("string".to_string()), + not_null: Some(true), + ..Default::default() + }); + // `created_at = db.Column(db.DateTime, default=datetime.now)` — no + // `nullable=False`, so per the SQLAlchemy-DSL mapping table this is + // `not_null: None` (nullable, schema-total). + m.fields.push(Field { + name: "created_at".to_string(), + field_type: Some("datetime".to_string()), + not_null: None, + ..Default::default() + }); + + let mut g = ModelGraph::new("woa"); + g.models.push(m); + g +} + +/// The hand-derived expected `@dataclass` body, derived from the source +/// above with SPEC-0's FK-dedup canon applied (`timesheet_id` deduped +/// against the `timesheet` association). The classid reflects the REAL +/// `WoaPort` alias resolution (`0x0103_0003`, see the module doc), not +/// SPEC-5's assumed bootstrap value. +const EXPECTED_CLASSID_LINE: &str = " CLASSID: ClassVar[int] = 0x01030003"; +const EXPECTED_LINES: &[&str] = &[ + "class TimesheetActivity:", + EXPECTED_CLASSID_LINE, + " id: OgInt", + " beschreibung: OgStr", + " created_at: OgDateTime", + " timesheet: ToOne[\"TimeSheet\"]", +]; + +#[test] +fn woa_timesheet_activity_lifts_and_emits_structurally_1_to_1() { + let graph = timesheet_activity_graph(); + let compiled = compile_graph_sqlalchemy::(&graph); + assert_eq!(compiled.len(), 1, "exactly one class compiled"); + let py = emit_python(&compiled[0]); + + for line in EXPECTED_LINES { + assert!( + py.contains(line), + "expected line {line:?} missing from emitted Python:\n{py}" + ); + } + assert!( + !py.contains("timesheet_id"), + "FK column timesheet_id must be deduped (shadowed by the `timesheet` association):\n{py}" + ); +} + +#[test] +fn woa_parity_probe_prints_the_d_parity_probe_wp_1_metric() { + let graph = timesheet_activity_graph(); + let compiled = compile_graph_sqlalchemy::(&graph); + assert_eq!(compiled.len(), 1); + let cc = &compiled[0]; + let py = emit_python(cc); + + // ── classes axis ── + let classes_axis = (compiled.len(), 1usize); + + // ── columns typed / nullability axes (computed from the live Class, + // not hardcoded, so the metric can't silently drift from the lift) ── + let expected_scalars = ["id", "beschreibung", "created_at"]; + let typed = expected_scalars + .iter() + .filter(|n| { + cc.class + .attributes + .iter() + .any(|a| &a.name == *n && a.type_name.is_some()) + }) + .count(); + let nullability_wired = expected_scalars + .iter() + .filter(|n| { + cc.class + .attributes + .iter() + .any(|a| &a.name == *n && a.options.required.is_some()) + }) + .count(); + assert_eq!(cc.class.attributes.len(), 3, "no extra/missing attributes (FK deduped, no dup)"); + + // ── associations axis ── + let timesheet_assoc = cc.class.associations.iter().find(|a| a.name == "timesheet"); + let fk_deduped = !cc.class.attributes.iter().any(|a| a.name == "timesheet_id"); + let associations_axis = (usize::from(timesheet_assoc.is_some() && fk_deduped), 1usize); + + // ── action names axis (schema-only v0: WoA methods aren't harvested + // yet — `model.functions` is empty in the synthetic graph, honestly + // reflecting Part A not being built) ── + let actions_axis = (0usize, 0usize); + + // ── drift: the classid deviation (named above), otherwise clean type mapping ── + let classid_drift = format!( + "classid 0x{:08X} is the WOA_ALIASES convergence pin (BILLABLE_WORK_ENTRY) — \ + SPEC-5 assumed an unaliased bootstrap 0x00000003; TimesheetActivity IS aliased \ + in ogar-vocab/src/ports.rs (see src/mint.rs doc)", + cc.facet.facet_classid() + ); + let emit_nullability_drift = "emit_python rendert options.required nicht — \ +nullable created_at wird als pflichtiges @dataclass-Feld emittiert (kein Optional[]); \ +vorbestehende emit.rs-Luecke, Follow-up" + .to_string(); + let drift_list = [classid_drift, emit_nullability_drift]; + + let metric = format!( + "WoA parity probe — TimesheetActivity\n \ + classes: {}/{}\n \ + columns typed: {}/3 (id:integer, beschreibung:string, created_at:datetime)\n \ + nullability: {}/3 (id:req, beschreibung:req, created_at:opt)\n \ + associations: {}/{} (timesheet -> ToOne, FK timesheet_id deduped)\n \ + action names: {}/{} (methods harvested — 0 in schema-only v0)\n \ + DRIFT: {}", + classes_axis.0, + classes_axis.1, + typed, + nullability_wired, + associations_axis.0, + associations_axis.1, + actions_axis.0, + actions_axis.1, + if drift_list.is_empty() { "none".to_string() } else { drift_list.join("; ") }, + ); + println!("{metric}"); + println!("--- emitted Python ---\n{py}"); + + // Structural axes must be N/N — a parity failure here is the finding + // the mission cares about (per SPEC-5's guardrail: don't massage the + // fixture, report it). All hold at N/N for this fixture. + assert_eq!(classes_axis.0, classes_axis.1, "classes axis must be N/N"); + assert_eq!(typed, 3, "columns typed axis must be 3/3"); + assert_eq!(nullability_wired, 3, "nullability axis must be 3/3"); + assert_eq!(associations_axis.0, associations_axis.1, "associations axis must be N/N"); + assert_eq!(actions_axis.0, actions_axis.1, "action names axis must be N/N"); + + assert!(metric.contains("classes: 1/1")); + assert!(metric.contains("columns typed: 3/3")); + assert!(metric.contains("nullability: 3/3")); + assert!(metric.contains("associations: 1/1")); + assert!(metric.contains("action names: 0/0")); + assert!(metric.contains("DRIFT:")); +} + +// ───────────────────────── Part D — py_compile + instantiate gate ───────────────────────── +// +// The emitted `@dataclass` text is the Python mirror of `CompiledClass` +// (Operator §6 / E-PYTHON-SUBSTRATE-MIRROR). Prove it is real, importable +// Python: py_compile it, then instantiate it with dummy values. + +/// Wrap the raw `emit_python` output with the imports/type-alias stand-ins +/// it references (`dataclass`/`ClassVar`, and `OgInt`/`OgStr`/`OgDateTime`/ +/// `ToOne` as lightweight `object`-alias stand-ins for the real consumer +/// wrapper-contract — SPEC-5 Part D step 1) so the module imports cleanly. +fn wrap_module(body: &str) -> String { + format!( + "from dataclasses import dataclass\n\ + from typing import ClassVar, Generic, TypeVar\n\ + \n\ + _T = TypeVar(\"_T\")\n\ + \n\ + \n\ + class ToOne(Generic[_T]):\n \ + \"\"\"Stand-in wrapper-contract type (real consumer supplies this).\"\"\"\n\ + \n\ + \n\ + class ToMany(Generic[_T]):\n \ + \"\"\"Stand-in wrapper-contract type (real consumer supplies this).\"\"\"\n\ + \n\ + \n\ + OgScalar = object\n\ + OgInt = int\n\ + OgStr = str\n\ + OgFloat = float\n\ + OgBool = bool\n\ + OgMoney = object\n\ + OgDate = object\n\ + OgDateTime = object\n\ + OgBytes = bytes\n\ + OgSelection = object\n\ + OgJson = object\n\ + \n\ + \n\ + {body}" + ) +} + +#[test] +fn woa_dataclass_py_compiles_and_instantiates() { + let graph = timesheet_activity_graph(); + let compiled = compile_graph_sqlalchemy::(&graph); + let py_body = emit_python(&compiled[0]); + let module_src = wrap_module(&py_body); + + let tmp_dir = std::env::var("CARGO_TARGET_TMPDIR") + .unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().to_string()); + std::fs::create_dir_all(&tmp_dir).expect("tmp dir"); + let py_path = std::path::Path::new(&tmp_dir).join("woa_timesheet_activity.py"); + std::fs::write(&py_path, &module_src).expect("write emitted module"); + + // Gate 1: valid Python (py_compile, exit 0). + let compile_status = std::process::Command::new("python3") + .args(["-m", "py_compile", py_path.to_str().unwrap()]) + .status() + .expect("python3 available"); + assert!(compile_status.success(), "py_compile must exit 0 on the emitted module"); + + // Gate 2: usable dataclass (instantiate_check.py imports + instantiates it). + let checker = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/py/instantiate_check.py"); + let check_status = std::process::Command::new("python3") + .arg(&checker) + .arg(&py_path) + .status() + .expect("python3 available"); + assert!(check_status.success(), "instantiate_check.py must exit 0"); +} diff --git a/crates/ogar-from-schema/src/lib.rs b/crates/ogar-from-schema/src/lib.rs index 51fee59..33f7974 100644 --- a/crates/ogar-from-schema/src/lib.rs +++ b/crates/ogar-from-schema/src/lib.rs @@ -203,3 +203,51 @@ pub fn into_class(entity: &EntityDecl, attributes: &[(&str, &AttributeDecl)]) -> cls.enums = enums; cls } + +/// Lower one OGIT entity TTL plus the TTL of each attribute it references +/// into the canonical [`Class`] (the controller-DTO wire shape). The +/// membrane pipeline: `entity.ttl (+ attribute .ttl files) → EntityDecl + +/// AttributeDecls → into_class`. Attribute sources are matched to the +/// entity's mandatory+optional attribute list by local name; an attribute +/// whose TTL is not supplied is carried as a bare wire-name column (no +/// enum/validation facts). +#[must_use] +pub fn lift_ogit_entity(entity_src: &str, attribute_srcs: &[&str]) -> Option { + let TtlDeclaration::Entity(entity) = ttl::parse_file(entity_src)? else { + return None; + }; + // Parse each supplied attribute TTL into its AttributeDecl. + let parsed: Vec = attribute_srcs + .iter() + .filter_map(|s| match ttl::parse_file(s)? { + TtlDeclaration::DatatypeAttribute(a) => Some(a), + _ => None, + }) + .collect(); + + // The wire-name column set = the entity's declared attribute local + // names (mandatory ++ optional), localized from the curie form the + // entity's `optional-attributes`/`mandatory-attributes` lists carry. + let wire_names: Vec = entity + .mandatory_attributes + .iter() + .chain(entity.optional_attributes.iter()) + .map(|curie| ttl::local_name_from_curie(curie)) + .collect(); + + // Attributes without a supplied TTL source are carried as bare + // wire-name columns (default decl → no enum/validation facts). + let default_decl = AttributeDecl::default(); + let pairs: Vec<(&str, &AttributeDecl)> = wire_names + .iter() + .map(|name| { + let decl = parsed + .iter() + .find(|a| &a.name == name) + .unwrap_or(&default_decl); + (name.as_str(), decl) + }) + .collect(); + + Some(into_class(&entity, &pairs)) +} diff --git a/crates/ogar-from-schema/src/ttl.rs b/crates/ogar-from-schema/src/ttl.rs index 04e56ab..affeaaa 100644 --- a/crates/ogar-from-schema/src/ttl.rs +++ b/crates/ogar-from-schema/src/ttl.rs @@ -76,7 +76,7 @@ pub(crate) fn find_subject_curie(src: &str) -> Option { None } -fn local_name_from_curie(curie: &str) -> String { +pub(crate) fn local_name_from_curie(curie: &str) -> String { // `ogit.MARS:Machine` → `Machine` // `ogit.MARS.Application:class` → `class` curie.rsplit(':').next().unwrap_or(curie).trim().to_owned() diff --git a/crates/ogar-from-schema/tests/membrane_probe.rs b/crates/ogar-from-schema/tests/membrane_probe.rs new file mode 100644 index 0000000..6d5a161 --- /dev/null +++ b/crates/ogar-from-schema/tests/membrane_probe.rs @@ -0,0 +1,66 @@ +//! Membrane probe: OGIT entity TTL (+ its attribute TTLs) → `ogar-from-schema` +//! → canonical `Class` (the controller-DTO wire shape). +//! +//! What this probe proves: TTL entity + attributes → canonical `Class` (DTO +//! wire shape), wire-names pinned, via the named `lift_ogit_entity` pipeline +//! helper. What it does NOT yet do (named, not implemented here): +//! - **Label→wire-name late resolution** (woa-rs practice: DTO field labels +//! from `rdfs:label`) — the `Class` carries local names; a controller +//! DTO's human labels resolve from the OGIT hashmap at render. +//! - **owl:Class support** so `vocab/ogar.ttl` (the OGAR meta-vocab) itself +//! parses (walker enhancement) — out of scope per SPEC-3 Non-Goals. +//! - **DTO emission**: turning the `Class` into a concrete controller DTO +//! type (Rust/Python) needs a Facet (classid) — for OGIT entities that is +//! the registry-resolve path, not a port mint. `emit_python` on a +//! bootstrap `CompiledClass { class, facet: classid 0 }` would show the +//! `@dataclass` DTO text; a follow-up can add a dedicated +//! `ogar-from-schema` DTO lowering pass. +//! +//! Fixture note: the mission named `vocab/ogar.ttl`, but that file is +//! `owl:Class` meta-vocab (26 occurrences of `a owl:Class`, 0 `rdfs:Class`) +//! which the walker's `detect_kind` does not recognise (only `a rdfs:Class` +//! / `a owl:DatatypeProperty`). This probe uses the real OGIT NTO entity +//! fixture family (`rdfs:Class` dialect) instead: +//! `vocab/imports/ogit/NTO/Documents/entities/DocumentInfoRecord.ttl`. + +use ogar_from_schema::lift_ogit_entity; + +const ENTITY: &str = + include_str!("../../../vocab/imports/ogit/NTO/Documents/entities/DocumentInfoRecord.ttl"); +const A_NUMBER: &str = + include_str!("../../../vocab/imports/ogit/NTO/Documents/attributes/documentNumber.ttl"); +const A_TYPE: &str = + include_str!("../../../vocab/imports/ogit/NTO/Documents/attributes/documentType.ttl"); +const A_PARTID: &str = + include_str!("../../../vocab/imports/ogit/NTO/Documents/attributes/documentPartId.ttl"); +const A_VERSION: &str = + include_str!("../../../vocab/imports/ogit/NTO/Documents/attributes/documentVersion.ttl"); + +#[test] +fn document_info_record_lowers_to_controller_dto_with_expected_wire_names() { + let class = lift_ogit_entity(ENTITY, &[A_NUMBER, A_TYPE, A_PARTID, A_VERSION]) + .expect("DocumentInfoRecord entity must lower"); + // (a) entity → class name. + assert_eq!(class.name, "DocumentInfoRecord"); + // (b) parent lowered from rdfs:subClassOf (verified against + // DocumentInfoRecord.ttl: `rdfs:subClassOf ogit:Entity;`). + assert_eq!(class.parent.as_deref(), Some("ogit:Entity")); + // (c) the DTO wire-name set — the controller-DTO field names — includes + // every declared optional attribute local name. The entity's + // `optional-attributes` list carries curies + // (`ogit.Documents:documentNumber`); `lift_ogit_entity` localizes + // them to match `into_class`'s column names. + let names: std::collections::HashSet<&str> = + class.attributes.iter().map(|a| a.name.as_str()).collect(); + for wire in [ + "documentNumber", + "documentType", + "documentPartId", + "documentVersion", + ] { + assert!( + names.contains(wire), + "missing wire name `{wire}` in DTO: {names:?}" + ); + } +} diff --git a/crates/ogar-render-askama/Cargo.toml b/crates/ogar-render-askama/Cargo.toml index 8a0c4e1..262e467 100644 --- a/crates/ogar-render-askama/Cargo.toml +++ b/crates/ogar-render-askama/Cargo.toml @@ -13,3 +13,7 @@ askama = "0.12" [dev-dependencies] # harvest driver for examples/render_osm.rs (Rails app/models → Class → Rust) ogar-from-rails = { path = "../ogar-from-rails" } +# shared (fields, mask) fixture for the askama<->jinja dual-target +# round-trip (tests/mask_dual_target.rs + tests/jinja/test_mask_roundtrip.py) +serde = { workspace = true } +serde_json = "1.0" diff --git a/crates/ogar-render-askama/examples/render_osm.rs b/crates/ogar-render-askama/examples/render_osm.rs index 6e9a669..d6662e9 100644 --- a/crates/ogar-render-askama/examples/render_osm.rs +++ b/crates/ogar-render-askama/examples/render_osm.rs @@ -1,5 +1,6 @@ //! Render the OSM Rails harvest → Rust structs (ClassView × FieldMask → struct -//! + `ActionDef` methods), one file per class. +//! plus `ActionDef` methods), one file per class. +//! //! `cargo run -p ogar-render-askama --example render_osm -- ` use lance_graph_contract::class_view::FieldMask; use ogar_render_askama::render_class_with_methods; diff --git a/crates/ogar-render-askama/examples/render_python.rs b/crates/ogar-render-askama/examples/render_python.rs index 531f3e4..6f2b24d 100644 --- a/crates/ogar-render-askama/examples/render_python.rs +++ b/crates/ogar-render-askama/examples/render_python.rs @@ -8,9 +8,9 @@ //! Emits, under `/osm/`: //! - `__init__.py` — package marker; re-exports the models + `CLASS_IDS`. //! - `models.py` — one `@dataclass` per THINK class (associations → typed -//! id fields), each carrying its `CLASS_ID`. +//! id fields), each carrying its `CLASS_ID`. //! - `.py` — the DO arm: one module per container, `def (inp)` -//! free functions (`osm.node.show(inp)`), NOT methods. +//! free functions (`osm.node.show(inp)`), NOT methods. //! //! and, one level up, `ogar_sdk.py` — the thin substrate-pull SDK: the codebook //! as a dict + `class_id(concept)` / `render_classid(concept, app_prefix)`, the @@ -133,13 +133,13 @@ fn main() { for c in &classes { let cls = c.name.replace("::", "_"); models.push_str(&format!("@dataclass\nclass {cls}:\n")); - if let Some(concept) = &c.canonical_concept { - if let Some(id) = canonical_concept_id(concept) { - models.push_str(&format!( - " CLASS_ID: ClassVar[int] = 0x{id:04X} # canonical concept `{concept}`\n" - )); - class_ids.push((cls.clone(), id)); - } + if let Some(concept) = &c.canonical_concept + && let Some(id) = canonical_concept_id(concept) + { + models.push_str(&format!( + " CLASS_ID: ClassVar[int] = 0x{id:04X} # canonical concept `{concept}`\n" + )); + class_ids.push((cls.clone(), id)); } let mut wrote = false; for a in &c.attributes { diff --git a/crates/ogar-render-askama/src/lib.rs b/crates/ogar-render-askama/src/lib.rs index e624948..165eead 100644 --- a/crates/ogar-render-askama/src/lib.rs +++ b/crates/ogar-render-askama/src/lib.rs @@ -81,7 +81,7 @@ pub use artifact_kinds::{ }; pub use form_view::{default_input_kind_for, InputKind}; pub use list_view::{default_kind_for, ColumnKind, RenderColumn, SortOrder}; -pub use rust_class::render_class_with_methods; +pub use rust_class::{render_class_with_methods, RenderError}; pub use spec::{ArtifactKind, ArtifactSpec}; use ogar_vocab::Class; diff --git a/crates/ogar-render-askama/src/rust_class.rs b/crates/ogar-render-askama/src/rust_class.rs index 945496c..835ce11 100644 --- a/crates/ogar-render-askama/src/rust_class.rs +++ b/crates/ogar-render-askama/src/rust_class.rs @@ -76,6 +76,44 @@ struct RustMethod { mutates: bool, } +/// Render failure for the masked class projection. +#[derive(Debug)] +pub enum RenderError { + /// Template rendering failed. + Askama(askama::Error), + /// The class carries more fields than a single-`u64` `FieldMask` can + /// address (> `FieldMask::MAX_FIELDS` = 64) AND the caller passed a + /// non-FULL mask, so presence for fields 64+ cannot be represented and + /// would silently drop. Loud-fail instead. (FieldMask `[u64;N]` widening + /// is the cross-repo follow-up — see D-FIELDMASK-LOUD-FAIL.) + TooManyFieldsForMask { + /// Total attribute + association count on the class. + field_count: usize, + /// `FieldMask::MAX_FIELDS` — the single-`u64` ceiling. + max: u32, + }, +} + +impl From for RenderError { + fn from(e: askama::Error) -> Self { + RenderError::Askama(e) + } +} + +impl std::fmt::Display for RenderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RenderError::Askama(e) => write!(f, "template render failed: {e}"), + RenderError::TooManyFieldsForMask { field_count, max } => write!( + f, + "{field_count} fields > {max}-bit FieldMask ceiling; use FieldMask::FULL or widen" + ), + } + } +} + +impl std::error::Error for RenderError {} + /// Render a canonical [`Class`] as a Rust struct whose FIELDS are the /// `ClassView × FieldMask` projection and whose METHODS are the OGAR /// [`ActionDef`] DO-arm, assembled as a struct-of-methods constructor. @@ -91,13 +129,25 @@ struct RustMethod { /// /// # Errors /// -/// Propagates [`askama::Error`] if template rendering fails (it never should -/// for well-formed input — the template has no fallible expressions). +/// Returns [`RenderError::TooManyFieldsForMask`] when the class has more +/// fields than the mask can address AND the caller passed a non-FULL mask +/// (loud-fail instead of silently dropping fields 64+; the FULL sentinel +/// still bypasses the guard and emits everything). Propagates +/// [`RenderError::Askama`] if template rendering fails (it never should for +/// well-formed input — the template has no fallible expressions). pub fn render_class_with_methods( class: &Class, mask: FieldMask, actions: &[ActionDef], -) -> Result { +) -> Result { + let field_count = class.attributes.len() + class.associations.len(); + if field_count > FieldMask::MAX_FIELDS as usize && mask != FieldMask::FULL { + return Err(RenderError::TooManyFieldsForMask { + field_count, + max: FieldMask::MAX_FIELDS, + }); + } + let concept = class.canonical_concept.as_deref().unwrap_or(""); let class_id_hex = canonical_concept_id(concept) .map(|id| format!("0x{id:04X}")) @@ -151,12 +201,15 @@ pub fn render_class_with_methods( ctor_inits, methods, }; - ctx.render() + Ok(ctx.render()?) } /// An all-bits-set mask is the "unmasked" sentinel (emit everything, /// including any field beyond the 64-bit ceiling). Any narrower mask consults /// the bit — and a position past `MAX_FIELDS` can't be present, so it drops. +/// NOTE: a >64-field class whose mask happens to equal `u64::MAX` (bits +/// 0..63 all set) aliases to the FULL sentinel and escapes the loud guard — +/// resolved by the FieldMask widening (WideFieldMask: full_for/max_fields). fn field_present(mask: FieldMask, idx: u8) -> bool { if mask.0 == u64::MAX { true @@ -358,6 +411,95 @@ mod tests { assert!(src.contains("pub fn new() -> Self"), "{src}"); } + /// A wide class: 70 string attributes `f0..f69` — one past the 64-bit + /// `FieldMask` ceiling (`FieldMask::MAX_FIELDS`). + fn wide_class() -> Class { + let mut c = Class::new("wide_thing"); + c.canonical_concept = None; + c.attributes = (0..70) + .map(|i| { + let mut a = Attribute::default(); + a.name = format!("f{i}"); + a.type_name = Some("string".to_string()); + a + }) + .collect(); + c + } + + /// D-FIELDMASK-LOUD-FAIL pin (part i): document today's silent drop for + /// field positions >= 64 — `FieldMask::with` is a documented no-op past + /// `MAX_FIELDS`, so a caller intending bit 65 gets nothing and no error. + /// The `FULL` sentinel is the one escape hatch that still emits + /// everything, including overflow positions. + #[test] + fn wide_class_primitive_pins_pre_fix_silent_drop() { + // `with(65)` is a no-op: position 65 >= MAX_FIELDS (64), so the bit + // is never set — pin the FieldMask contract this test relies on. + let mask = FieldMask::EMPTY.with(0).with(65); + assert!(!mask.has(65), "position 65 must never be representable"); + assert_eq!( + mask, + FieldMask::EMPTY.with(0), + "with(65) must be a no-op, not fold onto another bit" + ); + + let class = wide_class(); + // Guard (ii) loud-fails a >64-field class under a non-FULL mask, so + // this pin must use a mask the guard still lets through: FULL for + // the "everything, including overflow" side, or (documented + // separately) fall back to constructing the pre-guard behavior via + // a class at exactly the ceiling would not exercise the drop. To + // pin the *historical* silent-drop shape without tripping the new + // loud-fail guard, assert directly against `field_present` (the + // primitive the guard wraps) rather than through + // `render_class_with_methods`, whose contract the mission + // explicitly upgraded to loud-fail in this same PR. + assert!(field_present(mask, 0), "bit 0 (f0) present"); + assert!( + !field_present(mask, 65), + "bit 65 (f65) can never be present — the silent drop this test pins" + ); + // FULL sentinel bypasses per-bit gating — every position emits, + // including field 65 beyond the ceiling. + assert!(field_present(FieldMask::FULL, 65), "FULL emits field 65 too"); + + // Cross-check against the actual render output for the FULL case + // (the one case the loud-fail guard still allows for a wide class). + let src = render_class_with_methods(&class, FieldMask::FULL, &[]).unwrap(); + assert!(src.contains("pub f0:"), "{src}"); + assert!( + src.contains("pub f65:"), + "FULL must still emit overflow field f65:\n{src}" + ); + } + + /// D-FIELDMASK-LOUD-FAIL (part ii): a >64-field class under a non-FULL + /// mask must loud-fail (`RenderError::TooManyFieldsForMask`) instead of + /// silently dropping fields 64+. FULL and any <=64-field class remain + /// `Ok` — the guard doesn't false-positive. + #[test] + fn wide_class_under_partial_mask_loud_fails() { + let class = wide_class(); + let mask = FieldMask::EMPTY.with(0); + let err = render_class_with_methods(&class, mask, &[]).unwrap_err(); + match err { + RenderError::TooManyFieldsForMask { field_count, max } => { + assert_eq!(field_count, 70); + assert_eq!(max, FieldMask::MAX_FIELDS); + } + other => panic!("expected TooManyFieldsForMask, got {other:?}"), + } + + // FULL still bypasses the guard for a wide class. + assert!(render_class_with_methods(&class, FieldMask::FULL, &[]).is_ok()); + + // A <=64-field class under a partial mask is unaffected (no + // false-positive). + let small = sample_class(); + assert!(render_class_with_methods(&small, FieldMask::EMPTY.with(0), &[]).is_ok()); + } + #[test] fn sanitize_ident_handles_dotted_and_leading_digit() { assert_eq!(sanitize_ident("action_post"), "action_post"); diff --git a/crates/ogar-render-askama/tests/fixtures/mask_roundtrip.json b/crates/ogar-render-askama/tests/fixtures/mask_roundtrip.json new file mode 100644 index 0000000..3301daa --- /dev/null +++ b/crates/ogar-render-askama/tests/fixtures/mask_roundtrip.json @@ -0,0 +1,6 @@ +{ + "class_name": "commercial_document", + "fields": ["name", "state", "amount_total", "partner_id", "currency_id", "date_invoice", "reference"], + "mask": 45, + "expected_present": ["name", "amount_total", "partner_id", "date_invoice"] +} diff --git a/crates/ogar-render-askama/tests/jinja/render_mask.py.j2 b/crates/ogar-render-askama/tests/jinja/render_mask.py.j2 new file mode 100644 index 0000000..a51b33e --- /dev/null +++ b/crates/ogar-render-askama/tests/jinja/render_mask.py.j2 @@ -0,0 +1,2 @@ +{% for f in fields %}{% if (mask // (2 ** loop.index0)) % 2 == 1 %}{{ f }} +{% endif %}{% endfor %} diff --git a/crates/ogar-render-askama/tests/jinja/test_mask_roundtrip.py b/crates/ogar-render-askama/tests/jinja/test_mask_roundtrip.py new file mode 100644 index 0000000..2a90fa3 --- /dev/null +++ b/crates/ogar-render-askama/tests/jinja/test_mask_roundtrip.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Falsifier #2, jinja side — same (fields, mask) as the askama-side Rust +test (`tests/mask_dual_target.rs`), read from the SAME fixture file, must +render the SAME field-name set as askama produces. + +One ClassView x FieldMask projection, two template engines +(askama/Rust, jinja2/Python), identical result (Operator Sec.5/6; +D-FIELDMASK-LOUD-FAIL companion falsifier, E-ONE-MASK-TWO-ENGINES). + +Run: + uv run --with jinja2 python \ + crates/ogar-render-askama/tests/jinja/test_mask_roundtrip.py +""" +import json +import sys +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader + +HERE = Path(__file__).resolve().parent +FIXTURE = HERE.parent / "fixtures" / "mask_roundtrip.json" + + +def main() -> int: + fixture = json.loads(FIXTURE.read_text()) + fields = fixture["fields"] + mask = fixture["mask"] + expected_present = fixture["expected_present"] + + # Pin the fixture is internally self-consistent before trusting the + # engine output against it (same invariant SPEC-2 states: expected_present[k] + # == fields[i] for every i where (mask >> i) & 1 == 1, in order). + recomputed = [f for i, f in enumerate(fields) if (mask >> i) & 1] + assert recomputed == expected_present, ( + f"fixture is not self-consistent: mask {mask} over {fields} gives " + f"{recomputed}, but expected_present says {expected_present}" + ) + + env = Environment(loader=FileSystemLoader(str(HERE))) + template = env.get_template("render_mask.py.j2") + rendered = template.render(fields=fields, mask=mask) + + present = {line.strip() for line in rendered.splitlines() if line.strip()} + + assert present == set(expected_present), ( + f"jinja render mismatch: got {sorted(present)}, " + f"expected {sorted(expected_present)}" + ) + + print("OK: jinja field set == askama field set == expected_present:", sorted(present)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/crates/ogar-render-askama/tests/mask_dual_target.rs b/crates/ogar-render-askama/tests/mask_dual_target.rs new file mode 100644 index 0000000..59b4db9 --- /dev/null +++ b/crates/ogar-render-askama/tests/mask_dual_target.rs @@ -0,0 +1,117 @@ +//! Falsifier #2, askama (Rust) side — one ClassView x FieldMask projection, +//! read from `tests/fixtures/mask_roundtrip.json`, must emit exactly the +//! `expected_present` field set. `tests/jinja/test_mask_roundtrip.py` reads +//! the SAME fixture and must agree (Operator Sec.5/6; D-FIELDMASK-LOUD-FAIL +//! companion falsifier, E-ONE-MASK-TWO-ENGINES). +//! +//! Class stays <=64 fields on purpose (SPEC-2 non-goal): the >64 case is +//! covered by the pin test + loud-fail guard in `src/rust_class.rs`, not by +//! a round-trip fidelity proof here. + +use std::collections::BTreeSet; + +use lance_graph_contract::class_view::FieldMask; +use ogar_render_askama::render_class_with_methods; +use ogar_vocab::{Attribute, Class}; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +struct MaskRoundtripFixture { + class_name: String, + fields: Vec, + mask: u64, + expected_present: Vec, +} + +fn load_fixture() -> MaskRoundtripFixture { + let raw = include_str!("fixtures/mask_roundtrip.json"); + serde_json::from_str(raw).expect("fixture must be valid JSON matching MaskRoundtripFixture") +} + +fn build_class(fixture: &MaskRoundtripFixture) -> Class { + let mut class = Class::new(&fixture.class_name); + class.canonical_concept = None; + class.attributes = fixture + .fields + .iter() + .map(|name| { + let mut a = Attribute::default(); + a.name = name.clone(); + a.type_name = Some("string".to_string()); + a + }) + .collect(); + class +} + +/// Parse `pub : ,` field-declaration lines out of the emitted +/// struct body into the set of present field names. +fn present_field_names(src: &str) -> BTreeSet { + src.lines() + .filter_map(|line| { + let trimmed = line.trim(); + // Only bare `pub : ,` field-declaration lines — + // excludes `pub struct Foo {`, `pub const CLASS_ID: ...;`, and + // `pub fn new(...) -> Self {` (none of which end in `,`, and + // the const/fn variants also carry a keyword right after `pub `). + let rest = trimmed.strip_prefix("pub ")?; + if !trimmed.ends_with(',') || rest.starts_with("const ") || rest.starts_with("fn ") { + return None; + } + let ident = rest.split(':').next()?; + Some(ident.trim().to_string()) + }) + .collect() +} + +#[test] +fn fixture_is_self_consistent_mask_matches_expected_present() { + // The invariant SPEC-2 mandates: expected_present[k] == fields[i] for + // every i where (mask >> i) & 1 == 1, in order. Recompute independently + // of the JSON's own `expected_present` value so a hand-miscomputed + // fixture is caught here rather than silently trusted. + let fixture = load_fixture(); + let recomputed: Vec = fixture + .fields + .iter() + .enumerate() + .filter(|(i, _)| (fixture.mask >> i) & 1 == 1) + .map(|(_, f)| f.clone()) + .collect(); + assert_eq!( + recomputed, fixture.expected_present, + "fixture expected_present disagrees with mask bit-walk over fields" + ); + assert!( + fixture.fields.len() <= FieldMask::MAX_FIELDS as usize, + "fixture class must stay <=64 fields (SPEC-2 non-goal: no wide class here)" + ); +} + +#[test] +fn askama_mask_projection_yields_expected_present_field_set() { + let fixture = load_fixture(); + let class = build_class(&fixture); + let mask = FieldMask(fixture.mask); + + let src = render_class_with_methods(&class, mask, &[]) + .expect("<=64-field class under a partial mask must render Ok"); + + let present = present_field_names(&src); + let expected: BTreeSet = fixture.expected_present.iter().cloned().collect(); + assert_eq!( + present, expected, + "askama-rendered field set does not match the fixture's expected_present:\n{src}" + ); + + // Direct per-bit pin against the mask contract: presence of fields[i] in + // the output == ((mask >> i) & 1) == 1, for every field position. + for (i, name) in fixture.fields.iter().enumerate() { + let bit_set = (fixture.mask >> i) & 1 == 1; + let in_output = src.contains(&format!("pub {name}:")); + assert_eq!( + bit_set, in_output, + "field `{name}` (bit {i}) presence mismatch: mask bit={bit_set} output_has_field={in_output}\n{src}" + ); + } +} diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 1956392..5ff524b 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -392,6 +392,19 @@ pub struct ActionDef { /// Decorator names that drove the extraction (Odoo `@api.depends`, /// Rails callback macro name). pub decorators: Vec, + /// Fields this action READS (name-level effect facts (writes: authoritative; reads: inferred — ruff classification) from the + /// frontend — `ruff_spo_triplet::Function::reads`). Effect annotation + /// per OGAR-AS-IR §3 test 2; NOT a reactive `@api.depends` claim (a plain + /// method read is not a recomputation trigger — see `lift_actions`). + pub reads: Vec, + /// Fields this action WRITES (`Function::writes` — `self. = …` + /// setter targets, Authoritative). Effect annotation; the *value* written + /// is not captured by today's frontend, so this is name-level only and + /// does NOT auto-build an `on_enter` `EnterEffect`. + pub writes: Vec, + /// Lifecycle-mutator dispatch CALLS (`Function::calls`, `.`). + /// Effect annotation for call-graph analysis; no body is captured. + pub calls: Vec, // ── Rubicon statem carriers (OGAR-AST-CONTRACT §6) ── // The three semantics that don't survive Action-flattening; each lowers diff --git a/docs/DISCOVERY-MAP.md b/docs/DISCOVERY-MAP.md index 03967b8..5a5a36f 100644 --- a/docs/DISCOVERY-MAP.md +++ b/docs/DISCOVERY-MAP.md @@ -892,3 +892,147 @@ isolation. The map's job is to keep them visible. in ruff + OGAR, never op-side. `ruff_python_dto_check` re-framed as the ERB-fieldview → askama render recipes + Action-kind corpus that seeds the `ogar-render-askama` kit and the recipe codebook, not dead weight. +- **D-156-CORRECTNESS-FIXES (2026-07-06; [G]):** the three PR-#156 + open findings closed with red-before-green regression tests — + (a) `ogar-from-rails::extract_app*` now routes through + `ruff_ruby_spo::extract_app_with_schema`, so the physical schema + stratum reaches `Class` on the shipped mainline API (not only a + direct `lift_model` call); (b) `is_fk_shadowed_by_association` now + honours an association's explicit `foreign_key:` (not just the + `_id` naming convention), killing the `user_id`+`author` + double projection; (c) `project_rails_fields` skips a physical + column whose name already exists as a lifted AR-DSL attribute, + ending the duplicate-struct-field bug (the dup-guard). WS-V gate + honoured: each fix hunk was stashed individually and its paired + test confirmed RED, then GREEN — a test that passed both ways would + not have shipped. Physical-vs-declared type reconciliation on + collision noted as a follow-up, not taken. Drive-by: the + `ogar-class-view` "68" doc fuse corrected to 79. Cross-ref + D-PARITY-PROBE-WOA-1 (the parity this unblocks on the mainline + path). + +- **D-BEHAVIOR-ACTION-EDGES (2026-07-06; [G] facts / [H] lowering; + commit 789c7ed):** `lift_actions` now carries ruff's + `Function.reads/writes/calls` onto `ActionDef` as first-class + effect annotations — the IR surface signed off against OGAR-AS-IR + §3 (test 2) — making behavior part of the compile-time substrate + (Operator §3) without fabricating reactive `kausal` from plain + reads. Effect provenance is kept honest rather than flattened: + **writes/calls are authoritative** (assignment + call targets the + extractor sees directly) while **reads are inferred** (name + references, an over-approximation), annotated as such. Full body + lowering (`body_source`, params, value-carrying `on_enter`) stays + blocked on a named `ruff_spo_triplet::Function` extension — the + render crate emits honest `// TODO: port` stubs until then. + Cross-ref E-BEHAVIOR-AT-COMPILE-TIME. + +- **D-FIELDMASK-LOUD-FAIL (2026-07-06; [G]):** the single-`u64` + `FieldMask` 64-field ceiling is now loud, not silent — a >64-field + class under a non-FULL mask returns + `RenderError::TooManyFieldsForMask` from + `render_class_with_methods` instead of dropping fields 64+ (this + loud-fail guard replaces the old silent-drop; the FULL sentinel + still emits all). A pin test documents the pre-fix silent drop. + **Falsifier #2 RAN and is GREEN** for the ≤64 case: one + ClassView×FieldMask projection at **mask=45** yields the identical + field-name set through askama (Rust) and jinja2 (Python) — the + dual-target render proof (Operator §5). Caveat pinned: the FULL + sentinel *aliases* every genuine all-ones mask, so FULL-vs- + explicit-full is indistinguishable at the u64 tier — documented, + not papered over (it is why the widening carries a canonical form). + This is the INTERIM OGAR-side guard; the authorized real fix is + D-FIELDMASK-WIDENING (Ruling c). Cross-ref D-FIELDMASK-WIDENING, + E-ONE-MASK-TWO-ENGINES. + +- **D-FIELDMASK-WIDENING (2026-07-06; [G] — cross-repo lance-graph + PR):** the authorized backward-compatible `FieldMask` widening + (Ruling c) lives as a lance-graph-contract PR: a `WideFieldMask` + carrying a **canonical form** (trailing all-zero chunks trimmed) so + a ≤64-position mask and its wide spelling compare equal — + **repr-independent Eq/Hash**, the non-footgun invariant. Every + existing `FieldMask(u64)` constructor/semantic stays exact; + positions 64+ become representable without moving bits 0..63 (N3 + stability). A review-found P0 (**V-L-P0**: the first cut hashed the + raw repr, so `Small(x)` and `Wide([x])` hashed differently while + comparing equal — a broken Eq/Hash contract) was caught and FIXED + **before merge** via the canonical-form normaliser plus + cross-tier Eq/Hash-agreement tests. `account.move` (109 fields, + Odoo) is the motivating >64 case; WoA parity uses ≤64-field + classes. No classid version split needed. Cross-ref + D-FIELDMASK-LOUD-FAIL. + +- **D-MEMBRANE-TTL-DTO (2026-07-06; [G] shape):** the membrane now + has a named pipeline — `ogar_from_schema::lift_ogit_entity` lowers + an OGIT entity TTL + its attribute TTLs into the canonical `Class` + (controller-DTO wire shape), pinned by a wire-name test on the + `DocumentInfoRecord` fixture (documentNumber / Type / PartId / + Version). Key correction to the mission's `vocab/ogar.ttl` pointer: + that file is `owl:Class` meta-vocab that the current `rdfs:Class` + walker **deliberately does not recognise** — the OGIT NTO corpus is + the real entity-fixture family, not the meta-vocab. Remaining + `[H]`: label→wire-name late resolution, owl:Class walker support, + and a dedicated Facet-bearing DTO-emit pass. Cross-ref + E-OGAR-CONVERGENCE-SHAPE membrane layer. + +- **D-EXEC-ONE-ACTION (2026-07-06; [G] machinery / [H] ERP-wiring):** + one lifted `ActionDef` now punches end-to-end through the reference + `NativeCommandExecutor` (`lift_actions → ActionDef → + NativeCommandExecutor → result`), the executable half of Falsifier + #3. The remainder — kanban transition + Lance tombstone — needs the + lance-graph ractor runtime (outside `/workspace/ogar`) and is + documented as the named gap, staying `[H]`. On the cognition side a + fuse pins that `ActionDef` lowers onto the `UnifiedStep` shape while + `StepDomain` carries **no** ERP/controller variant yet: the + exhaustive `match` **breaks loud at compile time** the moment the + ERP arm is added (the named ActionDef↔UnifiedStep seam). Cross-ref + E-OGAR-CONVERGENCE-SHAPE falsifier #3 + cognition layer, + E-ONE-MASK-THREE-PORTS. + +- **D-PARITY-PROBE-WOA-1 (2026-07-06; [G] for the OGAR half):** WoA + `TimesheetActivity` (`models.py:1746`, transcribed as a synthetic + `ModelGraph` standing in for the not-yet-built + `ruff_sqlalchemy_spo` frontend) lifts through the new + `project_sqlalchemy_fields` / `compile_graph_sqlalchemy` + (`WoaPort`) path and emits via `emit_python` to a structurally 1:1 + `@dataclass` — **classes 1/1, columns typed 3/3, nullability 3/3, + associations 1/1** (FK `timesheet_id` deduped to the `timesheet` + BelongsTo); the emitted Python **py_compiles AND instantiates** + (the language mirror, DP-(b) Option C). **The spec's "unaliased + bootstrap" assumption is REFUTED and pinned by test:** the class + does not sit on classid 0 — it converges to **`0x0103_0003`** via a + `WOA_ALIASES` pin onto the `BILLABLE_WORK_ENTRY` canonical concept + (`0x0103` high ‖ WoA app `0x0003` low). Bootstrap 0 is the literal + *pre-alias* value; the alias table maps it, so the test pins the + aliased id, not 0 — and no new codebook mint is introduced (the pin + reuses an existing concept). **Honest drift:** `emit_python` does + not render `options.required`, so nullability annotations are + absent from the emitted text — a real emitter gap, logged as a + follow-up, not hand-edited. The FK-dedup logic from + D-156-CORRECTNESS-FIXES has a **copy in `sqlalchemy.rs`, synced in + lockstep** here; a consolidation follow-up (one shared dedup + helper) is named. **Remaining producer gap (named):** ruff has no + SQLAlchemy frontend — Part A (`ruff_sqlalchemy_spo`, an ruff-side + harvest brick) is the follow-up that turns the synthetic graph into + a real harvest of all ~151 WoA models. Methodology re-derived from + D-PARITY-PROBE-WP-1 (original probe dir gone, RECON R8). Cross-ref + E-PYTHON-SUBSTRATE-MIRROR, E-OGAR-CONVERGENCE-SHAPE falsifier #1. + +- **D-PY-PERSIST-PG-FACET (2026-07-06; [G]):** the Ruling-(b) + persistence core landed minimal: `ogar-adapter-postgres-ddl` emits + PostgreSQL DDL from the ClassView — the V3 **facet table** + (`classid` + **12 axis-indexed SMALLINT** payload columns, each + axis independently indexable) alongside a per-class typed+nullable + `CREATE TABLE`, the transactional System-of-Record shape. Role + split pinned in the DDL doctrine: **PG = System-of-Record** + (Writes / ACID / GoBD), **lance-graph = zero-copy read hot-path** + (never the sole booking store); **moka caches ONLY the PG side** + (rows are materialised there — a cache in front of lance would copy + Arrow buffers into owned entries and break zero-copy). A + `check_parity` drift-fuse **skeleton** (COUNT_FUSE pattern) compares + sink-in vs legacy-ORM `Class` shapes and screams on divergence, + seeding the legacy-parity revival mode. Follow-ups named (not + built): transactional-outbox / dual-write, CDC, PostgreSQL-ORM full + adapter, lance-graph Python hot-path bindings, moka↔moka-py tier, + Mongo (only on real blob need). Precedent: + ogar-adapter-clickhouse-ddl. Cross-ref D-PARITY-PROBE-WOA-1, + E-PYTHON-SUBSTRATE-MIRROR.