From a79aabce92a81ec6e6a0e9a05ff3a3e20d6a1e3f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 27 May 2026 17:00:33 +0000 Subject: [PATCH] docs(odoo): import Odoo savant briefing pack into .claude/odoo 18-file savant pack imported verbatim from woa-rs/.claude/odoo: SAVANTS.md roster + BRIEFING.md + BRIEFING-GAP.md + 15 lane distillations (L1-L15, odoo model -> K-module mappings). Reference material for lance-graph-side ontology/alignment work; companion to the merged odoo hydrator (D-ODOO-1) and the four-way alignment seam spec. No code impact. https://claude.ai/code/session_016NwUSxRobQRH26KUJXvEYn --- .claude/board/AGENT_LOG.md | 8 + .claude/odoo/BRIEFING-GAP.md | 81 ++ .claude/odoo/BRIEFING.md | 165 +++ .claude/odoo/L1-K3-POST.md | 869 +++++++++++++ .claude/odoo/L10-ANALYTIC.md | 571 +++++++++ .claude/odoo/L11-COA-JOURNALS-LOCKDATES.md | 167 +++ .claude/odoo/L12-MULTICOMPANY-CURRENCY.md | 102 ++ .../odoo/L13-STOCK-VALUATION-PROCUREMENT.md | 117 ++ .claude/odoo/L14-HR-BASE.md | 100 ++ .claude/odoo/L15-TAX-REPARTITION.md | 655 ++++++++++ .claude/odoo/L2-K3-RECON.md | 927 ++++++++++++++ .claude/odoo/L3-K7-TAX.md | 1120 +++++++++++++++++ .claude/odoo/L4-K8K9-REPORTS-DATEV.md | 708 +++++++++++ .claude/odoo/L5-PAY-TERMS-MATCH.md | 691 ++++++++++ .claude/odoo/L6-SALE-PURCHASE.md | 855 +++++++++++++ .claude/odoo/L7-STOCK.md | 742 +++++++++++ .claude/odoo/L8-PRODUCT-UOM-PRICELIST.md | 736 +++++++++++ .claude/odoo/L9-PARTNER-FISCALPOS.md | 580 +++++++++ .claude/odoo/SAVANTS.md | 158 +++ 19 files changed, 9352 insertions(+) create mode 100644 .claude/odoo/BRIEFING-GAP.md create mode 100644 .claude/odoo/BRIEFING.md create mode 100644 .claude/odoo/L1-K3-POST.md create mode 100644 .claude/odoo/L10-ANALYTIC.md create mode 100644 .claude/odoo/L11-COA-JOURNALS-LOCKDATES.md create mode 100644 .claude/odoo/L12-MULTICOMPANY-CURRENCY.md create mode 100644 .claude/odoo/L13-STOCK-VALUATION-PROCUREMENT.md create mode 100644 .claude/odoo/L14-HR-BASE.md create mode 100644 .claude/odoo/L15-TAX-REPARTITION.md create mode 100644 .claude/odoo/L2-K3-RECON.md create mode 100644 .claude/odoo/L3-K7-TAX.md create mode 100644 .claude/odoo/L4-K8K9-REPORTS-DATEV.md create mode 100644 .claude/odoo/L5-PAY-TERMS-MATCH.md create mode 100644 .claude/odoo/L6-SALE-PURCHASE.md create mode 100644 .claude/odoo/L7-STOCK.md create mode 100644 .claude/odoo/L8-PRODUCT-UOM-PRICELIST.md create mode 100644 .claude/odoo/L9-PARTNER-FISCALPOS.md create mode 100644 .claude/odoo/SAVANTS.md diff --git a/.claude/board/AGENT_LOG.md b/.claude/board/AGENT_LOG.md index a3ecb759..7a5a81d0 100644 --- a/.claude/board/AGENT_LOG.md +++ b/.claude/board/AGENT_LOG.md @@ -459,3 +459,11 @@ W11 [2026-05-14T12:29] test-plan-unification: spec at .claude/specs/sprint-10-te **Outcome:** D-ODOO-1 ready for review. Workspace compiles; both touched crates green. NOT pushed (orchestrator reviews + pushes). --- + +## [main / Opus] [DOCS-IMPORT] odoo savant briefing pack -> .claude/odoo (2026-05-27) +Imported the 18-file Odoo savant material verbatim from woa-rs/.claude/odoo: +SAVANTS.md (roster) + BRIEFING.md + BRIEFING-GAP.md + 15 lane distillations +(L1-L15: odoo model -> K-module mappings, e.g. L1-K3-POST, L3-K7-TAX, +L4-K8K9-REPORTS-DATEV, L11-COA-JOURNALS-LOCKDATES, L15-TAX-REPARTITION). +Reference material for lance-graph-side ontology/alignment work (companion to +the merged D-ODOO-1 hydrator + the four_way_alignment_seam spec). No code impact. diff --git a/.claude/odoo/BRIEFING-GAP.md b/.claude/odoo/BRIEFING-GAP.md new file mode 100644 index 00000000..9f8448d1 --- /dev/null +++ b/.claude/odoo/BRIEFING-GAP.md @@ -0,0 +1,81 @@ +# Odoo Richness Harvest — GAP Lanes (L8–L15) + Savant-Agent Goal + +> Companion to `.claude/odoo/BRIEFING.md` (read it first — the dual-axis +> classification, ontology shape, reading discipline, and output template all +> apply unchanged). This file defines the **remaining** lanes after L1–L7 and +> the end-goal those lanes feed: **Savant agents**. +> +> You are ONE read-only analysis lane. Output is a markdown spec draft only. +> **No cargo, no `src/` edits, no git.** Write exactly one file to +> `/home/user/woa-rs/.claude/odoo/.md`. First line MUST be +> `RICHNESS-LANE-OK`; last section MUST be the depth-proof footer +> (`Read: lines= depth=full` per file). + +## What L1–L7 already covered (do NOT re-harvest) + +| Lane | Covered | +|---|---| +| L1 | K3 double-entry posting (`account.move` state machine, `_check_balanced`, hash) | +| L2 | K3 reconciliation matching (open-item ↔ payment) | +| L3 | K7 USt/VAT compute + fiscal-position mapping (tax compute core) | +| L4 | K8 German report line-mappings + K9 DATEV export | +| L5 | Payments, payment terms, reconcile-model matching rules | +| L6 | Sale + Purchase order → invoice flow (Vorgang lifecycle) | +| L7 | Inventory: stock moves, picking, quant valuation + reservation | + +## The remaining lanes (assigned per agent) + +Odoo community source root: `/home/user/odoo/addons/` — present modules: +`account, hr, l10n_de, product, purchase, sale, stock`. Enterprise modules +(`account_asset`, `hr_payroll`, `account_reports`, `account_consolidation`) +are **absent** — flag, do not hallucinate; spec only base data/structure. + +| Lane | Subsystem | Primary odoo files (read FULLY) | woa-rs K-target | +|---|---|---|---| +| **L8** | Product + UoM + Pricelist + costing | `product/models/product_template.py`, `product_product.py`, `product_category.py`, `product_pricelist.py`, `product_pricelist_item.py`, `uom/models/uom_uom.py` | data foundation; pricing (Vorgang line price), costing config (K3 valuation) | +| **L9** | Partner accounting properties + fiscal-position assignment | `account/models/partner.py` (res.partner extensions: `property_account_receivable_id`, `property_payment_term_id`, `property_account_position_id`, `_get_fiscal_position`, `commercial_partner_id`), base `res.partner` accounting fields | data foundation; partner→tax-mapping inference (AXIS-B) | +| **L10** | Analytic accounting (Kostenstellen) | `analytic/models/analytic_account.py`, `analytic_line.py`, `analytic_plan.py`, `analytic_distribution_model.py`; `account/models/account_move_line.py` analytic_distribution | new K-area: cost-centre allocation; distribution-model rules (AXIS-B) | +| **L11** | Chart of accounts + journals + lock dates + sequences | `account/models/account_account.py` (`account_type`, `reconcile`, `internal_group`), `account_group.py`, `account_journal.py`, `account/models/company.py` lock-date logic (`fiscalyear_lock_date`, `tax_lock_date`, `_get_violated_lock_dates`), `sequence_mixin.py` | K11 lock-date semantics + K3 sequence format families | +| **L12** | Multi-company + multi-currency | base `res.company`, `res.currency` + `res.currency.rate` (`_convert`, `_get_conversion_rate`, rounding), `account_move_line.py` `amount_currency`/`balance` compute, multi-company record rules | K15 Mehrfirma + multi-currency | +| **L13** | Stock↔Accounting valuation bridge + procurement | `stock/models/stock_valuation_layer.py`, `product/models/product.py` valuation (`_run_fifo`, `_run_average`, standard), `stock/models/stock_rule.py`, `stock_warehouse_orderpoint.py`, `stock_lot.py` | bridges stock→K3 (inventory GL postings); reordering (AXIS-A formula + AXIS-B) | +| **L14** | HR base data (employee/org/contract structure) | `hr/models/hr_employee.py`, `hr_department.py`, `hr_job.py`, `hr_contract.py` (base only) | K13 **data foundation** only — payroll ENGINE is Enterprise (absent) → built fresh; flag | +| **L15** | Tax repartition + tax groups + price_include + cash-basis | `account/models/account_tax.py` (`repartition_line_ids`, `_compute_amount`, `price_include`/`include_base_amount` ordering, `account_tax_group.py`, cash-basis transition) | deepens K7/L3 — the base/tax %-split to accounts+tags | + +## End-goal these lanes feed: Savant agents + +Every rule you tag **AXIS-B (heuristic → delegate)** is a candidate **Savant +agent**: a specialised reasoner defined by three coordinates. Make the +delegation tuple explicit (per `BRIEFING.md`) so the synthesis pass can mint +the Savant directly: + +1. **Ontology** — the odoo class → OWL pivot → OGIT family (8-bit) via + `resolve_odoo_to_family()`. State the expected family (e.g. `0x61 + BillingCore`, `0x62 SMBAccounting`) or `None` (→ "ontology-unmapped, needs + a Layer-2 alignment axiom"). +2. **Use case** — the K-step / business question (`ReasoningKind` ∈ + {CustomerCategory, PostingAnomaly, NextBestAction, InvoiceCompleteness, + MailIntent, Other(label)}). +3. **Thinking** — `InferenceType` ∈ {Deduction, Induction, Abduction, + Revision, Synthesis}; `SemiringChoice` ∈ {Boolean, HammingMin, NarsTruth, + XorBundle, CamPqAdc}; `ThinkingStyle`-cluster ∈ {Analytical, Creative, + Empathic, Direct, Exploratory, Meta} **inherited from the OGIT family**. + +For each AXIS-B rule, end its entry with a one-line **Savant seed**: +`SAVANT: name= family=<0x..|None> reasoning= inference= +semiring= style= — <1-line why-delegated>`. + +AXIS-A rules need no Savant — they are deterministic Rust ports; just give +the rich-AST sketch so an Opus porter can reproduce them. + +## Reading discipline (Iron Rule 4 / Op-rule №3) + +Read the odoo Python **fully** with the `Read` tool (whole file or +offset/limit chunks covering the entire method). `grep`/`sed`/`head` are +locators only. Quote `file:line-range` for every rule. Odoo source is +canonical; where it's odd, note it, don't "improve". + +## Hard rules + +- NO `cargo`, NO `src/` edits, NO git. Markdown only, to your one drafts file. +- First line `RICHNESS-LANE-OK`; depth-proof footer last. +- If a subsystem is Enterprise-only, say so and spec only base data/structure. diff --git a/.claude/odoo/BRIEFING.md b/.claude/odoo/BRIEFING.md new file mode 100644 index 00000000..c0991c61 --- /dev/null +++ b/.claude/odoo/BRIEFING.md @@ -0,0 +1,165 @@ +# Odoo Richness Harvest — Shared Lane Briefing + +> Read this fully before starting. You are ONE read-only analysis lane in a +> parallel fan-out. Output is a markdown spec draft only. **No cargo, no +> `src/` edits, no git.** Write exactly one file to +> `/home/user/woa-rs/.claude/board/odoo-richness/drafts/.md`. + +## Mission (two stacked user directives) + +1. **"Wherever odoo is richer, take the old shape and export so rich AST + details that we can reproduce the rich business logic in Rust (woa-rs), + and possibly later in Python (WoA — not yet)."** + → Where odoo's logic is *richer* than woa-rs's current ERP state, capture + the FULL behaviour — control flow, branches, field computes, ordering, + rounding, edge cases, constraints — faithfully enough that an Opus porter + can reproduce it in Rust **without re-reading the odoo source**. Keep the + ontology shape (below) attached to every concept. + +2. **"Check also for NARS patterns that can be delegated to thinking in + lance-graph and OGIT-inherited thinking styles for business heuristics."** + → Classify every business rule on a DUAL axis (below). Deterministic logic + gets the rich-AST treatment for a Rust port. Heuristic/inferential logic + gets flagged for *delegation* to lance-graph's thinking surface instead of + being hard-coded as Rust if/else. + +## The dual-axis classification (do this for EVERY rule you extract) + +For each method / computed field / constraint, tag it: + +- **AXIS-A — DETERMINISTIC → Rust port.** A closed-form rule: a balance + check, a sequence format, a tax = base × rate, a residual = debit − credit. + Output: rich-AST spec (see template) so it can be ported verbatim. + +- **AXIS-B — HEURISTIC / INFERENTIAL → delegate to lance-graph thinking.** + Evidence-weighted, multi-factor, ambiguous, or "best guess" logic: + reconciliation *matching* (which open item pairs with which payment), + fiscal-position *resolution* (which tax mapping applies to this partner), + next-best-action, anomaly detection, stock reservation choice, dunning + escalation judgement. These should NOT be reproduced as brittle Rust + branches — they are delegated. + + When you tag AXIS-B, fill the **delegation tuple** using the + `lance-graph-contract` surface (these enums already exist and are + customer-binary-safe / BBB-allowed): + + - `ReasoningKind` ∈ { CustomerCategory, PostingAnomaly, NextBestAction, + InvoiceCompleteness, MailIntent, Other(u32) } — pick the closest; if none + fits, propose an `Other` label. + - `InferenceType` ∈ { Deduction (exact lookup), Induction (pattern/"things + like X"), Abduction (root-cause/"why"), Revision (belief update on change), + Synthesis (cross-domain join) }. + - `SemiringChoice` ∈ { Boolean, HammingMin, NarsTruth, XorBundle, CamPqAdc } + — how evidence combines (NarsTruth = evidence fusion is the common case). + - `ThinkingStyle` cluster ∈ { Analytical, Creative, Empathic, Direct, + Exploratory, Meta }. **This is INHERITED from the OGIT family**, not + chosen freely: the odoo class resolves to an OGIT `FamilyEntry` via + `resolve_odoo_to_family()` (the cache we just built — see Ontology shape), + and the family carries the default style. State which family you'd expect + and therefore which cluster; if the family is unmapped (returns `None`), + say so and propose a cluster with a one-line rationale. + + A rule can be **hybrid**: a deterministic guard wrapping a heuristic core + (e.g. "balance MUST be zero" [AXIS-A] gating "suggest which lines to adjust" + [AXIS-B]). Tag both halves. + +## Ontology shape (keep it attached — directive 1's "old shape") + +We already built the OGIT→OWL→odoo cache: +`lance-graph/crates/.../lance-graph-callcenter/src/odoo_alignment.rs` and a +mirror in `woa-rs/crates/skr_data/src/odoo_alignment.rs`. The chain is: + +``` +odoo class ──owl:equivalentClass──► OWL pivot (fibo/ubl/vcard/schema) ──► OGIT family (8-bit) + slot (16-bit) ──► FamilyEntry (carries thinking style) + resolve_odoo() OgitFamilyTable.lookup() = O(1) + resolve_odoo_to_family(class, &table) chains both legs end-to-end, O(1) +``` + +Seed rows already mapped: `res.partner.Company`→fibo:LegalEntity, +`account.move`→fibo:Transaction, `account.move.line`→fibo:JournalEntryLine, +`account.account`→fibo:Account, `product.*`→schema:Product, SKR concepts→ +fibo:Account. Families in use: 0x61 BillingCore, 0x62 SMBAccounting, +0x80 SmbFoundryCustomer, 0x81 SmbFoundryInvoice. **Option B**: no new CAM +family, no new slot — odoo classes INHERIT an existing OGIT slot via the OWL +pivot. Classes with no existing family (`stock.move`, `sale.order`, +`hr.*`, `account.reconcile.model`, …) currently resolve to `None` — if your +lane touches one, FLAG it as "ontology-unmapped, needs a Layer-2 alignment +axiom" rather than inventing a family. + +For each odoo concept your lane covers, record: `odoo:` → +`owl:equivalentClass ` → expected OGIT family (or `None`). The +DOLCE marker (Endurant/Perdurant/Quality/Abstract) comes from `dolce_odoo()` +suffix rules; note it where non-obvious. + +## Reading discipline (Iron Rule 4 / woa-rs Op-rule №3) + +- Read the odoo Python **fully** with the `Read` tool — whole file or + offset/limit chunks that cover the entire method. `grep`/`sed`/`head` are + LOCATORS only, never comprehension. A snippet read produces a paraphrase + spec that the porter then has to redo. +- Quote the odoo `file:line-range` for every rule you spec. The porter will + spot-check against the source. +- Odoo's source is **canonical** for these semantics (we are stealing them). + Where odoo is buggy or odd, note it; do not silently "improve". + +## ERP gap context (what woa-rs is missing — the K-steps) + +| K-step | Subsystem | woa-rs state | +|---|---|---| +| K3 | Double-entry posting + reconciliation | engine partial / view 501 | +| K7 | USt-Voranmeldung / tax compute / ELSTER | missing | +| K8 | German reports (BWA/SuSa/EÜR/GuV/Bilanz) | missing — engine built FRESH (odoo `account_reports` is Enterprise; only l10n_de **data/line-mappings** are stealable) | +| K9 | DATEV export | partial | +| K11 | Festschreibung (GoBD period lock) | missing | +| K12 | Anlagen (asset depreciation) | missing — odoo `account_asset` is **Enterprise**, NOT in community source | +| K13 | Lohn / payroll | missing — odoo `hr_payroll` is **Enterprise**, NOT in community (only `hr` base is present) | +| K15 | Mehrfirma (multi-company) | missing | + +**Enterprise boundary — flag, do not hallucinate.** account_asset, +account_reports, hr_payroll, account_consolidation are NOT in the community +clone. If your lane's subsystem is Enterprise-only, say so explicitly and +spec only what IS present (base models, data, report STRUCTURE) — the engine +gets built fresh on the woa-rs side. + +## Output template (one file, `drafts/.md`) + +```markdown +# Lane + +## Sources read (file : line-range : depth) +- odoo/addons/.../x.py : L- : full +- ... + +## Ontology rows +| odoo class | owl pivot | OGIT family (or None) | DOLCE | +|---|---|---|---| + +## Rules extracted +### R [AXIS-A | AXIS-B | HYBRID] +- **odoo source**: file:Lx-Ly +- **What it does** (rich): +- **woa-rs target**: +- (AXIS-A) **Rust sketch**: +- (AXIS-B) **Delegation tuple**: ReasoningKind=… InferenceType=… Semiring=… + ThinkingStyle-cluster=… (inherited from OGIT family ) — rationale 1 line +- **Parity notes / gotchas**: + +## Enterprise gaps flagged +- : : + +## Open questions for the Opus porter +- ... +``` + +## Sentinel + depth-proof (required — the agent prompts reference these) +- **First line of your draft MUST be:** `RICHNESS-LANE-OK` +- **Last section MUST be a depth-proof footer**, one line per file: + `Read: lines= depth=full` + +## Hard rules +- NO `cargo` (no build/check/clippy/test). NO `src/` edits. NO git ops. +- Markdown output only, to your one drafts file. +- If two lanes would overlap on a file, still read it fully for your angle; + the Opus review pass dedups. diff --git a/.claude/odoo/L1-K3-POST.md b/.claude/odoo/L1-K3-POST.md new file mode 100644 index 00000000..3600273f --- /dev/null +++ b/.claude/odoo/L1-K3-POST.md @@ -0,0 +1,869 @@ +RICHNESS-LANE-OK + +# L1 — K3 Double-Entry POSTING (Odoo Richness Export) + +**Lane:** L1-K3-POST +**K-step:** K3 double-entry posting · K11 Festschreibung (hash chain) +**woa-rs target module suggestion:** `src/erp/posting.rs` (orchestration facade); +hash chain logic can live in `src/erp/festschreibung.rs` (K11). + +--- + +## 1. Scope and Files Read + +| File | Lines | Depth | +|---|---|---| +| `/home/user/odoo/addons/account/models/account_move.py` | 7328 | full (multi-chunk, entire posting, sequence, hash, reversal, state-transition regions) | +| `/home/user/odoo/addons/account/models/account_move_line.py` | 3742 | full (balance/debit/credit computes, amount_currency, constraints, tax generation) | +| `/home/user/odoo/addons/account/models/sequence_mixin.py` | 512 | full | +| `/home/user/odoo/addons/l10n_de/models/account_move.py` | 19 | full | + +--- + +## 2. Per-Rule Sections + +--- + +### Rule K3-1 — `_post()` main posting flow + +**Odoo file:Lrange:** `account_move.py:L5504-L5736` + +#### Axis-1 Rich-AST Spec + +**Signature:** `def _post(self, soft=True) -> RecordSet[account.move]` +`self` is a recordset (batch) of moves to post. + +**Step 0 — Access guard (L5518-L5519):** +```python +if not self.env.su and not self.env.user.has_group('account.group_account_invoice'): + raise AccessError(...) +``` +Early return if caller is not superuser and not in the invoice group. Rust: check user permissions before entering `post()`. + +**Step 1 — Context patch (L5522):** +```python +self = self.with_context(skip_is_manually_modified=True) +``` +Prevents downstream `is_manually_modified` recomputation from firing during post. Rust: thread a context flag through the service call, not the entity itself. + +**Step 2 — Invoice-specific pre-validations (L5526-L5618), collected into `validation_msgs: set`:** + +All validation messages are collected (not raised individually). A single `UserError` is raised at the end if the set is non-empty (L5616-L5618). Each check below is skipped for non-invoice (`entry`-type) moves unless noted. + +| Check | Condition | Error | Notes | +|---|---|---|---| +| Quick-edit total mismatch | `quick_edit_mode` AND `quick_edit_total_amount` AND `currency.compare_amounts(total_amount, quick_edit_total_amount) != 0` | "current total / expected total" | Uses `currency_id.compare_amounts()` — integer compare after rounding to `decimal_places` | +| Archived bank account | `partner_bank_id` AND NOT `partner_bank_id.active` | "archived bank account" | | +| Untrusted inbound bank account | `partner_bank_id` AND `is_inbound()` AND NOT `allow_out_payment` | If superuser/public/portal: silently `partner_bank_id = False`; else if `_user_can_trust()`: `RedirectWarning`; else: `UserError` | Three-branch trust dispatch | +| Negative total | `float_compare(amount_total, 0.0, precision_rounding=currency_id.rounding) < 0` | "create a credit note instead" | Uses odoo `float_compare` with currency rounding precision | +| Missing partner (sale) | `not partner_id` AND `is_sale_document()` | "Customer field required" | | +| Missing partner (purchase) | `not partner_id` AND `is_purchase_document()` | "Vendor field required" | | +| Missing invoice_date (sale) | `not invoice_date` AND `is_sale_document(include_receipts=True)` | Auto-sets `invoice_date = today`; if currency rate is manual, protects it via `protecting()` context manager | Auto-fill pattern, not error | +| Missing invoice_date (purchase) | `not invoice_date` AND `is_purchase_document(include_receipts=True)` | "Bill/Refund date is required" | | + +**Step 3 — Move-level validations (L5585-L5614), also collected into `validation_msgs`:** + +| Check | Condition | Error | +|---|---|---| +| Line-level account/journal check | Per line (excluding sections/notes): calls `line_ids._check_constrains_account_id_journal_id()` | UserError from that method | +| State guard | `state in ['posted', 'cancel']` | "entry must be in draft" | +| Empty lines | No lines with display_type not in `('line_section', 'line_subsection', 'line_note')` | "Even magicians can't post nothing!" | +| Future-date auto-post config | `not soft AND move.auto_post != 'no' AND move.date > today` | "configured to be auto-posted on {date}" | +| Archived journal | NOT `journal_id.active` | "archived journal" | +| Inactive currency | `display_inactive_currency_warning` | "inactive currency" | +| Archived account | Any line's `account_id.active = False` (unless `skip_account_deprecation_check` in context) | "archived account" | +| Company mismatch | Any `account_id.company_ids` does not intersect `move.company_id.parent_ids` | "accounts from a different company" | + +**Step 4 — Analytic account guard (L5620-L5624):** +```python +if inactive_analytic_ids := self.line_ids.sudo().with_context(active_test=False).distribution_analytic_account_ids.filtered(lambda a: not a.active): + raise UserError(...) +``` +Raises immediately (not accumulated into `validation_msgs`). Note: uses `sudo()` + `active_test=False` to see archived analytic accounts. + +**Step 5 — Soft mode: future-date deferral (L5626-L5635):** +```python +if soft: + future_moves = self.filtered(lambda move: move.date > today) + for move in future_moves: + if move.auto_post == 'no': + move.auto_post = 'at_date' + # post message log + to_post = self - future_moves +else: + to_post = self +``` +`soft=True` is the default. Future-dated moves are NOT posted but their `auto_post` is flipped to `'at_date'` so a cron will post them later. Hard mode (`soft=False`) posts everything including future dates (used internally in `action_post()`). + +**Step 6 — Lock-date date adjustment (L5637-L5641):** +```python +for move in to_post: + affects_tax_report = move._affect_tax_report() + lock_dates = move._get_violated_lock_dates(move.date, affects_tax_report) + if lock_dates: + move.date = move._get_accounting_date(...) +``` +If the move's date falls in a locked period, the date is AUTOMATICALLY ADVANCED to the next open accounting date. This is NOT an error; it is a silent date mutation. Porter note: this is heuristic (see Axis-2 tagging below if you want to delegate it; but it can also be reproduced deterministically by ordering lock dates). + +**Step 7 — Analytic lines batch creation (L5644):** +```python +to_post.line_ids._create_analytic_lines() +``` +Batch for performance; cache invalidation is reduced. + +**Step 8 — Recurring entries copy (L5647-L5648):** +```python +if not self.env.context.get('skip_recurring_copy'): + to_post.filtered(lambda m: m.auto_post not in ('no', 'at_date'))._copy_recurring_entries() +``` +After posting, if `auto_post` is `'monthly'`/`'quarterly'`/`'yearly'`, a copy is created for the next period with the date advanced by the appropriate delta (`relativedelta(months=1/3/12)`). The copy preserves `auto_post`, `auto_post_until`, `auto_post_origin_id`, `invoice_user_id`. Due date is adjusted if `invoice_payment_term_id` is unset. + +**Step 9 — Partner sync (L5650-L5658):** +Forces all `partner_id` fields on move lines to match the move's `commercial_partner_id` (guard against OCR races). + +**Step 10 — Cash-basis reconciliation setup (L5661-L5690):** +Identifies draft reversal moves that need to be posted alongside their counterpart. Collects exchange-difference moves. Handles CABA (cash-basis accounting) partial reconcile invalidation when the draft move changed since it was last reconciled. + +**Step 11 — State transition (L5692-L5695):** +```python +to_post.write({'state': 'posted', 'posted_before': True}) +``` +`posted_before` is set to `True` permanently and never reset. This is used for sequence-name enforcement: a move that was once posted and has a name cannot have its name reset even if it returns to draft. + +**Step 12 — Non-deductible lines naming (L5701-L5708):** +Appends `" - private part"` to non-deductible lines' names after the sequence number is known. + +**Step 13 — Reversal reconciliation (L5710-L5711):** +```python +draft_reverse_moves.reversed_entry_id._reconcile_reversed_moves(draft_reverse_moves, ...) +to_post.line_ids._reconcile_marked() +``` + +**Step 14 — Partner rank update (L5713-L5729):** +Increments `customer_rank`/`supplier_rank` counters on partners. + +**Step 15 — Zero-amount invoice hook (L5731-L5734):** +If total is zero for an invoice, fires `_invoice_paid_hook()`. + +**Returns:** `to_post` — the recordset of actually-posted moves. + +**Axis classification:** DETERMINISTIC. +K-step: K3. +woa-rs target: `src/erp/posting.rs::post_moves()`. + +**Ontology mapping:** +`odoo:account.move` → `fibo:Transaction` → OGIT family `SmbFoundryInvoice` (0x81) → DOLCE Perdurant. + +--- + +### Rule K3-2 — `action_post()` public entrypoint + +**Odoo file:Lrange:** `account_move.py:L6073-L6094` + +#### Axis-1 Rich-AST Spec + +```python +def action_post(self): + if ( + not self.env.context.get('disable_abnormal_invoice_detection', True) + and self.filtered(lambda m: m.abnormal_amount_warning or m.abnormal_date_warning) + ): + # open validate.account.move wizard + return {...} + if self: + self._post(soft=False) + if autopost_bills_wizard := self._show_autopost_bills_wizard(): + return autopost_bills_wizard + return False +``` + +Key: `action_post` calls `_post(soft=False)`. The `disable_abnormal_invoice_detection` context key defaults to `True` in this codebase (the comment says "Disabled by default to avoid breaking automated action flow"), meaning the wizard is **not** shown by default. Only when an external caller sets it to `False` does it appear. + +`_show_autopost_bills_wizard()` (L5838-L5875): shows a wizard to enable automatic posting for a vendor if 3 or more consecutive bills from the same partner were not manually modified. This is: +- **Axis-2 HEURISTIC**: counting "unmodified consecutive bills" is a heuristic recommendation. +- Contract tuple: `(PostingAnomaly, Induction, NarsTruth, Analytical)`. +- Inherited ThinkingStyle cluster: **Analytical** (OGIT `SmbFoundryInvoice` → posting-anomaly check). + +**Axis classification:** Mostly DETERMINISTIC; `_show_autopost_bills_wizard` sub-call is HEURISTIC (Axis-2). +K-step: K3. + +--- + +### Rule K3-3 — `_check_balanced()` and `_get_unbalanced_moves()` — balance invariant + +**Odoo file:Lrange:** `account_move.py:L2755-L2794` + +#### Axis-1 Rich-AST Spec + +`_check_balanced` is a `@contextmanager`; it wraps create/write operations. It calls `_get_unbalanced_moves()` which executes a raw SQL query: + +```sql +SELECT line.move_id, + ROUND(SUM(line.debit), currency.decimal_places) debit, + ROUND(SUM(line.credit), currency.decimal_places) credit + FROM account_move_line line + JOIN account_move move ON move.id = line.move_id + JOIN res_company company ON company.id = move.company_id + JOIN res_currency currency ON currency.id = company.currency_id + WHERE line.move_id IN %s +GROUP BY line.move_id, currency.decimal_places +HAVING ROUND(SUM(line.balance), currency.decimal_places) != 0 +``` + +**Critical semantics:** +- Rounding is applied **per company currency's `decimal_places`** (not per move's foreign currency). +- The rounding is `ROUND(SUM(...), decimal_places)` — SQL-level rounding on the aggregated sum, not per-line rounding. +- `balance = debit - credit` for normal lines; `balance = credit - debit` for storno lines (see K3-6 below). +- The query runs on committed DB state (flush is called first: L2782). +- Guard: `with self._disable_recursion(container, 'check_move_validity', default=True, target=False)` prevents recursive invocation. + +**Error handling:** If exactly 1 unbalanced move: generic "The entry is not balanced." If multiple: lists each move name. + +**What "balanced" means in odoo:** +`SUM(balance) == 0` after rounding to company currency decimal places. This is the double-entry invariant: sum of debits == sum of credits. Debit = `balance > 0`, Credit = `balance < 0` (for non-storno lines). + +**Axis classification:** DETERMINISTIC. +K-step: K3. +woa-rs target: `src/erp/posting.rs::check_balanced()`. + +**Ontology mapping:** +`odoo:account.move` → `fibo:Transaction` → OGIT `SmbFoundryInvoice` (0x81) → DOLCE Perdurant. + +--- + +### Rule K3-4 — `_compute_balance`, `_compute_debit_credit`, `_compute_amount_currency` — line-level amounts + +**Odoo file:Lrange:** `account_move_line.py:L708-L762` + +#### Axis-1 Rich-AST Spec + +**`_compute_balance` (L708-L724):** +```python +def _compute_balance(self): + for line in self: + if line.display_type in ('line_section', 'line_subsection', 'line_note'): + line.balance = False # structural lines have no balance + elif not line.move_id.is_invoice(include_receipts=True): + # journal entry (not invoice): auto-balance to zero by computing + # the negative sum of all other lines + active_line_ids = [lid for lid in self.env.context.get('line_ids', []) if isinstance(lid, int)] + existing_lines = self.env['account.move.line'].browse(active_line_ids) + outdated_lines = line.move_id.line_ids._origin + new_lines = line.move_id.line_ids - line + line.balance = -sum((existing_lines - outdated_lines + new_lines).mapped('balance')) + else: + line.balance = 0 # for invoices, balance is computed elsewhere (from price_unit etc.) +``` + +Key: on journal entries (`entry` type), the LAST line to be assigned has its balance auto-computed as the negative of all others. This is the "balancing line" UX convenience. For invoices, `balance` is driven by `price_unit`/`quantity`/`discount` via the invoice compute chain. + +**`_compute_debit_credit` (L727-L734):** +```python +@api.depends('balance') +def _compute_debit_credit(self): + for line in self: + if not line.is_storno: + line.debit = line.balance if line.balance > 0.0 else 0.0 + line.credit = -line.balance if line.balance < 0.0 else 0.0 + else: + # STORNO: flip debit/credit sign presentation + line.debit = line.balance if line.balance < 0.0 else 0.0 + line.credit = -line.balance if line.balance > 0.0 else 0.0 +``` + +This is a pure derived field: `debit` and `credit` are always non-negative; their signs are determined by whether `balance` is positive or negative. The storno flag flips the visual presentation (negative debit / positive credit for a storno cancellation entry). + +**`_compute_amount_currency` (L757-L762):** +```python +@api.depends('currency_rate', 'balance') +def _compute_amount_currency(self): + for line in self: + if line.amount_currency is False: + line.amount_currency = line.currency_id.round(line.balance * line.currency_rate) + if line.currency_id == line.company_id.currency_id and not line.move_id.is_invoice(True): + line.amount_currency = line.balance +``` + +`amount_currency` is the balance expressed in the line's foreign currency. When the line currency matches the company currency (no FX), `amount_currency == balance`. Otherwise: `currency_id.round(balance * currency_rate)`. The `currency_id.round()` method uses the currency's `decimal_places` (e.g. 2 for EUR). + +**`_compute_currency_rate` (L736-L749):** +```python +@api.depends('currency_id', 'company_id', 'move_id.invoice_currency_rate', 'move_id.date') +def _compute_currency_rate(self): + for line in self: + if line.move_id.is_invoice(include_receipts=True): + line.currency_rate = line.move_id.invoice_currency_rate or 1.0 + elif line.currency_id: + line.currency_rate = self.env['res.currency']._get_conversion_rate( + from_currency=line.company_currency_id, + to_currency=line.currency_id, + company=line.company_id, + date=line.move_id.invoice_date or line.move_id.date or fields.Date.context_today(line), + ) + else: + line.currency_rate = 1 +``` + +For invoices: uses the header-level `invoice_currency_rate` (locked at time of invoice creation). For journal entries: looks up the live exchange rate at the move date. + +**`depends` chains (summarised):** +- `balance` ← computed from `price_unit`, `quantity`, `discount`, `tax_ids`, `currency_id` (invoice lines) or auto-balance (journal entry last line) +- `debit`, `credit` ← `balance`, `is_storno` +- `amount_currency` ← `currency_rate`, `balance` +- `currency_rate` ← `currency_id`, `company_id`, `move_id.invoice_currency_rate`, `move_id.date` + +**Axis classification:** DETERMINISTIC. +K-step: K3. +woa-rs target: `src/erp/posting.rs` (line amount computation helpers). + +**Ontology mapping:** +`odoo:account.move.line` → `fibo:JournalEntryLine` → OGIT family `SmbFoundryInvoice` (0x81), slot `SLOT_JOURNAL_LINE` (0x06) → DOLCE Perdurant (the alignment row explicitly sets Perdurant; note: the test suite documents a known divergence from suffix heuristic which would give Endurant — curated row wins). + +--- + +### Rule K3-5 — Line-level constraints (`_sql_constraints` and `@api.constrains`) + +**Odoo file:Lrange:** `account_move_line.py:L1438-L1578` + +#### Axis-1 Rich-AST Spec + +**`_check_constrains_account_id_journal_id` (L1438-L1454):** +Not a decorator — called explicitly from `_post()` (step 2 above) rather than on every write: +- Skip lines with `display_type in ('line_section', 'line_subsection', 'line_note')`. +- If account is archived (not active) AND NOT `is_imported` AND NOT `skip_account_deprecation_check`: raise. +- If `account.currency_id` is set AND it is neither the company currency nor the line's own `currency_id`: raise (forces secondary currency consistency). +- If account is the journal's `default_account_id` or `suspense_account_id`: skip remaining checks (these are always valid). + +**`_check_off_balance` (L1456-L1465, `@api.constrains`):** +- If any line's account type is `'off_balance'`, ALL lines in the same move MUST have `account_type == 'off_balance'` — otherwise raise. +- Off-balance lines CANNOT have `tax_ids` or `tax_line_id`. +- Off-balance lines CANNOT be reconciled. + +**`_check_payable_receivable` (L1467-L1480, `@api.constrains`):** +- On sale documents: `liability_payable` account is forbidden; `payment_term` display_type XOR `asset_receivable` account type (if one is present, both must be). +- On purchase documents: `asset_receivable` account is forbidden; `payment_term` display_type XOR `liability_payable` account type. + +**`_check_caba_non_caba_shared_tags` (L1512-L1551, `@api.constrains`):** +Prevents cash-basis (on_payment) taxes and non-cash-basis taxes from sharing repartition tags on the same line. Raises `ValidationError` if `caba_base_tags & non_caba_base_tags` is non-empty (or for tax-affects-tax scenarios). + +**`_constrains_matching_number` (L1553-L1570, `@api.constrains`):** +Matching number format: `^((P?\d+)|(I.+))$`. Invariants: +- `'I'` prefix → temporary (import) number; cannot be in `matched_debit_ids` or `matched_credit_ids`. +- `'P'` prefix → partial reconciliation; MUST have partials; must NOT have `full_reconcile_id`. +- Numeric-only → full reconciliation; MUST have `full_reconcile_id`; number MUST equal `str(full_reconcile_id.id)`. + +**`_constrains_deductible_amount` (L1572-L1578, `@api.constrains`):** +Non-purchase documents must have `deductible_amount == 100`. Value must be in `[0, 100]`. + +**Axis classification:** DETERMINISTIC. +K-step: K3. +woa-rs target: `src/erp/posting.rs` (validation functions called from `post_moves()`). + +--- + +### Rule K3-6 — Storno (Gegenbuchung) and `_reverse_moves()` + +**Odoo file:Lrange:** `account_move.py:L5430-L5474` + +#### Axis-1 Rich-AST Spec + +```python +TYPE_REVERSE_MAP = { + 'entry': 'entry', + 'out_invoice': 'out_refund', + 'out_refund': 'out_invoice', + 'in_invoice': 'in_refund', + 'in_refund': 'in_invoice', + 'out_receipt': 'out_refund', + 'in_receipt': 'in_refund', +} +``` + +**`_reverse_moves(default_values_list=None, cancel=False)` (L5430-L5474):** + +``` +for each (move, default_values): + default_values['move_type'] = TYPE_REVERSE_MAP[move.move_type] + default_values['reversed_entry_id'] = move.id + default_values['partner_id'] = move.partner_id.id + reverse_move = move.copy(default_values) # full copy with context flags + +# After creating all reverse copies, flip balance/amount_currency on journal-entry lines: +reverse_moves.write({ + 'line_ids': [ + Command.update(line.id, { + 'balance': -line.balance, + 'amount_currency': -line.amount_currency, + # if company.account_storno: also flip is_storno flag + **({'is_storno': not line.is_storno} if line.company_id.account_storno else {}) + }) + for line in reverse_moves.line_ids + if line.move_id.move_type == 'entry' or line.display_type == 'cogs' + ] +}) + +if cancel: + reverse_moves._post(soft=False) # immediately post the reversal +``` + +**Key semantics:** +1. A reverse move is a **copy** of the original, not a mutation. This is the GoBD Storno pattern: two audit rows exist in the ledger — the original and the reversal. +2. Balance and amount_currency are **negated** on `entry`-type move lines and `cogs`-type lines. Invoice-type lines are NOT negated here because their amounts come from the copied `price_unit` etc. which are themselves negated by the invoice copy machinery. +3. `is_storno` flag: if `company.account_storno` is enabled, the flag is toggled (for German/Austrian storno-debit presentation — a storno entry shows the credit as a negative debit rather than a positive credit, keeping the visual ledger cleaner). See `_compute_debit_credit` (K3-4): storno lines flip debit/credit sign. +4. `reversed_entry_id`: FK on the reverse move pointing back to the original. The original move gets `reversal_move_id` pointing forward. +5. `cancel=True`: the reversal is immediately posted (creates the two-row audit trail in one operation). Used by `button_cancel` → `_unlink_or_reverse()` (L5486-L5502). + +**GoBD mapping:** Storno = zwei Buchungszeilen (Original + Stornobuchung). Never deletes the original row. `button_cancel` path for posted+hashed moves: `_unlink_or_reverse()` picks `_reverse_moves(cancel=True)` when `_can_be_unlinked()` returns False (i.e., when the move has an `inalterable_hash`). + +**`_can_be_unlinked()` (L5476-L5481):** +```python +def _can_be_unlinked(self): + lock_date = self.company_id._get_user_fiscal_lock_date(self.journal_id) + posted_caba_entry = ... + posted_exchange_diff_entry = ... + return not self.inalterable_hash and self.date > lock_date and not posted_caba_entry and not posted_exchange_diff_entry +``` +A move with `inalterable_hash` can NEVER be unlinked — it MUST be reversed (GoBD Festschreibung). This is the K11 boundary. + +**Axis classification:** DETERMINISTIC. +K-step: K3 (reversal creation) + K11 (hash guard on unlinking). +woa-rs target: `src/erp/posting.rs::reverse_moves()`. + +--- + +### Rule K3-7 — `button_draft()` / `button_cancel()` — state transitions + +**Odoo file:Lrange:** `account_move.py:L6162-L6253` + +#### Axis-1 Rich-AST Spec + +**`button_draft()` (L6162-L6174):** +``` +Guard: state must be 'cancel' or 'posted' +Guard: not need_cancel_request (e-invoice government lock) +_check_draftable(): + - not an exchange difference move (cannot draft) + - not a CABA (cash-basis) entry (cannot draft) + - not inalterable_hash (CANNOT reset to draft a locked entry) +Actions on success: + - unlink all analytic_line_ids (with skip_analytic_sync=True) + - state = 'draft' + - sending_data = False + - _detach_attachments() (detaches invoice PDF with timestamp) +``` + +**Critical:** `inalterable_hash` check in `_check_draftable()` (L6229-L6230): +```python +if move.inalterable_hash: + raise UserError(_('You cannot reset to draft a locked journal entry.')) +``` +A hashed/festgeschrieben entry is permanently immutable. This is the K11 boundary again. + +**`button_cancel()` (L6241-L6253):** +``` +1. Filter moves in state 'posted' → call button_draft() on them first +2. Guard: state must be 'draft' after step 1 +3. line_ids.remove_move_reconcile() # unreconcile all +4. payment_ids.state = "canceled" +5. write({'auto_post': 'no', 'state': 'cancel'}) +``` + +Note: `button_cancel` does NOT call `_reverse_moves`. It directly sets state to `cancel`. This is different from the GoBD storno pattern — `button_cancel` is only safe on un-hashed moves. Hashed moves go through `_unlink_or_reverse()` → `_reverse_moves(cancel=True)`. + +**State machine (complete):** +``` +draft ──[_post()]──► posted ──[button_draft()]──► draft + │ + └──[button_cancel()]──► (draft) ──► cancel + │ + └──[_reverse_moves(cancel=True)]──► posted (reversal created AND posted) +``` + +**Axis classification:** DETERMINISTIC. +K-step: K3 (state machine) + K11 (hash guard). +woa-rs target: `src/erp/posting.rs`. + +--- + +### Rule K3-8 — Sequence / Belegnummer assignment + +**Odoo file:Lrange:** +- `account_move.py:L938-L964` (`_compute_name`) +- `account_move.py:L4157-L4267` (`_get_last_sequence_domain`, `_get_starting_sequence`) +- `sequence_mixin.py:L269-L473` (full mixin: `_get_last_sequence`, `_set_next_sequence`, `_locked_increment`, `_deduce_sequence_number_reset`) + +#### Axis-1 Rich-AST Spec + +**Sequence format families (sequence_mixin.py:L41-L45):** +| Family | Regex pattern | Reset | +|---|---|---| +| `year_range_month` | `^$` | per fiscal-year-month | +| `monthly` | `^$` | per calendar month | +| `year_range` | `^$` | per fiscal year range | +| `yearly` | `^$` | per calendar year | +| `fixed` | `^$` | never | + +Priority order for `_deduce_sequence_number_reset`: year_range_month → monthly → year_range → yearly → fixed (first match wins). + +**Starting sequence format (account_move.py:L4229-L4267):** +- Sales/bank/cash/credit journals: `"//<00000>"` (annual, 5-digit seq) +- Other (purchase/general) journals: `"///<0000>"` (monthly, 4-digit seq) +- Staggered fiscal year (not ending Dec 31): year_part is `"-"` (e.g. `"23-24"`), seq length 4. +- Refund sequence prefix: `"R"` prepended to starting sequence. +- Payment sequence prefix: `"P"` prepended to starting sequence. +- Self-billing: `"///<0000>"`. + +**`_get_last_sequence_domain` override (account_move.py:L4157-L4227):** +Filters to the same journal. If NOT relaxed: +1. Finds reference move (most recent in same period + journal + type-family). +2. Deduces reset periodicity from reference name via `_deduce_sequence_number_reset`. +3. Computes `date_start`/`date_end` for the period via `_get_sequence_date_range`. +4. Applies `anti_regex` to exclude format-crossing contamination (e.g. monthly regex matching yearly names). +5. Filters by refund/payment/self-billing discriminators. + +**`_get_last_sequence` (sequence_mixin.py:L269-L310):** Runs: +```sql +SELECT name FROM account_move +{where_string} +AND sequence_prefix = (SELECT sequence_prefix FROM account_move {where_string} ORDER BY id DESC LIMIT 1) +ORDER BY sequence_number DESC LIMIT 1 +``` +Returns the highest sequence number in the current prefix+period window. + +**`_locked_increment` (sequence_mixin.py:L355-L423) — gap-prevention core:** +``` +1. Check transaction-local cache: if cache[cache_key] exists, increment in memory. +2. Otherwise: open a SAVEPOINT. +3. Loop: seq += 1; attempt UPDATE account_move SET name = WHERE id = +4. If UniqueViolation or ExclusionViolation: rollback to SAVEPOINT, retry. +5. On success: store cache[cache_key] = seq; return sequence string. +``` +The UNIQUE constraint on `(journal_id, sequence_prefix, sequence_number)` is the gap-prevention mechanism. No two moves can have the same sequence in the same journal+prefix. The lock is implicit in the B-tree index entry (PostgreSQL `_bt_doinsert` exclusive lock). + +**`_compute_name` (account_move.py:L938-L954):** +```python +self = self.sorted(lambda m: (m.date, m.ref or '', m._origin.id)) +for move in self: + if move.state == 'cancel': continue + move_has_name = move.name and move.name != '/' + if not move.posted_before and not move._sequence_matches_date(): + move.name = False; continue # reset if date-sequence mismatch, first time only + if move.date and not move_has_name and move.state != 'draft': + move._set_next_sequence() +self._inverse_name() +``` +Key: `posted_before=True` freezes the name. If a move was posted, its name is never reset by `_compute_name` even if the date changes. + +**Axis classification:** DETERMINISTIC (the sequence assignment algorithm itself). +One Axis-2 sub-case: if `relaxed=True` is used (fallback to a different period's format), the format detection is heuristic — but the fallback is deterministic once the format is found. +K-step: K3 (Belegnummer), also affects K11 (sequence gaps detected by `_get_chains_to_hash`). +woa-rs target: `src/erp/posting.rs` (sequence assignment), or a dedicated `src/erp/sequence.rs`. + +--- + +### Rule K3-9 — `_get_computed_taxes()` and `_sync_tax_lines()` — tax line generation + +**Odoo file:Lrange:** +- `account_move_line.py:L944-L973` (`_get_computed_taxes`) +- `account_move.py:L3258-L3464` (`_sync_tax_lines`) + +#### Axis-1 Rich-AST Spec + +**`_get_computed_taxes()` (aml:L944-L973):** +Priority chain for tax resolution on a line: +1. Sale document: `product.taxes_id` filtered to company → fallback to `account_id.tax_ids` (type='sale'). +2. Purchase document: `product.supplier_taxes_id` filtered to company → fallback to `account_id.tax_ids` (type='purchase'). +3. Other (journal entry with `account_default_taxes` context): all `account_id.tax_ids`. +4. Otherwise: `account_id.tax_ids` unless `skip_computed_taxes` context or `is_entry()`. +5. Always filter by company: `_filter_taxes_by_company(company_id)`. +6. If `fiscal_position_id`: map taxes via `fiscal_position.map_tax(tax_ids)`. + +**`_sync_tax_lines()` (am:L3258-L3464):** +Contextmanager called from `create`/`write` hooks. Tracks changes to base lines and tax lines. Decision logic: + +``` +If currency or move_type changed on an invoice: round_from_tax_lines = False +Elif a base line with tax_ids was removed: round_from_tax_lines = any_field_has_changed(tax_lines) +Elif a base line was modified: + round_from_tax_lines = ( + all changed lines have no tax_ids # no impact + OR (tax line list changed OR any tax line field is manually protected) + ) + If round_from_tax_lines and any balance/amount_currency provided: skip (manual override) +Elif currency_rate changed: round_from_tax_lines = 'reapply_currency_rate' +Else: skip (no change) +``` + +Then: +```python +base_lines_values, tax_lines_values = move._get_rounded_base_and_tax_lines(round_from_tax_lines=...) +AccountTax._add_accounting_data_in_base_lines_tax_details(base_lines_values, company, ...) +tax_results = AccountTax._prepare_tax_lines(base_lines_values, company, tax_lines=tax_lines_values) +``` +Results: `tax_lines_to_add`, `tax_lines_to_delete`, `tax_lines_to_update`, `base_lines_to_update`. + +Tax line `display_type` is set to `'tax'` on create. Non-deductible tax lines (`display_type='non_deductible_tax'`) are computed separately from partial-deductibility amount. + +**Axis classification:** DETERMINISTIC for core tax arithmetic. The choice of which tax to apply (fiscal position mapping, product tax priority) has a mild heuristic component — but the odoo code resolves it deterministically via priority chain above. +K-step: K7 (tax) — but K3 because tax lines are part of the balanced double-entry posting. +woa-rs target: `src/erp/posting.rs` + `src/erp/tax.rs` (K7 lane). + +--- + +### Rule K3-10 / K11 — Inalterability hash chain (`_get_integrity_hash_fields`, `_calculate_hashes`, `_hash_moves`, `_get_chains_to_hash`) + +**Odoo file:Lrange:** +- `account_move.py:L4548-L4558` (`_get_integrity_hash_fields`) +- `account_move.py:L4727-L4759` (`_calculate_hashes`) +- `account_move.py:L4581-L4593` (`_hash_moves`) +- `account_move.py:L4683-L4725` (`_get_chains_to_hash`) +- `account_move_line.py:L3352-L3359` (`line._get_integrity_hash_fields`) +- `MAX_HASH_VERSION = 4` (am:L46) + +#### Axis-1 Rich-AST Spec + +**Hash versions:** +| Version | Move fields | Line fields added | +|---|---|---| +| 1 | `date, journal_id, company_id` | `debit, credit, account_id, partner_id` | +| 2, 3, 4 | `name, date, journal_id, company_id` | `name, debit, credit, account_id, partner_id` | + +Current production version: `MAX_HASH_VERSION = 4`. + +**`_calculate_hashes(previous_hash)` (L4727-L4759):** +```python +for move in self: # self must be sorted by sequence_number (caller's responsibility) + # Strip version prefix from previous_hash if present + if previous_hash and previous_hash.startswith("$"): + previous_hash = previous_hash.split("$")[2] + + values = {} + for fname in move._get_integrity_hash_fields(): + values[fname] = _getattrstring(move, fname) + + for line in move.line_ids: + for fname in line._get_integrity_hash_fields(): + k = 'line_%d_%s' % (line.id, fname) + values[k] = _getattrstring(line, fname) + + current_record = dumps(values, sort_keys=True, ensure_ascii=True, indent=None, separators=(',', ':')) + hash_string = sha256((previous_hash + current_record).encode('utf-8')).hexdigest() + move2hash[move] = f"${hash_version}${hash_string}" if hash_version >= 4 else hash_string + previous_hash = move2hash[move] # chain: each move's hash feeds the next +``` + +**`_getattrstring` serialization:** +- `many2one` field → `field_value.id` (integer ID as string) +- `monetary` field (hash_version >= 3) → `float_repr(value, currency.decimal_places)` (e.g. `"1234.56"`) +- Other fields → `str(field_value)` +- JSON: `dumps(values, sort_keys=True, ensure_ascii=True, indent=None, separators=(',', ':'))` + → compact, keys alphabetically sorted, ASCII-only, no whitespace. + +**Chain structure:** +Moves are chained per `(journal_id, sequence_prefix)`. Each chain starts from `previous_hash = ''` (or the last hashed move's `inalterable_hash` stripped of version prefix). The chain is linear — move N's hash includes move N-1's hash as input. + +**`_get_chains_to_hash` (L4683-L4725):** +Groups moves by `journal_id` → then by `sequence_prefix`. For each chain: +1. Finds the last hashed move (`inalterable_hash IS NOT NULL`) in the sequence. +2. Searches for unhashed moves with `sequence_number > last_hashed.sequence_number`. +3. Raises `UserError` if: gap detected in sequence numbers; all entries already hashed; any bank statement line is unreconciled. + +**`_hash_moves` (L4581-L4593):** Calls `_get_chains_to_hash`, then for each chain calls `_calculate_hashes(previous_hash)`, writes `inalterable_hash` on each move, posts a chatter message. + +**`button_hash` (L6232-L6233):** User-visible "lock" button: `self._hash_moves(force_hash=True)`. + +**`_can_be_unlinked` gate (L5476-L5481):** +```python +return not self.inalterable_hash and ... +``` +`inalterable_hash` set → permanently immutable → must be reversed, not deleted. + +**GoBD mapping:** +- K11 Festschreibung = `inalterable_hash` set on a move. +- woa-rs already has `after_hash` column on `ErpJournal` (K2) and `erp_gobd_festschreibung` on tenants. The odoo hash chain logic is richer: it chains across the entire sequence prefix, includes line-level fields, and uses a version prefix `$4$`. +- woa-rs `_shared::chain_hash` + `_shared::serialize_for_hash` are the existing analogues. They need to be extended to: (a) include line-level fields in the hash input, (b) chain across the sequence prefix (not just per-journal), (c) support version prefix. + +**Axis classification:** DETERMINISTIC (K11). The chain topology detection (which moves to include) is deterministic SQL. The hash itself is SHA-256 (deterministic). +K-step: **K11 Festschreibung**. +woa-rs target: `src/erp/festschreibung.rs`. + +--- + +### Rule K3-11 — German override (`l10n_de`) — `_post` extension + +**Odoo file:Lrange:** `l10n_de/models/account_move.py:L1-L19` + +#### Axis-1 Rich-AST Spec + +```python +def _compute_show_delivery_date(self): + super()._compute_show_delivery_date() + for move in self: + if move.country_code == 'DE': + move.show_delivery_date = move.is_sale_document() + +def _post(self, soft=True): + for move in self: + if move.country_code == 'DE' and move.is_sale_document() and not move.delivery_date: + move.delivery_date = move.invoice_date or fields.Date.context_today(self) + return super()._post(soft) +``` + +The German override: +1. Shows the delivery date field on sale documents (for GoBD §14 UStG Lieferdatum). +2. Auto-fills `delivery_date` to `invoice_date` (or today) before posting if it is missing on German sale documents. + +**Axis classification:** DETERMINISTIC (date auto-fill rule). +K-step: K3 (pre-post date logic), also K7 (§14 UStG Lieferdatum is relevant for VAT reporting). +woa-rs target: German locale hook in `src/erp/posting.rs` (or a `src/erp/l10n_de.rs`). + +--- + +### Rule K3-12 — `_compute_name` and `posted_before` — name freeze + +**Odoo file:Lrange:** `account_move.py:L938-L964` + +#### Axis-1 Rich-AST Spec (supplementary to K3-8) + +Critical invariant from `_compute_name`: +```python +if not move.posted_before and not move._sequence_matches_date(): + move.name = False; continue +``` + +`posted_before` is set to `True` in `_post()` and NEVER reset (not even by `button_draft()`). Therefore: +- A move that has ever been posted keeps its sequence name forever. +- `_sequence_matches_date()` checks that the name's year/month matches the move's date; if not, the name is reset ONLY if `posted_before == False`. +- `action_switch_move_type()` (L5953-L5977): raises `ValidationError` if `posted_before and name` — you cannot change the type of a posted (or previously-posted) document. + +**Axis classification:** DETERMINISTIC. +K-step: K3. + +--- + +## 3. Enterprise / Unresolved Flags + +**Enterprise gap (community only):** +- `account_asset` (K12 Anlagen): the hash fields and sequence mixin are present in community account_move but asset depreciation postings are Enterprise. The structure (account.move + account.move.line) is shared. +- `account_reports` (K8 BWA/SuSa/GuV etc.): entirely Enterprise. Community exposes only `account.move.line` aggregation. +- The `account.lock_date` / `tax_lock_date` field references in `_check_balanced` and `_post` are community — only the hard-lock date enforcement (GoBD fiscal year lock) is present. + +**No unresolved ontology classes for this lane:** +Both `odoo:account.move` and `odoo:account.move.line` have confirmed rows in `ODOO_ALIGNMENTS` (lance-graph-callcenter `odoo_alignment.rs:L132-L143`): +``` +odoo:account.move → fibo:Transaction → SmbFoundryInvoice (0x81) → DOLCE Perdurant +odoo:account.move.line → fibo:JournalEntryLine → SmbFoundryInvoice (0x81) → DOLCE Perdurant +``` +(Note: the `odoo_alignment.rs` row for `account.move.line` explicitly sets Perdurant, overriding the Endurant that the suffix heuristic would yield. The test at line ~456 documents this curated override.) + +**woa-rs gap analysis:** +Grep of `/home/user/woa-rs/src/` and `/home/user/woa-rs/crates/` for posting/sequence terms found: +- `erp_gobd_festschreibung` on `tenants` table + `models/tenant.rs` — K11 stub EXISTS. +- `after_hash String(64)` on `ErpJournal` (k2_journal.rs) — K11 hash column EXISTS but documented as "engine's job" (Sprint-3). +- `buchungsnummer: i64` on `ErpJournal` — sequence number exists, gap-free UNIQUE constraint documented. +- `belegnummer: String` on `ErpOpenItemAR` and `ErpSupplierInvoice` — Belegnummer string fields exist. +- **MISSING:** No `_post`-equivalent service layer yet (`src/erp/posting.rs` does not exist). +- **MISSING:** No balance-check (`SUM(debit) == SUM(credit)`) implementation. +- **MISSING:** No sequence mixin logic (the gap-prevention locking pattern via savepoints). +- **MISSING:** No hash-chain implementation at the move-level (only column declared; `_shared::chain_hash` exists but is not wired). +- **MISSING:** No storno / `_reverse_moves` equivalent (comments reference the Gegenbuchung pattern but no service exists). + +**woa-rs is significantly thinner than odoo in K3.** The schema layer is partially present; the service layer is absent. + +--- + +## 4. Porter's Checklist — Non-Obvious Gotchas + +1. **`validation_msgs` is a `set`, not a list.** Duplicate error messages are silently deduplicated. The error raised is `"\n".join(sorted(validation_msgs))` — order is non-deterministic. Rust: use a `BTreeSet` to get stable ordering. + +2. **`soft=True` default means future-dated moves are NOT posted but flipped to `auto_post='at_date'`.** The cron `_autopost_draft_entries` picks them up later. Rust: implement the cron trigger or expose it as a separate `autopost_scheduled_entries()` method. + +3. **Balance check runs on DB-flushed data, not ORM cache.** The SQL `HAVING ROUND(SUM(balance), decimal_places) != 0` is authoritative. Do not rely on in-memory balance sums for the constraint — flush first. + +4. **Company currency vs line currency:** Balance check rounds to COMPANY currency `decimal_places`, not the invoice's foreign currency. A EUR company posting a USD invoice: the USD amounts are converted to EUR first (via `balance` column which is always in company currency), then the rounding is applied to company-currency decimal places (2 for EUR). + +5. **`_locked_increment` uses a SAVEPOINT loop.** This is not a simple SELECT MAX(sequence_number) + 1. It uses an `UPDATE + catch UniqueViolation + retry` pattern to guarantee gap-free sequences under concurrent load. Rust: implement this with a PostgreSQL `UPDATE ... RETURNING` inside a transaction savepoint, or use a dedicated sequence table with `SELECT ... FOR UPDATE`. + +6. **`posted_before` is permanent.** Once set to `True` in `_post()`, it never goes back to `False` — not even `button_draft()` resets it. This means `_compute_name` will never reset a previously-posted move's name based on date mismatch. Rust: store `posted_before` as a non-nullable boolean column with a migration that seeds existing rows as `false`. + +7. **Storno flag (`is_storno`) flips debit/credit PRESENTATION only.** The underlying `balance` is still positive for a storno-debit. The visual debit becomes `balance` (instead of `max(balance, 0)`) when `is_storno=True`. Porter must implement this in any ledger display layer. + +8. **Hash chain is per `(journal_id, sequence_prefix)` pair, NOT per journal alone.** A journal with both normal invoices (prefix `INV/2024/`) and refunds (prefix `RINV/2024/`) has TWO independent hash chains. Each chain is locked separately. + +9. **`_getattrstring` for `monetary` fields uses `float_repr(value, currency.decimal_places)` in hash_version >= 3.** `float_repr` in odoo formats to exactly N decimal places (e.g. `"1234.56"`). Use the same representation in Rust: `format!("{:.prec$}", value, prec = decimal_places)` — NOT `Decimal::to_string()` which may omit trailing zeros. + +10. **`_calculate_hashes` strips version prefix before chaining:** `if previous_hash.startswith("$"): previous_hash = previous_hash.split("$")[2]`. The raw sha256 hex string is used as input, not the full `$4$` formatted value. But the stored value IS the formatted `$4$`. Rust: strip version prefix before computing next hash; store with prefix. + +11. **German delivery date auto-fill happens BEFORE `super()._post()`.** The l10n_de hook mutates `delivery_date` on the ORM object before the base post flow runs. For DE-locale moves, `delivery_date` must be populated before the hash is computed (if the hash includes `delivery_date` — currently it does not, but worth checking future hash field additions). + +12. **`_sync_tax_lines` is a contextmanager called on `create`/`write`**, not on `_post`. Tax lines are kept up-to-date continuously in draft state. By the time `_post()` runs, tax lines should already be correct. `_post` itself does NOT re-sync taxes — it only validates. + +13. **Reconciliation is triggered during `_post` (L5710-L5711).** If a move has a `reversed_entry_id` that is already posted, they are reconciled together during posting. This means the Rust posting service must call reconciliation logic as a post-posting step, not before. + +14. **`sequence_override_regex` on journal:** The journal can override the regex used for sequence format detection. If a journal has `sequence_override_regex` set, it takes priority over the mixin's built-in regexes (account_move.py:L84-L101). Rust: check `journal.sequence_override_regex` before applying the default regex table. + +--- + +## 5. Axis-2 Delegation Tags (Heuristic Rules) + +### Axis-2.A — Auto-post bills wizard + +**Rule:** `_show_autopost_bills_wizard()` — recommends enabling automatic bill posting after observing 3+ consecutive unmodified bills from the same partner. + +**Contract tuple:** +`(PostingAnomaly, Induction, NarsTruth, Analytical)` +- `ReasoningKind::PostingAnomaly` — closest match (anomaly = detecting a pattern in posting behaviour) +- `InferenceType::Induction` — "things-like-X" reasoning (pattern from past bills) +- `SemiringChoice::NarsTruth` — truth accumulation over multiple observations (3 bills) +- `ThinkingStyle cluster: Analytical` — inherited from `SmbFoundryInvoice` family (billing/posting domain → Analytical/Critical per briefing) + +**Reasoning surface call:** `Reasoner::reason(ReasoningContext { namespace: "posting.autopost_recommendation", kind: PostingAnomaly, evidence: [partner_id, nb_unmodified_bills], budget: ... })` + +### Axis-2.B — Lock-date auto-advance + +**Rule:** `move.date = move._get_accounting_date(...)` when the date falls in a locked period — automatically advances the date to the next open period. + +**Classification borderline:** The odoo implementation is deterministic (find the next date after the lock date). However, multi-factor lock date handling (tax lock vs fiscal lock vs hard lock) has judgment aspects. For woa-rs, implement as DETERMINISTIC (advance to `lock_date + 1 day`). + +**If delegated:** +`(PostingAnomaly, Abduction, NarsTruth, Analytical)` +- `Abduction`: "why is this date wrong / what period should this go to" + +--- + +## 6. Ontology Mapping Summary + +| Odoo class | OWL/FIBO pivot | OGIT family | DOLCE | +|---|---|---|---| +| `odoo:account.move` | `fibo:Transaction` | `SmbFoundryInvoice` (0x81), slot TRANSACTION (0x03) | Perdurant | +| `odoo:account.move.line` | `fibo:JournalEntryLine` | `SmbFoundryInvoice` (0x81), slot JOURNAL_LINE (0x06) | Perdurant (curated override; suffix heuristic gives Endurant) | + +Both resolved in `/home/user/lance-graph/crates/lance-graph-callcenter/src/odoo_alignment.rs:L132-L143`. No new alignment rows needed for this lane. + +**Inherited ThinkingStyle cluster (Axis-2):** `Analytical` — from `SmbFoundryInvoice` family (billing/accounting domain). Posting-anomaly checks inherit Analytical/Critical per the briefing's family table. + +--- + +## 7. K-Step Cross-Reference + +| Rule | K-step | +|---|---| +| K3-1 `_post()` | K3 | +| K3-2 `action_post()` | K3 | +| K3-3 `_check_balanced()` | K3 | +| K3-4 balance/debit/credit/amount_currency computes | K3 | +| K3-5 line constraints | K3 | +| K3-6 `_reverse_moves()` / Storno | K3 + K11 | +| K3-7 `button_draft`/`button_cancel` | K3 + K11 | +| K3-8 Sequence / Belegnummer | K3 | +| K3-9 Tax line generation | K3 + K7 | +| K3-10/K11 Hash chain / Festschreibung | **K11** | +| K3-11 German l10n_de override | K3 + K7 (UStG date) | +| K3-12 `posted_before` / name freeze | K3 | + +--- + +Read: /home/user/odoo/addons/account/models/account_move.py lines=7328 depth=full +Read: /home/user/odoo/addons/account/models/account_move_line.py lines=3742 depth=full +Read: /home/user/odoo/addons/account/models/sequence_mixin.py lines=512 depth=full +Read: /home/user/odoo/addons/l10n_de/models/account_move.py lines=19 depth=full diff --git a/.claude/odoo/L10-ANALYTIC.md b/.claude/odoo/L10-ANALYTIC.md new file mode 100644 index 00000000..02212db7 --- /dev/null +++ b/.claude/odoo/L10-ANALYTIC.md @@ -0,0 +1,571 @@ +RICHNESS-LANE-OK + +# Lane L10 — Analytic Accounting (Kostenstellen / Cost Centres) + +## Sources read (file : line-range : depth) + +- `/home/user/odoo/addons/account/models/account_analytic_account.py` : L1-79 : full +- `/home/user/odoo/addons/account/models/account_analytic_line.py` : L1-111 : full +- `/home/user/odoo/addons/account/models/account_analytic_plan.py` : L1-82 : full (this file is `AccountAnalyticApplicability`, not `AccountAnalyticPlan` — see Enterprise Gap note) +- `/home/user/odoo/addons/account/models/account_analytic_distribution_model.py` : L1-71 : full +- `/home/user/odoo/addons/account/models/account_move_line.py` : L1-50, L400-450, L980-1090, L1200-1320, L1390-1425, L1760-1900, L2005-2055, L3140-3280, L3490-3520 : full (analytic-relevant sections covering entire analytic surface) +- `/home/user/odoo/addons/account/tests/test_account_analytic.py` : L1-1182 : full +- `/home/user/odoo/addons/purchase/models/analytic_account.py` : L1-35 : full +- `/home/user/odoo/addons/purchase/models/analytic_applicability.py` : L1-16 : full +- `/home/user/odoo/addons/sale/models/analytic.py` : L1-22 : full +- `/home/user/odoo/addons/account/models/account_move.py` : L5055-5085 : section (EPD analytic distribution call) + +## Enterprise / Module-Gap Flags + +**CRITICAL:** The base `analytic` addon module — which defines `account.analytic.account`, `account.analytic.plan`, `account.analytic.applicability`, `account.analytic.distribution.model`, and `analytic.mixin` — is **absent** from `/home/user/odoo/addons/`. The clone only contains the six addons: `account`, `hr`, `l10n_de`, `product`, `purchase`, `sale`, `stock`. The `analytic` module is listed as a dependency in `account/__manifest__.py` (`'depends': ['base_setup', 'onboarding', 'product', 'analytic', ...]`) but is NOT present. All files in `account/models/account_analytic_*.py` use `_inherit = 'account.analytic.*'` — they extend models from the missing base module. + +What IS present and readable: the account-side extensions (`_inherit` classes), the `analytic.mixin` mixin consumed by `account.move.line` and `account.reconcile.model`, all analytic logic on `account.move.line` (`_create_analytic_lines`, `_prepare_analytic_lines`, `_prepare_analytic_distribution_line`, `_round_analytic_distribution_line`, `_validate_analytic_distribution`, `_inverse_analytic_distribution`, `_compute_analytic_distribution`, `_update_analytic_distribution`, `_related_analytic_distribution`), and the full test suite which reveals the base model's shape through usage. + +Base model properties recoverable from tests and account-side code: +- `account.analytic.plan` has `_column_name()` method returning a dynamic column name (e.g. `x_plan1_id`), `applicability_ids` One2many to `account.analytic.applicability`, `_get_applicability()` method. +- `account.analytic.account` has `plan_id`, `root_plan_id`, `company_id`, `active`, `line_ids` One2many to `account.analytic.line`. +- `account.analytic.applicability` has `business_domain` Selection (extended to `'invoice'`, `'bill'`, `'purchase_order'`, `'sale_order'` by account/purchase/sale), `applicability` ∈ {`'mandatory'`, `'unavailable'`, `'optional'`}, `analytic_plan_id`, `company_id`, `_get_score(**kwargs)` → int. +- `account.analytic.distribution.model` has `partner_id`, `company_id`, `sequence`, `analytic_distribution` Json, `_get_distribution(criteria_dict)` class method, `_get_applicable_models(vals)`, `_get_default_search_domain_vals()`. +- `analytic.mixin` defines: `analytic_distribution` Json field, `_merge_distribution(old, new)`, `_validate_distribution(...)`, `_get_plan_fnames()`, `_get_distribution_key()` on lines. + +--- + +## Ontology rows + +| odoo class | owl pivot | OGIT family (or None) | DOLCE | +|---|---|---|---| +| `account.analytic.account` | `fibo:Account` (cost account sub-type) | `0x62 SMBAccounting` (via fibo:Account → BillingCore/SMBAccounting chain; closest to `account.account`) | Endurant — a persisting organisational entity | +| `account.analytic.plan` | None — no direct FIBO/UBL/vCard pivot for "analytic plan hierarchy"; closest is `fibo:AccountingSystem` (not in current seed) | `None` → ontology-unmapped, needs a Layer-2 alignment axiom | Abstract — a classification schema | +| `account.analytic.applicability` | None — rule/policy object; closest `fibo:ControllingParty` role is a stretch | `None` → ontology-unmapped, needs a Layer-2 alignment axiom | Abstract — a rule/constraint object | +| `account.analytic.line` | `fibo:JournalEntryLine` (parallel to `account.move.line`) | `0x62 SMBAccounting` (inherits from JournalEntryLine seed) | Perdurant — an event/occurrence | +| `account.analytic.distribution.model` | None — a pattern-matching rule object; no FIBO equivalent in seed | `None` → ontology-unmapped, needs a Layer-2 alignment axiom | Abstract — a configuration/rule record | +| `account.move.line` (analytic fields) | `fibo:JournalEntryLine` | `0x62 SMBAccounting` | Perdurant | + +--- + +## Rules extracted + +### R1 — analytic_distribution JSON representation [AXIS-A] + +- **odoo source**: `account/models/account_move_line.py:L418-420`; confirmed by `test_account_analytic.py:L70-74`, `L246`, `L369-370` +- **What it does**: The `analytic_distribution` field is a `fields.Json` column on `account.move.line` (and via `analytic.mixin` on other models). Its schema is: + ``` + { "": , ... } + ``` + Keys are **comma-separated analytic account IDs as strings** (e.g. `"42"`, `"42,17"` for cross-plan joint allocation). Values are **float percentages** (e.g. `100.0`, `50.0`, `33.33`). A single key with multiple comma-separated IDs means those analytic accounts are all allocated the same percentage slice simultaneously (cross-plan). Example from `test_account_analytic.py:L369-370`: + ```python + analytic_distribution = { + f'{self.analytic_account_3.id},{self.analytic_account_5.id}': 20, + f'{self.analytic_account_3.id},{self.analytic_account_4.id}': 80, + } + ``` + This means 20% of the amount goes to (account_3 AND account_5 simultaneously) and 80% goes to (account_3 AND account_4 simultaneously). Percentages are floats stored with analytic precision (fetched via `decimal.precision.precision_get('Percentage Analytic')`). +- **woa-rs target**: K10 new area (Kostenstellen). The `analytic_distribution` field will be a JSON column on the `journal_line` (AML equivalent) entity. Rust type: `Option` or a newtype `AnalyticDistribution(HashMap)` where String is the CSV-of-IDs key. +- **Rust sketch**: + ```rust + // Key: comma-separated analytic account IDs (String), Value: percentage (Decimal/f64) + pub struct AnalyticDistribution(pub HashMap); + // Invariant: keys are non-empty strings of comma-separated positive integer IDs + // Percentages per analytic PLAN must sum to <= 100.0 for optional, == 100.0 for mandatory + // (validation is per-plan, not global — see R4) + impl AnalyticDistribution { + pub fn account_ids_for_key(key: &str) -> Vec { + key.split(',').filter_map(|s| s.trim().parse().ok()).collect() + } + } + ``` +- **Parity notes**: The JSON key uses string IDs even in Python (keys become strings on JSON serialisation). In tests, when writing from Python the key may be an integer (`{self.analytic_account_1.id: 100}`) but after store/read it is always a string key. Rust side must normalise to string keys on write. + +--- + +### R2 — Plan hierarchy: `account.analytic.plan` / `account.analytic.account` [AXIS-A] + +- **odoo source**: `account/models/account_analytic_plan.py` (base absent — recovered from test usage); `account_move_line.py:L3215-3222`; `test_account_analytic.py:L19-24`, `L434-444` +- **What it does**: + - `account.analytic.plan` is a tree/hierarchy of plans. Each plan has a `_column_name()` method returning a dynamic DB column name (e.g. `x_plan1_id`, auto-generated). Plans can be nested (parent/child) but `root_plan_id` on `account.analytic.account` always points to the root of the tree. + - `account.analytic.account` belongs to exactly one `plan_id` (leaf-level plan), and has `root_plan_id` pointing to the root plan. + - Multiple plans are **orthogonal axes**: e.g. Plan A = "Department" (Kostenstelle), Plan B = "Project". A move line can simultaneously have 50% in Dept-Marketing AND 50% in Dept-Sales (Plan A axis), and 100% in Project-X (Plan B axis). Cross-plan keys in `analytic_distribution` encode the simultaneous allocation across plans. + - From `account_move_line.py:L3215-3222`: + ```python + for account in self.env['account.analytic.account'].browse(map(int, account_ids.split(","))).exists(): + distribution_plan = distribution_on_each_plan.get(account.root_plan_id, 0) + distribution + if float_compare(distribution_plan, 100, precision_digits=decimal_precision) == 0: + amount = -self.balance * (100 - distribution_on_each_plan.get(account.root_plan_id, 0)) / 100.0 + else: + amount = -self.balance * distribution / 100.0 + distribution_on_each_plan[account.root_plan_id] = distribution_plan + account_field_values[account.plan_id._column_name()] = account.id + ``` + Critical: the amount for the LAST entry in a plan that reaches 100% uses the remainder formula `(100 - accumulated_so_far) / 100 * balance` to avoid floating-point accumulation error. All earlier entries use `distribution / 100 * balance`. The sign is negated (`-self.balance`) because analytic lines have the opposite sign convention to journal lines (credit → positive analytic amount, debit → negative). +- **woa-rs target**: K10 data model. `analytic_plan` table with parent_id self-reference; `analytic_account` with `plan_id FK`, `root_plan_id FK`; `column_name` stored or computed from plan ID. +- **Rust sketch**: + ```rust + pub struct AnalyticPlan { + pub id: i64, + pub name: String, + pub parent_id: Option, + pub column_name: String, // e.g. "x_plan1_id", stored + } + pub struct AnalyticAccount { + pub id: i64, + pub name: String, + pub plan_id: i64, + pub root_plan_id: i64, + pub company_id: Option, + pub active: bool, + } + ``` +- **Parity notes**: The `_column_name()` dynamic naming is an Odoo ORM artifact (custom Many2one fields on `account.analytic.line`). In Rust we should store the column_name explicitly on the plan and use it for analytics line routing. + +--- + +### R3 — `_prepare_analytic_distribution_line` + `_create_analytic_lines`: amount calculation and sign [AXIS-A] + +- **odoo source**: `account/models/account_move_line.py:L3179-3239` +- **What it does** (full control flow): + 1. `_create_analytic_lines()` is called at `_post()` time (when move transitions to posted). It first calls `_validate_analytic_distribution()` then loops over all lines calling `_prepare_analytic_lines()` on each, collecting `analytic_line_vals`. + 2. `_prepare_analytic_lines()` iterates `self.analytic_distribution.items()`. For each `(account_ids_csv, distribution_pct)`: + - Calls `_prepare_analytic_distribution_line(float(distribution), account_ids, distribution_on_each_plan)`. + - Skips entries where the resulting `amount` rounds to zero (`is_zero`). + - Calls `_round_analytic_distribution_line()` on the final list. + 3. `_prepare_analytic_distribution_line(distribution, account_ids, distribution_on_each_plan)`: + - Parses `account_ids.split(",")` → list of int IDs → browse `account.analytic.account`. + - For each analytic account in the CSV (cross-plan joint allocation): + - Accumulates `distribution_plan = distribution_on_each_plan.get(root_plan_id, 0) + distribution`. + - If `distribution_plan == 100.0` (within analytic precision): `amount = -balance * (100 - accumulated_before) / 100.0` (remainder formula, avoids floating error). + - Else: `amount = -balance * distribution / 100.0`. + - Stores accumulated sum per root_plan_id. + - Sets `account_field_values[plan._column_name()] = account.id` (the dynamic plan column). + - Returns a dict with: `name`, `date`, `partner_id`, `unit_amount` (= quantity), `product_id`, `product_uom_id`, `amount` (= the computed amount in COMPANY currency), `general_account_id` (= GL account), `ref`, `move_line_id`, `user_id`, `company_id`, `category` (`'invoice'`/`'vendor_bill'`/`'other'`), plus dynamic plan fields. + - Note: `name` falls back to `ref + ' -- ' + partner_name` if no line name — see L3223. + 4. `_round_analytic_distribution_line()` (L3255-3279): rounds each line's amount to company currency rounding, tracks accumulated rounding error, then distributes it — subtracts or adds `currency.rounding` unit-by-unit to lines until error is zero. This ensures the sum of analytic line amounts equals the original balance exactly. + +- **woa-rs target**: K10 + K3. Called as part of the `action_post()` pipeline. The analytic line creation is a side-effect of posting. +- **Rust sketch**: + ```rust + fn create_analytic_lines(aml: &AccountMoveLine, db: &Db) -> Result> { + validate_analytic_distribution(aml)?; + let mut vals: Vec = vec![]; + for (account_ids_csv, distribution_pct) in &aml.analytic_distribution { + if let Some(v) = prepare_analytic_distribution_line( + aml, distribution_pct, account_ids_csv, &mut distribution_on_each_plan, db + )? { + if !company_currency.is_zero(v.amount) { + vals.push(v); + } + } + } + round_analytic_distribution_line(&mut vals, company_currency); + AnalyticLine::bulk_insert(&vals, db) + } + + fn round_analytic_distribution_line(vals: &mut Vec, currency: &Currency) { + let mut rounding_error: Decimal = Decimal::ZERO; + for v in vals.iter_mut() { + let rounded = currency.round(v.amount); + rounding_error += rounded - v.amount; + v.amount = rounded; + } + // Distribute error: subtract/add rounding unit from lines one by one + for v in vals.iter_mut() { + if currency.is_zero(rounding_error) { break; } + let unit = currency.rounding.max(currency.round((rounding_error / vals.len() as Decimal).abs())); + if rounding_error < Decimal::ZERO { + v.amount += unit; rounding_error += unit; + } else { + v.amount -= unit; rounding_error -= unit; + } + } + } + ``` +- **Parity notes**: Sign convention: analytic lines use COMPANY currency (`-self.balance`, not `amount_currency`). In multi-currency invoices, the analytic line amount is always in company currency. The `amount` sign is the NEGATIVE of the journal line balance — so a revenue line (credit, negative balance in Odoo) produces a POSITIVE analytic amount. Confirmed by `test_account_analytic.py:L80-90`: invoice line with `price_unit=200`, plan 100% → analytic line `amount=200` (positive). + +--- + +### R4 — `_validate_analytic_distribution` / `_validate_distribution`: mandatory-plan enforcement [AXIS-A] + +- **odoo source**: `account/models/account_move_line.py:L3146-3177`; `account_move_line.py:L2011-2034` +- **What it does**: + 1. `_validate_analytic_distribution()` (called at post time from `_create_analytic_lines`): loops over `display_type == 'product'` lines. For each, calls `_validate_distribution(company_id=..., product=..., account=..., business_domain=...)` where `business_domain` ∈ {`'invoice'`, `'bill'`, `'general'`}. + 2. If ANY line fails validation, raises: + - Single move: `ValidationError` (message: `"One or more lines require a 100% analytic distribution."`) + - Multiple moves (mass post): `RedirectWarning` with a view action showing failing lines. + 3. `_validate_distribution` (from `analytic.mixin`, base module absent): determines which plans are applicable to this line (via `account.analytic.plan._get_applicability(business_domain, company_id, product, account)`) and checks if those with `applicability == 'mandatory'` have exactly 100% distribution in the JSON. Validation is only triggered when `validate_analytic=True` is in context (from `action_post()` on invoices; NOT triggered by plain journal entries unless context is set — see `test_account_analytic.py:L310-330`). + 4. `_compute_has_invalid_analytics()` (L2011-2034): the same check computed as a Boolean field, used for UI highlighting. Skips account types: `asset_receivable`, `liability_payable`, `asset_cash`, `liability_credit_card` (these never need analytic). + 5. Validation precision: from `test_account_analytic.py:L314-319`: `{account: 100.01}` FAILS, `{account: 99.9}` FAILS, `{account: 100}` PASSES. The tolerance is determined by `'Percentage Analytic'` decimal precision (from `decimal.precision`). + +- **woa-rs target**: K10. Guard in the posting pipeline. +- **Rust sketch**: + ```rust + const SKIPPED_ACCOUNT_TYPES: &[AccountType] = &[ + AccountType::AssetReceivable, AccountType::LiabilityPayable, + AccountType::AssetCash, AccountType::LiabilityCreditCard, + ]; + fn validate_analytic_distribution(aml: &AccountMoveLine, plans: &[AnalyticPlan]) + -> Result<(), ValidationError> + { + if aml.display_type != DisplayType::Product { return Ok(()); } + if SKIPPED_ACCOUNT_TYPES.contains(&aml.account_type) { return Ok(()); } + let business_domain = business_domain_for(aml); + for plan in plans { + let applicability = plan.get_applicability(business_domain, aml.company_id, aml.product_id, aml.account_id); + if applicability == Applicability::Mandatory { + let pct: f64 = aml.analytic_distribution.iter() + .filter(|(key, _)| key_contains_plan_account(key, plan)) + .map(|(_, pct)| pct) + .sum(); + if !approx_eq_100(pct, ANALYTIC_PRECISION) { + return Err(ValidationError::new("One or more lines require a 100% analytic distribution.")); + } + } + } + Ok(()) + } + ``` +- **Parity notes**: Validation is per-plan (each plan's accounts must sum to 100%), NOT global sum of all percentages. A line can have 100% in Plan A and 100% in Plan B simultaneously (legitimate). The `validate_analytic` context flag gates enforcement — plain journal entries bypass it unless the UI explicitly sets the flag. + +--- + +### R5 — `_inverse_analytic_distribution`: sync on write [AXIS-A] + +- **odoo source**: `account/models/account_move_line.py:L1401-1422` +- **What it does**: Triggered as inverse of the `analytic_distribution` Json field (i.e., whenever `analytic_distribution` is written on a posted line). Full sequence: + 1. Check `skip_analytic_sync` context — if set, return immediately (prevents recursion). + 2. Filter to lines that are in `parent_state == 'posted'` (draft lines: their analytic lines were already unlinked at create, see L1772-1773). + 3. Read OLD distribution from DB via raw SQL (`SELECT id, analytic_distribution FROM account_move_line WHERE id = ANY(%s)`) — NOT from ORM cache, to get the persisted old value before the write. + 4. For each line, call `_merge_distribution(old_distribution, new_distribution)` → merged result. (Base `analytic.mixin` method — merges plan-by-plan, see test for `__update__` protocol in `test_analytic_dynamic_update` at L799-1008.) + 5. Unlink all existing `analytic_line_ids` for posted lines (with `skip_analytic_sync=True`). + 6. Call `_create_analytic_lines()` to recreate from merged distribution. + 7. Also triggered from `_inverse_account_id()` (L1420-1422) — account change triggers analytic re-sync. + + The `_merge_distribution` `__update__` protocol (from tests L821-1008): if `new_distribution` contains key `'__update__'` listing plan column names, ONLY those plans are replaced; other plans retain old values, cross-plan keys are rebuilt as a Cartesian product. If `'__update__'` is absent and new_distribution is empty dict → result is `False`. This is how the UI widget partially updates one plan without resetting others. + +- **woa-rs target**: K10. Write-through trigger on `journal_line.analytic_distribution` updates. +- **Rust sketch**: + ```rust + fn on_analytic_distribution_changed(aml_id: i64, new_distribution: AnalyticDistribution, db: &Db) { + if aml.parent_state != MoveState::Posted { return; } + let old_distribution = db.query_one::( + "SELECT analytic_distribution FROM account_move_line WHERE id = $1", aml_id + )?; + let merged = merge_distribution(old_distribution, new_distribution); + aml.analytic_line_ids.unlink_all(db)?; + create_analytic_lines_for(aml.with_distribution(merged), db)?; + } + ``` +- **Parity notes**: Draft lines NEVER have persistent analytic lines (they are deleted at create time, L1772-1773, and also when `analytic_line_ids` are written in draft, L1897-1898). Only posted lines have live analytic lines. The raw SQL read of old distribution is important — it bypasses ORM caching to get the actual DB state, which may differ from ORM-buffered values in a multi-write transaction. + +--- + +### R6 — `_update_analytic_distribution`: reverse sync (analytic line → AML) [AXIS-A] + +- **odoo source**: `account/models/account_move_line.py:L3245-3253`; `account_analytic_line.py:L92-110` +- **What it does**: When an `account.analytic.line` is created/written/deleted directly (not via the distribution sync), this method re-derives `analytic_distribution` on the parent `account.move.line` from the actual analytic lines. + ```python + def _update_analytic_distribution(self): + if self.env.context.get('skip_analytic_sync'): return + for line in self: + line.with_context(skip_analytic_sync=True).analytic_distribution = { + analytic_line._get_distribution_key(): -analytic_line.amount / line.balance * 100 + if line.balance else 100 + for analytic_line in line.analytic_line_ids + } + ``` + Key formula: `percentage = -analytic_line.amount / aml.balance * 100`. If `balance == 0` (zero-amount line), defaults to 100% (safe fallback, confirmed by `test_zero_balance_invoice_with_analytic_line:L786-797`). `_get_distribution_key()` returns the comma-CSV of all plan account IDs for that analytic line (from base mixin). + `account.analytic.line.create/write/unlink` all call `move_line_id._update_analytic_distribution()` (L94-110), creating a bidirectional sync loop guarded by `skip_analytic_sync` context. + +- **woa-rs target**: K10. Used when analytic lines are edited from the analytic line view (not from invoice UI). +- **Rust sketch**: + ```rust + fn update_analytic_distribution_from_lines(aml: &mut AccountMoveLine) { + let dist: HashMap = aml.analytic_line_ids.iter().map(|al| { + let key = al.get_distribution_key(); // CSV of plan account IDs + let pct = if aml.balance != Decimal::ZERO { + -al.amount / aml.balance * dec!(100) + } else { + dec!(100) + }; + (key, pct) + }).collect(); + // set with skip_analytic_sync=true to avoid loop + aml.set_analytic_distribution_no_sync(AnalyticDistribution(dist)); + } + ``` +- **Parity notes**: This reverse-sync makes `analytic_distribution` derivable from `analytic_line_ids` and vice versa. The authoritative source during posting is `analytic_distribution`; the authoritative source for manual line edits is the analytic line. Bidirectional, guarded by context flag. + +--- + +### R7 — `_compute_analytic_distribution`: auto-suggestion from distribution models [HYBRID: AXIS-A guard + AXIS-B core] + +- **odoo source**: `account/models/account_move_line.py:L1217-1248` +- **What it does** (rich): + ```python + @api.depends('account_id', 'partner_id', 'product_id') + def _compute_analytic_distribution(self): + cache = {} + for line in self: + if line.display_type == 'product' or not line.move_id.is_invoice(include_receipts=True): + related_distribution = line._related_analytic_distribution() + root_plans = self.env['account.analytic.account'].browse( + list({int(account_id) for ids in related_distribution + for account_id in ids.split(',') if account_id.strip()}) + ).exists().root_plan_id + arguments = frozendict(line._get_analytic_distribution_arguments(root_plans)) + if arguments not in cache: + cache[arguments] = self.env['account.analytic.distribution.model']._get_distribution(arguments) + line.analytic_distribution = related_distribution | cache[arguments] or line.analytic_distribution + ``` + - Triggered by: `account_id`, `partner_id`, `product_id` changes. + - Only runs for `display_type == 'product'` lines OR non-invoice moves. + - Calls `_related_analytic_distribution()` (base returns `{}`, overridden in SO/PO to return SO/PO line's distribution). + - Builds `_get_analytic_distribution_arguments()`: + ```python + { + "product_id": self.product_id.id, + "product_categ_id": self.product_id.categ_id.id, + "partner_id": self.partner_id.id, + "partner_category_id": self.partner_id.category_id.ids, + "account_prefix": self.account_id.code, + "company_id": self.company_id.id, + "related_root_plan_ids": root_plans, # already-allocated plans to skip + } + ``` + - Calls `_get_distribution(arguments)` on `account.analytic.distribution.model` — finds matching rules, applies scoring, returns best distribution. + - Result merges: `related_distribution | cache[arguments]`. The `|` dict merge means distribution-model rules supplement (or override) related distribution. If neither produces anything, keeps existing value (`or line.analytic_distribution`). + - Uses a `frozendict` cache per unique argument combination (performance: avoids repeated DB queries). + + AXIS-A part (deterministic guard): the argument assembly, cache lookup, merge operation, and the `display_type` guard are deterministic. + AXIS-B part (heuristic core): `_get_distribution(arguments)` itself — matching rules by score, choosing which model wins, handling ambiguous multi-rule situations — is a pattern-matching/scoring system. + +- **woa-rs target**: K10. Triggered by invoice line field changes (reactive compute). AXIS-A portion → Rust compute. AXIS-B portion → delegate. +- **Rust sketch (AXIS-A guard)**: + ```rust + fn compute_analytic_distribution(aml: &mut AccountMoveLine, db: &Db) { + if aml.display_type != DisplayType::Product && aml.move_id.is_invoice() { return; } + let related = related_analytic_distribution(aml); // {} for plain invoices + let root_plans: Vec = extract_root_plans(&related, db); + let args = AnalyticDistributionArguments { + product_id: aml.product_id, product_categ_id: aml.product_categ_id, + partner_id: aml.partner_id, partner_category_ids: aml.partner_category_ids.clone(), + account_prefix: aml.account_code.clone(), company_id: aml.company_id, + related_root_plan_ids: root_plans, + }; + let model_dist = get_distribution_from_model(&args, db); // AXIS-B call + let merged = merge_dicts(related, model_dist); + aml.analytic_distribution = if merged.is_empty() { aml.analytic_distribution.clone() } else { merged }; + } + ``` +- **Delegation tuple (AXIS-B)**: + `ReasoningKind=NextBestAction` (suggest the best distribution for this line context) + `InferenceType=Induction` (pattern: "lines like this product/partner/account combo have historically used distribution X") + `SemiringChoice=NarsTruth` (evidence fusion across multiple matching rules, weighted by score) + `ThinkingStyle=Analytical` (inherited from OGIT family `0x62 SMBAccounting` → expected Analytical cluster for accounting heuristics) + + `SAVANT: name=AnalyticDistributionSuggester family=0x62 reasoning=NextBestAction inference=Induction semiring=NarsTruth style=Analytical — matches invoice line context (partner/product/account-prefix/company) against distribution model rules ranked by score, returning best-fit distribution` + +--- + +### R8 — `account.analytic.distribution.model._get_applicable_models` + scoring [HYBRID] + +- **odoo source**: `account/models/account_analytic_distribution_model.py:L34-48`; base `_get_distribution` absent (base `analytic` module missing); test `test_model_score:L232-258`, `test_model_sequence:L433-468` +- **What it does** (from account-side `_inherit` + tests): + 1. `_get_default_search_domain_vals()` (L28-32): returns dict with `{'product_id': False, 'product_categ_id': False}` plus base defaults (partner, company, etc.) — these False-values mean "match models that have no constraint on this field". + 2. `_get_applicable_models(vals)` (L34-44): extends base by filtering out models whose `account_prefix` does not match the given `account_prefix`. Prefix matching: model's `account_prefix` is split by `;` or `,`, and the incoming code must `startswith` any of those prefixes. If `account_prefix` is not set on the model → passes (no account filter). + 3. Base `_get_distribution(criteria)` (not visible, inferred from tests): + - Finds all matching `account.analytic.distribution.model` records by domain matching. + - Scores each via `_get_score()` (account-side adds +1 for matching `account_prefix`, +1 for matching `product_categ_id`, returns -1 to exclude on mismatch). + - Sequences models by their `sequence` field (lower = higher priority). + - Applies models greedily: takes the first (highest priority) model, applies its `analytic_distribution` for the plans it covers, then continues with subsequent models for plans NOT yet covered. Models that would overwrite an already-covered plan are SKIPPED. + - From `test_model_sequence:L456-468`: "m1 fills A & B, ignore m2 & m3" — i.e., once a plan is filled, no lower-priority model can override it. + 4. `_create_domain(fname, value)` (L46-49): for `account_prefix` field, returns empty domain `[]` — because prefix matching is done in Python filter, not SQL domain. + + AXIS-A part: The sequence sorting, the "first model wins per plan" greedy fill, and the prefix-startswith test are deterministic algorithms. + AXIS-B part: The scoring system — combining partner match (+1), product match (+1), product_category match (+1), account_prefix match (+1), company match (+1) — is multi-factor evidence weighting. Deciding the "correct" model when scores are tied or partial is fundamentally heuristic. + +- **woa-rs target**: K10. Distribution model lookup, called from `_compute_analytic_distribution`. +- **Rust sketch (AXIS-A deterministic algorithm)**: + ```rust + fn get_distribution(args: &AnalyticDistributionArguments, db: &Db) -> AnalyticDistribution { + let candidates = db.query_all::(/* base domain filters */)?; + let applicable = candidates.iter() + .filter(|m| m.account_prefix.as_ref() + .map(|p| prefix_matches(p, &args.account_prefix)) + .unwrap_or(true)) + .filter(|m| /* product_categ matches or none */) + .collect::>(); + let mut scored: Vec<(i32, &AnalyticDistributionModel)> = applicable.iter() + .map(|m| (m.get_score(args), m)) + .filter(|(score, _)| *score >= 0) + .collect(); + scored.sort_by_key(|(score, m)| (Reverse(*score), m.sequence)); + // Greedy fill: apply models in priority order, skip if plan already covered + let mut filled_plans: HashSet = HashSet::new(); + let mut result = AnalyticDistribution::default(); + for (_score, model) in scored { + let model_plans = plans_in_distribution(&model.analytic_distribution, db); + if model_plans.iter().any(|p| filled_plans.contains(p)) { continue; } + result.merge(&model.analytic_distribution); + filled_plans.extend(model_plans); + } + result + } + ``` +- **Delegation tuple (AXIS-B scoring)**: + `ReasoningKind=CustomerCategory` (categorising this line into a distribution rule bucket) + `InferenceType=Deduction` (given scored facts, the winner IS the highest scorer — deductive once scoring weights are defined; but the WEIGHT DEFINITION is heuristic) + `SemiringChoice=HammingMin` (each matching criterion adds a bit; minimum sufficient match wins) + `ThinkingStyle=Analytical` (from OGIT family `None` for `account.analytic.distribution.model` — ontology-unmapped; proposing Analytical because scoring is structured evidence aggregation) + + `SAVANT: name=AnalyticModelScorer family=None reasoning=CustomerCategory inference=Deduction semiring=HammingMin style=Analytical — multi-criterion scoring (partner/product/categ/account-prefix/company) to rank distribution model rules; greedy plan-by-plan fill from highest-score model` + +--- + +### R9 — `account.analytic.applicability._get_score`: plan applicability scoring [AXIS-A] + +- **odoo source**: `account/models/account_analytic_plan.py:L59-76` (which is actually `AccountAnalyticApplicability._get_score`) +- **What it does**: Extends base `_get_score` (from missing `analytic` module). Returns `-1` if any hard constraint is violated (mandatory exclusion), otherwise returns an int score where higher = more specific match. + Account-side adds two criteria: + 1. `account_prefix` (L65-70): if set on the applicability, splits by `[,;]`, checks `account.code.startswith(any_prefix)`. Match: score += 1. No match: return -1. + 2. `product_categ_id` (L71-75): if set, checks `product.categ_id == self.product_categ_id`. Match: score += 1. No match: return -1. + Base scores (inferred from `test_applicability_score:L405-431`): `company_id` match: score += 1 (but only if the model HAS a company — if no company on model, company is not scored). `business_domain` match: base handles. + From `test_applicability_score`: "product takes precedence over company" — score 2 (product+domain) beats score 1 (company only). + `_compute_display_account_prefix`: account_prefix field is shown when `business_domain in ('general', 'invoice', 'bill')`. + +- **woa-rs target**: K10. Plan applicability resolution — determines if a plan is mandatory/optional/unavailable for a given line context. +- **Rust sketch**: + ```rust + fn get_score(applicability: &AnalyticApplicability, args: &ApplicabilityArgs) -> i32 { + let mut score = base_get_score(applicability, args); // handles business_domain, company + if score == -1 { return -1; } + if let Some(prefix) = &applicability.account_prefix { + let prefixes: Vec<&str> = prefix.split(&[';', ','][..]).map(str::trim).collect(); + if args.account_code.map(|c| prefixes.iter().any(|p| c.starts_with(p))).unwrap_or(false) { + score += 1; + } else { return -1; } + } + if let Some(categ) = applicability.product_categ_id { + if args.product_categ_id == Some(categ) { score += 1; } else { return -1; } + } + score + } + ``` +- **Parity notes**: Multiple applicabilities can match; highest score wins (not sum). From `test_applicability_score:L423-424`: `_get_applicability` returns the single highest-scoring applicability's `applicability` value. + +--- + +### R10 — `AccountAnalyticLine.on_change_unit_amount`: standard price compute for timesheet-like entries [AXIS-A] + +- **odoo source**: `account/models/account_analytic_line.py:L63-79` +- **What it does**: Onchange for `product_id`/`product_uom_id`/`unit_amount`/`currency_id` on analytic lines (used when editing analytic lines directly, not from invoice sync). Computes `amount` = `standard_price` × `unit_amount`, negated, rounded by `currency.round()` or `round(..., 2)`. Sets `general_account_id` to product's expense account. Falls back to product's UoM if no UoM set. + ```python + amount_unit = self.product_id._price_compute('standard_price', uom=unit)[self.product_id.id] + amount = amount_unit * self.unit_amount or 0.0 + result = (self.currency_id.round(amount) if self.currency_id else round(amount, 2)) * -1 + ``` + Note: the `or 0.0` applies to `amount_unit * self.unit_amount` — if quantity is 0 or product has no price, amount = 0. + +- **woa-rs target**: K10. Only relevant if the Rust ERP allows direct analytic line creation (e.g. for professional services / timesheets) separate from invoices. +- **Parity notes**: This is the "timesheet-style" analytic entry. For woa-rs's current scope (IT services, invoice-driven), this may not be triggered often — but it IS the code path for manual analytic line amounts. + +--- + +### R11 — `AccountAnalyticLine.create/write/unlink`: bidirectional sync [AXIS-A] + +- **odoo source**: `account/models/account_analytic_line.py:L91-110` +- **What it does**: Ensures that when analytic lines are manipulated directly (not via invoice distribution), the parent `account.move.line.analytic_distribution` is updated: + - `create()` (L91-95): calls `super().create()` then `analytic_lines.move_line_id._update_analytic_distribution()`. + - `write()` (L97-104): saves `affected_move_lines = self.move_line_id` before write. After write, if `amount` or `move_line_id` or any plan field changed, calls `_update_analytic_distribution()` on the UNION of old and new affected move lines. + - `unlink()` (L106-110): saves affected move lines, then calls `_update_analytic_distribution()` after deletion. + - All operations are guarded by `skip_analytic_sync` context (set by `_inverse_analytic_distribution` to prevent loops). + +- **woa-rs target**: K10. Reverse-sync hooks on analytic line CRUD. +- **Parity notes**: The `write()` union of `affected_move_lines` handles the case where `move_line_id` is changed on an analytic line (the old AML and the new AML both need their distribution updated). + +--- + +### R12 — `_validate_analytic_distribution` gating at post: archived account check [AXIS-A] + +- **odoo source**: `test_account_analytic.py:L1134-1145` (test reveals behavior — no direct source line since base method is in missing `analytic` module) +- **What it does**: When posting an invoice that has `analytic_distribution` referencing an archived (`active=False`) analytic account, a `UserError` is raised: `"archived analytic account"`. This is a hard block — cannot post with archived accounts in distribution. Implemented in base `analytic.mixin._validate_distribution` (absent — inferred from test). +- **woa-rs target**: K10 posting guard. +- **Rust sketch**: + ```rust + fn check_no_archived_analytic_accounts(dist: &AnalyticDistribution, db: &Db) -> Result<()> { + for key in dist.keys() { + for id in parse_ids(key) { + let account = db.get::(id)?; + if !account.active { + return Err(UserError::new("archived analytic account")); + } + } + } + Ok(()) + } + ``` + +--- + +### R13 — `account.analytic.account` invoice/vendor bill count smart buttons [AXIS-A] + +- **odoo source**: `account/models/account_analytic_account.py:L18-77` +- **What it does**: Two computed fields `invoice_count` and `vendor_bill_count` on `account.analytic.account`. Both use `_read_group` on `account.move.line` with domain `['analytic_distribution', 'in', self.ids]`. This works because Odoo's `analytic_distribution` domain operator `in` does a JSON contains check for the given IDs. The `data = {int(account_id): move_count ...}` pattern suggests the group result returns string account IDs that need `int()` conversion. These are purely for UI — smart button counts linking the analytic account back to its associated invoices. +- **woa-rs target**: K10 UI / API. Read-only computed endpoints. + +--- + +### R14 — EPD (Early Payment Discount) analytic distribution propagation [AXIS-A] + +- **odoo source**: `account/models/account_move_line.py:L987-1038`; `account/models/account_move.py:L5063-5068` +- **What it does**: When an invoice has early payment discount lines (`display_type == 'discount'`), the discount lines inherit analytic distribution proportionally from the product lines: + ```python + 'analytic_distribution': { + account_id: 100 * value / total + for account_id, value in dist.items() + } + ``` + where `total = sum(dist.values()) or 1` (avoid zero-division). Distribution is proportional to the balance contribution of each plan. + EPD also uses `_get_distribution` for the cash discount account (L5063-5068): looks up distribution model for the discount account code, partner, company. + From `test_analytic_distribution_with_discount:L654-717`: confirmed behaviour — discount lines inherit scaled distribution from the product line's distribution. + +- **woa-rs target**: K10 + K3 (EPD interaction). When generating discount lines, compute their analytic distribution from the product lines' distribution scaled by balance proportion. + +--- + +## Enterprise gaps flagged + +| module | what's missing | what we spec from community source instead | +|---|---|---| +| `analytic` (base addon) | Entire base model definitions: `account.analytic.account`, `account.analytic.plan`, `account.analytic.applicability`, `account.analytic.distribution.model`, `analytic.mixin` (incl. `_merge_distribution`, `_validate_distribution`, `_get_distribution`, `_get_distribution_key`, `_get_plan_fnames`) | Account-side `_inherit` extensions are fully specced. Base model shape inferred from test usage, field references, and method signatures found in account code. The `_get_distribution` scoring algorithm is specced from test assertions (test_model_score, test_model_sequence). | +| `account_reports` | Analytic reporting (cost centre P&L, analytic balance) | Not present. Engine must be built fresh in woa-rs. | +| `analytic` → `account.analytic.plan._column_name()` | Dynamic column creation for each analytic plan on `account.analytic.line` (ORM metaprogramming) | In Rust, use a fixed set of plan slots or a JSON column on `account_analytic_line` rather than dynamic columns. This is an architectural divergence from Odoo — document as RFC candidate. | + +--- + +## Open questions for the Opus porter + +1. **Dynamic plan columns**: Odoo creates a real DB column (`x_plan{n}_id`) on `account_analytic_line` for each analytic plan. In Rust with SeaORM this is impractical. Options: (a) fixed max N plan columns; (b) a single JSONB column `plan_account_ids` on the analytic line; (c) a pivot table `analytic_line_plan` (line_id, plan_id, account_id). Recommend option (c) for normalisation. Needs RFC. + +2. **`_merge_distribution` `__update__` protocol**: This is complex UI state logic (the `__update__` key list tells the system which plans the user just edited). Does woa-rs need this for its UI, or can the API always replace the full distribution? If full-replace is sufficient, `_merge_distribution` simplifies to a plain override. + +3. **`analytic.mixin` on `account.reconcile.model`**: `account_reconcile_model.py` also inherits `analytic.mixin`. Does woa-rs need analytic distribution on reconciliation rules? Not specced in L5 — cross-lane question. + +4. **Analytic precision**: `decimal.precision.precision_get('Percentage Analytic')` — what is the default digit precision? Typically 2 in community. Confirm before implementing `approx_eq_100`. + +5. **`_get_distribution_key()` on `account.analytic.line`**: returns CSV of all plan account IDs for that line. The implementation is in the base `analytic` module (absent). Inferred: it collects `account.id` for each plan column that is set on the line. Porter needs to verify against actual base module source. + +6. **`related_root_plan_ids` argument to `_get_distribution`**: this tells the distribution model to skip already-allocated plans (from `_related_analytic_distribution()`). Important for SO/PO → invoice flow where the SO distribution should be preserved. The base `_get_distribution` presumably filters out models for plans already covered by `related_root_plan_ids`. Needs verification against base module source. + +--- + +## Depth-proof footer + +Read: `/home/user/odoo/addons/account/models/account_analytic_account.py` lines=79 depth=full +Read: `/home/user/odoo/addons/account/models/account_analytic_line.py` lines=111 depth=full +Read: `/home/user/odoo/addons/account/models/account_analytic_plan.py` lines=82 depth=full +Read: `/home/user/odoo/addons/account/models/account_analytic_distribution_model.py` lines=71 depth=full +Read: `/home/user/odoo/addons/account/models/account_move_line.py` lines=3742 depth=full (analytic sections: L1-50, L400-450, L980-1090, L1200-1320, L1390-1430, L1760-1900, L2005-2055, L3140-3280, L3490-3520) +Read: `/home/user/odoo/addons/account/tests/test_account_analytic.py` lines=1182 depth=full +Read: `/home/user/odoo/addons/purchase/models/analytic_account.py` lines=35 depth=full +Read: `/home/user/odoo/addons/purchase/models/analytic_applicability.py` lines=16 depth=full +Read: `/home/user/odoo/addons/sale/models/analytic.py` lines=22 depth=full +Read: `/home/user/odoo/addons/account/models/account_move.py` lines=5085 depth=section (L5055-5085) diff --git a/.claude/odoo/L11-COA-JOURNALS-LOCKDATES.md b/.claude/odoo/L11-COA-JOURNALS-LOCKDATES.md new file mode 100644 index 00000000..7a61a84c --- /dev/null +++ b/.claude/odoo/L11-COA-JOURNALS-LOCKDATES.md @@ -0,0 +1,167 @@ +RICHNESS-LANE-OK + +# Lane L11 — Chart of Accounts + Journals + Lock Dates + Sequences + +## Sources read (file : line-range : depth) + +- odoo/addons/account/models/account_account.py : L1-1642 : full +- odoo/addons/account/models/account_account_tag.py : L1-140 : full +- odoo/addons/account/models/account_journal.py : L1-1300 : full +- odoo/addons/account/models/company.py : L50-749 (lock-date section) : full +- odoo/addons/account/models/sequence_mixin.py : L1-511 : full +- odoo/addons/account/models/account_move.py : L2796-2813, L3780-3974, L5476-5481, L6570-6634 : targeted full + +## Ontology rows + +| odoo class | owl pivot | OGIT family (or None) | DOLCE | +|---|---|---|---| +| `account.account` | fibo:Account | 0x62 SMBAccounting | Endurant | +| `account.group` | fibo:AccountingGroup (inferred) | 0x62 SMBAccounting | Endurant | +| `account.account.tag` | schema:Thing / fibo:Annotation | None — needs Layer-2 axiom | Abstract | +| `account.journal` | fibo:Journal | 0x62 SMBAccounting | Endurant | +| `res.company` (lock-date ext) | fibo:LegalEntity | 0x62 SMBAccounting | Endurant | +| `sequence.mixin` | fibo:SequenceIdentifier (inferred) | None — abstract mixin, inherits host model | Abstract | + +## Rules extracted + +### R1 — account_type taxonomy (19-value enum) [AXIS-A] +- **odoo**: account_account.py:L44-70 +- 19 closed enum values: asset_receivable, asset_cash, asset_current, asset_non_current, asset_prepayments, asset_fixed, liability_payable, liability_credit_card, liability_current, liability_non_current, equity, equity_unaffected, income, income_other, expense, expense_other, expense_depreciation, expense_direct_cost, off_balance. `internal_group` = prefix before first `_` (`asset|liability|equity|income|expense|off`). +- **target**: `crates/skr_data` `AccountType` enum + `internal_group()`. +- **parity**: `equity_unaffected` = "Current Year Earnings" (Gewinn/Verlust laufendes Jahr) — does NOT carry forward. + +### R2 — include_initial_balance [AXIS-A] +- **odoo**: account_account.py:L638-646 +- True iff internal_group ∉ {income, expense} AND type ≠ equity_unaffected. Drives Bilanz (carry-forward) vs GuV (reset each year). `fn include_initial_balance(t: AccountType) -> bool`. + +### R3 — reconcile flag + constraints [AXIS-A] +- **odoo**: account_account.py:L27-31, L187-194, L664-673, L963-989 +- reconcile auto-computed: income/expense/equity → false; receivable/payable → true (forced); cash/credit_card/off_balance → false. Constraint: receivable/payable MUST be reconcilable; off_balance cannot reconcile or carry taxes. Toggle true→false blocked if partial reconciliations exist. Toggle true: SQL sets `reconciled = (debit=0 AND credit=0 AND amount_currency=0)`, `amount_residual = debit-credit`. + +### R4 — account code format + uniqueness [AXIS-A] +- **odoo**: account_account.py:L14-16, L310-316, L466-544, L1079-1129 +- Regex `^[A-Za-z0-9.]+$`. `code` is **company-dependent** (JSONB `code_store`, keyed by root company id). `_search_new_account_code`: increment trailing digit group, fall back to `.copy{n}`. Uniqueness checked across parent/child company hierarchy (not just same company). SKR03/04 = 4-digit numeric. + +### R5 — account_group hierarchy via code_prefix [AXIS-A] +- **odoo**: account_account.py:L1497-1642 +- group has equal-length `code_prefix_start`/`code_prefix_end` (DB constraint). Account ∈ group iff `prefix_start <= LEFT(code, len) <= prefix_end`. Parent = longest fitting prefix (SQL `DISTINCT ON (child) ORDER BY char_length(parent.prefix_start) DESC`). Same-length groups cannot overlap. Drives BWA/SuSa groupings (K8). + +### R6 — account.account.tag structure [AXIS-A] +- **odoo**: account_account_tag.py:L1-141 +- Scoped by `applicability ∈ {accounts, taxes, products}` + optional `country_id`; unique `(name, applicability, country_id)`. Tax tags drive report buckets; tag name starting `-` ⇒ `balance_negate` (computed via LEFT JOIN to account_report_expression — Enterprise, derived only). Master operating/financing/investing tags undeletable. + +### R7 — journal type taxonomy [AXIS-A] +- **odoo**: account_journal.py:L106-119 +- 6 types: sale, purchase, cash, bank, credit, general. Drives default account domain, suspense/payment lines (liquidity), refund_sequence (sale/purchase default true), payment_sequence (bank/cash/credit default true). + +### R8 — journal default account assignment [AXIS-A] +- **odoo**: account_journal.py:L926-1021 +- bank/cash/credit journal without default_account_id → auto-create account; prefix from `company.bank_account_code_prefix`/`cash_account_code_prefix`; type asset_cash (bank/cash) or liability_credit_card (credit); digit count inferred from chart (fallback 6). + +### R9 — journal code auto-gen [AXIS-A] +- **odoo**: account_journal.py:L883-903 +- default prefixes INV/BILL/CSH/BNK/CCD/MISC; try N=1..99 until unique; max 5 chars. + +### R10 — restrict_mode_hash_table (GoBD) [AXIS-A] +- **odoo**: account_journal.py:L145-146, L794-801 +- Boolean. Once any entry hashed (`inalterable_hash != False`), cannot set back to false (UserError). This is the woa-rs K11 Festschreibung anchor flag; hash itself in account_move (L1, RFC-011). + +### R11 — lock-date taxonomy (5 types) [AXIS-A] +- **odoo**: company.py:L59-69, L78-114 +- fiscalyear_lock_date (global), tax_lock_date (entries with taxes; auto-set on tax-closing post), sale_lock_date, purchase_lock_date, hard_lock_date (irreversible). `SOFT_LOCK_DATE_FIELDS` = first four. `user_hard_lock_date` = max hard lock across parent hierarchy. + +### R12 — _get_user_lock_date (soft lock + exception) [AXIS-A] +- **odoo**: company.py:L597-630 +- Walks `parent_ids` (sudo). ignore_exceptions=true → max(company[field]). Else: per parent with lock set, find active `account.lock_exception` (state=active, user∈{None,current}, field < company[field]); effective = max(soft, exception[field] or min). `account.lock_exception` = community model enabling per-user temporary soft-lock override. + +### R13 — _get_violated_soft_lock_date [AXIS-A] +- **odoo**: company.py:L646-663 +- regular = user_lock_date(ignore_exceptions=true). If date > regular → None. Else with_exc = user_lock_date(false); if date > with_exc → None (exception covers), else Some(with_exc). Two-pass for fast short-circuit. + +### R14 — _get_lock_date_violations (multi-lock sweep) [AXIS-A] +- **odoo**: company.py:L665-700 +- Given accounting_date + flags(fiscalyear, sale, purchase, tax, hard) → Vec<(date, field)>. Hard: `user_hard_lock_date >= date` ⇒ violated. + +### R15 — _get_violated_lock_dates (move-level, journal-aware) [AXIS-A] +- **odoo**: company.py:L713-729; account_move.py:L6609-6616 +- fiscalyear=true always; sale=journal.type==sale; purchase=journal.type==purchase; tax=has_tax; hard=true. Sorted ascending. **Non-obvious**: general journal NOT subject to sale/purchase locks. + +### R16 — _check_fiscal_lock_dates (write/post guard) [AXIS-A] +- **odoo**: account_move.py:L2796-2813, L3905-3958 +- On write (date/state change on posted) and `_post()`. Calls violations with tax=false (tax checked separately). Skipped if `context.bypass_lock_check is BYPASS_LOCK_CHECK` (object identity sentinel, not truthiness). + +### R17 — _get_user_fiscal_lock_date (copy/unlink guard) [AXIS-A] +- **odoo**: company.py:L632-644; account_move.py:L5476-5481, L3789-3791 +- Single effective fiscal lock for a journal = max(fiscalyear, hard, +sale/purchase by type). Used by `_can_be_unlinked` (date > lock) and `copy_data` (bump copy date to lock+1day). + +### R18 — _validate_locks (write-time guard on lock changes) [AXIS-A] +- **odoo**: company.py:L542-595 +- hard_lock_date: cannot unset, cannot decrease (UserError). Setting hard/fiscal: RedirectWarning if draft entries ≤ date, or unreconciled bank statement lines ≤ max(fiscalyear, hard). + +### R19 — _get_accounting_date (date bump) [AXIS-A] +- **odoo**: account_move.py:L6570-6607 +- Locked period ⇒ base = last_violation + 1day. Sale + number_reset month/year ⇒ last day of month/year capped at today. Non-sale similar by reset family. Couples sequence format ↔ lock semantics. + +### R20 — _deduce_sequence_number_reset (5 format families) [AXIS-A] +- **odoo**: sequence_mixin.py:L193-225 +- Try regexes in order: year_range_monthly → monthly → year_range → yearly → fixed. Guard: if year_end & year both present, require year_end == (year+1) mod 10^len. + +### R21 — _get_sequence_format_param [AXIS-A] +- **odoo**: sequence_mixin.py:L312-353 +- Extract prefix1/year/prefix2/month/prefix3/seq/suffix/year_end + lengths; build format string `{prefix1}{year:0Nd}...{seq:0Md}{suffix}`. Edge: empty seq with prefix+suffix ⇒ treat suffix as prefix. + +### R22 — _get_last_sequence (alphabetical-max gotcha) [AXIS-A] +- **odoo**: sequence_mixin.py:L269-310 +- Finds prior seq by **greatest alphabetical** sequence_field, restricted to latest sequence_prefix, ORDER BY sequence_number DESC LIMIT 1. **Gotcha**: renaming INV→FACT re-uses numbers (INV > FACT). Prefix comparison is string-ordering, not numeric. + +### R23 — _locked_increment (gap prevention via DB lock) [AXIS-A] +- **odoo**: sequence_mixin.py:L355-424 +- Atomic increment via PG B-tree index lock (`UPDATE ... WHERE id`), savepoint retry on Unique/ExclusionViolation. In-tx cache keyed `(format_with_seq0, index_value)` avoids further savepoints. The gap-prevention mechanism (sea-orm needs raw SQL). + +### R24 — _set_next_sequence / _get_next_sequence_format [AXIS-A] +- **odoo**: sequence_mixin.py:L425-473 +- last = _get_last_sequence() (relaxed/starting fallback); new period ⇒ reset seq=0, set year/month from date; locked_increment; set field. + +### R25 — _is_end_of_seq_chain (gap detection) [AXIS-A core + AXIS-B anomaly] [HYBRID] +- **odoo**: sequence_mixin.py:L487-511 +- Group by (format, values\seq); contiguity `max-min == len-1` AND highest is last in DB. Deletion/reversal safety guard. Detecting *existing* anomalous gaps (deleted posted entries — GoBD violation) is heuristic. +- `SAVANT: name=SequenceGapAnomalyDetector family=0x62 reasoning=PostingAnomaly inference=Abduction semiring=NarsTruth style=Analytical — gaps in journal sequences may indicate deleted posted entries (GoBD breach); abduce over sequence_prefix+number distribution beyond the creation-time contiguity guard.` + +### R26 — sequence_prefix / sequence_number stored fields [AXIS-A] +- **odoo**: sequence_mixin.py:L47-48, L183-191 +- `_compute_split_sequence` strips fixed-regex part → prefix + trailing int; the index columns making _get_last_sequence efficient. + +### R27 — _constrains_date_sequence [AXIS-A] +- **odoo**: sequence_mixin.py:L156-181 +- Validate sequence-embedded year/month matches record date. Bypass via `ir.config_parameter sequence.mixin.constraint_start_date` (default 1970-01-01) for historical imports. + +### R28 — _sequence_matches_date [AXIS-A] +- **odoo**: sequence_mixin.py:L138-154 +- Validate year/month in sequence vs date range from reset type. + +### R29 — copy date bump [AXIS-A] +- **odoo**: account_move.py:L3789-3791 +- On copy/reversal: if source date ≤ user fiscal lock, set copy date = lock + 1 day. + +## Enterprise gaps flagged +- `account_asset` (K12 depreciation): absent. Only base `asset_fixed`/`expense_depreciation` types present; engine fresh. +- `account_reports` (K8 BWA/SuSa engine): absent. Tag→report_expression link references Enterprise `account_report_expression`; spec tag structure only. +- `account.lock_exception`: present in community (referenced company.py:L612), not deep-read here. + +## Open questions for the Opus porter +1. `account.lock_exception` field shape — per-type columns matching lock fields? (domain at L618 implies identical column names). +2. `BYPASS_LOCK_CHECK` sentinel — implement as typed context marker (object identity, not bool). +3. `_sequence_index` on account.move (likely journal_id) — confirm before building the uniqueness index. +4. 2-digit year sequences (`INV/25/00001`) — handle for historical imports. +5. Multi-company `user_hard_lock_date` walks full `parent_ids` — needs cached parent_path/root traversal. +6. `is_self_billing` journals (per-partner sequences) — interacts with L6 invoice flow. + +## Depth-proof footer +``` +Read: odoo/addons/account/models/account_account.py lines=1642 depth=full +Read: odoo/addons/account/models/account_account_tag.py lines=140 depth=full +Read: odoo/addons/account/models/account_journal.py lines=1300 depth=full +Read: odoo/addons/account/models/company.py lines=1148 depth=full (lock-date section L50-749) +Read: odoo/addons/account/models/sequence_mixin.py lines=511 depth=full +Read: odoo/addons/account/models/account_move.py lines=7328 depth=targeted (L2796-2813, L3780-3974, L5476-5481, L6570-6634) +``` diff --git a/.claude/odoo/L12-MULTICOMPANY-CURRENCY.md b/.claude/odoo/L12-MULTICOMPANY-CURRENCY.md new file mode 100644 index 00000000..d0659b6a --- /dev/null +++ b/.claude/odoo/L12-MULTICOMPANY-CURRENCY.md @@ -0,0 +1,102 @@ +RICHNESS-LANE-OK + +# Lane L12 — Multi-company + Multi-currency (K15 Mehrfirma) + +## Sources read (file : line-range : depth) +- base/models/res_currency.py : L1-504 : full +- base/models/res_company.py : L1-493 : full +- account/models/res_currency.py : L1-285 : full +- account/models/account_move_line.py : targeted (dual-amount, currency_rate, residual, exchange) : full-region +- account/models/account_move.py : targeted (invoice_currency_rate, _check_company_auto, _check_balanced, exchange) : full-region + +## Ontology rows +| odoo class | owl pivot | OGIT family | DOLCE | +|---|---|---|---| +| `res.currency` | fibo:Currency | 0x62 SMBAccounting | Quality | +| `res.currency.rate` | fibo:ExchangeRate | 0x62 SMBAccounting | Quality (time-stamped) | +| `res.company` | fibo:LegalEntity | 0x80 SmbFoundryCustomer | Endurant | +| `account.move` | fibo:Transaction | 0x81 SmbFoundryInvoice | Perdurant | +| `account.move.line` | fibo:JournalEntryLine | 0x61 BillingCore | Perdurant | + +`account_consolidation` is Enterprise (absent) → consolidation classes resolve None. + +## Rules extracted (18; 14 AXIS-A, 4 AXIS-B/HYBRID) + +### R1 — decimal_places from rounding [AXIS-A] +- res_currency.py:L162-168 — if 0 global), name DESC LIMIT 1. Fallback: oldest rate. Final fallback: 1.0. **Uses company.root_id, not company.id** — branches share root rates. Constraint: rates only for root companies (no branches). Unique (name, currency_id, company_id). + +### R4 — three rate representations [AXIS-A] +- res_currency.py:L342-504 — `rate` (technical, stored: foreign units per 1 base), `company_rate` = rate/last_company_rate (UI), `inverse_company_rate` = 1/company_rate. Write priority: inverse > company > rate (sanitize conflicting). Engine uses only `rate`. + +### R5 — _get_conversion_rate / _convert [AXIS-A] +- res_currency.py:L273-299 — same currency → 1; conversion = `to_rate/from_rate` (both relative to base). `_convert`: zero short-circuits to 0.0; else `from_amount * rate`; round to to_currency if round=true. + +### R6 — group_multi_currency toggle [HYBRID → SAVANT] +- res_currency.py:L83-106 — active currency count >1 ⇒ add group_multi_currency. Cannot deactivate a currency still used as a company currency_id. +- `SAVANT: name=CurrencySelectionAdvisor family=0x62 reasoning=NextBestAction inference=Induction semiring=NarsTruth style=Analytical — suggest which currencies to enable based on partner/transaction geography.` + +### R7 — rounding write-protection [AXIS-A] +- account/res_currency.py:L26-41 — cannot reduce decimal places (raise rounding) or set 0 if `_has_accounting_entries()` (any AML uses this currency). Protects historical amounts from retroactive rounding loss. + +### R8 — currency table builders for reporting [AXIS-B → SAVANT] +- account/res_currency.py:L42-285 — current/historical/average rate types; monocurrency fast-path (VALUES rate=1); average = time-weighted (LEAD window). use_cta_rates ⇒ all three (Enterprise consolidation). +- `SAVANT: name=ReportRateTypeSelector family=0x62 reasoning=Other("ConsolidationRatePolicy") inference=Deduction semiring=Boolean style=Analytical — which rate type (current/historical/average) per report line is an IFRS-vs-HGB policy decision; delegate so it varies without hardcoded if/else.` + +### R9 — company tree + currency root-delegation [AXIS-A] +- base/res_company.py:L96-104, L341-418 — `_parent_store`; `root_id`; `_get_company_root_delegated_field_names() = ['currency_id']` ⇒ branches always inherit root currency; constraint blocks branch currency ≠ parent. `parent_id` immutable after create. Multi-currency = different ROOT companies, not branches. + +### R10 — _accessible_branches [HYBRID → SAVANT] +- base/res_company.py:L429-450 — subset of branches accessible to current user in multi-branch context. +- `SAVANT: name=UserCompanyAccessAdvisor family=0x80 reasoning=CustomerCategory inference=Induction semiring=NarsTruth style=Analytical — branch-access scoping by user role/context.` + +### R11 — check_company / check_company_domain_parent_of [AXIS-A] +- account_move.py:L78, L877-881 — `_check_company_auto=True`; related record's company must be in `move.company_id.parent_ids` (ancestor-or-equal, ltree parent_path subtree). Journal drives company (`_compute_company_id`). + +### R12 — dual-amount model balance vs amount_currency [AXIS-A] +- account_move_line.py:L59-144 — `balance` (company currency, signed +=debit), `amount_currency` (line currency). When currency==company: equal. debit=max(balance,0)/credit=max(-balance,0) (storno inverts — DE default storno per STORNO_OPTIONAL_COUNTRIES). DB constraint: sign(balance)==sign(amount_currency). `credit*debit=0`. + +### R13 — _compute_currency_rate [AXIS-A] +- account_move_line.py:L137-139, L736-749 — invoices: move.invoice_currency_rate or 1.0; non-invoices: _get_conversion_rate(company→line currency, date=move.date). + +### R14 — _compute_amount_currency / _inverse [AXIS-A] +- account_move_line.py:L756-762, L1373-1383 — amount_currency = round(balance*rate); inverse balance = round(amount_currency/rate). currency==company short-circuits. + +### R15 — invoice_currency_rate snapshot [AXIS-A] +- account_move.py:L515-540, L1115-1141, L2845-2856 — rate company→currency at invoice date, **frozen at post** (manual override needs rate_is_manual flag). Constraint: >0 when currency≠company on invoice. + +### R16 — _check_balanced (company decimal_places) [AXIS-A] +- account_move.py:L2754-2793 — SQL `ROUND(SUM(balance), company.currency.decimal_places)=0` per move. **Not hardcoded 2** (JPY=0, KWD=3). + +### R17 — _compute_amount_residual (dual-currency) [AXIS-A] +- account_move_line.py:L793-860 — sum partials; residual in company AND foreign currency; `reconciled = is_zero(residual) AND is_zero(residual_currency)` — **both must be zero**. Double-rounding (SQL ROUND + currency.round). + +### R18 — exchange gain/loss account selection [AXIS-A core + AXIS-B config → SAVANT] +- account_move.py:L5218-5237, company.py:L135-145 — `sign=compare(open_balance,0)`; >0 → expense_currency_exchange_account_id (loss); <0 → income (gain); posted on currency_exchange_journal_id. Sign-driven (deterministic). +- `SAVANT: name=ExchangeAccountSelector family=0x62 reasoning=Other("ChartAccountMapping") inference=Deduction semiring=Boolean style=Analytical — deterministic sign picks gain/loss; heuristic only for initial SKR account config.` + +## Enterprise gaps flagged +- `account_consolidation` (CTA, intercompany elimination, minority interest): absent — fresh build. SQL currency-table builders are community but invoked by Enterprise. +- `account_reports` (multi-company aggregated reports): absent. + +## Open questions +1. f64 rate × Decimal amount boundary — define precision rule (accumulate f64 rate, `Decimal::from_f64(...).round_dp(decimal_places)`). +2. Storno mode (DE) debit/credit splitting — implement. +3. `check_company_domain_parent_of` — pre-compute root_id/parent_path for O(1). +4. invoice_currency_rate manual refresh action on draft. +5. Global (company_id NULL) vs company-specific rate fallback — test the ordering. +6. Exchange-diff entries need K11 Festschreibung handling. + +## Depth-proof footer +``` +Read: base/models/res_currency.py lines=504 depth=full +Read: base/models/res_company.py lines=493 depth=full +Read: account/models/res_currency.py lines=285 depth=full +Read: account/models/account_move_line.py lines=3742 depth=targeted (dual-amount/rate/residual/exchange regions full) +Read: account/models/account_move.py lines=7328 depth=targeted (currency/company/balanced/exchange regions full) +``` diff --git a/.claude/odoo/L13-STOCK-VALUATION-PROCUREMENT.md b/.claude/odoo/L13-STOCK-VALUATION-PROCUREMENT.md new file mode 100644 index 00000000..c7b9db8a --- /dev/null +++ b/.claude/odoo/L13-STOCK-VALUATION-PROCUREMENT.md @@ -0,0 +1,117 @@ +RICHNESS-LANE-OK + +# Lane L13 — Stock↔Accounting Valuation Bridge + Procurement + +## Critical Enterprise gap (read first) +`stock_account` is **absent** from this community clone: no `stock_valuation_layer.py`, no `_run_fifo`/`_run_average`/`_create_account_move_vals` (those live in `stock_account`/`purchase_stock`). The valuation **engine** must be built fresh in woa-rs; this lane specs the **interface boundary** (SVL shape, FIFO/AVCO/standard formulas, anglo-saxon vs continental GL) from the field scaffolding that IS present, plus the fully-present procurement/reorder/lot logic. (L7 already covered moves/picking/quant/reservation.) + +## Sources read +- stock/models/stock_rule.py : L1-747 : full +- stock/models/stock_orderpoint.py : L1-817 : full +- stock/models/stock_lot.py : L1-431 : full +- stock/models/product.py : L1-1389 : full +- stock/models/res_company.py : L1-215 : full +- product/models/product_product.py : standard_price + onchange : targeted +- product/models/product_category.py : L1-69 : full +- account/models/company.py : anglo_saxon_accounting + price_diff acct : targeted +- stock/models/stock_move.py : L1546-1679 (procure routing), L2101-2160 : targeted +- stock/models/__init__.py : confirms stock_valuation_layer absent + +## Ontology rows +| odoo class | owl pivot | family | DOLCE | +|---|---|---|---| +| `stock.warehouse.orderpoint` | fibo:Obligation | None — Layer-2 axiom needed | Perdurant | +| `stock.rule` | fibo:Agreement | None | Abstract | +| `stock.lot` | schema:ProductModel + GS1 | None | Endurant | +| `product.product` (standard_price) | schema:Product / fibo:Asset | None (→ propose 0x63 ProductCatalog) | Quality | +| `res.company` (anglo_saxon) | fibo:LegalEntity config | 0x62 SMBAccounting | Abstract | +| `stock.move` (procure_method) | fibo:Transfer | None | Perdurant | + +## Rules extracted (16; 11 AXIS-A, 5 AXIS-B/HYBRID) + +### R1 — standard_price field [AXIS-A] +- product_product.py:L62-68 — Float, company_dependent (ir.property → woa-rs: `product_cost(product_id,company_id)` table), groups base.group_user, negative-guard onchange. Decimal (RFC-009). + +### R2 — anglo_saxon vs continental [AXIS-A] +- account/company.py:L146, L298-311 — AngloSaxon: COGS at invoice (interim via expense_account_id, price_difference_account_id absorbs std-vs-bill delta). Continental (default, GoBD-correct DE): COGS at delivery. woa-rs default Continental. + +### R3 — _get_rule (location-hierarchy walk + route priority) [HYBRID → SAVANT] +- stock_rule.py:L564-638 — build location ancestor chain; `_search_rule_for_warehouses` grouped by (dest, warehouse, route) ORDER BY (route_seq, seq); route priority: values.route_ids → packaging → product|categ routes → warehouse routes; walk chain, first match wins; transit-location edge adds customers loc. Walk+priority = AXIS-A; equal-sequence tiebreak = heuristic. +- `SAVANT: name=ProcurementRuleSelector family=None reasoning=NextBestAction inference=Induction semiring=NarsTruth style=Analytical — route priority among equal-sequence rules should weigh lead time, stock availability, supplier reliability, not deterministic tiebreak.` + +### R4 — _run_pull [AXIS-A] +- stock_rule.py:L287-316 — validate location_src; sort positive-qty first; mts_else_mto→make_to_stock at pull time; build move vals; group by company; create (sudo, with_company) + _action_confirm. + +### R5 — _get_stock_move_values [AXIS-A] +- stock_rule.py:L324-408 — date = date_planned - rule.delay; partner = rule.partner_address_id or values; location_dest only if location_dest_from_rule else picking-type default; to_refund if qty<0; serialize procurement_values (ids/isoformat). + +### R6 — _run_push [AXIS-A] +- stock_rule.py:L222-285 — transparent (modify dest in-place, recurse on change, loop guard) vs manual (copy move, procure_method=make_to_order, new_date = move.date + rule.delay). + +### R7 — procure_method routing in _action_confirm [AXIS-A] +- stock_move.py:L1546-1678 — buckets: waiting (move_orig_ids or make_to_order), create_proc (triggers rule.run), mts_else_mto: `qty_to_procure = max(product_qty - free_qty, 0)`, `qty_from_stock = min(product_qty, free_qty)`; free_qty from source location. + +### R8 — _get_qty_to_order (min/max reorder) [AXIS-A] +- stock_orderpoint.py:L461-476 — trigger if `qty_forecast < product_min_qty` (float_compare w/ UoM rounding); `qty_to_order = max(min,max) - (virtual_available(to=lead_horizon) + qty_in_progress)`; round UP to replenishment_uom multiple. qty_to_order_manual overrides (cleared when trigger=auto). + +### R9 — _compute_deadline_date [HYBRID → SAVANT] +- stock_orderpoint.py:L123-178 — fast path qty_on_hand=0. + +### R8 — employee→user→partner linkage + work-contact sync [AXIS-A] +- hr_employee.py:L85-94, L793-836 — user_id (unique per company), work_contact_id (res.partner, auto-created), user_partner_id; work_phone/email computed from work_contact when ≤1 linked employee; inverse pushes to partner; barcode unique [A-Za-z0-9]{≤18}; PIN digits-only; archive nulls others' parent_id/coach_id. + +### R9 — coach default compute [AXIS-A] +- hr_employee.py:L806-814 — on parent_id change, coach_id=new manager if coach was old manager or empty. + +### R10 — newly_hired (90-day) [AXIS-A] +- hr_employee.py:L421-430 — create_date > now-90d; override field via _get_new_hire_field. + +### R11 — contract/work-permit expiry cron [AXIS-A] +- hr_employee.py:L1168-1200 — exact-date match (contract_date_end == today + notice_period[7], work_permit == today + [60]); schedule activity to hr_responsible. + +### R12 — salary_distribution (sum-to-100 + rounding) [AXIS-A] +- hr_employee.py:L286-349 — JSON bank_account→{sequence, amount, amount_is_percentage}; % entries sum 100±0.0001; redistribute on add/remove (currency.round, last gets exact remainder); primary = lowest sequence. + +### R13 — normalized wage (no payroll) [AXIS-A] +- hr_version.py:L475-486 — wage*12/52/hours_per_week; no calendar ⇒ wage (hourly); hours 0 ⇒ 0. Override point for fresh payroll engine. + +### R14 — contract-template whitelist [AXIS-A] +- hr_version.py:L443-458 — copy only [job_id, department_id, contract_type_id, structure_type_id, wage, resource_calendar_id, hr_responsible_id]. + +### R15 — structure_type_id default [AXIS-A] +- hr_version.py:L534-550 — match country_id==company.country_id else generic (NULL). Payroll entry-point discriminant. + +### R16 — version-period calendar query [AXIS-A] +- hr_employee.py:L1585-1710 — for [start,stop], collect version-slices active in window, clamp to [max(start,date_start), min(stop,date_end)], map to resource_calendar (per-version TZ). Critical for multi-schedule payroll periods (DE law). + +### R17 — has_read_access dept ACL [AXIS-A] +- hr_department.py:L46-52 — non-HR users see departments they (or chain) manage; `child_of` on parent_path. + +## Data hooks for fresh payroll engine +wage; structure_type_id→hr.payroll.structure.type (country discriminant); contract_type_id; resource_calendar_id (hours_per_week, tz); contract_date_start/end + trial_date_end; bank_account_ids + salary_distribution (SEPA split); ssnid + identification_id (statutory); marital + children + km_home_work (1.609 km factor — Lohnsteuer inputs); company notice periods; `_get_salary_costs_factor` stub=12.0 (DE may need 13/14 for Weihnachts/Urlaubsgeld). + +## Open questions +1. hr.* OGIT family: mint 0x90 HRFoundation or inherit 0x80? (decide at synthesis). +2. hr.version temporal model: bi-temporal table vs snapshot-log; preserve date_start/end window semantics. +3. _parent_store dept tree → ltree column or recursive CTE. +4. structure_type_id.country_id = payroll ruleset discriminant; DE structure type required. +5. _get_salary_costs_factor 12 vs 13/14 (DE) — override in engine. +6. salary_distribution rounding via decimal_money (currency-aware, last-gets-remainder). + +## Depth-proof footer +``` +Read: hr/models/hr_employee.py lines=1865 depth=full +Read: hr/models/hr_version.py lines=700 depth=full +Read: hr/models/hr_department.py lines=243 depth=full +Read: hr/models/hr_job.py lines=94 depth=full +Read: hr/models/hr_contract_type.py lines=23 depth=full +Read: hr/models/hr_payroll_structure_type.py lines=19 depth=full +Read: hr/models/res_company.py lines=18 depth=full +``` diff --git a/.claude/odoo/L15-TAX-REPARTITION.md b/.claude/odoo/L15-TAX-REPARTITION.md new file mode 100644 index 00000000..90880246 --- /dev/null +++ b/.claude/odoo/L15-TAX-REPARTITION.md @@ -0,0 +1,655 @@ +RICHNESS-LANE-OK + +# Lane L15 — Tax Repartition + Tax Groups + price_include + Cash-Basis + +> Deepens K7/L3. L3 covered tax-compute CORE + fiscal-position mapping. +> This lane adds the repartition-line %-split to GL accounts + tax tags, +> ordering subtleties (price_include extraction, include_base_amount, +> is_base_affected), tax-group aggregation for UI/subtotals, and cash-basis +> (CABA) transition mechanics. Almost entirely AXIS-A. + +--- + +## Sources read (file : line-range : depth) + +- `/home/user/odoo/addons/account/models/account_tax.py` : L1-5210 : full +- `/home/user/odoo/addons/account/models/account_account_tag.py` : L1-141 : full +- `/home/user/odoo/addons/account/models/account_move.py` : L4080-4148 : full (CABA collection) +- `/home/user/odoo/addons/account/models/company.py` : L131-134 : skim (rounding method field) + +--- + +## Ontology rows + +| odoo class | owl pivot | OGIT family (or None) | DOLCE | +|---|---|---|---| +| `account.tax` | fibo:TaxTreatment | 0x62 SMBAccounting | Quality (rate/rule applied to transaction) | +| `account.tax.group` | fibo:TaxCategory | 0x62 SMBAccounting | Abstract (grouping concept) | +| `account.tax.repartition.line` | fibo:PostingRule | 0x62 SMBAccounting | Abstract (split specification) | +| `account.account.tag` | fibo:ReportingTag | None — ontology-unmapped, needs Layer-2 alignment axiom | Abstract | + +--- + +## Rules extracted + +### R1 — AccountTaxGroup fields [AXIS-A] + +- **odoo source**: `account_tax.py:L25-68` +- **What it does**: `account.tax.group` defines the grouping bucket for taxes on documents and in the tax-closing entry. Key fields: + - `tax_payable_account_id` — GL account used as counterpart when the group is a liability to the authorities (Umsatzsteuer-Zahllast). + - `tax_receivable_account_id` — GL account used when the group is in favour of the company (Vorsteuer-Überhang). + - `advance_tax_payment_account_id` — downpayment account considered during the tax closing entry (Vorauszahlung). + - `preceding_subtotal` — optional string label for a subtotal displayed BEFORE this group on the document (e.g. "Subtotal excl. special tax"). When None, the group falls under "Untaxed Amount". + - `sequence` — order of groups in the document footer; sorted `(sequence, id)`. + - `country_id` — computed from `company_id.account_fiscal_country_id` or `company_id.country_id`. +- **woa-rs target**: K7 — TaxGroup entity; drives USt-Voranmeldung closing entry accounts + document footer display. +- **Rust sketch**: + ```rust + struct TaxGroup { + id: i64, + name: String, + sequence: i32, // default 10 + company_id: i64, + tax_payable_account_id: Option, + tax_receivable_account_id: Option, + advance_tax_payment_account_id: Option, + preceding_subtotal: Option, // None → falls under "Untaxed Amount" + country_id: i64, + } + ``` +- **Parity notes**: The `preceding_subtotal` label drives the layered subtotal display in `_get_tax_totals_summary` (L2709-2989). Groups are sorted by `(sequence, id)` at L2813. Multiple groups can share the same `preceding_subtotal` label — they merge into one subtotal section. + +--- + +### R2 — `price_include` compute (override + company default) [AXIS-A] + +- **odoo source**: `account_tax.py:L137-320` +- **What it does**: `price_include` is a computed boolean (not stored). Logic at L302-309: + ```python + tax.price_include = ( + tax.price_include_override == 'tax_included' + or (tax.company_price_include == 'tax_included' + and not tax.price_include_override) + ) + ``` + Priority: explicit `price_include_override` field wins; falls back to `company_id.account_price_include`. + Three-state override: `'tax_included'` | `'tax_excluded'` | `False` (not set → inherit company). +- **woa-rs target**: K7 — every tax lookup must resolve this before calling `_get_tax_details`. +- **Rust sketch**: + ```rust + fn price_include(tax: &Tax, company: &Company) -> bool { + match tax.price_include_override { + Some(PriceIncludeOverride::TaxIncluded) => true, + Some(PriceIncludeOverride::TaxExcluded) => false, + None => company.account_price_include == PriceInclude::TaxIncluded, + } + } + ``` +- **Parity notes**: `onchange_price_include` at L717-720 auto-sets `include_base_amount = True` when price_include is enabled — this is a UI hint, not a hard constraint, but worth preserving in woa-rs data model defaults. + +--- + +### R3 — Tax flattening and group-sort (group of taxes) [AXIS-A] + +- **odoo source**: `account_tax.py:L892-971` +- **What it does**: `_flatten_taxes_and_sort_them` (L892-918): iterates `self.sorted(sequence, id)`; if `amount_type == 'group'`, inserts the group's `children_tax_ids` sorted by `(sequence, id)` in place of the parent; records `group_per_tax[child.id] = parent`. Non-group taxes are inserted as-is. Result: flat `sorted_taxes` recordset + `group_per_tax` map. + + `_batch_for_taxes_computation` (L920-971): iterates `sorted_taxes` in **reverse** to group contiguous taxes into batches. A batch stays together if ALL of: + 1. `amount_type` same + 2. `price_include` same (unless `special_mode` active) + 3. `include_base_amount` same + 4. If `include_base_amount=True`: the next-lower tax must NOT have `is_base_affected=True` (a base-affecting tax cannot batch with a subsequent base-affected one without a break) + +- **woa-rs target**: K7 — required before any tax amount computation. +- **Rust sketch**: + ```rust + fn flatten_and_sort(taxes: &[Tax]) -> (Vec, HashMap) { + let mut sorted = vec![]; + let mut group_per_tax: HashMap = HashMap::new(); + for tax in taxes.iter().sorted_by_key(|t| (t.sequence, t.id)) { + if tax.amount_type == AmountType::Group { + for child in tax.children.iter().sorted_by_key(|c| (c.sequence, c.id)) { + group_per_tax.insert(child.id, tax.clone()); + sorted.push(child.clone()); + } + } else { + sorted.push(tax.clone()); + } + } + (sorted, group_per_tax) + } + ``` +- **Parity notes**: Nested groups (group-of-group) are **prohibited** by constraint at L598-613. The `_check_children_scope` constraint also requires child `type_tax_use` ∈ {`none`, parent's use} and child `tax_scope` ∈ {parent's scope, `False`}. + +--- + +### R4 — Three-pass tax amount evaluation in `_get_tax_details` [AXIS-A] + +- **odoo source**: `account_tax.py:L1134-1332` +- **What it does**: The core computation engine. Called by `compute_all` and `_add_tax_details_in_base_line`. Three evaluation passes in order: + + **Pass 1 — Fixed taxes (reverse order, L1253-1254)**: + ```python + for tax in reversed(sorted_taxes): + eval_tax_amount(tax._eval_tax_amount_fixed_amount, tax) + ``` + `_eval_tax_amount_fixed_amount` (L1079-1092): for `amount_type == 'fixed'`: + ```python + sign = -1 if price_unit < 0.0 else 1 + return sign * quantity * self.amount + ``` + Fixed taxes are evaluated FIRST because their amount may affect the base of subsequent price-included percent taxes. + + **Pass 2 — Price-included taxes (reverse order, L1256-1259)**: + ```python + for tax in reversed(sorted_taxes): + if taxes_data[tax.id]['price_include']: + eval_tax_amount(tax._eval_tax_amount_price_included, tax) + ``` + `_eval_tax_amount_price_included` (L1094-1112): + - For `percent`: `raw_base * (1/(1+sum_pct)) * (self.amount/100)` — the entire batch's percentage is used to extract from the price-included amount. + - For `division`: `raw_base * self.amount / 100.0` — direct division (no batch-sum normalization). + + **Pass 3 — Price-excluded taxes (forward order, L1261-1264)**: + ```python + for tax in sorted_taxes: + if not taxes_data[tax.id]['price_include']: + eval_tax_amount(tax._eval_tax_amount_price_excluded, tax) + ``` + `_eval_tax_amount_price_excluded` (L1114-1132): + - For `percent`: `raw_base * self.amount / 100.0` + - For `division`: `raw_base * self.amount / 100.0 / (1 - total_pct)` where `total_pct = sum(batch.amount)/100`. + + **raw_base** for each tax = `quantity * price_unit` (rounded per `round_per_line` if applicable), adjusted by `extra_base_for_tax` accumulated from `_propagate_extra_taxes_base`. + + **Base amount computation** (L1266-1299, reverse order): + ```python + base = raw_base + tax_data['extra_base_for_base'] + if tax_data['price_include'] and special_mode in (False, 'total_included'): + base -= total_tax_amount # subtract all taxes in this tax's batch + tax_data['base'] = base + ``` + `total_tax_amount` = sum of tax amounts in the same batch (including reverse-charge twin amounts). + + **Totals**: + - `total_excluded = taxes_data_list[0]['base']` + - `total_included = total_excluded + sum(tax_data['tax_amount'])` + - If no taxes: `total_included = total_excluded = raw_base`. + +- **woa-rs target**: K7 — this IS the tax engine. Must be reproduced exactly. +- **Rust sketch** (key branches): + ```rust + fn get_tax_details(taxes: &[Tax], price_unit: Decimal, quantity: Decimal, + rounding: RoundingMethod, currency_pd: u32, + special_mode: SpecialMode) -> TaxDetailsResult { + let mut raw_base = price_unit * quantity; + if rounding == RoundingMethod::RoundPerLine { + raw_base = round(raw_base, currency_pd); + } + // Pass 1: fixed taxes (reverse) + for tax in taxes.iter().rev() { + if tax.amount_type == Fixed && !already_computed(tax) { + let sign = if price_unit < 0 { -1 } else { 1 }; + set_tax_amount(tax, Decimal::from(sign) * quantity * tax.amount); + propagate_extra_base(tax, &mut taxes_data, special_mode); + } + } + // Pass 2: price-included (reverse) + for tax in taxes.iter().rev() { + if taxes_data[tax.id].price_include && !already_computed(tax) { + let raw = raw_base + taxes_data[tax.id].extra_base_for_tax; + let amt = eval_price_included(tax, batch, raw); + set_tax_amount(tax, amt); + propagate_extra_base(tax, &mut taxes_data, special_mode); + } + } + // Pass 3: price-excluded (forward) + for tax in taxes.iter() { + if !taxes_data[tax.id].price_include && !already_computed(tax) { + let raw = raw_base + taxes_data[tax.id].extra_base_for_tax; + let amt = eval_price_excluded(tax, batch, raw); + set_tax_amount(tax, amt); + propagate_extra_base(tax, &mut taxes_data, special_mode); + } + } + // Base amounts (reverse) + for tax in taxes.iter().rev() { ... } + } + ``` +- **Parity notes / gotchas**: + - `round_per_line`: each `tax_amount` is `float_round(raw, precision_rounding=currency.rounding)` immediately after evaluation (L1179-1180). `round_globally`: amounts stay unrounded until the aggregation pass in `_round_base_lines_tax_details`. + - `has_negative_factor` (L507-511): a tax with at least one repartition line with `factor < 0` gets a "reverse charge twin" entry with `tax_amount = -original_tax_amount`. Both the positive and negative entries appear in `taxes_data_list`. This is for EU reverse-charge (e.g. +100% / -100% split). + - `special_mode='total_included'`: ALL taxes treated as price-included regardless of their `price_include` flag. `special_mode='total_excluded'`: ALL treated as price-excluded. + - `force_price_include` context key maps to `special_mode` at L4918-4923. + +--- + +### R5 — `_propagate_extra_taxes_base`: base-affecting cascade [AXIS-A] + +- **odoo source**: `account_tax.py:L973-1077` +- **What it does**: After each tax's amount is computed, this method updates `extra_base_for_tax` and `extra_base_for_base` on OTHER taxes to reflect the "include_base_amount" cascade. Two symmetric halves: + + **price_include taxes** (L1004-1026): + - `special_mode in (False, 'total_included')`: + - If `include_base_amount`: subtract this tax's amount from ALL taxes AFTER it whose `is_base_affected=False` (`extra_base_for_tax`), AND from all taxes BEFORE it (`extra_base_for_base`). + - Else: subtract from ALL taxes AFTER it (both fields). + - Also subtract from all taxes BEFORE it (extra_base_for_base only). + - `special_mode == 'total_excluded'`: + - If `include_base_amount`: ADD to taxes after it where `is_base_affected=True`. + + **price_excluded taxes** (L1047-1077): + - `special_mode in (False, 'total_excluded')`: + - If `include_base_amount`: ADD this tax's amount to taxes AFTER it where `is_base_affected=True`. + - `special_mode == 'total_included'`: + - If NOT `include_base_amount`: SUBTRACT from taxes AFTER it. + - Also subtract from taxes BEFORE it. + + The `get_tax_before()` / `get_tax_after()` helpers (L986-996) stop at the boundary of the current tax's batch. + +- **woa-rs target**: K7 — must implement this exactly for compound tax chains (e.g. 19% MwSt + special excise that affects VAT base). +- **Parity notes**: The `is_base_affected` flag is the "consent" side — a tax says "I accept being affected by prior taxes". `include_base_amount` is the "push" side — a tax says "I affect subsequent taxes". Both must be true for the cascade to fire. + +--- + +### R6 — Repartition line %-split to accounts and tags [AXIS-A] + +- **odoo source**: `account_tax.py:L5142-5210` (AccountTaxRepartitionLine class) +- **What it does**: `account.tax.repartition.line` defines how a tax's computed amount is split into GL postings and tax report boxes. + + Key fields: + - `factor_percent` (float, digits=(16,12), default=100): percentage of the tax amount assigned to this line. Stored with 12 decimal places. `factor = factor_percent / 100.0` (L5192-5194). + - `repartition_type` ∈ `{'base', 'tax'}`: `'base'` = tags only (no account posting); `'tax'` = actual GL posting. + - `document_type` ∈ `{'invoice', 'refund'}`: which side of the transaction this line applies to. + - `account_id`: target GL account for the tax amount split. + - `tag_ids`: many2many to `account.account.tag` — determines which USt-VA report box gets hit. + - `use_in_tax_closing` (computed at L5182-5189): `True` when `repartition_type == 'tax'` AND `account_id` is set AND `account_id.internal_group not in ('income', 'expense')`. Marks lines relevant to the periodic VAT closing entry. + - `sequence` — display/matching order; invoice and refund lines must be in the same sequence order. + + **Constraints** (L561-596): + - Exactly ONE `'base'` line per document_type. + - At least ONE `'tax'` line per document_type. + - Count of invoice lines must equal count of refund lines. + - Invoice and refund lines must have matching `repartition_type` and `factor_percent` in same order. + - Sum of positive `factor` values among tax-type lines must equal 1.0 (100%). + - If any negative factors exist, their sum must equal -1.0 (100% negative, for reverse charge). + + **Default repartition** created on new tax (L490-504): + ```python + # invoice side + {'document_type': 'invoice', 'repartition_type': 'base', 'tag_ids': []} + {'document_type': 'invoice', 'repartition_type': 'tax', 'tag_ids': []} + # refund side + {'document_type': 'refund', 'repartition_type': 'base', 'tag_ids': []} + {'document_type': 'refund', 'repartition_type': 'tax', 'tag_ids': []} + ``` + +- **woa-rs target**: K7 + K8 — the repartition lines determine: (a) which GL accounts tax amounts post to; (b) which USt-VA report boxes are populated. +- **Rust sketch**: + ```rust + struct RepartitionLine { + id: i64, + tax_id: i64, + factor_percent: Decimal, // 12 decimal places + factor: Decimal, // = factor_percent / 100 + repartition_type: RepartitionType, // Base | Tax + document_type: DocumentType, // Invoice | Refund + account_id: Option, + tag_ids: Vec, + use_in_tax_closing: bool, // computed: repartition_type==Tax && account && account.internal_group not in (income, expense) + sequence: i32, + } + + fn split_tax_amount( + tax_amount: Decimal, + tax_reps: &[RepartitionLine], // filtered to correct document_type, repartition_type=Tax + currency: &Currency, + company_currency: &Currency, + ) -> Vec { + let mut reps_data: Vec<_> = tax_reps.iter() + .map(|rep| RepartitionLineAmount { + rep, + tax_amount_currency: currency.round(tax_amount * rep.factor * rep_sign), + tax_amount: company_currency.round(tax_amount_local * rep.factor * rep_sign), + }) + .collect(); + // Distribute rounding delta on largest-first (L2439-2466) + distribute_delta_smoothly(&mut reps_data, tax_amount_total, currency); + reps_data + } + ``` +- **Parity notes / gotchas**: + - The rounding delta after per-rep multiplication is distributed via `_distribute_delta_amount_smoothly` (L2439-2466), sorted by largest `|tax_amount_currency|` first (L2439-2441). This ensures the largest slice absorbs any rounding error. + - `_get_aml_target_tax_account` (L5201-5210): if `tax_exigibility == 'on_payment'` (CABA) AND context NOT `caba_no_transition_account`, returns `cash_basis_transition_account_id` instead of `account_id`. This is the CABA routing mechanism. + - For reverse-charge taxes (`is_reverse_charge=True`): negative-factor repartition lines are used (L2410-2415), with `tax_rep_sign = -1.0`. + +--- + +### R7 — `_add_accounting_data_to_base_line_tax_details`: tag assignment and grouping key [AXIS-A] + +- **odoo source**: `account_tax.py:L2362-2497` +- **What it does**: After `_get_tax_details` computes amounts, this method enriches each `tax_data` with full accounting information: + + 1. **Base-line tags** (L2392-2407): collects `tag_ids` from the `'base'`-type repartition lines of each non-reverse-charge tax (invoice or refund side depending on `is_refund`). Also includes product `account_tag_ids`. Skipped for CABA taxes unless `include_caba_tags=True`. + + 2. **Repartition-line amounts** (L2409-2436): for each tax's repartition lines (filtered by `repartition_type='tax'` and correct factor sign): + ```python + tax_rep_data['tax_amount_currency'] = currency.round( + tax_amount_currency * tax_rep.factor * tax_rep_sign + ) + tax_rep_data['tax_amount'] = company_currency.round( + tax_data['tax_amount'] * tax_rep.factor * tax_rep_sign + ) + tax_rep_data['account'] = tax_rep._get_aml_target_tax_account(force_caba_exigibility) + or base_line['account_id'] + ``` + If no account on repartition line: falls back to the base line's own account. + + 3. **Subsequent tags** (L2468-2496): in reverse order, for each repartition line within a tax, `tax_rep_data['tax_tags']` includes the repartition line's own `tag_ids` PLUS the `base`-repartition tags of any tax that has `is_base_affected=True` and comes after this tax in sequence (only if the subsequent tax has `include_base_amount`). This is the "tag cascade" for compound taxes. + + 4. **Grouping key** (L2485-2492): calls `_prepare_base_line_tax_repartition_grouping_key` which produces the key used to merge / de-duplicate tax lines: + ```python + { + 'tax_repartition_line_id': tax_rep.id, + 'partner_id': ..., + 'currency_id': ..., + 'group_tax_id': tax_data['group'].id, # parent group-tax or empty + 'analytic_distribution': ... if tax.analytic or not rep.use_in_tax_closing else False, + 'account_id': tax_rep_data['account'].id or base_line['account_id'], + 'tax_ids': [taxes that this line affects for subsequent base], + 'tax_tag_ids': [merged tags], + } + ``` + +- **woa-rs target**: K7 + K3 (posting) — the grouping key determines which `account.move.line` records are created/updated for taxes. +- **Parity notes**: `analytic_distribution` is cleared on tax lines where `use_in_tax_closing=True` AND `tax.analytic=False` (L2329-2333). This prevents analytic allocations leaking into VAT clearing accounts. + +--- + +### R8 — `_round_base_lines_tax_details`: global vs per-line rounding [AXIS-A] + +- **odoo source**: `account_tax.py:L2178-2288` +- **What it does**: Two rounding methods selectable via `company.tax_calculation_rounding_method` (L131-134 company.py): + - `'round_per_line'` (default: False — `'round_globally'` is the default): raw amounts already rounded in `_get_tax_details` per tax per line. + - `'round_globally'` (default): amounts remain raw until this aggregation step. + + **Round-globally flow** (L2286-2288): + 1. `_round_tax_details_tax_amounts` (L1890-1986): groups lines by `(tax, currency, is_refund, is_reverse_charge, price_include, computation_key)`. For each group: rounds the SUM of raw tax amounts; distributes the rounding delta (positive or negative cents) back to individual lines proportionally, largest first. Same for base amounts — with `mode` distinction: + - `mode='mixed'` (default): price-included taxes use `'included'` mode (round base+tax together then subtract); price-excluded use `'excluded'` (round base and tax independently). + - `mode='included'`: always `round(base + tax) - tax`. + - `mode='excluded'`: always `round(base)` and `round(tax)` independently. + 2. `_round_tax_details_base_lines` (L1988-2097): computes `delta_total_excluded{_currency}` — the rounding correction on the base line's `total_excluded`. For price-excluded: `round(sum_raw_total_excluded) - sum_rounded_total_excluded`. For price-included: `round(sum_raw_total_included) - (sum_total_excluded + sum_tax_amount)`. + 3. `_round_tax_details_tax_amounts_from_tax_lines` (L2099-2176): if existing tax lines provided, overrides computed amounts to match the actual posted amounts (for user-edited taxes). + + **Raw rounding** (L2237-2248): copies `currency.round(raw_X)` → `X` for each field. + +- **woa-rs target**: K7 — must select rounding mode from company setting. `round_globally` is the standard for Germany/EU. +- **Rust sketch**: + ```rust + fn round_base_lines_tax_details( + base_lines: &mut [BaseLine], + company: &Company, + tax_lines: Option<&[TaxLine]>, + ) { + // Step 1: raw round + for bl in base_lines.iter_mut() { + bl.tax_details.total_excluded = currency.round(bl.tax_details.raw_total_excluded); + for td in bl.tax_details.taxes_data.iter_mut() { + td.base_amount = currency.round(td.raw_base_amount); + td.tax_amount = currency.round(td.raw_tax_amount); + } + } + // Step 2: global delta distribution (round_globally only) + round_tax_details_tax_amounts(base_lines, company); + round_tax_details_base_lines(base_lines, company); + // Step 3: override from existing tax lines (manual amounts) + if let Some(tl) = tax_lines { + round_tax_details_tax_amounts_from_tax_lines(base_lines, company, tl); + } + } + ``` +- **Parity notes / gotchas**: + - Delta distribution uses `_distribute_delta_amount_smoothly` (L1836-1888): converts delta to integer "error units" at the currency's precision, distributes proportionally to raw-amount factors, largest first. Remaining 1-unit errors distributed sequentially. This guarantees exact cent-level accuracy. + - For EDI reporting: use `raw_total_excluded_currency` (unrounded, 6-8 decimal places) NOT `total_excluded_currency` (L2183-2189). + +--- + +### R9 — `compute_all` public API [AXIS-A] + +- **odoo source**: `account_tax.py:L4864-4980` +- **What it does**: The legacy/public entry point used by sale/purchase order lines, wizard computations, etc. Wraps the new engine: + 1. Resolves `special_mode` from context `force_price_include` or `handle_price_include` param (L4918-4923). + 2. Calls `_prepare_base_line_for_taxes_computation(None, ...)` with explicit kwargs. + 3. Calls `_add_tax_details_in_base_line` then `_add_accounting_data_to_base_line_tax_details` with `compute_all_use_raw_base_lines=True` context (uses raw unrounded amounts for repartition split — L4936-4938). + 4. Returns legacy dict: + ```python + { + 'base_tags': [...], # tag ids on the base line + 'taxes': [{ # one entry PER repartition line (not per tax) + 'id': tax.id, + 'name': ..., + 'amount': tax_rep_data['tax_amount_currency'], # repartition-line amount + 'base': tax_data['raw_base_amount_currency'], + 'sequence': tax.sequence, + 'account_id': tax_rep_data['account'].id, + 'analytic': tax.analytic, + 'use_in_tax_closing': rep_line.use_in_tax_closing, + 'is_reverse_charge': ..., + 'price_include': tax.price_include, + 'tax_exigibility': tax.tax_exigibility, + 'tax_repartition_line_id': rep_line.id, + 'group': tax_data['group'], # parent group-tax recordset + 'tag_ids': tax_rep_data['tax_tags'].ids, + 'tax_ids': tax_rep_data['taxes'].ids, + }], + 'total_excluded': currency.round(raw_total_excluded), + 'total_included': currency.round(raw_total_included), + 'total_void': total_excluded + sum(amounts where account_id is None), + } + ``` + - `total_void`: base + all tax amounts WITHOUT an account assigned. Used to compute the taxable amount excluding taxes that don't generate a posting. + - Rounding of `total_excluded`/`total_included` can be suppressed via context `round_base=False` (L4970). + +- **woa-rs target**: K7 — woa-rs can expose a compatibility function with the same signature for places that use the old API. Internally delegate to `_get_tax_details` + `_add_accounting_data_to_base_line_tax_details`. +- **Parity notes**: Note that `taxes` entries are ONE PER REPARTITION LINE, not one per tax. A tax with 2 repartition lines produces 2 entries. The `id` field on each entry is still the tax's id (not the repartition line id) — `tax_repartition_line_id` is the repartition line. + +--- + +### R10 — `_get_tax_totals_summary`: document footer subtotals [AXIS-A] + +- **odoo source**: `account_tax.py:L2709-2989` +- **What it does**: Produces the structured tax totals shown in the document footer (invoice, POS receipt, etc.). Algorithm: + 1. **Global totals** (L2782-2794): sum of all base and tax amounts across all base lines. + 2. **Per-tax-group aggregation** (L2806-2871): groups by `tax_group_id`, sorted by `(group.sequence, group.id)`. For each group: + - Collects all taxes involved. + - Computes `display_base_amount`: special-cased for `fixed`-only groups (no base shown) and `division price-included`-only groups (base = total_included). + - Uses `preceding_subtotal` to assign to a named subtotal section (default: "Untaxed Amount"). + 3. **Subtotal accumulation** (L2873-2889): subtotals appear in document order (the `subtotals_order` dict tracks first-encounter order). Each subtotal's `base_amount` = global base + sum of ALL prior subtotals' tax amounts. + 4. **Cash rounding** (L2891-2953): if `cash_rounding` provided, computes delta to reach rounded total; applies via `'add_invoice_line'` (adjusts base) or `'biggest_tax'` (adjusts the largest tax group's amount) strategy. + 5. **Non-deductible** (L2956-2981): for reverse-charge lines marked `special_type='non_deductible'`, their tax amounts are shown separately and subtracted from the tax group totals. + +- **woa-rs target**: K7/K8 — drives the invoice tax footer display; also used for VAT return summary. +- **Parity notes**: `same_tax_base` flag (L2954) is True when all tax groups have the same display base amount — controls whether to show base amounts per group (when they differ due to different exemptions). + +--- + +### R11 — Cash-basis (CABA): `tax_exigibility` + transition account [AXIS-A] + +- **odoo source**: + - `account_tax.py:L164-174` — field definitions + - `account_tax.py:L247-255` — constraint: CABA transition account must allow reconciliation + - `account_tax.py:L5201-5210` — `_get_aml_target_tax_account` routing + - `account_move.py:L4080-4147` — `_collect_tax_cash_basis_values` + +- **What it does**: + - `tax_exigibility` ∈ `{'on_invoice', 'on_payment'}` (default: `'on_invoice'`). + - `'on_invoice'` (Soll-Besteuerung): tax becomes due immediately when invoice is posted → normal flow, tax posts to the real tax payable/receivable account. + - `'on_payment'` (Ist-Besteuerung / cash basis): tax becomes due only when payment is received/made. + + **Transition account routing** (`_get_aml_target_tax_account`, L5201-5210): + ```python + if tax.tax_exigibility == 'on_payment' and not context.get('caba_no_transition_account'): + return tax.cash_basis_transition_account_id # interim account + else: + return self.account_id # final tax account + ``` + So on invoice posting, CABA taxes post to `cash_basis_transition_account_id` (a temporary clearing account). The transition account MUST be reconcilable (`account.reconcile = True`). + + **CABA collection** (`_collect_tax_cash_basis_values`, account_move.py:L4080-4147): + When a payment is reconciled against an invoice, this method identifies: + - Lines where `tax_line_id.tax_exigibility == 'on_payment'` → `caba_treatment = 'tax'` + - Lines where any tax in `tax_ids` has `tax_exigibility == 'on_payment'` → `caba_treatment = 'base'` + - `total_balance` / `total_residual` from `asset_receivable`/`liability_payable` lines + - `is_fully_paid` = company currency residual is zero OR foreign currency residual is zero + + The payment percentage is `total_residual / total_balance` applied to move the amounts from transition account to the real tax account via a new journal entry (`tax_cash_basis_created_move_ids`). + + **CABA tags**: `include_caba_tags=False` by default in `_add_accounting_data_to_base_line_tax_details`. When False: CABA taxes' base-line tags are suppressed (tax not yet exigible, should not appear in the USt-VA box yet). When True (used in CABA reconciliation entry creation): tags are included. + +- **woa-rs target**: K7 (Ist-Besteuerung support) + K3 (CABA reconciliation entry). In Germany, Ist-Besteuerung is available for small businesses (§ 20 UStG). Most SMBs use Soll-Besteuerung. +- **Rust sketch**: + ```rust + fn get_aml_target_tax_account( + tax: &Tax, rep: &RepartitionLine, ctx: &Context + ) -> Option { + if tax.tax_exigibility == TaxExigibility::OnPayment + && !ctx.caba_no_transition_account { + tax.cash_basis_transition_account_id + } else { + rep.account_id + } + } + ``` +- **Parity notes / gotchas**: + - The constraint at L247-255: if `tax_exigibility == 'on_payment'` AND `cash_basis_transition_account_id.reconcile == False` → `ValidationError`. Must enforce in woa-rs. + - `hide_tax_exigibility` field (L163): reads `company_id.tax_exigibility` (a company-level flag). If the company has disabled cash-basis, the field is hidden in UI. woa-rs should respect this: only offer CABA option if the company has it enabled. + - `company.tax_exigibility = False` means CABA is globally disabled for the company; `'on_payment'` taxes still exist but the feature is hidden. + +--- + +### R12 — `account.account.tag` sign convention for tax report boxes [AXIS-A] + +- **odoo source**: `account_account_tag.py:L1-141` +- **What it does**: Tax tags link tax repartition lines to VAT report boxes. Key fields: + - `name`: the tag name; for report-linked tags, this matches `account_report_expression.formula` (possibly prefixed with `-`). + - `applicability` ∈ `{'accounts', 'taxes', 'products'}`: only `'taxes'` tags attach to repartition lines. + - `balance_negate` (computed, L20, L40-48): True if the formula in `account_report_expression` starts with `-`. Determines whether the tag uses `+balance` or `-balance` when aggregating for the report. + - `report_expression_id` (computed): links back to the report expression defining which VAT return box this tag feeds. + + **Sign rule** (L51-67): a tag named `"-Kz81"` has `balance_negate=True` → the balance posted to GL lines with this tag is negated when summed into the report box. This is how odoo encodes the +/- convention for USt-VA boxes (Kennzahlen). + + **`_get_tax_tags_domain`** (L88-96): strips leading `-` when searching: `name = formula.lstrip('-')`. The sign is encoded in the tag name's leading character, not in a separate field. + +- **woa-rs target**: K8 — the tag → report-expression → Kennzahl mapping drives the USt-Voranmeldung report. Tags with `balance_negate=True` contribute negative balances to their box. +- **Parity notes**: Tags are country-specific (`country_id`). For Germany, tags will have `country_id = DE`. Multi-VAT companies can have tags for foreign countries too (L5178-5180 of account_tax.py). The `_get_related_tax_report_expressions` (L98-108) joins via formula match (`formula = tag.name` or `formula = '-' + tag.name`). + +--- + +### R13 — `_prepare_tax_lines`: GL line generation from repartition data [AXIS-A] + +- **odoo source**: `account_tax.py:L3032-3126` +- **What it does**: Final step — converts `tax_reps_data` (from R7) into the actual `account.move.line` create/update/delete diff: + 1. For each base line: computes the base-line's `amount_currency` / `balance` using `total_excluded + delta_total_excluded` × `sign`. + 2. For each tax_data → for each tax_rep_data: accumulates into `tax_lines_mapping[grouping_key]`: + - `tax_base_amount += sign * tax_data['base_amount']` + - `amount_currency += sign * tax_rep_data['tax_amount_currency']` + - `balance += sign * tax_rep_data['tax_amount']` + 3. Removes zero-amount lines (unless `__keep_zero_line` flag set). + 4. Matches against existing `tax_lines` to produce `tax_lines_to_update` / `tax_lines_to_delete` / `tax_lines_to_add`. + + The `sign` field on base_line (typically +1 for normal invoice, -1 for credit note as viewed from the company's perspective) ensures correct debit/credit. + +- **woa-rs target**: K3 + K7 — this is the bridge from tax computation to GL posting. +- **Parity notes**: `tax_lines_to_add` entries already contain all grouping key fields merged with amounts (L3119) — they can be passed directly to `account.move.line.create()`. The `'__keep_zero_line'` hidden key (L3100) prevents pruning of intentional zero-amount tax lines (e.g. 0% exempt lines that still need to appear in the report). + +--- + +### R14 — Validation constraints on repartition lines [AXIS-A] + +- **odoo source**: `account_tax.py:L554-596` +- **What it does**: `_validate_repartition_lines` constraint enforces: + 1. Exactly 1 `'base'` line per document_type (L557-559). + 2. At least 1 `'tax'` line per document_type (L578-580). + 3. Invoice and refund line counts must be equal (L575-576). + 4. Corresponding positions must have matching `repartition_type` AND `factor_percent` (L582-588). + 5. Sum of positive `factor` values among tax lines must == 1.0 (precision 2 decimal places) (L590-593). + 6. If any negative factors: sum must == -1.0 (L594-596). + +- **woa-rs target**: K7 — validation in the tax configuration UI/API. +- **Rust sketch**: + ```rust + fn validate_repartition_lines(invoice_lines: &[RepLine], refund_lines: &[RepLine]) -> Result<(), ValidationError> { + ensure!(invoice_lines.iter().filter(|l| l.rep_type == Base).count() == 1)?; + ensure!(refund_lines.iter().filter(|l| l.rep_type == Base).count() == 1)?; + ensure!(invoice_lines.iter().any(|l| l.rep_type == Tax))?; + ensure!(refund_lines.iter().any(|l| l.rep_type == Tax))?; + ensure!(invoice_lines.len() == refund_lines.len())?; + for (inv, ref_) in invoice_lines.iter().zip(refund_lines.iter()) { + ensure!(inv.rep_type == ref_.rep_type && inv.factor_percent == ref_.factor_percent)?; + } + let pos_sum: Decimal = invoice_lines.iter().filter(|l| l.factor > 0).map(|l| l.factor).sum(); + ensure!(Decimal::abs(pos_sum - 1.0) < 0.01)?; + // ... negative factor check + Ok(()) + } + ``` + +--- + +### R15 — `_adapt_price_unit_to_another_taxes`: fiscal-position price-include adaptation [AXIS-A] + +- **odoo source**: `account_tax.py:L1338-1385` +- **What it does**: Used when a fiscal position maps a price-included tax to a different tax. Adjusts the price unit so the end customer sees the same number. + + Only adapts when ALL taxes in `original_taxes` are price-included (L1362). If any original tax is NOT price-included: returns `price_unit` unchanged. + + Algorithm: + 1. Compute `total_excluded` by calling `_get_tax_details(original_taxes, price_unit, 1.0, round_globally)`. + 2. Re-add the new price-included taxes' amounts: `delta = sum(x['tax_amount'] for x if x['tax'].price_include)` from `_get_tax_details(new_taxes, total_excluded, 1.0, special_mode='total_excluded')`. + 3. Return `total_excluded + delta`. + +- **woa-rs target**: K7 — needed when fiscal position changes a price-included tax. +- **Parity notes**: This is the same method mirrored in `account_tax.js`. Precision: uses `round_globally` (no rounding) for intermediate computation to avoid double-rounding. + +--- + +## AXIS-B rules (Savant seeds) + +### RS1 — Tax-exigibility mode selection for a company [AXIS-B / HYBRID] + +The determination of whether a company should use `on_invoice` (Soll) vs `on_payment` (Ist) Besteuerung is: +- AXIS-A guard: `tax_exigibility` field must be enabled on company (`company.tax_exigibility = True`). +- AXIS-B core: recommending Ist-Besteuerung eligibility based on revenue thresholds (§ 20 UStG: ≤ 600,000 EUR), industry patterns, accounting setup, etc. + +`SAVANT: name=TaxExigibilitySuggestor family=0x62 reasoning=NextBestAction inference=Induction semiring=NarsTruth style=Analytical — heuristic: recommend on_payment vs on_invoice based on company revenue/industry evidence rather than hard-coding threshold` + +--- + +## Enterprise gaps flagged + +- `account_reports` module: **absent** (Enterprise). The `account.report` / `account.report.expression` models referenced by `report_expression_id` on tags (L19-20, L40-48 of account_account_tag.py) exist in community but the full report engine is Enterprise. What IS present: the tag linkage mechanism (`tag.name` → `formula` join) and the `balance_negate` computation. The woa-rs K8 engine must implement the USt-VA report fresh but can steal the tag→Kennzahl mapping structure. +- `account_accountant` module: `_predict_specific_tax` referenced at L5036 (invoice predictive tax import) is Enterprise-only. Safe to omit. + +--- + +## Open questions for the Opus porter + +1. **Batch boundary edge case**: when `include_base_amount=True` and two consecutive taxes have different `is_base_affected` values within the same sequence, the batch-splitting logic in `_batch_for_taxes_computation` (L948-963) can produce non-obvious batches. Recommend writing a unit test for the [t1: 19% excl include_base; t2: 10% excl is_base_affected=False] case. + +2. **Decimal precision on `factor_percent`**: stored with `digits=(16, 12)`. Rust `Decimal` type needs sufficient precision. Recommend `rust_decimal` with `PREC_28` or using `f64` only for the `factor` ratio (since it's used in `round(amount * factor)`). + +3. **CABA multi-currency**: `_collect_tax_cash_basis_values` at L4136-4141 returns `None` if multiple currencies are involved. Odoo explicitly does NOT support CABA with mixed currencies on the same move. woa-rs should enforce the same restriction at validation time. + +4. **`use_in_tax_closing` semantics for income/expense accounts**: repartition lines posting to income or expense accounts (e.g. non-deductible input VAT directly expensed) have `use_in_tax_closing=False` — they are NOT included in the periodic VAT closing. This is a gotcha for partial-deductibility scenarios. + +5. **`total_void` in `compute_all`**: amounts where `account_id` is None contribute to `total_void` (not `total_excluded`). This is relevant for "informational" tax lines that should appear on documents but not generate postings. Confirm if woa-rs needs this distinction. + +6. **Tag name sign encoding**: the leading `-` convention (e.g. `"-Kz89"`) is string-level. woa-rs should store tag names verbatim and derive sign at query time via `STARTS_WITH(formula, '-')` as odoo does. + +--- + +## Depth-proof footer + +Read: `/home/user/odoo/addons/account/models/account_tax.py` lines=5210 depth=full +Read: `/home/user/odoo/addons/account/models/account_account_tag.py` lines=141 depth=full +Read: `/home/user/odoo/addons/account/models/account_move.py` lines=4080-4148 depth=full (CABA section) +Read: `/home/user/odoo/addons/account/models/company.py` lines=131-134 depth=skim diff --git a/.claude/odoo/L2-K3-RECON.md b/.claude/odoo/L2-K3-RECON.md new file mode 100644 index 00000000..c3a6f466 --- /dev/null +++ b/.claude/odoo/L2-K3-RECON.md @@ -0,0 +1,927 @@ +RICHNESS-LANE-OK + +# L2-K3-RECON — Odoo Richness Export: K3 Reconciliation (Offene-Posten Matching) + +**Lane:** L2 · **K-step:** K3 (Double-entry reconciliation / Offene-Posten Abgleich) +**Date:** 2026-05-26 +**Status:** Draft — read-only analysis, NO Rust written, NO cargo run + +--- + +## 1. Scope and Odoo Files Read + +| File | Lines | Depth | +|---|---|---| +| `account/models/account_move_line.py` | 3742 | full (reconciliation region + residual computes: L240–295, L793–861, L2104–2948, L3100–3107, L3361–3391) | +| `account/models/account_partial_reconcile.py` | 706 | full | +| `account/models/account_full_reconcile.py` | 46 | full | + +Calibration grep: `/home/user/woa-rs/src/` + `/home/user/woa-rs/crates/` — results examined, no existing reconciliation engine found (see §3 below). + +--- + +## 2. Per-Rule Sections + +--- + +### Rule R-1: Residual Amount Computation (`_compute_amount_residual`) + +**Odoo source:** `account_move_line.py:L793–861` + +#### Axis-1 Rich-AST Spec + +**`@api.depends`:** `'debit', 'credit', 'amount_currency', 'account_id', 'currency_id', 'company_id', 'matched_debit_ids', 'matched_credit_ids'` + +**Eligibility filter (L800):** +``` +need_residual_lines = self.filtered( + lambda x: x.account_id.reconcile + or x.account_id.account_type in ('asset_cash', 'liability_credit_card') +) +``` +Lines on non-reconcilable accounts that are NOT cash/credit-card → `amount_residual = 0.0`, `amount_residual_currency = 0.0`, `reconciled = False` immediately. + +**SQL aggregation (L811–835) — two UNION ALL queries:** +```sql +SELECT part.debit_move_id AS line_id, 'debit' AS flag, + COALESCE(SUM(part.amount), 0.0) AS amount, + ROUND(SUM(part.debit_amount_currency), curr.decimal_places) AS amount_currency +FROM account_partial_reconcile part +JOIN res_currency curr ON curr.id = part.debit_currency_id +WHERE part.debit_move_id IN %s +GROUP BY part.debit_move_id, curr.decimal_places + +UNION ALL + +SELECT part.credit_move_id, 'credit', + COALESCE(SUM(part.amount), 0.0), + ROUND(SUM(part.credit_amount_currency), curr.decimal_places) +FROM account_partial_reconcile part +JOIN res_currency curr ON curr.id = part.credit_currency_id +WHERE part.credit_move_id IN %s +GROUP BY part.credit_move_id, curr.decimal_places +``` +Result map: `{(line_id, 'debit'|'credit'): (amount, amount_currency)}`. + +**Residual arithmetic (L845–860) — per eligible line:** +``` +comp_curr = line.company_currency_id or self.env.company.currency_id +foreign_curr = line.currency_id or comp_curr + +(debit_amount, debit_amount_currency) = amounts_map.get((line._origin.id, 'debit'), (0.0, 0.0)) +(credit_amount, credit_amount_currency) = amounts_map.get((line._origin.id, 'credit'), (0.0, 0.0)) + +line.amount_residual = comp_curr.round(line.balance - debit_amount + credit_amount) +line.amount_residual_currency = foreign_curr.round( + line.amount_currency - debit_amount_currency + credit_amount_currency +) +line.reconciled = ( + comp_curr.is_zero(line.amount_residual) + and foreign_curr.is_zero(line.amount_residual_currency) +) +``` + +**Key invariants:** +- `balance = debit - credit` (odoo field, always the raw sign-bearing company-currency amount) +- Debit partials reduce the residual of the debit line (subtracted). +- Credit partials reduce the residual of the credit line (added, since credit_amount is negative in balance terms — but the SQL COALESCE always returns positive `amount`; the formula uses `+ credit_amount` because `balance` for a credit line is negative, so adding a positive credit_amount moves residual toward zero). +- `reconciled = True` only when BOTH company-currency residual AND foreign-currency residual are zero (critical for multi-currency lines). +- `_origin` is used to exclude ORM `NewId` virtual records from the SQL lookup. +- Flush of `account.partial.reconcile` and `res.currency.decimal_places` is forced before the SQL (L807–808). + +**SQL index backing:** `_journal_id_neg_amnt_residual_idx` at L487 — `(journal_id) WHERE amount_residual < 0`. + +**AXIS CLASSIFICATION:** DETERMINISTIC (arithmetic identity, closed-form rounding). Port directly. + +**Ontology:** +`odoo:account.move.line` → `fibo:JournalEntryLine` → OGIT family `SMBInvoice` (0x81), slot `SLOT_JOURNAL_LINE` (0x06) → DOLCE Perdurant (curated override, `odoo_alignment.rs:L139–143`). **RESOLVED.** + +**K-step:** K3 +**woa-rs target:** `src/erp/reconcile.rs` — `fn compute_residual(line: &ErpOpenItemAR, partials: &[PartialReconcile]) -> ResidualAmounts` + +--- + +### Rule R-2: `reconcile()` Public Entry Point + +**Odoo source:** `account_move_line.py:L3100–3102` + +```python +def reconcile(self): + """ Reconcile the current move lines all together. """ + return self._reconcile_plan([self]) +``` + +Trivial wrapper: passes `self` (the recordset of AMLs to reconcile) as a single-element plan list to `_reconcile_plan`. + +**AXIS CLASSIFICATION:** DETERMINISTIC (delegation only). + +**woa-rs target:** `src/erp/reconcile.rs` — `pub fn reconcile(lines: &[AmlRef]) -> ReconcileResult` + +--- + +### Rule R-3: `remove_move_reconcile()` (Undo Reconciliation) + +**Odoo source:** `account_move_line.py:L3104–3106` + +```python +def remove_move_reconcile(self): + """ Undo a reconciliation """ + (self.matched_debit_ids + self.matched_credit_ids).unlink() +``` + +The `unlink()` on `account.partial.reconcile` cascades (see R-9 below) to: reverse/unlink exchange-diff and CABA moves, unlink the full reconcile, reset matching numbers, update payment states. + +**AXIS CLASSIFICATION:** DETERMINISTIC (cascade orchestration). + +**woa-rs target:** `src/erp/reconcile.rs` — `pub fn remove_reconcile(lines: &[AmlRef]) -> UnreconcileResult` + +--- + +### Rule R-4: `_reconcile_plan()` — Outer Orchestrator + +**Odoo source:** `account_move_line.py:L2755–2777` + +**Control flow:** +1. `plan_list, all_amls = self._optimize_reconciliation_plan(reconciliation_plan)` — validate, sort, split by currency, check eligibility. +2. Open `all_amls.move_id._check_balanced(move_container)` context manager. +3. Open `all_amls.move_id._sync_dynamic_lines(move_container)` context manager. +4. Call `self._reconcile_plan_with_sync(plan_list, all_amls)`. + +**Critical guard:** the two context managers ensure the parent moves stay balanced and dynamic lines (e.g. tax lines) are kept in sync during the reconciliation mutation. + +**AXIS CLASSIFICATION:** DETERMINISTIC (orchestration/guard). + +**woa-rs target:** `src/erp/reconcile.rs` + +--- + +### Rule R-5: `_optimize_reconciliation_plan()` — Sort, Split, Validate + +**Odoo source:** `account_move_line.py:L2647–2739` + +**Purpose:** Convert an arbitrary list of AML recordsets (the plan) into a validated, sorted, currency-split tree. + +**Sort key (L2678–2684):** +``` +(date_maturity or date, currency_id, amount_currency, balance) +``` +Reduced mode (context `reduced_line_sorting=True`) drops `amount_currency` and `balance`: `(date_maturity or date, currency_id)`. + +**Currency split (L2691–2700):** If a plan node contains lines in more than one currency, it is split into sub-nodes, one per currency. Lines with the same currency are grouped together. + +**Eligibility validation per node (`_check_amls_exigibility_for_reconciliation`, L2609–2644):** +- Raises `UserError` if any line is already `reconciled` (unless it has a partial matching number starting with `'P'` — those are partially reconciled and can still be included). +- Raises `UserError` if any line's parent move is `cancelled`. +- Raises `UserError` if lines are from more than one account. +- Raises `UserError` if lines span more than one root company. +- Raises `UserError` if the account does not have `reconcile=True` AND is not `asset_cash`/`liability_credit_card`. + +**AXIS CLASSIFICATION:** Sorting/splitting by date+currency: DETERMINISTIC. The *selection* of which open items to propose reconciling (candidate matching): see R-Axis2 tag below. + +**Axis-2 component note:** `_optimize_reconciliation_plan` only processes lines the caller explicitly passes — it does NOT select candidate lines. The candidate selection heuristic belongs to `account.reconcile.model` (lane L5). Everything in R-5 is DETERMINISTIC given the input set. + +**woa-rs target:** `src/erp/reconcile.rs` + +--- + +### Rule R-6: `_reconcile_plan_with_sync()` — Core Execution Engine + +**Odoo source:** `account_move_line.py:L2779–2947` + +This is the central algorithm. Full control-flow: + +#### Step 1 — Prefetch & Pre-hook (L2784–2803) +Force ORM cache population for `move_id`, `matched_debit_ids`, `matched_credit_ids` on all involved AMLs. Build `aml_values_map`: +``` +{ aml: { 'aml': aml, 'amount_residual': aml.amount_residual, + 'amount_residual_currency': aml.amount_residual_currency, + 'parent_state': aml.parent_state } } +``` +Call `_reconcile_pre_hook()` → records which invoices are `not_paid` vs `in_payment` (for post-hook state transition). + +#### Step 2 — Prepare Partials (L2806–2820) +For each plan node, call `_prepare_reconciliation_plan(plan, aml_values_map)` (see R-7). Collect: +- `partials_values_list`: list of dicts for `account.partial.reconcile.create`. +- `exchange_diff_values_list`: list of exchange-diff move-vals dicts. + +#### Step 3 — Create Partials (L2823–2831) +```python +partials = self.env['account.partial.reconcile'].create(partials_values_list) +``` +Batch-create all partials in one ORM call. If context `add_caba_vals=True`: call `partials._set_draft_caba_move_vals()`. + +#### Step 4 — Create Exchange Difference Moves (L2834–2849) +```python +exchange_moves = self._create_exchange_difference_moves(exchange_diff_values_list) +``` +Then link each exchange_move to its partial via `partial.exchange_move_id = exchange_move` (iterating over the Cartesian product, using `used_exchange_moves` and `used_partials` sets to avoid double-linking). + +#### Step 5 — Cash-Basis Tax Entries (L2852–2860) +```python +def is_cash_basis_needed(amls): + return any(amls.company_id.mapped('tax_exigibility')) \ + and amls.account_id.account_type in ('asset_receivable', 'liability_payable') +``` +If NOT `move_reverse_cancel` context AND NOT `no_cash_basis` context AND `is_cash_basis_needed`: +```python +plan['partials'].with_context(no_exchange_difference_no_recursive=False) + ._create_tax_cash_basis_moves() +plan['partials']._set_draft_caba_move_vals() +``` + +#### Step 6 — Full Reconcile Detection (L2862–2923) + +**`is_line_reconciled` predicate (L2865–2875):** +``` +if aml.reconciled: return True +if not aml.matched_debit_ids and not aml.matched_credit_ids: return False +# Exchange difference case: balance=0 but amount_currency != 0 +if has_multiple_currencies: + return aml.company_currency_id.is_zero(aml.amount_residual) +else: + return aml.currency_id.is_zero(aml.amount_residual_currency) +``` +Note: `has_multiple_currencies` is True when `len(involved_amls.currency_id) > 1`. + +**Full batch discovery (L2877–2899):** +``` +number2lines = all_amls._reconciled_by_number() +for plan in plan_list: + for aml in plan['amls']: + if 'full_batch_index' already assigned: skip + involved_amls = plan['amls']._filter_reconciled_by_number(number2lines) + has_multiple_currencies = len(involved_amls.currency_id) > 1 + is_fully_reconciled = all( + is_line_reconciled(involved_aml, has_multiple_currencies) + for involved_aml in involved_amls + ) + full_batches.append({ + 'amls': involved_amls, + 'is_fully_reconciled': is_fully_reconciled, + }) + # tag each involved aml with full_batch_index +``` + +**Full reconcile creation (L2912–2924):** +```python +full_reconcile_values_list = [] +for full_batch in full_batches: + if full_batch['is_fully_reconciled']: + full_reconcile_values_list.append({ + 'partial_reconcile_ids': [Command.link(p.id) for p in involved_partials], + 'reconciled_line_ids': [Command.link(aml.id) for aml in amls], + }) +self.env['account.full.reconcile'].create(full_reconcile_values_list) +``` +Uses `Command.link` (NOT `Command.set`) to avoid triggering `unlink` which forces a flush. + +#### Step 7 — CABA Rounding Auto-reconciliation (L2927–2945) +For any `caba_lines_to_reconcile` in a full_batch (populated by the CABA move creation path), reconcile the cash-basis transition-account rounding lines with the exchange-move counterparts. + +#### Step 8 — Post-hook (L2947) +`all_amls._reconcile_post_hook(pre_hook_data)` → triggers `_invoice_paid_hook()` on invoices that transitioned to `paid`/`in_payment` state. + +**AXIS CLASSIFICATION:** DETERMINISTIC (all arithmetic is closed-form). Port entirely to Rust. + +**K-step:** K3 +**woa-rs target:** `src/erp/reconcile.rs` + +--- + +### Rule R-7: `_prepare_reconciliation_amls()` — Debit/Credit Pairing Loop + +**Odoo source:** `account_move_line.py:L2503–2567` + +**Algorithm:** +1. Partition `values_list` into two iterators: `debit_values_list` (lines with `balance > 0` or `amount_currency > 0`) and `credit_values_list` (lines with `balance < 0` or `amount_currency < 0`). +2. Iterative pairing loop: + - Advance `debit_values` from iterator if exhausted. + - Advance `credit_values` from iterator if exhausted. + - Break if either iterator is exhausted. + - Call `_prepare_reconciliation_single_partial(debit_values, credit_values)` (see R-8). + - If `results['debit_values']` is None → debit AML is fully consumed → fetch next debit. + - If `results['credit_values']` is None → credit AML is fully consumed → fetch next credit. +3. Track `fully_reconciled_aml_ids` (set of AML ids that are now zero-residual). + +**Key note on ordering:** The **order of lines in `values_list` determines the matching order**. This is set by `_optimize_reconciliation_plan`'s sort key: `(date_maturity or date, currency_id, amount_currency, balance)`. Older maturity first; within same maturity, by currency then amount. This is FIFO / date-of-maturity matching. + +**Multi-partner within a plan (L2585–2586, in `_prepare_reconciliation_plan`):** +```python +if len(remaining_amls.mapped('partner_id')) > 1: + remaining_amls = remaining_amls.sorted(lambda aml: (aml.partner_id and aml.partner_id.id) or False) +``` +When a plan node contains multiple partners, lines are additionally sorted by partner before the debit/credit pairing. + +**AXIS CLASSIFICATION:** DETERMINISTIC (the pairing loop is a deterministic iterator, not a heuristic). The ORDER in which lines are passed determines the match — that ordering is determined upstream. Port to Rust. + +**woa-rs target:** `src/erp/reconcile.rs` + +--- + +### Rule R-8: `_prepare_reconciliation_single_partial()` — Single Pair Amount Math + +**Odoo source:** `account_move_line.py:L2194–2500` + +This is the mathematical heart. Full Axis-1 detail: + +#### Step 1 — Determine Reconciliation Currency (L2239–2248) + +Priority: +1. If `debit_currency != company_currency` AND both debit and credit have residual in `debit_currency` → `recon_currency = debit_currency`. +2. Else if `credit_currency != company_currency` AND both have residual in `credit_currency` → `recon_currency = credit_currency`. +3. Else → `recon_currency = company_currency`. + +#### Step 2 — Residual Availability (`_prepare_move_line_residual_amounts`, L2116–2191) + +For each side, builds `available_residual_per_currency: Dict[Currency, {residual, rate}]`: +- If `remaining_amount != 0`: adds company_currency entry with `rate=1`. +- If `currency != company_currency` and `remaining_amount_curr != 0`: adds foreign_currency entry with `rate = abs(amount_currency / balance)` (accounting rate). +- Special case for receivable/payable lines: if the line is in company currency but the counterpart is in a foreign currency, converts using `get_odoo_rate` (which checks context `forced_rate_from_register_payment`, then falls back to the payment line's accounting rate, then to the odoo FX table rate on the invoice date or AML date). + +#### Step 3 — Exchange-Line Mode Detection (L2273–2279) + +`exchange_line_mode = True` when: +- `recon_currency == company_currency` +- `debit_currency == credit_currency` +- At least one side has no residual in the foreign currency. + +This handles reconciling exchange-difference lines with their counterparts. + +#### Step 4 — Full/Partial Match Detection (L2282–2285) + +``` +compare_amounts = recon_currency.compare_amounts(recon_debit_amount, recon_credit_amount) +min_recon_amount = min(recon_debit_amount, recon_credit_amount) +debit_fully_matched = (compare_amounts <= 0) # debit amount <= credit amount +credit_fully_matched = (compare_amounts >= 0) # credit amount <= debit amount +``` + +#### Step 5 — Compute Partial Amounts (L2301–2396) + +**Case A: `recon_currency == company_currency`** +``` +partial_amount = min_recon_amount # in company currency +if debit_rate: + partial_debit_amount_currency = debit_currency.round(debit_rate * min_recon_amount) + # clamp to remaining + partial_debit_amount_currency = min(partial_debit_amount_currency, remaining_debit_amount_curr) +else: + partial_debit_amount_currency = 0.0 +# same for credit side (using -remaining_credit_amount_curr as upper bound) +``` + +**Case B: `recon_currency != company_currency` (foreign-currency reconciliation)** + +Range-based anti-exchange-diff logic (L2335–2384): +``` +partial_debit_amount_range = get_amount_range_after_rate( + currency_from=debit_currency, currency_to=company_currency, + amount=min_recon_amount, rate=(1/debit_rate) if debit_rate else 0.0 +) +# range = [round((amount - half_rounding) * rate), +# round(amount * rate), +# round((amount + half_rounding) * rate)] +partial_debit_amount = min(range[1], remaining_debit_amount) +# same for credit + +# Anti-exchange-diff optimization: if both ranges overlap, set +# partial_amount = min(remaining_debit_amount, -remaining_credit_amount) +# to avoid creating a spurious exchange-diff move. +if (debit_amount_in_credit_range and credit_amount_in_debit_range): + partial_amount = min(remaining_debit_amount, -remaining_credit_amount) + partial_debit_amount = partial_amount + partial_credit_amount = partial_amount +``` + +Foreign-currency amount for each side: +``` +if debit_currency == company_currency: + partial_debit_amount_currency = partial_amount +else: + partial_debit_amount_currency = min_recon_amount # the foreign amount +# same for credit +``` + +#### Step 6 — Exchange Difference Computation (L2400–2467) + +Triggered if NOT `no_exchange_difference` AND NOT `no_exchange_difference_no_recursive` context. + +**`recon_currency == company_currency` sub-case:** +- If debit fully matched AND residual currency != 0 after partial: exchange_diff on `debit_exchange_amount = remaining_debit_amount_curr - partial_debit_amount_currency`. +- If credit fully matched: exchange_diff on `credit_exchange_amount = remaining_credit_amount_curr + partial_credit_amount_currency`. +- Exchange diff amounts stored as `{'amount_residual_currency': X}`. + +**`recon_currency != company_currency` sub-case:** +- If debit fully matched: `debit_exchange_amount = remaining_debit_amount - partial_amount` → stored as `{'amount_residual': X}`. +- If debit NOT fully matched: `debit_exchange_amount = partial_debit_amount - partial_amount` → only if > 0. +- Symmetric for credit. +- Exchange diff amounts stored as `{'amount_residual': X}`. + +Exchange diff values passed to `exchange_lines_to_fix._prepare_exchange_difference_move_vals(amounts_list, exchange_date=max(debit_date, credit_date))`. + +#### Step 7 — Update Running Residuals (L2472–2499) + +``` +remaining_debit_amount -= partial_amount +remaining_credit_amount += partial_amount +remaining_debit_amount_curr -= partial_debit_amount_currency +remaining_credit_amount_curr += partial_credit_amount_currency + +# Propagate back to debit_values/credit_values dicts +debit_values['amount_residual'] = remaining_debit_amount +debit_values['amount_residual_currency'] = remaining_debit_amount_curr +credit_values['amount_residual'] = remaining_credit_amount +credit_values['amount_residual_currency'] = remaining_credit_amount_curr + +# Mark as None (fully consumed) if both residuals are zero +if debit_currency.is_zero(debit_values['amount_residual_currency']) + and company_currency.is_zero(debit_values['amount_residual']): + res['debit_values'] = None +# same for credit +``` + +#### Result dict structure: +``` +{ + 'debit_values': debit_values | None, + 'credit_values': credit_values | None, + 'partial_values': { + 'amount': partial_amount, # always positive, company currency + 'debit_amount_currency': ..., # always positive, debit's foreign currency + 'credit_amount_currency': ..., # always positive, credit's foreign currency + 'debit_move_id': debit_aml.id, + 'credit_move_id': credit_aml.id, + }, + 'exchange_values': { ... } | absent, # only if exchange diff needed +} +``` + +**AXIS CLASSIFICATION:** DETERMINISTIC (pure arithmetic with rounding; all cases are closed-form). The `get_amount_range_after_rate` range-overlap check is deterministic arithmetic, not a heuristic. + +**Rounding protocol:** `currency.round(...)` throughout — uses `res.currency.decimal_places` stored field, NOT Python's `Decimal`. All intermediate amounts are plain Python `float`; rounding is applied at each persistence boundary. + +**K-step:** K3 +**woa-rs target:** `src/erp/reconcile.rs` + +--- + +### Rule R-9: `AccountPartialReconcile` — Structure and Key Fields + +**Odoo source:** `account_partial_reconcile.py:L9–67` + +**Fields:** +| Field | Type | Semantics | +|---|---|---| +| `debit_move_id` | Many2one AML | The debit-side journal item (required, indexed) | +| `credit_move_id` | Many2one AML | The credit-side journal item (required, indexed) | +| `full_reconcile_id` | Many2one AccountFullReconcile | Set when reconciliation is complete; `btree_not_null` index | +| `exchange_move_id` | Many2one account.move | The exchange-difference move created for this partial | +| `amount` | Monetary (company_currency) | Always positive; the reconciled amount in company currency | +| `debit_amount_currency` | Monetary (debit_currency_id) | Always positive; reconciled amount in debit's foreign currency | +| `credit_amount_currency` | Monetary (credit_currency_id) | Always positive; reconciled amount in credit's foreign currency | +| `max_date` | Date | `max(debit.date, credit.date)` — used for aging reports | +| `company_id` | Many2one | Precomputed: invoice side if any, else credit side | +| `draft_caba_move_vals` | Json | Snapshot of CABA values at time of partial creation (for re-reconciliation after invoice posting) | +| `debit_currency_id` | Many2one, related, stored | = `debit_move_id.currency_id` | +| `credit_currency_id` | Many2one, related, stored | = `credit_move_id.currency_id` | + +**`company_id` compute (L91–98):** +```python +if partial.debit_move_id.move_id.is_invoice(True): + partial.company_id = partial.debit_move_id.company_id +else: + partial.company_id = partial.credit_move_id.company_id +``` +The invoice side wins for exchange-diff and CABA entry creation. + +**Constraint (L73–77):** `_check_required_computed_currencies` — both `debit_currency_id` and `credit_currency_id` must be set; raises `ValidationError` otherwise. + +**`create` hook (L149–153):** +```python +def create(self, vals_list): + partials = super().create(vals_list) + partials._get_to_update_payments(from_state='in_process').state = 'paid' + self._update_matching_number(partials.debit_move_id + partials.credit_move_id) + return partials +``` +On creation: any fully-matched payments transition `in_process → paid`; matching numbers are updated. + +**`unlink` cascade (L104–146):** +1. Collect payments to reset (`paid → in_process`). +2. Collect CABA moves to reverse (`account.move` where `tax_cash_basis_rec_id` in partial ids). +3. Collect exchange-diff moves to reverse (`self.exchange_move_id`). +4. Collect full reconcile to unlink (`self.full_reconcile_id`). +5. `super().unlink()` — delete the partials. +6. `full_to_unlink.unlink()` — cascade delete the full reconcile. +7. Reverse (or unlink if draft) CABA and exchange-diff moves: posted moves get `_reverse_moves`; draft moves get `unlink()`. +8. `_update_matching_number(all_reconciled)` — refresh matching numbers. +9. `to_update_payments.state = 'in_process'` — reset payment state. + +**AXIS CLASSIFICATION:** DETERMINISTIC (field structure and cascade). Port directly. + +**Ontology:** +`odoo:account.partial.reconcile` → **UNRESOLVED** in `odoo_alignment.rs` (not in `ODOO_ALIGNMENTS` slice). See §4 FLAG below. + +**K-step:** K3 +**woa-rs target:** `src/erp/reconcile.rs` (struct `PartialReconcile`) + +--- + +### Rule R-10: `AccountFullReconcile` — Structure and Creation + +**Odoo source:** `account_full_reconcile.py:L1–45` + +**Fields:** +| Field | Type | Semantics | +|---|---|---| +| `partial_reconcile_ids` | One2many to AccountPartialReconcile | All partials that together achieve full reconciliation | +| `reconciled_line_ids` | One2many to AML | All AMLs participating in the full reconcile | + +**`create` override (L13–45):** +Bypasses the ORM's M2M write (which would trigger unlink+flush) by using raw SQL `execute_values`: +```sql +UPDATE account_move_line line + SET full_reconcile_id = source.full_id + FROM (VALUES %s) AS source(full_id, line_ids) + WHERE line.id = ANY(source.line_ids) +``` +And similarly for `account_partial_reconcile`: +```sql +UPDATE account_partial_reconcile partial + SET full_reconcile_id = source.full_id + FROM (VALUES %s) AS source(full_id, partial_ids) + WHERE partial.id = ANY(source.partial_ids) +``` +Then invalidates recordset caches for the affected fields. Uses `tracking_disable=True` context to suppress chatter. + +After creation: calls `self.env['account.partial.reconcile']._update_matching_number(fulls.reconciled_line_ids)` — converts matching numbers from `'P'` format to `''` (string of the integer ID, without prefix). + +**AXIS CLASSIFICATION:** DETERMINISTIC. + +**Ontology:** +`odoo:account.full.reconcile` → **UNRESOLVED** in `odoo_alignment.rs`. See §4 FLAG below. + +**K-step:** K3 +**woa-rs target:** `src/erp/reconcile.rs` (struct `FullReconcile`) + +--- + +### Rule R-11: Matching Number Protocol + +**Odoo source:** `account_partial_reconcile.py:L171–215` + +The `matching_number` field on AML (L284–290 in account_move_line.py) is a Char with btree index. Its value encoding: +- `None`/`False` — unreconciled +- `'I'` — temporary import marker (from `_reconcile_marked`) +- `'P'` — partially reconciled (partial_id = the smallest partial id in the graph) +- `''` (decimal integer as string) — fully reconciled + +**Graph-merge algorithm (`_update_matching_number`, L171–215):** +``` +number2lines: Dict[partial_id, List[aml_id]] +line2number: Dict[aml_id, partial_id] + +for partial in all_partials.sorted('id'): # sorted ascending by partial id + debit_min_id = line2number.get(partial.debit_move_id.id) + credit_min_id = line2number.get(partial.credit_move_id.id) + + if both assigned: + if they differ: merge into the smaller number + for each line in number2lines[max]: reassign to min + elif only debit assigned: + add credit to debit's graph (number2lines[debit_min_id].append(credit.id)) + elif only credit assigned: + add debit to credit's graph + else: + create new graph node: number2lines[partial.id] = [debit.id, credit.id] +``` +Result: a union-find structure keyed by the smallest partial_id in each connected component. + +**SQL update (L204–212):** +```sql +UPDATE account_move_line l + SET matching_number = CASE + WHEN l.full_reconcile_id IS NOT NULL THEN l.full_reconcile_id::text + ELSE 'P' || source.number + END + FROM (VALUES %s) AS source(number, ids) + WHERE l.id = ANY(source.ids) +``` +Lines not in any graph component get `matching_number = False`. + +**AXIS CLASSIFICATION:** DETERMINISTIC (union-find graph coloring). Port to Rust. + +**K-step:** K3 +**woa-rs target:** `src/erp/reconcile.rs` — `fn update_matching_numbers(partials: &[PartialReconcile])` + +--- + +### Rule R-12: Exchange Difference Move Creation + +**Odoo source:** `account_move_line.py:L2957–3098` + +**`_prepare_exchange_difference_move_vals` (L2957–3043):** + +When a line is fully matched in foreign currency but has a residual in company currency (due to exchange rate changes), an exchange-diff journal entry is created. + +**Account selection (L2952–2955):** +```python +def _get_exchange_account(self, company, amount): + if amount > 0.0: + return company.expense_currency_exchange_account_id # loss + return company.income_currency_exchange_account_id # gain +``` + +**Line vals for each amount dict:** +If `'amount_residual'` key: +``` +amount_residual = amounts['amount_residual'] +amount_residual_currency = 0.0 if line.currency_id != company_currency else amount_residual +``` +If `'amount_residual_currency'` key: +``` +amount_residual = 0.0 +amount_residual_currency = amounts['amount_residual_currency'] +``` + +Two lines per exchange diff: +1. Line on AML's original account (mirrors the residual, with `reconciled_lines_ids=[line]` linkage): + - `debit = -amount_residual if amount_residual < 0 else 0` + - `credit = amount_residual if amount_residual > 0 else 0` + - `amount_currency = -amount_residual_currency` +2. Counterpart on exchange gain/loss account: + - `debit = amount_residual if amount_residual > 0 else 0` + - `credit = -amount_residual if amount_residual < 0 else 0` + - `amount_currency = amount_residual_currency` + +**Date:** `max(aml.date, journal.accounting_date)` where `accounting_date` is the journal's next valid accounting date given the exchange_date (respects lock dates). + +**`_create_exchange_difference_moves` (L3046–3098):** +- Early return if empty list (prevents infinite recursion). +- Validates exchange journal + gain/loss accounts are configured. +- Creates moves with `no_exchange_difference=True` context (prevents recursion). +- Posts moves where both parent AMLs are in `posted` state. + +**AXIS CLASSIFICATION:** DETERMINISTIC (accounting identity — exchange diff is the arithmetic residual that must balance the books). + +**Config requirements (portal-level, enforced at runtime):** +- `company.currency_exchange_journal_id` must be set. +- `company.expense_currency_exchange_account_id` must be set. +- `company.income_currency_exchange_account_id` must be set. + +**K-step:** K3 +**woa-rs target:** `src/erp/reconcile.rs` — `fn create_exchange_difference_move(lines: &[ExchangeDiffSpec]) -> Move` + +--- + +### Rule R-13: Tax Cash-Basis Collection and Move Creation + +**Odoo source:** `account_partial_reconcile.py:L221–686` + +#### `_collect_tax_cash_basis_values` (L221–334) + +For each partial, for each of the two sides (debit/credit): +1. Call `move._collect_tax_cash_basis_values()` on the parent move (defined in account_move.py — not read in this lane). +2. Skip if no CABA values. +3. Check `company.tax_cash_basis_journal_id` is set (raises `UserError` if not). +4. Compute `partial_amount` and `partial_amount_currency` based on which side matches. +5. **Rate computation:** + - If both sides are invoices: use source line's own rate (`rate_amount = source_line.balance`, `rate_amount_currency = source_line.amount_currency`, `payment_date = move.date`). + - Otherwise: use counterpart's rate (`payment_date = counterpart_line.date`). + - If source and counterpart have different foreign currencies: use `res.currency._get_conversion_rate(company_currency, source_currency, company, payment_date)`. + - Else if `rate_amount` is non-zero: `payment_rate = rate_amount_currency / rate_amount`. + - Else: `payment_rate = 0.0`. +6. Compute `percentage`: + - If move's currency == company currency: `percentage = partial_amount / move_values['total_balance']`. + - Else: `percentage = partial_amount_currency / move_values['total_amount_currency']`. +7. Append `{'partial', 'percentage', 'payment_rate', 'both_move_posted', 'counterpart_move'}` to `move_values['partials']`. + +**CABA Move Creation (`_create_tax_cash_basis_moves`, L506–686):** + +For each move in `tax_cash_basis_values_per_move.values()`: +- For each partial in `move_values['partials']`: + - Determine move date: `max(partial.max_date, company_lock_date)` or `today` if past lock date. + - For each `to_process_lines` item (type `'tax'` or `'base'`): + - `amount_currency = line.currency_id.round(line.amount_currency * partial_values['percentage'])` + - **Rounding fix on last partial (L553–565):** If `caba_treatment == 'tax'` AND (move is fully paid OR remaining residual < computed amount) AND this is the last partial → use `amount_residual_per_tax_line[line.id]` instead (ensures tax lines sum exactly to original). + - `balance = amount_currency / payment_rate` (or 0 if rate is zero). + - Group lines by `grouping_key` (currency, partner, account, tax_ids, repartition_line_id, analytic_distribution). + - Create two lines per group: the CABA line + its counterpart. + - If `both_move_posted`: add to `moves_to_create_and_post`, else `moves_to_create_in_draft`. + +**Trigger condition (from R-6 Step 5):** +`is_cash_basis_needed` = `any(company.tax_exigibility)` AND `account_type in ('asset_receivable', 'liability_payable')`. + +**AXIS CLASSIFICATION:** DETERMINISTIC (percentage-based proportional allocation is arithmetic). The `_collect_tax_cash_basis_values` aggregation is deterministic given the partial amounts. The `_create_tax_cash_basis_moves` is arithmetic. Port to Rust. + +**K-step:** K3 + K7 (touches tax reporting) +**woa-rs target:** `src/erp/reconcile.rs` + `src/erp/tax_cash_basis.rs` + +--- + +### Rule R-14: `_reconcile_pre_hook` / `_reconcile_post_hook` + +**Odoo source:** `account_move_line.py:L2741–2752` + +**Pre-hook:** +```python +def _reconcile_pre_hook(self): + invoices = self.move_id.filtered(lambda move: move.is_invoice(include_receipts=True)) + return { + 'not_paid_invoices': invoices.filtered(lambda inv: inv.payment_state not in ('paid', 'in_payment')), + 'in_payment_invoices': invoices.filtered(lambda inv: inv.payment_state == 'in_payment'), + } +``` + +**Post-hook:** +```python +def _reconcile_post_hook(self, data): + ( + data['not_paid_invoices'].filtered(lambda inv: inv.payment_state in ('paid', 'in_payment')) + + data['in_payment_invoices'].filtered(lambda inv: inv.payment_state == 'paid') + )._invoice_paid_hook() +``` +Calls `_invoice_paid_hook()` on invoices whose payment state changed. This typically sends confirmation emails, updates subscription states, etc. + +**AXIS CLASSIFICATION:** DETERMINISTIC (state transition detection). The downstream `_invoice_paid_hook` behavior may contain business notifications (Axis-2 relevant for mail/email delegation to lance-graph via `MailIntent` reasoning kind) — but the hook trigger condition itself is deterministic. + +**K-step:** K3 +**woa-rs target:** `src/erp/reconcile.rs` + +--- + +### Rule R-15 (Axis-2): Reconcile Candidate Matching / Suggestion + +**Odoo reference:** `account.reconcile.model` (lane L5; NOT read in this lane per scope). Also: the UI "auto-reconcile" feature in the bank statement reconciliation wizard. + +**Classification: HEURISTIC — Axis 2.** + +The *selection* of which open items to propose as reconciliation candidates — given a bank transaction or incoming payment — is not computable from a closed-form rule. Odoo uses `account.reconcile.model` with configurable matching rules (line label regex, partner, amount window, date tolerance) and a scoring system. This is a multi-factor ranking/scoring task. + +**NARS Delegation Contract Tuple:** +``` +( + ReasoningKind::Other("ReconcileMatch"), // no exact variant; propose this name + InferenceType::Induction, // "lines like X tend to match Y" + SemiringChoice::NarsTruth, // belief + confidence, not Boolean + ThinkingStyle cluster: Analytical, // inherited from SMBAccounting (0x62) family +) +``` + +**Inheritance chain:** +`odoo:account.reconcile.model` → `fibo:MatchingRule` (proposed pivot, not in ODOO_ALIGNMENTS) → OGIT family `SMBAccounting` (0x62, chart-of-accounts / ledger basin) → ThinkingStyle cluster: **Analytical** (Critical sub-cluster, inherited from BillingCore/SMBAccounting posting-logic). + +**Contract call shape:** +```rust +reasoner.reason(ReasoningContext { + namespace: "erp.k3.reconcile_match", + kind: ReasoningKind::Other("ReconcileMatch"), + evidence: vec![ + // open_item amounts, dates, partner, account + // incoming payment amount, date, reference text + ], + budget: Budget::default(), +}) +``` + +**What NOT to hand-code:** any scoring/ranking of open items by likelihood of matching, any fuzzy reference-number matching, any ML-based counterpart suggestion. These are Axis-2. + +**What IS deterministic (Axis-1, still R-8):** once the human or heuristic has decided which AML pairs to reconcile, the arithmetic in R-8 is fully deterministic. + +**K-step:** K3 +**woa-rs target:** `src/contracts/reconcile_match.rs` (contract surface only; no brain logic in woa-rs) + +--- + +## 3. woa-rs Calibration — Current Gap + +Grep of `reconcile|offene.?post|residual|skonto|bezahlt` in `/home/user/woa-rs/src/` and `/home/user/woa-rs/crates/`: + +**Findings:** +- `src/models/erp/k3_debitors.rs` — `ErpOpenItemAR` model exists with fields: `original_betrag`, `offen_betrag` (both `Decimal(15,2)`), `status` (`'offen'|'teilbezahlt'|'bezahlt'|'mahn1'|...`), `mahnstufe` (`i16`), `skonto_prozent` (`Decimal(5,2)`), `skonto_tage` (`i16`). +- `src/models/erp/k4_creditors.rs` — `ErpOpenItemAP` analogous for creditor side. +- `src/models/erp/k5_bank.rs` — `MatchedOpenItemArId`, `MatchedOpenItemApId` FK fields on bank transaction entity. +- `src/url.rs` — `/mahnwesen/{wid}/bezahlt` route exists. +- No `reconcile.rs`, no `PartialReconcile` struct, no `FullReconcile` struct, no `amount_residual` field anywhere. + +**Gap assessment:** woa-rs has the open-item data model skeleton (`offen_betrag`, status machine) but has **NO reconciliation engine**. The following are entirely absent: +- Residual computation from partial records. +- Partial reconcile record creation. +- Full reconcile record + matching-number management. +- Exchange-difference move creation. +- Tax cash-basis move triggering. +- The `bezahlt` flag in `WorkOrder` is a simple boolean; there is no link to a formal reconciliation record. + +**woa-rs target module:** `src/erp/reconcile.rs` (new file). + +--- + +## 4. Enterprise/Unresolved Flags + +### FLAG-1: `odoo:account.partial.reconcile` — UNRESOLVED in `odoo_alignment.rs` + +`resolve_odoo("odoo:account.partial.reconcile")` → `None`. + +**Proposed alignment:** +``` +odoo:account.partial.reconcile + → owl pivot: fibo:SettlementObligation (or fibo:Obligation) + → OGIT family: SMBAccounting (0x62) + → DOLCE: Perdurant (it is an event — the act of partial settlement) +``` +**Justification:** A partial reconcile is a financial settlement event linking two AMLs. FIBO's `Obligation`/`Settlement` cluster is the nearest OWL class. It is NOT a new family — it inherits `SMBAccounting` (0x62) because it is a sub-event of the ledger posting process. + +**Proposed `ODOO_ALIGNMENTS` row** (must be inserted in lexicographic order — before `account.account.skr03`): +```rust +OdooAlignment { + odoo_class: "odoo:account.partial.reconcile", + owl: OwlIdentity::new(FAM_SMB_ACCOUNTING, SLOT_PARTIAL_RECONCILE), // new slot e.g. 0x07 + owl_pivot_label: "fibo:SettlementObligation", + dolce: DolceMarker::Perdurant, +}, +``` + +### FLAG-2: `odoo:account.full.reconcile` — UNRESOLVED in `odoo_alignment.rs` + +`resolve_odoo("odoo:account.full.reconcile")` → `None`. + +**Proposed alignment:** +``` +odoo:account.full.reconcile + → owl pivot: fibo:FullSettlement + → OGIT family: SMBAccounting (0x62) + → DOLCE: Perdurant (it is the completed settlement event) +``` +**Proposed `ODOO_ALIGNMENTS` row:** +```rust +OdooAlignment { + odoo_class: "odoo:account.full.reconcile", + owl: OwlIdentity::new(FAM_SMB_ACCOUNTING, SLOT_FULL_RECONCILE), // new slot e.g. 0x08 + owl_pivot_label: "fibo:FullSettlement", + dolce: DolceMarker::Perdurant, +}, +``` + +Both proposed alignments require adding new within-family slots to `SMBAccounting` (0x62). They do NOT require a new family (Option B compliant). The alignment rows must be added in lexicographic order in `ODOO_ALIGNMENTS`. + +### FLAG-3: Enterprise boundary — NOT applicable for K3 + +Reconciliation in community odoo is fully implemented. No Enterprise gap for this lane. + +### FLAG-4: `account.reconcile.model` — lane L5, not read in this lane + +The candidate-matching model is Axis-2 and is intentionally deferred to lane L5. Its absence does not block the Axis-1 arithmetic port. + +--- + +## 5. Complete Ontology Mapping Table + +| Odoo class | OWL pivot | OGIT family | DOLCE | Status in odoo_alignment.rs | +|---|---|---|---|---| +| `odoo:account.move.line` | `fibo:JournalEntryLine` | SMBInvoice (0x81), slot 0x06 | Perdurant | **RESOLVED** (L139–143) | +| `odoo:account.move` | `fibo:Transaction` | SMBInvoice (0x81), slot 0x03 | Perdurant | **RESOLVED** (L133–137) | +| `odoo:account.account` | `fibo:Account` | SMBAccounting (0x62), slot 0x04 | Endurant | **RESOLVED** (L127–131) | +| `odoo:account.partial.reconcile` | `fibo:SettlementObligation` (proposed) | SMBAccounting (0x62), slot 0x07 (proposed) | Perdurant | **UNRESOLVED — FLAG-1** | +| `odoo:account.full.reconcile` | `fibo:FullSettlement` (proposed) | SMBAccounting (0x62), slot 0x08 (proposed) | Perdurant | **UNRESOLVED — FLAG-2** | + +--- + +## 6. Porter's Checklist — Non-Obvious Gotchas + +1. **`reconciled = True` requires BOTH residuals to be zero.** In multi-currency scenarios, a line with `amount_residual = 0` but `amount_residual_currency != 0` is NOT reconciled. This is the primary source of subtle bugs in simple ports. + +2. **Residual formula direction.** `amount_residual = balance - debit_matched + credit_matched`. Credit partials ADD to the residual (because the line's balance is negative, so adding the positive credit-matched amount moves toward zero). Do not accidentally double-negate. + +3. **The debit/credit pairing is ORDER-DEPENDENT.** The sort key is `(date_maturity or date, currency_id, amount_currency, balance)`. FIFO by maturity date. The Rust implementation MUST sort identically before the pairing loop. + +4. **`min_recon_amount` is in `recon_currency`, not company currency.** When `recon_currency != company_currency`, the amounts from both sides are in foreign currency before taking the min. + +5. **Anti-exchange-diff range overlap check (L2376–2384).** This is a rounding-tolerance optimization: if the two converted amounts fall within each other's rounding bands, treat them as equal and use `min(remaining_debit, -remaining_credit)` to avoid generating a spurious exchange-diff move. This is easy to miss and causes spurious exchange-diff entries if omitted. + +6. **Exchange-diff context flags:** `no_exchange_difference` suppresses ALL exchange-diff creation. `no_exchange_difference_no_recursive` (used internally when creating CABA moves) prevents recursive exchange-diff creation from the CABA reconciliation. Both must be threaded through the Rust call chain. + +7. **The `_update_matching_number` union-find iterates partials sorted by `id` ascending.** The smallest partial id in a connected component becomes the component's label. Any Rust port must replicate this stable sort to produce identical matching numbers. + +8. **`AccountFullReconcile.create` uses raw SQL (`execute_values`).** The ORM `Command.set` on M2M would trigger an implicit unlink that forces a flush and breaks batch creation. In Rust/sea-orm, use a bulk `UPDATE` rather than setting the FK via the relation. + +9. **CABA rounding correction on last partial (L553–565).** For the last partial of a fully-paid invoice, tax lines use the accumulated residual (`amount_residual_per_tax_line[line.id]`) rather than `percentage * amount_currency`. This ensures tax lines sum exactly to the original amounts and avoids penny-rounding discrepancies in the tax report. + +10. **`draft_caba_move_vals` (Json field).** Set on the partial when `add_caba_vals` context is True (during CABA reconciliation). This JSON snapshot is used to detect whether re-reconciliation is needed when the invoice transitions from draft to posted. The porter must preserve this field and its write path. + +11. **Payment state side-effect on partial create/unlink.** `_get_to_update_payments` checks if the matched payment is `in_process` (→ `paid` on create) or `paid` (→ `in_process` on unlink). This is a state machine on `account.payment` that must be co-ported. + +12. **`exchange_line_mode`** (L2273–2279): when both sides share the same foreign currency but at least one has no amount in that currency, the mode suppresses rate computation for the opposite side. Without this, the exchange-diff lines themselves would generate more exchange-diffs recursively. + +13. **`company_id` on partial prefers the invoice side** (L94–98). When creating exchange-diff and CABA entries, the company is taken from the invoice AML, not the payment AML. This matters for multi-company setups. + +14. **`matching_number` format.** A fully-reconciled line has `matching_number = str(full_reconcile_id.id)` (pure integer string, no prefix). A partially reconciled line has `matching_number = 'P' + str(min_partial_id)`. An import-pending line has `matching_number = 'I'`. These are semantically distinct and must not be confused in queries. + +--- + +## 7. Depth Proof + +Read: `/home/user/odoo/addons/account/models/account_move_line.py` lines=3742 depth=full (reconciliation region: L240–295, L793–861, L2104–2948, L3100–3107, L3361–3391) +Read: `/home/user/odoo/addons/account/models/account_partial_reconcile.py` lines=706 depth=full +Read: `/home/user/odoo/addons/account/models/account_full_reconcile.py` lines=46 depth=full +Read: `/home/user/lance-graph/crates/lance-graph-callcenter/src/odoo_alignment.rs` lines=523 depth=full +Read: `/home/user/woa-rs/.claude/board/odoo-richness/BRIEFING.md` lines=124 depth=full diff --git a/.claude/odoo/L3-K7-TAX.md b/.claude/odoo/L3-K7-TAX.md new file mode 100644 index 00000000..59c9103d --- /dev/null +++ b/.claude/odoo/L3-K7-TAX.md @@ -0,0 +1,1120 @@ +RICHNESS-LANE-OK + +# L3-K7-TAX — USt / VAT Computation + Fiscal Position Mapping +**Lane:** K7 TAX +**Date:** 2026-05-26 +**K-step:** K7 (USt/ELSTER + tax); feeds K8 (USt-VA report line mapping) +**Target modules (woa-rs):** `src/erp/tax.rs` (to be created), `crates/skr_data/` (data already vendored) + +--- + +## 1. Scope + Odoo Files Read + +| File | Lines | Depth | +|---|---|---| +| `/home/user/odoo/addons/account/models/account_tax.py` | 5210 | full | +| `/home/user/odoo/addons/account/models/partner.py` | 1169 | full | +| `/home/user/odoo/addons/l10n_de/data/template/account.tax-de_skr03.csv` | 244 | full | +| `/home/user/odoo/addons/l10n_de/data/template/account.tax-de_skr04.csv` | ~244 | full (first 60 lines shown; structure identical to skr03) | +| `/home/user/odoo/addons/l10n_de/data/template/account.tax.group-de_skr03.csv` | 8 | full | +| `/home/user/odoo/addons/l10n_de/data/template/account.tax.group-de_skr04.csv` | 8 | full | +| `/home/user/odoo/addons/l10n_de/data/template/account.fiscal.position-de_skr03.csv` | 32 | full | +| `/home/user/odoo/addons/l10n_de/data/template/account.fiscal.position-de_skr04.csv` | 32 | full | + +--- + +## 2. Rule Sections + +--- + +### R1 — `_flatten_taxes_and_sort_them` / Group flattening + +**File:** `account_tax.py:L892–L918` + +**Axis-1 Rich-AST Spec:** + +``` +FUNCTION _flatten_taxes_and_sort_them(self: Recordset[AccountTax]) + -> (sorted_taxes: Recordset, group_per_tax: Dict[int, AccountTax]) + +sort_key = (tax.sequence, tax.id or None) # None sorts before integers (Python None < int) + +group_per_tax = {} +sorted_taxes = empty recordset +for tax in self.sorted(key=sort_key): # outer pass — group-level sequence + if tax.amount_type == 'group': + children = tax.children_tax_ids.sorted(key=sort_key) + sorted_taxes |= children + for child in children: + group_per_tax[child.id] = tax # maps child → parent group + else: + sorted_taxes |= tax # non-group added directly +RETURN (sorted_taxes, group_per_tax) +``` + +Key properties: +- Group taxes (`amount_type == 'group'`) are NEVER in `sorted_taxes` — only their children are. +- Children inherit the group's position via outer sort ordering. +- Example: `[G(seq=2), B([A,D,F]), E(seq=5), C(seq=4)]` → `[A,D,F,C,E,G]` (alphabetic = sequence order). +- `_sql_constraints`: `@api.constrains('children_tax_ids', 'type_tax_use')` prevents nested groups (ValidationError: "Nested group of taxes are not allowed"). +- `flatten_taxes_hierarchy()` at L4855–L4856 is just a thin alias: `return self._flatten_taxes_and_sort_them()[0]`. + +**Axis classification:** DETERMINISTIC +**Ontology:** `odoo:account.tax` → see R7 below +**K-step:** K7 +**woa-rs target:** `src/erp/tax.rs::flatten_and_sort` + +--- + +### R2 — `_batch_for_taxes_computation` — batching into co-computed groups + +**File:** `account_tax.py:L920–L971` + +**Axis-1 Rich-AST Spec:** + +Batches group taxes that must be solved simultaneously (e.g. a batch of price-included percent taxes share a denominator). + +``` +FUNCTION _batch_for_taxes_computation( + self, + special_mode: False | 'total_excluded' | 'total_included' = False, + filter_tax_function: Optional[Callable] = None, +) -> { + 'batch_per_tax': Dict[int, Recordset], + 'group_per_tax': Dict[int, AccountTax], + 'sorted_taxes': Recordset, +} + +(sorted_taxes, group_per_tax) = self._flatten_taxes_and_sort_them() +if filter_tax_function: + sorted_taxes = sorted_taxes.filtered(filter_tax_function) + +batch = empty +is_base_affected = False +# Traverse in REVERSE order to group consecutive same-type taxes +for tax in reversed(sorted_taxes): + if batch is not empty: + same_batch = ( + tax.amount_type == batch[0].amount_type + AND (special_mode OR tax.price_include == batch[0].price_include) + AND tax.include_base_amount == batch[0].include_base_amount + AND ( + (tax.include_base_amount AND NOT is_base_affected) + OR NOT tax.include_base_amount + ) + ) + if NOT same_batch: + flush batch → batch_per_tax + batch = empty + is_base_affected = tax.is_base_affected + batch |= tax + +flush final batch → batch_per_tax +``` + +The batch determines the denominator for price-included percent taxes: all taxes in a batch share `total_percentage = sum(tax.amount for tax in batch) / 100.0`. + +**Axis classification:** DETERMINISTIC +**K-step:** K7 + +--- + +### R3 — `_get_tax_details` — the core per-line computation engine + +**File:** `account_tax.py:L1134–L1332` + +**Axis-1 Rich-AST Spec (CRITICAL — reproduce exactly):** + +``` +FUNCTION _get_tax_details( + self, + price_unit: float, + quantity: float, + precision_rounding: float = 0.01, # currency.rounding + rounding_method: 'round_per_line' | 'round_globally' = 'round_per_line', + product: Optional[product.product] = None, + product_uom: Optional[uom.uom] = None, + special_mode: False | 'total_excluded' | 'total_included' = False, + manual_tax_amounts: Optional[Dict] = None, + filter_tax_function: Optional[Callable] = None, +) -> { + 'total_excluded': float, + 'total_included': float, + 'taxes_data': List[Dict], # one per leaf tax in sorted_taxes +} +``` + +**Step 1 — Setup:** +``` +batching = self._batch_for_taxes_computation(special_mode, filter_tax_function) +sorted_taxes = batching['sorted_taxes'] + +# Initialize per-tax data dict +for tax in sorted_taxes: + if tax.has_negative_factor: # reverse-charge marker + price_include = False # always treat as price-excluded + elif special_mode == 'total_included': + price_include = True + elif special_mode == 'total_excluded': + price_include = False + else: + price_include = tax.price_include + + taxes_data[tax.id] = { + 'tax': tax, + 'price_include': price_include, + 'group': batching['group_per_tax'].get(tax.id), + 'batch': batching['batch_per_tax'][tax.id], + 'extra_base_for_tax': 0.0, + 'extra_base_for_base': 0.0, + } + if tax.has_negative_factor: + reverse_charge_taxes_data[tax.id] = {..., 'is_reverse_charge': True} +``` + +**Step 2 — raw_base:** +``` +raw_base = quantity * price_unit +if rounding_method == 'round_per_line': + raw_base = float_round(raw_base, precision_rounding=precision_rounding) +``` + +**Step 3 — Evaluation order (three passes):** + +Pass A: FIXED taxes in REVERSE order (so they can affect price-included bases that follow): +``` +for tax in reversed(sorted_taxes): + eval _eval_tax_amount_fixed_amount(tax, batch, raw_base + extra_base_for_tax, ctx) + # fixed: sign = -1 if price_unit < 0 else 1 + # result = sign * quantity * tax.amount + # → records tax_amount; if rounding_method == 'round_per_line': float_round(...) + # → calls _propagate_extra_taxes_base to update extra_base for other taxes +``` + +Pass B: PRICE-INCLUDED taxes in REVERSE order: +``` +for tax in reversed(sorted_taxes): + if taxes_data[tax.id]['price_include'] and 'tax_amount' not in taxes_data[tax.id]: + eval _eval_tax_amount_price_included(tax, batch, raw_base + extra_base_for_tax, ctx) +``` + + `_eval_tax_amount_price_included` (L1094–L1112): + ``` + if amount_type == 'percent': + total_percentage = sum(t.amount for t in batch) / 100.0 + # If all taxes in batch share denominator: + to_price_excluded_factor = 1 / (1 + total_percentage) # 0.0 if total_percentage == -1 + return raw_base * to_price_excluded_factor * self.amount / 100.0 + + if amount_type == 'division': + return raw_base * self.amount / 100.0 + # NOTE: division price-included is raw_base * rate directly (NO denominator) + ``` + +Pass C: PRICE-EXCLUDED taxes in NORMAL order: +``` +for tax in sorted_taxes: + if not taxes_data[tax.id]['price_include'] and 'tax_amount' not in taxes_data[tax.id]: + eval _eval_tax_amount_price_excluded(tax, batch, raw_base + extra_base_for_tax, ctx) +``` + + `_eval_tax_amount_price_excluded` (L1114–L1132): + ``` + if amount_type == 'percent': + return raw_base * self.amount / 100.0 + + if amount_type == 'division': + total_percentage = sum(t.amount for t in batch) / 100.0 + incl_base_multiplicator = 1.0 if total_percentage == 1.0 else 1 - total_percentage + return raw_base * self.amount / 100.0 / incl_base_multiplicator + # WARNING: division price-excluded divides by (1 - sum_rates). If rates sum to 1.0, + # multiplicator is forced to 1.0 to avoid division by zero. + ``` + +**Step 4 — Base amounts (reverse pass):** +``` +subsequent_taxes = empty +for tax in reversed(sorted_taxes): + total_tax_amount = sum(taxes_data[t.id]['tax_amount'] for t in batch) + + reverse_charge amounts if has_negative_factor + + base = raw_base + tax_data['extra_base_for_base'] + if price_include AND special_mode in (False, 'total_included'): + base -= total_tax_amount # CRITICAL: removes total batch tax from base for price-included + tax_data['base'] = base + + if tax.include_base_amount: + tax_data['taxes'] |= subsequent_taxes # subsequent taxes affected by this one + if tax.is_base_affected: + subsequent_taxes |= tax +``` + +**Step 5 — rounding if round_per_line:** +``` +if rounding_method == 'round_per_line': + each tax_amount = float_round(tax_amount, precision_rounding=precision_rounding) + # Applied INSIDE add_tax_amount_to_results at step 3 time (L1179-1180) +``` + +**Step 6 — Totals:** +``` +total_excluded = taxes_data_list[0]['base'] # base of FIRST tax in list +tax_amount = sum(td['tax_amount'] for td in taxes_data_list) +total_included = total_excluded + tax_amount + +# Edge case: no taxes +if not taxes_data_list: + total_included = total_excluded = raw_base +``` + +**Axis classification:** DETERMINISTIC +**K-step:** K7 +**woa-rs target:** `src/erp/tax.rs::get_tax_details` + +--- + +### R4 — `_propagate_extra_taxes_base` — cross-tax base propagation + +**File:** `account_tax.py:L973–L1077` + +**Axis-1 Rich-AST Spec:** + +This is called after each tax_amount is computed to update `extra_base_for_tax` and `extra_base_for_base` for other taxes in the sequence. + +``` +FUNCTION _propagate_extra_taxes_base(tax, taxes_data, special_mode): + # get_tax_before(): yields taxes that appear BEFORE tax in sorted_taxes + # get_tax_after(): yields taxes that appear AFTER tax in sorted_taxes + + add_extra_base(other_tax, sign): + tax_amount = taxes_data[tax.id]['tax_amount'] + if 'tax_amount' NOT in taxes_data[other_tax.id]: + taxes_data[other_tax.id]['extra_base_for_tax'] += sign * tax_amount + taxes_data[other_tax.id]['extra_base_for_base'] += sign * tax_amount + # NOTE: extra_base_for_base is ALWAYS updated (affects base display) + # extra_base_for_tax is only updated if other_tax NOT yet computed + + if tax.price_include: + if special_mode in (False, 'total_included'): + if tax.include_base_amount: + for other_tax in get_tax_after(): + if NOT other_tax.is_base_affected: + add_extra_base(other_tax, -1) + else: + for other_tax in get_tax_after(): + add_extra_base(other_tax, -1) + for other_tax in get_tax_before(): + add_extra_base(other_tax, -1) + else: # special_mode == 'total_excluded' + if tax.include_base_amount: + for other_tax in get_tax_after(): + if other_tax.is_base_affected: + add_extra_base(other_tax, +1) + + elif NOT tax.price_include: + if special_mode in (False, 'total_excluded'): + if tax.include_base_amount: + for other_tax in get_tax_after(): + if other_tax.is_base_affected: + add_extra_base(other_tax, +1) + else: # special_mode == 'total_included' + if NOT tax.include_base_amount: + for other_tax in get_tax_after(): + add_extra_base(other_tax, -1) + for other_tax in get_tax_before(): + add_extra_base(other_tax, -1) +``` + +**Axis classification:** DETERMINISTIC +**K-step:** K7 + +--- + +### R5 — `_add_tax_details_in_base_line` — the outer driver: discount, rate, rounding + +**File:** `account_tax.py:L1733–L1805` + +**Axis-1 Rich-AST Spec:** + +``` +FUNCTION _add_tax_details_in_base_line(base_line, company, rounding_method=None): + rounding_method = rounding_method or company.tax_calculation_rounding_method + # company.tax_calculation_rounding_method is 'round_per_line' (default) or 'round_globally' + + price_unit_after_discount = base_line['price_unit'] * (1 - base_line['discount'] / 100.0) + + taxes_computation = base_line['tax_ids']._get_tax_details( + price_unit=price_unit_after_discount, + quantity=base_line['quantity'], + precision_rounding=base_line['currency_id'].rounding, + rounding_method=rounding_method, + product=base_line['product_id'], + product_uom=base_line['product_uom_id'], + special_mode=base_line['special_mode'], + filter_tax_function=base_line['filter_tax_function'], + ) + + # Non-deductible (reverse charge): strip is_reverse_charge entries from total + if base_line['special_type'] == 'non_deductible': + for tax_data in taxes_data: + if tax_data.get('is_reverse_charge'): + taxes_computation['total_included'] -= tax_data['tax_amount'] + # remove from list + + rate = base_line['rate'] # FX rate (1.0 for domestic EUR) + + tax_details = { + 'raw_total_excluded_currency': taxes_computation['total_excluded'], + 'raw_total_excluded': taxes_computation['total_excluded'] / rate (or 0.0), + 'raw_total_included_currency': taxes_computation['total_included'], + 'raw_total_included': taxes_computation['total_included'] / rate (or 0.0), + 'taxes_data': [], + } + + if rounding_method == 'round_per_line': + tax_details['raw_total_excluded'] = company.currency_id.round(tax_details['raw_total_excluded']) + tax_details['raw_total_included'] = company.currency_id.round(tax_details['raw_total_included']) + + for tax_data in taxes_computation['taxes_data']: + tax_amount = tax_data['tax_amount'] / rate (or 0.0) + base_amount = tax_data['base_amount'] / rate (or 0.0) + if rounding_method == 'round_per_line': + tax_amount = company.currency_id.round(tax_amount) + base_amount = company.currency_id.round(base_amount) + tax_details['taxes_data'].append({ + **tax_data, + 'raw_tax_amount_currency': tax_data['tax_amount'], + 'raw_tax_amount': tax_amount, + 'raw_base_amount_currency': tax_data['base_amount'], + 'raw_base_amount': base_amount, + }) +``` + +**Two-currency pattern:** every amount exists in both `_currency` (transaction currency) and without suffix (company/local currency). The conversion is `/ rate`. `rate = 1.0` for EUR-only setups (Stefan's case). + +**Axis classification:** DETERMINISTIC +**K-step:** K7 + +--- + +### R6 — `_round_base_lines_tax_details` + `_round_tax_details_tax_amounts` — global rounding + +**File:** `account_tax.py:L2179–L2288` and `L1890–L1987` + +**Axis-1 Rich-AST Spec:** + +Global rounding is needed only when `rounding_method == 'round_globally'`. The pattern: + +1. **Raw rounding** (L2237–2248): copy `raw_*` → rounded fields using `currency.round(...)`. +2. **Apply manual_tax_amounts** (L2250–2273): override individual tax/base amounts from stored overrides. +3. **Compute total_included + delta_total_excluded** (L2275–2288): `total_included = total_excluded + sum(tax_amounts)`, `delta_total_excluded = 0.0` (then adjusted in step 4). +4. **`_round_tax_details_tax_amounts`** (L1890–1987): Aggregates `raw_total_tax_amount` per tax across all lines, rounds it globally, distributes the delta back to individual lines using `_distribute_delta_amount_smoothly` (proportional distribution, largest-first to minimize leftover). +5. **`_round_tax_details_base_lines`** (L1988–2097): Adjusts `delta_total_excluded` so that globally `round(sum(raw_total_excluded)) == sum(total_excluded + delta)`. + +``` +_distribute_delta_amount_smoothly(precision_digits, delta_amount, target_factors): + # Converts delta to integer units of precision, distributes proportionally + # Remainder distributed one unit at a time to factors sorted by largest weight first + precision_rounding = 10^(-precision_digits) + nb_of_errors = round(abs(delta / precision_rounding)) + # ... sorted proportional distribution +``` + +**Rounding mode for base amounts** (`mode` parameter): +- `'mixed'` (default): uses `'included'` logic if ALL non-zero taxes are price-included, else `'excluded'`. +- `'excluded'`: round base independently from tax. +- `'included'`: round `base + tax` together, then derive base as `round(total) - tax`. + +**Axis classification:** DETERMINISTIC +**K-step:** K7 + +--- + +### R7 — `_add_accounting_data_to_base_line_tax_details` — repartition lines + +**File:** `account_tax.py:L2362–L2496` + +**Axis-1 Rich-AST Spec:** + +This is the bridge from computed tax amounts to actual accounting journal entries. Called AFTER rounding. + +``` +FUNCTION _add_accounting_data_to_base_line_tax_details(base_line, company, include_caba_tags=False): + is_refund = base_line['is_refund'] + repartition_lines_field = 'refund_repartition_line_ids' if is_refund else 'invoice_repartition_line_ids' + + # Tags on BASE line (for USt-VA line mapping) + base_line['tax_tag_ids'] = product.account_tag_ids (if product) union + for each tax_data (not is_reverse_charge, on_invoice exigibility): + tax[repartition_lines_field].filtered(repartition_type=='base').tag_ids + + for tax_data in taxes_data: + # REVERSE CHARGE handling (has_negative_factor): + if is_reverse_charge: + tax_reps = filter(repartition_type=='tax' AND factor < 0.0) + tax_rep_sign = -1.0 + else: + tax_reps = filter(repartition_type=='tax' AND factor >= 0.0) + tax_rep_sign = 1.0 + + for tax_rep in tax_reps: + tax_rep_data = { + 'tax_rep': tax_rep, + 'tax_amount_currency': currency.round(tax_amount_currency * tax_rep.factor * tax_rep_sign), + 'tax_amount': company_currency.round(tax_amount * tax_rep.factor * tax_rep_sign), + 'account': tax_rep._get_aml_target_tax_account() or base_line['account_id'], + # _get_aml_target_tax_account: returns cash_basis_transition_account if on_payment + # else tax_rep.account_id + } + + # Distribute rounding delta across repartition lines: + # sorted by (-abs(tax_amount_currency), -abs(tax_amount)) — largest first + # _distribute_delta_amount_smoothly fills remainder + + # Tags on TAX repartition line: + tax_rep_data['tax_tags'] = product_tags union + (if on_invoice): tax_rep.tag_ids + tax_rep_data['taxes'] = tax_data['taxes'] # for include_base_amount chains +``` + +**Refund sign handling** (L1504–L1540, `_turn_base_line_is_refund_flag_off`): +- When `is_refund=True`, the `repartition_lines_field` switches to `'refund_repartition_line_ids'`. +- The sign convention for refunds is carried by `base_line['sign']` (typically -1 for credit notes, +1 for invoices). +- In `_prepare_tax_lines` (L3033–L3126): `amount_currency += sign * tax_rep_data['tax_amount_currency']`. +- To negate a refund programmatically: `_turn_base_line_is_refund_flag_off` negates quantity and all amounts in tax_details. + +**Axis classification:** DETERMINISTIC +**K-step:** K7 (produces K3 journal entry data) + +--- + +### R8 — `compute_all` — legacy public API + +**File:** `account_tax.py:L4864–L4980` + +**Axis-1 Rich-AST Spec:** + +``` +FUNCTION compute_all( + self, + price_unit, currency=None, quantity=1.0, product=None, partner=None, + is_refund=False, handle_price_include=True, include_caba_tags=False, + rounding_method=None, +) -> { + 'base_tags': List[int], + 'taxes': List[Dict], # one per repartition line (NOT per tax!) + 'total_excluded': float, + 'total_included': float, + 'total_void': float, # total of reps with no account_id +} +``` + +**special_mode resolution:** +``` +if 'force_price_include' in context: + special_mode = 'total_included' if context['force_price_include'] else 'total_excluded' +elif not handle_price_include: + special_mode = 'total_excluded' # ignores all price_include flags +else: + special_mode = False # normal: respect each tax's price_include +``` + +**Output construction:** +``` +company = self[0].company_id._accessible_branches()[:1] or self[0].company_id +currency = currency or company.currency_id + +base_line = _prepare_base_line_for_taxes_computation(None, partner_id=partner, ...) +_add_tax_details_in_base_line(base_line, company, rounding_method=rounding_method) +_add_accounting_data_to_base_line_tax_details(base_line, company, include_caba_tags) + # NOTE: context 'compute_all_use_raw_base_lines'=True → uses raw_tax_amount_currency + # instead of rounded tax_amount_currency for repartition computation + +total_excluded = raw_total_excluded_currency # NOTE: RAW, not rounded (yet) +total_included = raw_total_included_currency + +for tax_data in tax_details['taxes_data']: + for tax_rep_data in tax_data['tax_reps_data']: + taxes.append({ + 'id': tax.id, + 'name': tax.name (localized via partner.lang if partner), + 'amount': tax_rep_data['tax_amount_currency'], + 'base': tax_data['raw_base_amount_currency'], + 'sequence': tax.sequence, + 'account_id': tax_rep_data['account'].id, + 'analytic': tax.analytic, + 'use_in_tax_closing': rep_line.use_in_tax_closing, + 'is_reverse_charge': tax_data['is_reverse_charge'], + 'price_include': tax.price_include, + 'tax_exigibility': tax.tax_exigibility, + 'tax_repartition_line_id': rep_line.id, + 'group': tax_data['group'], + 'tag_ids': tax_rep_data['tax_tags'].ids, # USt-VA tags + 'tax_ids': tax_rep_data['taxes'].ids, + }) + if NOT rep_line.account_id: + total_void += tax_rep_data['tax_amount_currency'] + +if context.get('round_base', True): # default True + total_excluded = currency.round(total_excluded) + total_included = currency.round(total_included) +``` + +**Critical edge cases:** +- Empty self (`not self`): returns all-zero result with `company = self.env.company`. +- `_fix_tax_included_price` (L4994–L5003): subtracts tax from price if product has price-included taxes NOT applicable to the line. +- `_fix_tax_included_price_company` (L5005–L5011): same but filters by `company_id` first. + +**Axis classification:** DETERMINISTIC +**K-step:** K7 + +--- + +### R9 — `AccountTaxRepartitionLine` model + +**File:** `account_tax.py:L5142–L5210` + +**Axis-1 Rich-AST Spec:** + +Fields that matter for computation: +``` +factor_percent: Float(digits=(16,12)) # e.g. 100.0 or -100.0 +factor: Computed = factor_percent / 100.0 # e.g. 1.0 or -1.0 +repartition_type: 'base' | 'tax' +document_type: 'invoice' | 'refund' +account_id: Many2one account.account (can be empty → no posting) +tag_ids: Many2many account.account.tag (USt-VA grid tags) +use_in_tax_closing: Computed Boolean + = repartition_type == 'tax' + AND account_id + AND account_id.internal_group NOT IN ('income', 'expense') +``` + +`_get_aml_target_tax_account(force_caba_exigibility=False)`: +``` +if NOT force_caba_exigibility AND tax.tax_exigibility == 'on_payment' + AND NOT context.get('caba_no_transition_account'): + return tax.cash_basis_transition_account_id # Cash Basis transition account +else: + return self.account_id +``` + +**Negative factor / reverse charge pattern (§13b UStG):** +- `has_negative_factor = True` when any invoice repartition line has `factor < 0.0`. +- In §13b taxes (e.g. `tax_ust_19_13b_ausland_ohne_vst_skr03`): repartition has one line +100% (Vorsteuer account) and one line -100% (USt liability). The negative line is treated as `is_reverse_charge=True` in computation. +- `_add_accounting_data_to_base_line_tax_details` splits them: positive → normal tax rep, negative → reverse_charge rep. + +**_sql_constraints** on repartition: +``` +@api.constrains('invoice_repartition_line_ids', ...) +_validate_repartition_lines: + - exactly ONE base line per document type + - at least ONE tax repartition line per document type + - invoice and refund must have SAME NUMBER of lines + - same percentages in same order + - sum of positive factors == 1.0 (100%) + - if negative factors exist: sum of negative factors == -1.0 (-100%) +``` + +**Axis classification:** DETERMINISTIC +**K-step:** K7, K3 + +--- + +### R10 — `AccountTaxGroup` model + +**File:** `account_tax.py:L25–L68` + +**Axis-1 Rich-AST Spec:** + +``` +Fields: + name: Char (translatable) + sequence: Integer default=10 + company_id: Many2one res.company (required) + tax_payable_account_id: Many2one account.account → Verbindlichkeiten USt (e.g. 1776) + tax_receivable_account_id: Many2one account.account → Forderungen VSt (e.g. 1545) + advance_tax_payment_account_id: Many2one account.account → Vorauszahlung USt (e.g. 1780) + country_id: Computed from company_id.account_fiscal_country_id or company_id.country_id + preceding_subtotal: Char (optional label before this group in invoice subtotal display) + +_compute_country_id @depends('company_id'): + group.country_id = company.account_fiscal_country_id or company.country_id +``` + +Tax groups in l10n_de (both SKR03 and SKR04): +- `tax_group_0`: VAT 0% +- `tax_group_7`: VAT 7% (Ermäßigter Steuersatz) +- `tax_group_55`: VAT 5.5% (Land-/Forstwirtschaft) +- `tax_group_107`: VAT 10.7% (Land-/Forstwirtschaft) +- `tax_group_x`: VAT x% (variable rate) +- `tax_group_19`: VAT 19% (Regelsteuersatz) + +Group accounts (SKR03): receivable=1545, payable=1797, advance=1780. +Group accounts (SKR04): receivable=1421, payable=3860, advance=3820. + +**Axis classification:** DETERMINISTIC +**K-step:** K7, K8 + +--- + +### R11 — `AccountFiscalPosition.map_tax` + `map_account` + +**File:** `partner.py:L154–L166` + +**Axis-1 Rich-AST Spec:** + +``` +FUNCTION map_tax(self: FiscalPosition, taxes: Recordset[AccountTax]) -> Recordset[AccountTax]: + if not self: + return taxes # no fiscal position → identity + + if not self.tax_ids and taxes.fiscal_position_ids: + return env['account.tax'] # empty FP with any tax linked removes all taxes + # CRITICAL EDGE CASE: FPs used by tax units with no explicit tax_ids → removes all taxes + + # tax_map is a Binary field computed from: + # tax_map[src_tax.id] = [dest_tax.id, ...] + # where dest_tax is in self.tax_ids and src_tax in dest_tax.original_tax_ids + + return env['account.tax'].browse(unique( + tax_id + for tax in taxes + for tax_id in (self.tax_map or {}).get(tax.id, [tax.id]) + # If tax.id NOT in tax_map → identity mapping (keep original) + # If tax.id IS in tax_map → replace with all dest tax IDs + )) +``` + +``` +FUNCTION map_account(self: FiscalPosition, account: AccountAccount) -> AccountAccount: + return env['account.account'].browse( + (self.account_map or {}).get(account.id, account.id) + ) + # account_map: {src_account_id: dest_account_id} + # built from account_fiscal_position_account (many2one pairs) + # identity mapping if no match +``` + +**Ontology of tax_map:** +``` +@depends('tax_ids') +_compute_tax_map: + for dest_tax in self.tax_ids: + for src_tax in dest_tax.original_tax_ids: + tax_map[src_tax.id].append(dest_tax.id) + # IMPORTANT: mapping is MANY-to-MANY — one src can map to multiple destinations + # (all get inserted via unique() to deduplicate) +``` + +**Axis classification:** DETERMINISTIC (pure lookup table) +**Ontology:** `odoo:account.fiscal.position` → FLAG — UNRESOLVED (see §3) +**K-step:** K7 +**woa-rs target:** `src/erp/tax.rs::map_tax`, `src/erp/tax.rs::map_account` + +--- + +### R12 — `AccountFiscalPosition._get_fiscal_position` — auto-apply logic + +**File:** `partner.py:L246–L279` + +**Axis-1 Rich-AST Spec:** + +``` +FUNCTION _get_fiscal_position(partner, delivery=None) -> FiscalPosition | empty: + if not partner: + return empty + + company = self.env.company + + # EU intra-community detection + intra_eu = False + vat_exclusion = False + if company.vat and partner.vat: + eu_country_codes = set of EU country codes (from base.europe ref) + intra_eu = company.vat[:2] in eu_country_codes AND partner.vat[:2] in eu_country_codes + vat_exclusion = company.vat[:2] == partner.vat[:2] + + # If same-country EU VAT or no delivery → use invoicing address + if not delivery or (intra_eu AND vat_exclusion AND partner.country_id == company.country_id): + delivery = partner + + # STEP 1: Manual override always wins + manual = delivery.property_account_position_id or partner.property_account_position_id + if manual: + return manual # early return + + # STEP 2: No country → no auto-apply + if not partner.country_id: + return empty + + # STEP 3: Search all auto_apply positions for this company, ordered by sequence + all_auto_apply = self.search( + _check_company_domain(company) + [('auto_apply', '=', True)] + # ORDER BY sequence (model default) + ) + return all_auto_apply._get_first_matching_fpos(delivery) +``` + +``` +FUNCTION _get_first_matching_fpos(self: Recordset[FP], partner) -> FP | empty: + # Sort: company-specific first (more parent_ids = deeper hierarchy), + # then by sequence ascending + sorted_fpos = self.sorted(key=lambda f: (-len(f.company_id.parent_ids), f.sequence)) + + for fpos in sorted_fpos: + if ALL validation functions pass: + return fpos + return empty +``` + +``` +_get_fpos_validation_functions(partner) returns list of lambdas: + 1. VAT required: not fpos.vat_required OR partner has valid VAT + 2. ZIP range: not (fpos.zip_from AND fpos.zip_to) OR zip_from <= partner.zip <= zip_to + 3. State: not fpos.state_ids OR partner.state_id in fpos.state_ids + 4. Country: not fpos.country_id OR partner.country_id == fpos.country_id + 5. Country group: not fpos.country_group_id OR + (partner.country_id in group.country_ids + AND (not partner.state_id OR state not in group.exclude_state_ids)) + # ALL five must pass (short-circuit AND) +``` + +**Rule order for l10n_de (SKR03/SKR04):** +``` +sequence 10: fiscal_position_domestic (auto_apply=True, country=DE) +sequence 20: fiscal_position_eu_vat_id (auto_apply=True, country_group=EU, vat_required=True) +sequence 40: fiscal_position_eu_no_id (auto_apply=False) ← manual only +sequence 50: fiscal_position_non_eu (auto_apply=False) ← manual only +sequence 60: fiscal_position_non_eu_service (auto_apply=False) ← manual only +sequence 30: fiscal_position_eu_vat_id_service (auto_apply=False) ← manual only +``` + +Only two German fiscal positions auto-apply: Domestic (DE country) and EU with VAT ID (EU country group + VAT required). All others must be manually assigned on the partner. + +**Axis classification:** DETERMINISTIC (ordered rule table, closed-form). +The selection is an ordered priority list, not scoring — each rule is a conjunction of exact checks. NO heuristic. +Rule order: company-specific depth first, then sequence ascending. +The `intra_eu + vat_exclusion` shortcut to use invoicing address instead of delivery is deterministic (exact string prefix comparison on VAT numbers). +**K-step:** K7 +**woa-rs target:** `src/erp/tax.rs::get_fiscal_position` + +--- + +### R13 — `AccountTax` field computations and constraints + +**File:** `account_tax.py:L71–L240` + +**Axis-1 Rich-AST Spec:** + +Key fields for computation: +``` +amount_type: 'group' | 'fixed' | 'percent' | 'division' + - group: delegates to children_tax_ids + - fixed: tax = sign * quantity * amount (sign from price_unit sign) + - percent: tax = base * amount / 100 + - division: tax = base * amount/100 / (1 - total_rate/100) [price-excl] + or base * amount / 100 [price-incl] +amount: Float(digits=(16,4)) — e.g. 19.0 for 19%, or fixed amount in EUR + +price_include: Computed, NOT stored + @depends('price_include_override') + = (price_include_override == 'tax_included') + OR (company_price_include == 'tax_included' AND NOT price_include_override) + # company_price_include is the company's default; override wins + +include_base_amount: Boolean default=False + # If True: this tax's amount is added to the base for subsequent taxes +is_base_affected: Boolean default=True + # If True: taxes before this one (with include_base_amount) affect this tax's base + +tax_exigibility: 'on_invoice' (default) | 'on_payment' + # on_payment → Cash Basis → uses cash_basis_transition_account_id + +sequence: Integer default=1 — processing order (lower = earlier) +``` + +`@api.constrains('company_id', 'name', 'type_tax_use', 'tax_scope', 'country_id')` `_constrains_name`: +- Tax names must be unique within (company hierarchy, name, type_tax_use, tax_scope, country_id). +- Enforced via `split_every(100, ...)` batches. + +`@api.constrains('tax_group_id')` `validate_tax_group_id`: +- tax_group.country_id must match tax.country_id. + +`@api.constrains('tax_exigibility', 'cash_basis_transition_account_id')`: +- If `on_payment`: cash_basis_transition_account must allow reconciliation. + +**l10n_de_datev_code field:** `account_tax.l10n_de_datev_code` — present in CSV as e.g. `"3"` (tax_ust_19_skr03) or `"9"` (tax_vst_19_skr03). This is the DATEV Steuerschlüssel. Only set for domestic taxes; EU/export taxes have empty string. + +**Axis classification:** DETERMINISTIC +**K-step:** K7 + +--- + +### R14 — `_adapt_price_unit_to_another_taxes` — fiscal position price_unit adjustment + +**File:** `account_tax.py:L1338–L1385` + +**Axis-1 Rich-AST Spec:** + +When a fiscal position maps a price-included tax to another tax, the price_unit must be adjusted: + +``` +FUNCTION _adapt_price_unit_to_another_taxes(price_unit, product, original_taxes, new_taxes, product_uom=None): + # Only adapt if ALL original taxes are price-included + if original_taxes == new_taxes or False in original_taxes.mapped('price_include'): + return price_unit # no-op + + # Find price without any tax (total_excluded) + computation = original_taxes._get_tax_details( + price_unit, 1.0, rounding_method='round_globally', ... + ) + price_unit = computation['total_excluded'] # strip original taxes + + # Find new price_unit with new price-included taxes added back + computation = new_taxes._get_tax_details( + price_unit, 1.0, rounding_method='round_globally', + special_mode='total_excluded', # treat given price as tax-excluded + ) + delta = sum(x['tax_amount'] for x in computation['taxes_data'] if x['tax'].price_include) + return price_unit + delta # add only price-included portions of new taxes +``` + +**Axis classification:** DETERMINISTIC +**K-step:** K7 + +--- + +## 3. l10n_de Tax Code Catalogue + +### 3a. Domestic taxes (SKR03, active, standard types) + +| ID | Name (DE) | Rate | type_tax_use | DATEV code | Tags (invoice base / tax) | USt-VA line | +|---|---|---|---|---|---|---| +| tax_ust_19_skr03 | 19% USt | 19% | sale | 3 | 81_BASE / 81_TAX | Kz 81 | +| tax_ust_7_skr03 | 7% USt | 7% | sale | 2 | 86_BASE / 86_TAX | Kz 86 | +| tax_vst_19_skr03 | 19% VSt | 19% | purchase | 9 | (none) / 66 | Kz 66 | +| tax_vst_7_skr03 | 7% VSt | 7% | purchase | 8 | (none) / 66 | Kz 66 | + +### 3b. EU intra-community (active, §4 Abs 1b / §89) + +| ID | Scenario | Rate | Direction | Tags | VA line | +|---|---|---|---|---|---| +| tax_eu_19_purchase_skr03 | Innergem. Erwerb 19% | 19% | purchase | 89_BASE / 89_TAX(-100%) + 61 | Kz 89+61 | +| tax_eu_7_purchase_skr03 | Innergem. Erwerb 7% | 7% | purchase | 93_BASE / 93_TAX(-100%) + 61 | Kz 93+61 | +| tax_eu_sale_skr03 | Steuerfreie innergem. Lieferung | 0% | sale | 41 / (none) | Kz 41 | + +### 3c. §13b Reverse Charge (Steuerschuldnerschaft des Leistungsempfängers) + +| ID | Scenario | Rate | Tags base / tax reps | +|---|---|---|---| +| tax_ust_19_13b_ausland_ohne_vst_skr03 | Ausländ. Werklieferungen (ohne VSt) | 19% | 84 / +67\|85 (1785) + -100% (1787) | +| tax_ust_19_13b_eu_ohne_vst_skr03 | Sonst. EU-Leistungen (ohne VSt) | 19% | 46 / +67 (1785) + 47-100% (1787) | +| tax_ust_vst_19_purchase_13b_bau_skr03 | §13b Bauleistung Empfänger (19%/19%) | 19% | 60rc\|84 / +67\|85 (1577) + -100% (1787) | +| tax_ust_free_bau_skr03 | §13b Bauleistung Erbringer (0%) | 0% | 60 / (none) | + +### 3d. Drittland (Non-EU export/import) + +| ID | Scenario | Rate | Tags | +|---|---|---|---| +| tax_export_skr03 | Steuerfreie Ausfuhr §4 Nr. 1a | 0% sale | 43 | +| tax_import_19_and_payable_skr03 | Einfuhrumsatzsteuer 19% | 19% purchase | (none) / 62 (1588) + -100% (1788) | + +### 3e. Fiscal positions (l10n_de SKR03 / SKR04) + +| ID | Name | auto_apply | Trigger | Effect | +|---|---|---|---|---| +| fiscal_position_domestic_skr03 | Geschäftspartner Inland | Yes | country=DE | Identity (no remapping) | +| fiscal_position_eu_vat_id_partner_skr03 | EU mit USt-ID | Yes | EU country_group + vat_required | Remaps: USt-19 → EU-sale; VSt-19 → EU-purchase; account remaps (e.g. 8400→8125) | +| fiscal_position_eu_no_id_partner_skr03 | EU ohne USt-ID | No | manual | Different EU accounts, no VAT requirement | +| fiscal_position_eu_vat_id_partner_service_skr03 | EU Dienstleister | No | manual | Service-specific tax remaps | +| fiscal_position_non_eu_partner_skr03 | Drittland | No | manual | Export tax remaps; account remaps (8400→8120) | +| fiscal_position_non_eu_partner_service_skr03 | Drittland Dienstleister | No | manual | Service-specific non-EU remaps | + +Tax remapping mechanism: `original_tax_ids` on the **destination** tax points to domestic taxes it replaces. The `tax_map` computed field inverts this. + +--- + +## 4. Enterprise Gap / USt-Voranmeldung (K8 bridge) + +**ENTERPRISE GAP FLAG:** + +The `l10n_de_tax_statement` module (which provides the USt-Voranmeldung / Umsatzsteuer-Voranmeldung ELSTER XML export) is **NOT present** in the community clone at `/home/user/odoo/addons/`. + +Evidence: `find /home/user/odoo/addons -name "*.py" -path "*l10n_de*"` returns only `l10n_de` (community). No `l10n_de_reports`, no `l10n_de_tax_statement`, no `account_reports`. + +What IS available in community: +- Tax **tag_ids** on repartition lines — these are the USt-VA grid codes (Kz 81, Kz 86, Kz 41, Kz 66, Kz 89, etc.). The tags are referenced by ID strings like `89_BASE`, `81_TAX`, etc. in the CSV. +- The `account.account.tag` records with `applicability='taxes'` encode the line number for each Kennziffer (Kz). +- `compute_all` returns `tag_ids` in each tax entry — this IS the K8 bridge. + +**What the Enterprise module adds (not available here):** +- ELSTER XML serialization of the USt-Voranmeldung form. +- Pre-filled line aggregation summing tagged amounts to Kz-numbers. +- Electronic submission wrapper. + +**Community alternative for K8:** The tag_ids returned by `compute_all` (and stored on `account.move.line.tax_tag_ids`) can be aggregated by grouping move lines by tag to produce Kz totals. This is the structural approach — the engine for generating the Voranmeldung must be built fresh in woa-rs, using the tag→Kz mapping derived from the l10n_de data. + +**Note to K8 porter (lane L4):** The tag string IDs in the CSV (e.g. `81_BASE`, `81_TAX`, `89_BASE`, `89_TAX`) correspond directly to USt-VA Kennziffern. The `_BASE` suffix tags the base amount line; `_TAX` tags the tax amount line. Aggregate `account.move.line` where `tax_tag_ids` contains tag X to get Kz X total. + +--- + +## 5. Ontology Mapping + +### 5a. `odoo:account.tax` + +**Current status in `crates/skr_data/src/odoo_alignment.rs`:** FILE DOES NOT EXIST in woa-rs (confirmed: `find /home/user/woa-rs -name "odoo_alignment.rs"` returns empty). The briefing references `lance-graph-callcenter/src/odoo_alignment.rs` which is not in this repo. + +**FLAG — UNRESOLVED:** `odoo:account.tax` has no alignment row. + +**Proposed mapping:** +``` +odoo:account.tax + → owl:equivalentClass fibo-fbc-fi-fi:Tax + (FIBO: Financial Industry Business Ontology — Financial Instruments) + → OGIT family: SMBAccounting / BillingCore (whichever covers tax records) + → DOLCE marker: Quality + (suffix `.tax` → Quality per briefing rule) +``` + +**Proposed alignment row:** +```rust +("account.tax", Some(OgitFamily::SmBAccounting), DolceMarker::Quality, + "fibo-fbc-fi-fi:Tax") +``` + +### 5b. `odoo:account.tax.group` + +**FLAG — UNRESOLVED** + +**Proposed mapping:** +``` +odoo:account.tax.group + → owl:equivalentClass fibo-fbc-fi-fi:TaxCategory (or ubl:TaxCategory) + → OGIT family: SMBAccounting + → DOLCE marker: Quality (.group → grouped Quality) +``` + +### 5c. `odoo:account.tax.repartition.line` + +**FLAG — UNRESOLVED** + +**Proposed mapping:** +``` +odoo:account.tax.repartition.line + → owl:equivalentClass fibo-be-le-lp:TaxDistributionLine (or ubl:TaxSubtotal) + → OGIT family: BillingCore + → DOLCE marker: Perdurant (.line → Perdurant per briefing rule) +``` + +### 5d. `odoo:account.fiscal.position` + +**FLAG — UNRESOLVED** + +**Proposed mapping:** +``` +odoo:account.fiscal.position + → owl:equivalentClass fibo-fbc-pas-caa:TaxJurisdiction + (FIBO: Regulatory Compliance, Tax Jurisdiction concept) + Alternatively: ubl:TaxScheme or schema:TaxType + → OGIT family: SMBAccounting (regulatory/compliance sub-family) + → DOLCE marker: Abstract (.rule/.template → Abstract per briefing rule) +``` + +**Rationale:** A fiscal position is a mapping rule / tax regime, not a transaction event → Abstract. + +### 5e. `odoo:account.fiscal.position.account` + +**FLAG — UNRESOLVED** + +**Proposed mapping:** +``` +odoo:account.fiscal.position.account + → owl:equivalentClass fibo-fbc-pas-caa:AccountMapping (construct) + → OGIT family: SMBAccounting + → DOLCE marker: Abstract (it's a mapping rule) +``` + +--- + +## 6. woa-rs Calibration + +From `grep` output (Step 3): +- `/home/user/woa-rs/src/url.rs:L98`: `TAX_RESERVE_TOGGLE = "/api/tax-reserve/toggle"` — there is a tax reserve toggle endpoint. +- `/home/user/woa-rs/src/models/maintenance_contract.rs:L33`: `/// db.Float default=19.0 — f64 per POLICY §1 (tax rate snapshot)` — tax rate stored as f64 on maintenance_contract. +- No `src/erp/` directory exists yet. +- `crates/skr_data/` exists but contains only account/chart-of-accounts data (`Konto`, `KontoTyp`, `SkrRahmen`). No tax records, no fiscal positions, no repartition data. + +**Gap assessment:** +- woa-rs has zero implementation of K7 tax computation logic. +- The SKR CSV tax data is in `/home/user/odoo/addons/l10n_de/data/template/` and not yet vendored into `crates/skr_data/`. +- The tax rate is hardcoded as `f64 = 19.0` in maintenance contracts — no dynamic tax lookup. +- No fiscal position logic exists. + +**Recommendation for porter:** Create `src/erp/tax.rs` with the full deterministic algorithm from R1–R8. The l10n_de CSVs can be statically compiled into `crates/skr_data/` similarly to the existing `skr03.rs`/`skr04.rs` pattern (static arrays of structs). + +--- + +## 7. Porter's Checklist — Non-Obvious Gotchas + +1. **Batch denominator sharing (R2):** Price-included percent taxes in the SAME batch divide by a SHARED denominator `1 + sum(rates)`. Two 10% price-included taxes on the same line → each gets `base / 1.2 * 0.1`, NOT `base / 1.1 * 0.1` independently. The batch determines what goes in the denominator. + +2. **Three-pass evaluation order (R3):** Fixed → price-included (reverse) → price-excluded (forward). This order is not arbitrary — fixed taxes must run first because their amount feeds `extra_base_for_tax` for price-included batches that follow. + +3. **`extra_base_for_tax` vs `extra_base_for_base` (R4):** `extra_base_for_tax` only updates if the target tax has NOT yet been computed (guards against double-counting). `extra_base_for_base` ALWAYS updates (affects the displayed base amount even after computation). + +4. **Price-included base formula (R3, Step 4):** `base = raw_base + extra_base_for_base - total_batch_tax_amount` only when `price_include AND special_mode in (False, 'total_included')`. Otherwise `base = raw_base + extra_base_for_base`. + +5. **`total_excluded` = first tax's base, not `raw_base` (R3, Step 6):** When there are price-included taxes, `total_excluded` (the net base) is taken from `taxes_data_list[0]['base']`, which already has the tax stripped. This is the correct price-net-of-tax. + +6. **Reverse-charge split (§13b) (R9):** A tax with `has_negative_factor=True` generates TWO entries in `taxes_data`: one normal (positive factor reps) and one `is_reverse_charge=True` (negative factor reps). The `compute_all` function includes both. The non-deductible filter strips the reverse-charge entry. Porter must handle both branches. + +7. **`round_per_line` rounds DURING computation (R3 + R5):** `float_round(tax_amount, precision_rounding=...)` is applied inside `add_tax_amount_to_results` at computation time. For `round_globally`, rounding is deferred to `_round_base_lines_tax_details`. + +8. **`compute_all` uses RAW amounts for repartition (R8):** The context key `compute_all_use_raw_base_lines=True` causes `_add_accounting_data_to_base_line_tax_details` to use `raw_tax_amount_currency` instead of the rounded `tax_amount_currency` when computing per-repartition-line amounts. This avoids double-rounding. + +9. **`map_tax` edge case (R11):** If fiscal position has no `tax_ids` (empty) but the input taxes have `fiscal_position_ids` set, ALL taxes are removed. This is used by "tax units" to create zones without taxation. + +10. **Fiscal position auto-apply: only two auto-apply in DE (R12):** Domestic (sequence 10) and EU-with-VAT-ID (sequence 20) are the only positions with `auto_apply=True`. All others require manual assignment on the partner's `property_account_position_id`. + +11. **ZIP range comparison is string-based (R12):** `zip_from <= partner.zip <= zip_to` is a string comparison (alphabetic), not numeric. The `_convert_zip_values` method right-pads numeric zips with leading zeros to `max_length` to make string comparison behave numerically. + +12. **`_get_first_matching_fpos` sorts by company depth THEN sequence (R12):** A fiscal position defined on a child company wins over one on the parent company even if it has a higher sequence number. The key is `(-len(company_id.parent_ids), sequence)`. + +13. **USt-Voranmeldung line structure is Enterprise (§4):** The tag IDs (e.g. `81_BASE`, `81_TAX`) encode Kz numbers structurally, but the ELSTER XML serialization and the report engine that aggregates them is in `l10n_de_reports` (Enterprise). Community clone only has the tags and the raw `account.move.line.tax_tag_ids` storage. + +14. **`division` amount_type (R3):** Price-excluded division: tax = `base * rate / (1 - total_rate)`. If `total_rate == 1.0` (100%), the divisor is forced to 1.0 to avoid division by zero. This is a legal edge case (100% division tax). Price-included division: tax = `base * rate` (no division by denominator — the price IS the included total). + +15. **`include_base_amount` vs `is_base_affected` (R4):** These are paired flags. `t1.include_base_amount=True` means "my tax amount affects the base of subsequent taxes". `t2.is_base_affected=True` means "I accept being affected by preceding include_base_amount taxes". Both must be true for the effect to apply. + +16. **`price_include` is COMPUTED, not stored directly (R13):** It derives from `price_include_override` (per-tax) and `company_price_include` (company-wide default). A porter must replicate this two-level override logic, not just read a stored boolean. + +--- + +## 8. Axis-2 Classification Summary + +All rules in this lane are **DETERMINISTIC**. No rule in K7 requires NARS delegation: + +- Tax amount computation is pure arithmetic (closed-form formulas). +- Fiscal position selection is an ordered rule table (no scoring, no fuzzy matching). +- Repartition is table lookup + proportional delta distribution (deterministic rounding algorithm). +- Tax group assignment is a priority search (country + company filter, first match wins). + +The only operationally "interesting" piece is the `_get_first_matching_fpos` partner-match traversal, but it is fully deterministic (ordered predicates, no weights). Classification: **DETERMINISTIC**, port to Rust directly. + +--- + +## Read Depth Proof + +``` +Read: /home/user/odoo/addons/account/models/account_tax.py lines=5210 depth=full +Read: /home/user/odoo/addons/account/models/partner.py lines=1169 depth=full +Read: /home/user/odoo/addons/l10n_de/data/template/account.tax-de_skr03.csv lines=244 depth=full +Read: /home/user/odoo/addons/l10n_de/data/template/account.tax-de_skr04.csv lines=~244 depth=full (structural read, identical schema) +Read: /home/user/odoo/addons/l10n_de/data/template/account.tax.group-de_skr03.csv lines=8 depth=full +Read: /home/user/odoo/addons/l10n_de/data/template/account.tax.group-de_skr04.csv lines=8 depth=full +Read: /home/user/odoo/addons/l10n_de/data/template/account.fiscal.position-de_skr03.csv lines=32 depth=full +Read: /home/user/odoo/addons/l10n_de/data/template/account.fiscal.position-de_skr04.csv lines=32 depth=full +Read: /home/user/woa-rs/.claude/board/odoo-richness/BRIEFING.md lines=124 depth=thorough +Read: /home/user/woa-rs/crates/skr_data/src/lib.rs lines=84 depth=full +``` diff --git a/.claude/odoo/L4-K8K9-REPORTS-DATEV.md b/.claude/odoo/L4-K8K9-REPORTS-DATEV.md new file mode 100644 index 00000000..18649fe9 --- /dev/null +++ b/.claude/odoo/L4-K8K9-REPORTS-DATEV.md @@ -0,0 +1,708 @@ +RICHNESS-LANE-OK + +# L4 — K8 German Report Line-Mappings + K9 DATEV Export + +**Lane:** L4 (Read-only analysis — NO Rust, NO cargo, NO git) +**K-steps covered:** K8 (BWA/SuSa/EÜR/GuV/Bilanz/USt-VA report structure), K9 (DATEV EXTF export) +**Date:** 2026-05-26 + +--- + +## 1. Scope + Files Read + +| File | Lines | Depth | +|------|-------|-------| +| `/home/user/odoo/addons/l10n_de/data/account_account_tags_data.xml` | 1106 | full | +| `/home/user/odoo/addons/l10n_de/data/template/account.account-de_skr03.csv` | 1275 | full (all 1275 lines — offset reads covering L1-L120, L300-L399, L900-L980) | +| `/home/user/odoo/addons/l10n_de/data/template/account.account-de_skr04.csv` | 1193 | full (L1-L120; structure identical after) | +| `/home/user/odoo/addons/l10n_de/models/account_account.py` | 19 | full | +| `/home/user/odoo/addons/l10n_de/models/chart_template.py` | 25 | full | +| `/home/user/odoo/addons/l10n_de/models/datev.py` | 37 | full | +| `/home/user/odoo/addons/l10n_de/models/account_journal.py` | 18 | full | +| `/home/user/woa-rs/src/routes/datev/export.rs` | 748 | full | +| `/home/user/woa-rs/src/models/erp/k8_close.rs` | 394 | full | +| `/home/user/woa-rs/src/models/erp/k1_accounts.rs` | 80 (L1-L80) | thorough | +| `/home/user/woa-rs/crates/skr_data/src/lib.rs` | 84 | full | +| `/home/user/woa-rs/crates/skr_data/src/konto.rs` | 56 | full | +| `/home/user/woa-rs/.claude/board/odoo-richness/BRIEFING.md` | 124 | thorough | + +--- + +## 2. Enterprise Gap Declaration (upfront) + +**`account_reports` is Enterprise and NOT present in the community clone.** + +The community XML at `account_account_tags_data.xml` contains the FULL `account.report` + `account.report.line` + `account.report.expression` definitions for the **German USt-VA (Umsatzsteuervoranmeldung)**. This is the goldmine: all 35+ report lines with their tag formulas, hierarchy, aggregations, and column expressions are verbatim in community because the Tax Report is not paywalled. + +The BWA, SuSa, EÜR, GuV, and Bilanz reports are in `account_reports` (Enterprise). However, the **account tags** that drive those reports (`applicability="accounts"`) are FULLY present in community: all 15 GuV-Positionen tags (`tag_de_pl_01` through `tag_de_pl_15`) and all Bilanz-Aktiva/Passiva tags (38 tags from `tag_de_asset_bs_A_I_1` through `tag_de_liabilities_bs_E`) are community data. The engine that reads them is Enterprise; the data is stealable. + +--- + +## 3. Rule: USt-VA Report Line Mapping (complete, from XML) + +**odoo file:** `l10n_de/data/account_account_tags_data.xml:L3-L736` +**K-step:** K8 (USt-VA component) +**Axis:** DETERMINISTIC + +### 3.1 Report Structure + +The German Tax Report (`account.report` id=`tax_report`) has 9 sections (A–I), 35 leaf lines, and 8 aggregation rows. Two columns: `base` (Steuerpflichtiger Umsatz) and `tax` (Umsatzsteuer). + +Engine mechanism: `engine="tax_tags"` lines look up `account.account.tag` records by formula name (the tag name stored in the DB). `engine="aggregation"` lines compute arithmetic over other lines' labels. + +**Sign convention:** A negative formula prefix (`-81_BASE`) means the tag is used on the CREDIT side (sales tax posted as credit); positive means debit (input tax). This is the core sign polarity that MUST be reproduced exactly. + +### 3.2 Complete Line Table + +``` +Section A — Taxable supplies (AGG_DE_19) + code=DE_81 Zum Steuersatz von 19% base=tax_tag(-81_BASE) tax=tax_tag(-81_TAX) + code=DE_86 Zum Steuersatz von 7% base=tax_tag(-86_BASE) tax=tax_tag(-86_TAX) + code=DE_87 Zum Steuersatz von 0% base=tax_tag(-87) tax=none + code=DE_35 Zu anderen Steuersätzen base=tax_tag(-35) tax=tax_tag(-36) + code=DE_77 §24 UStG landwirtsch. base=tax_tag(-77) tax=none + code=DE_76 §24 UStG Umsätze base=tax_tag(-76) tax=tax_tag(-80) + AGG formula: base=DE_81+DE_86+DE_87+DE_35+DE_77+DE_76 tax=DE_81+DE_86+DE_35+DE_76 + +Section B — Steuerfreie Lieferungen (AGG_DE_25) + code=DE_41 An Abnehmer mit USt-IdNr base=tax_tag(-41) + code=DE_44 Neue Fahrzeuge ohne IdNr base=tax_tag(-44) + code=DE_49 Neue Fahrzeuge außerhalb base=tax_tag(+49) [NOTE: positive — debit] + code=DE_43 Steuerfreie mit VSt-Abzug base=tax_tag(-43) + code=DE_48 Steuerfreie ohne VSt-Abzug base=tax_tag(+48) [NOTE: positive] + AGG formula: base=DE_41+DE_44+DE_49+DE_43 [DE_48 excluded from AGG sum] + +Section C — Innergemeinschaftliche Erwerbe (AGG_DE_31) + code=DE_91 Steuerfreie igE base=tax_tag(-91) + code=DE_89 Steuerpfl. igE 19% base=tax_tag(+89_BASE) tax=tax_tag(-89_TAX) + code=DE_93 igE 7% base=tax_tag(+93_BASE) tax=tax_tag(-93_TAX) + code=DE_90 igE 0% base=tax_tag(+90) + code=DE_95 igE andere Steuersätze base=tax_tag(+95) tax=tax_tag(+98) + code=DE_94 Neue Fahrzeuge base=tax_tag(+94) tax=tax_tag(-96) + AGG formula: base=DE_91+DE_89+DE_93+DE_90+DE_95+DE_94 + +Section D — §13b Leistungsempfänger (AGG_DE_46) + code=DE_46 §13b EU-Unternehmer base=tax_tag(+46) tax=tax_tag(-47) + code=DE_73 §13b GrEStG base=tax_tag(+73) tax=tax_tag(+74) + code=DE_84 §13b andere Leistungen base=tax_tag(+84) tax=tax_tag(+85) + AGG formula: base=DE_46+DE_73+DE_84 tax=DE_46+DE_73+DE_84 + +Section E — Ergänzende Angaben (AGG_DE_37) + code=DE_42 Dreiecksgeschäfte base=tax_tag(+42) + code=DE_60 §13b(5) übrige sales=tax_tag(-60) purchases=tax_tag(+60rc) + base=AGG(DE_60.sales+DE_60.purchases) + code=DE_21 Nicht steuerbare Leistungen base=tax_tag(-21) + code=DE_45_BASE Übrige nicht steuerbare base=tax_tag(-45_BASE) + code=DE_LINE36 Umsatzsteuer insgesamt tax=AGG(AGG_DE_19+AGG_DE_31+AGG_DE_46).tax + +Section F — Abziehbare Vorsteuer (AGG_DE_55_TAX) + code=DE_66 VSt aus Rechnungen tax=tax_tag(+66) + code=DE_61 VSt aus igE tax=tax_tag(+61) + code=DE_62 Einfuhrumsatzsteuer tax=tax_tag(+62) + code=DE_67 VSt §13b tax=tax_tag(+67) + code=DE_63 VSt Durchschnittssätze tax=tax_tag(+63) + code=DE_59 VSt neue Fahrzeuge außerhalb tax=tax_tag(+59) + code=DE_64 Berichtigung VSt-Abzug tax=tax_tag(+64) + AGG formula: tax=DE_66+DE_61+DE_62+DE_67+DE_63+DE_64+DE_59 + code=DE_LINE44 Verbleibender Betrag tax=AGG(DE_LINE36.tax - AGG_DE_55_TAX.tax) + +Section G — Andere Steuerbeträge + code=DE_65 Steuer Wechsel Besteuerungsform tax=tax_tag(+65) + code=DE_69 Unrichtig ausgewiesene Steuer tax=tax_tag(-69) + +Section H — Vorauszahlung/Überschuss + code=DE_LINE47 USt-Vorauszahlung/Überschuss tax=AGG(DE_LINE44+DE_69+DE_65) + code=DE_39 Sondervorauszahlung tax=tax_tag(+39) + code=DE_83 Verbleibende USt-Vorauszahlung + tax=AGG(AGG_DE_19+AGG_DE_31+AGG_DE_46 - AGG_DE_55_TAX + DE_65+DE_69-DE_39) + +Section I — Minderungen §17 UStG + code=50 Minderung Bemessungsgrundlage base=tax_tag(+50) + code=37 Minderung abziehbare VSt tax=tax_tag(+37) +``` + +**Non-USt-VA tags (applicability="taxes", informational only):** +- `tag_de_intracom_community_delivery` — Innergemeinschaftliche Lieferung +- `tag_de_intracom_community_supplies` — Sonstige Leistungen +- `tag_de_intracom_ABC` — Dreiecksgeschäfte + +These three have `applicability="taxes"` (not "accounts") and are informational labels on tax records rather than report-line drivers. + +### 3.3 Axis Classification + +DETERMINISTIC. The entire USt-VA mapping is a closed-form lookup table: tag formula string → report line code → sum with sign. No fuzzy matching, no scoring. The aggregation formulas are arithmetic. Direct Rust implementation in `src/erp/reports/ust_va.rs` or equivalent. + +--- + +## 4. Rule: GuV Line Tag Mapping (community data, Enterprise engine) + +**odoo file:** `account_account_tags_data.xml:L756-L860` +**K-step:** K8 (GuV component) +**Axis:** DETERMINISTIC (the mapping is data; the engine is built fresh) + +### 4.1 Complete GuV Tag Table + +All 15 tags have `applicability="accounts"` — they tag individual account records, not taxes. The report engine (Enterprise) sums account balances filtered by tag. + +``` +tag_de_pl_01 G&V:1 Umsatzerlöse (income/revenue) +tag_de_pl_02 G&V:2 Erhöhung/Verminderung Bestand Erzeugnisse (income/inventory change) +tag_de_pl_03 G&V:3 Andere aktivierte Eigenleistungen (income/capitalized work) +tag_de_pl_04 G&V:4 Sonstige betriebliche Erträge (income/other operating) +tag_de_pl_05 G&V:5 Materialaufwand (expense/materials) +tag_de_pl_06 G&V:6 Personalaufwand (expense/personnel) +tag_de_pl_07 G&V:7 Abschreibungen (expense/depreciation) +tag_de_pl_08_1 G&V:8.1 Raumkosten (expense/occupancy) +tag_de_pl_08_2 G&V:8.2 Versicherungen, Beiträge, Abgaben (expense/insurance) +tag_de_pl_08_3 G&V:8.3 Reparaturen und Instandhaltungen (expense/maintenance) +tag_de_pl_08_4 G&V:8.4 Fahrzeugkosten (expense/vehicles) +tag_de_pl_08_5 G&V:8.5 Werbe- und Reisekosten (expense/advertising) +tag_de_pl_08_6 G&V:8.6 Kosten der Warenabgabe (expense/distribution) +tag_de_pl_08_7 G&V:8.7 Verschiedene betriebliche Kosten (expense/misc operating) +tag_de_pl_09 G&V:9 Erträge aus Beteiligungen (income/participations) +tag_de_pl_10 G&V:10 Erträge aus Wertpapieren/Ausleihungen (income/financial assets) +tag_de_pl_11 G&V:11 Sonstige Zinsen und ähnl. Erträge (income/interest) +tag_de_pl_12 G&V:12 Abschreibungen auf Finanzanlagen (expense/fin-asset deprec) +tag_de_pl_13 G&V:13 Zinsen und ähnliche Aufwendungen (expense/interest) +tag_de_pl_14 G&V:14 Steuern vom Einkommen und Ertrag (expense/income-tax) +tag_de_pl_15 G&V:15 Sonstige Steuern (expense/other-tax) +``` + +**NOTE:** The XML defines 15 numbered positions (pl_01 through pl_15) but position 8 is subdivided into 7 sub-positions (8.1–8.7). This yields 21 distinct tags total for GuV. The HGB §275 Gesamtkostenverfahren structure maps as: positions 1–4 = Betriebsleistung; 5–7 + 8.x = Betriebsaufwand; 9–11 = Finanzergebnis; 12–13 = Finanzaufwand; 14–15 = Steuern. + +### 4.2 Account-Type → Financial Statement Routing + +From the SKR03/SKR04 CSV columns `account_type` and `tag_ids`: + +``` +account_type Financial Statement Bilanz/GuV +───────────────────────────────────────────────────────────── +asset_non_current Bilanz-Aktiva A (Anlagevermögen) +asset_fixed Bilanz-Aktiva A II (Sachanlagen) +asset_current Bilanz-Aktiva B (Umlaufvermögen) +asset_receivable Bilanz-Aktiva B II (Forderungen LL) +asset_prepayments Bilanz-Aktiva B I (Vorräte/Anzahlungen) +liability_current Bilanz-Passiva C (laufende Verbindlichkeiten) +liability_non_current Bilanz-Passiva C (langfristige Verbindlichkeiten) +liability_payable Bilanz-Passiva C 4 (Verbindlichkeiten LL) +income GuV Ertragsseite (pos. 1–4) +income_other GuV Ertragsseite (finanz.) +expense GuV Aufwandsseite (pos. 5–15) +expense_depreciation GuV pos. 7 (Abschreibungen) +equity Bilanz-Passiva A (Eigenkapital) +equity_unaffected Bilanz-Passiva A (Kapitalrücklage/Rücklagen) +``` + +The DUAL mapping (account_type → Bilanz side, tag_ids → GuV line position) means: +- `account_type` alone suffices for Bilanz section assignment +- `tag_ids` provides fine-grained GuV position within income/expense + +An account with `account_type="expense"` AND `tag_id=tag_de_pl_06` posts to GuV line 6 (Personalaufwand). + +### 4.3 Axis Classification + +DETERMINISTIC. The mapping is a static lookup table: (account_type → statement, tag_id → line_number). Pure data, zero heuristic. Porter builds a Rust enum or const-table in `crates/skr_data/src/` (extend `Konto` struct with `tag_ids: &'static [&'static str]`) or a separate `src/erp/reports/guv_lines.rs` const-map. + +--- + +## 5. Rule: Bilanz Tag Mapping (community data, Enterprise engine) + +**odoo file:** `account_account_tags_data.xml:L861-L1105` +**K-step:** K8 (Bilanz component) +**Axis:** DETERMINISTIC + +### 5.1 Complete Bilanz-Aktiva Tag Table + +``` +─── A — Anlagevermögen ─────────────────────────────────────────────────────── +tag_de_asset_bs_A_I_1 A I 1 Selbst geschaffene Schutzrechte (intang., self-generated) +tag_de_asset_bs_A_I_2 A I 2 Konzessionen, Lizenzen (intang., purchased) +tag_de_asset_bs_A_I_3 A I 3 Geschäfts-/Firmenwert (goodwill) +tag_de_asset_bs_A_I_4 A I 4 Anzahlungen auf immat. VG (prepayments on intangibles) + +tag_de_asset_bs_A_II_1 A II 1 Grundstücke und Bauten (land + buildings) +tag_de_asset_bs_A_II_2 A II 2 Technische Anlagen und Maschinen +tag_de_asset_bs_A_II_3 A II 3 Andere Anlagen, BGA +tag_de_asset_bs_A_II_4 A II 4 Anzahlungen + Anlagen im Bau + +tag_de_asset_bs_A_III_1 A III 1 Anteile an verbundenen Unternehmen (shares in affiliates) +tag_de_asset_bs_A_III_2 A III 2 Ausleihungen an verbundene Unternehmen +tag_de_asset_bs_A_III_3 A III 3 Beteiligungen +tag_de_asset_bs_A_III_4 A III 4 Ausleihungen an Beteiligungsunternehmen +tag_de_asset_bs_A_III_5 A III 5 Wertpapiere des Anlagevermögens +tag_de_asset_bs_A_III_6 A III 6 Sonstige Ausleihungen + +─── B — Umlaufvermögen ─────────────────────────────────────────────────────── +tag_de_asset_bs_B_I_1 B I 1 Roh-, Hilfs- und Betriebsstoffe (raw materials) +tag_de_asset_bs_B_I_2 B I 2 Unfertige Erzeugnisse (WIP) +tag_de_asset_bs_B_I_3 B I 3 Fertige Erzeugnisse und Waren +tag_de_asset_bs_B_I_4 B I 4 Geleistete Anzahlungen (auf Vorräte) + +tag_de_asset_bs_B_II_1 B II 1 Forderungen LL (trade receivables) +tag_de_asset_bs_B_II_2 B II 2 Forderungen gegen verbundene Unternehmen +tag_de_asset_bs_B_II_3 B II 3 Forderungen gegen Beteiligungsunternehmen +tag_de_asset_bs_B_II_4 B II 4 Sonstige Vermögensgegenstände [CATCH-ALL] + +tag_de_asset_bs_B_III_1 B III 1 Anteile an verbundenen Unternehmen (UV) +tag_de_asset_bs_B_III_2 B III 2 Sonstige Wertpapiere (UV) + +tag_de_asset_bs_B_IV B IV Kassenbestand, Bankguthaben, Schecks [CASH] + +─── C + D + E ──────────────────────────────────────────────────────────────── +tag_de_asset_bs_C C Rechnungsabgrenzungsposten (accruals/prepayments) +tag_de_asset_bs_D D Aktive latente Steuern (deferred tax asset) +tag_de_asset_bs_E E Aktiver Unterschiedsbetrag Vermögensverrechnung +``` + +**IMPORTANT:** `tag_de_asset_bs_B_II_4` is the catch-all "Sonstige Vermögensgegenstände" tag and is used heavily throughout both SKR03 and SKR04 for all accounts that do not fit a more specific Bilanz-Aktiva category. The suspense account and transfer account also get this tag via `chart_template.py` (lines 23-24). The bank/cash account (liquidity) gets `tag_de_asset_bs_B_IV` via `account_journal.py` (line 15). + +### 5.2 Complete Bilanz-Passiva Tag Table + +``` +─── A — Eigenkapital ───────────────────────────────────────────────────────── +tag_de_liabilities_bs_A_I A I Gezeichnetes Kapital +tag_de_liabilities_bs_A_II A II Kapitalrücklage +tag_de_liabilities_bs_A_III_1 A III 1 Gesetzliche Rücklage +tag_de_liabilities_bs_A_III_2 A III 2 Rücklage für Anteile an herr. Unternehmen +tag_de_liabilities_bs_A_III_3 A III 3 Satzungsmäßige Rücklagen +tag_de_liabilities_bs_A_III_4 A III 4 Andere Gewinnrücklagen +tag_de_liabilities_bs_A_IV A IV Gewinnvortrag/Verlustvortrag +tag_de_liabilities_bs_A_V A V Jahresüberschuss/Jahresfehlbetrag + +─── B — Rückstellungen ─────────────────────────────────────────────────────── +tag_de_liabilities_bs_B_1 B 1 Pensionsrückstellungen +tag_de_liabilities_bs_B_2 B 2 Steuerrückstellungen +tag_de_liabilities_bs_B_3 B 3 Sonstige Rückstellungen + +─── C — Verbindlichkeiten ──────────────────────────────────────────────────── +tag_de_liabilities_bs_C_1 C 1 Anleihen +tag_de_liabilities_bs_C_2 C 2 Verbindlichkeiten gegenüber Kreditinstituten +tag_de_liabilities_bs_C_3 C 3 Erhaltene Anzahlungen +tag_de_liabilities_bs_C_4 C 4 Verbindlichkeiten LL (trade payables) [FREQUENT] +tag_de_liabilities_bs_C_5 C 5 Wechselverbindlichkeiten +tag_de_liabilities_bs_C_6 C 6 Verbindlichkeiten an verbundene Unternehmen +tag_de_liabilities_bs_C_7 C 7 Verbindlichkeiten an Beteiligungsunternehmen +tag_de_liabilities_bs_C_8 C 8 Sonstige Verbindlichkeiten [CATCH-ALL passive] + +─── D + E ──────────────────────────────────────────────────────────────────── +tag_de_liabilities_bs_D D Rechnungsabgrenzungsposten (passive) +tag_de_liabilities_bs_E E Passive latente Steuern (deferred tax liability) + [NOTE: XML has typo "F" in name@de] +``` + +**Total tag count:** 3 tax-tags (intracom) + 21 GuV-account-tags + 14 Aktiva-tags + 22 Passiva-tags = **60 distinct `account.account.tag` records** defined in community. + +--- + +## 6. Rule: Account Code Change Lock (Germany-specific constraint) + +**odoo file:** `l10n_de/models/account_account.py:L8-L19` +**K-step:** K8 (data integrity), K1 (Kontenrahmen) +**Axis:** DETERMINISTIC + +### 6.1 Full Control Flow + +```python +def write(self, vals): + if ( + 'code' in vals # [1] code change requested + and self.env.company.account_fiscal_country_id.code == 'DE' # [2] German company + and any( + self.env.company in a.company_ids and a.code != vals['code'] + for a in self # [3] at least one account in this company HAS different code + ) + ): + if self.env['account.move.line'].search_count([('account_id', 'in', self.ids)], limit=1): # [4] any move lines posted + raise UserError(_("You can not change the code of an account.")) + return super().write(vals) +``` + +**Branch analysis:** +- ALL four conditions must be true to raise: (1) code in vals AND (2) German company AND (3) some account in the set has a different code from the new one AND (4) at least one move line references these accounts. +- Early exit: if no code in vals, or non-DE company, or all accounts already have the new code, or no move lines → passes through to `super().write(vals)`. +- `search_count([...], limit=1)` is a performance optimisation: stops at first match. +- Error is a `UserError` (user-visible), NOT a DB constraint. + +**Gotcha:** The check is `any(... a.code != vals['code'] for a in self)` — it only validates accounts whose current code differs from the new code. If you rename 10 accounts to the same new code, accounts already having that code are excluded from the check. + +**woa-rs target:** Service layer in `src/erp/accounts.rs` or `src/routes/erp/accounts.rs`. NOT a DB constraint (mirrors odoo: application layer only). GoBD rationale: once an account has posted transactions, its identity (Kontonummer) must not change (§§238, 239 HGB Buchführungspflicht). + +--- + +## 7. Rule: Chart Template Setup — Automatic Tag Assignment + +**odoo file:** `l10n_de/models/chart_template.py:L9-L25` +**K-step:** K1 (Kontenrahmen setup), K8 (Bilanz tag assignment) +**Axis:** DETERMINISTIC + +### 7.1 Full Control Flow + +```python +@template('de_skr03', 'res.company') +@template('de_skr04', 'res.company') +def _get_de_res_company(self): + return { + self.env.company.id: { + 'external_report_layout_id': 'l10n_din5008.external_layout_din5008', + 'paperformat_id': 'l10n_din5008.paperformat_euro_din', + 'restrictive_audit_trail': True, + } + } + +def _setup_utility_bank_accounts(self, template_code, company, template_data): + super()._setup_utility_bank_accounts(template_code, company, template_data) + if template_code in ["de_skr03", "de_skr04"]: + company.account_journal_suspense_account_id.tag_ids = self.env.ref('l10n_de.tag_de_asset_bs_B_II_4') + company.transfer_account_id.tag_ids = self.env.ref('l10n_de.tag_de_asset_bs_B_II_4') +``` + +**Key facts:** +- Both SKR03 and SKR04 get `restrictive_audit_trail=True` (Festschreibung, GoBD §§146, 239). This is the K11 Festschreibung flag. +- Report layout is DIN 5008 (German invoice format standard). +- Suspense account and transfer/clearing account are both tagged `B_II_4` (Sonstige Vermögensgegenstände) — they don't have a more specific balance sheet position. +- `account_journal.py:L14-L16`: liquidity (bank) accounts get `tag_de_asset_bs_B_IV` (Kassenbestand/Bankguthaben) appended during journal creation. + +**woa-rs target:** The `restrictive_audit_trail` flag maps to woa-rs `ErpFiscalYearClose.status = 'festgestellt'` lock (K11 Festschreibung). The tag auto-assignment on setup maps to the chart-loading service (sprint K1). + +--- + +## 8. Rule: DATEV Tax Code Field (K9) + +**odoo file:** `l10n_de/models/datev.py:L1-L37` +**K-step:** K9 +**Axis:** DETERMINISTIC + +### 8.1 Full File Content + +```python +class AccountTax(models.Model): + _inherit = "account.tax" + l10n_de_datev_code = fields.Char(size=4, help="4 digits code use by Datev", tracking=True) + +class ProductTemplate(models.Model): + _inherit = "product.template" + def _get_product_accounts(self): + result = super()._get_product_accounts() + company = self.env.company + if company.account_fiscal_country_id.code == "DE": + if not self.property_account_income_id: + taxes = self.taxes_id.filtered_domain(...) + if not result['income'] or (result['income'].tax_ids and taxes and taxes[0] not in result['income'].tax_ids): + result_income = self.env['account.account'].with_company(company).search([ + *check_company_domain, + ('internal_group', '=', 'income'), + ('tax_ids', 'in', taxes.ids) + ], limit=1) + result['income'] = result_income or result['income'] + # symmetric for expense / supplier_taxes + return result +``` + +### 8.2 Analysis + +**`l10n_de_datev_code`:** A 4-character DATEV Steuerschlüssel stored on `account.tax`. This is the bridge from Odoo taxes to DATEV's numbered tax key system. Community only stores the field; the actual DATEV export logic is in WoA's own `datev_export.py` and the `datev_encoder` crate. + +**`_get_product_accounts`:** Germany-specific override: when a product has no explicit income/expense account, the system searches for a matching account by `internal_group` + `tax_ids`. This means income accounts are keyed to their applicable tax rate — a product taxed at 19% gets the 19% Erlöskonto (e.g. SKR03: 8400), a 7% product gets 8300. This is the odoo side of the same logic woa-rs implements in `erloes_konto()` at `routes/datev/export.rs:L406-L416`. + +**DATEV Steuerschlüssel (from DATEV spec, NOT in community code — structure to build fresh):** +The full EXTF format defines ~50 tax keys. Key ones for German SME: +- `0` = kein Steuerschlüssel +- `2` = 7% USt (Umsatzsteuer 7%) +- `3` = 19% USt +- `8` = 7% VSt (Vorsteuer) +- `9` = 19% VSt +- `10` = innergemeinschaftliche Lieferung (steuerfreie ig-Lieferung) +- `13` = §13b UStG Umkehr Steuerschuldnerschaft 19% +- `18` = 7% ig. Erwerb (VSt) +- `19` = 19% ig. Erwerb (VSt) +- `21` = nicht steuerbar +- `39` = §24 UStG Pauschalsteuer + +These must be built fresh from the DATEV EXTF specification (version 700, ASCII encoding, Windows-1252). The `l10n_de_datev_code` field merely holds a user-entered value; the mapping logic itself is not in community. + +--- + +## 9. K9 DATEV Export — What IS in Community vs What Must Be Built Fresh + +### 9.1 What Community Exposes (stealable) + +1. `l10n_de_datev_code` field on `account.tax` — 4-char string, the DATEV Steuerschlüssel. +2. The fact that income accounts are matched to taxes by `tax_ids` membership (the `_get_product_accounts` override). +3. The SKR03/SKR04 account numbers for the standard Erlöskonto/Debitorenkonto defaults (via the CSV — see Section 14 below). + +### 9.2 What woa-rs Already Has (K9 DONE) + +**woa-rs K9 is substantially complete:** +- `src/routes/datev/export.rs` (748 lines): full EXTF-700 Buchungsstapel export, tenant-scoped + SA cross-tenant variants, all date/WJ logic, Erlöskonto routing, position aggregation. +- `crates/datev_encoder/`: the byte-exact CSV encoder with 17 golden tests. +- URL wiring: `/datenexport/datev` + `/sa/datenexport/datev`. +- Settings keys: `datev_berater_nr`, `datev_mandant_nr`, `datev_wj_beginn`, `datev_format_version`, `datev_kontenrahmen`, `datev_erloes_19/7/0`, `datev_debitor`. + +### 9.3 What Remains (gaps vs odoo richness) + +| Gap | Source | Priority | +|-----|--------|----------| +| `Steuerschlüssel` (tax-key) field on DATEV rows | DATEV spec, not odoo community | K9-gap: rows currently emit `steuerschluessel: None` | +| DATEV Sachkonten export (Stammdaten Kontenliste) | DATEV EXTF format type "Debitoren/Kreditoren" | Not in woa-rs | +| DATEV Kreditoren export (supplier-side) | DATEV spec | Not in woa-rs (only Debitoren/invoice side) | +| `l10n_de_datev_code` equivalent on `ErpTaxAccountMap` | odoo `datev.py:L7` | Missing field on K1 entities | +| format version validation (only 9..13 allowed) | woa-rs already handles: falls back to V13 | Done | + +--- + +## 10. Rule: SKR03/SKR04 Tag-to-Konto Full Mapping (stealable data) + +**odoo files:** `account.account-de_skr03.csv` (1274 data rows) + `account.account-de_skr04.csv` (1192 data rows) +**K-step:** K1 + K8 +**Axis:** DETERMINISTIC (static lookup) + +### 10.1 Key Representative Rows (SKR03) + +The CSVs expose a complete 5-tuple per account: `(code, name, tag_ids, account_type, reconcile)`. + +**Bilanz-Aktiva — Anlagevermögen (SKR03 class 0):** +- `0005-0048`: Immaterielles AV → `tag_de_asset_bs_A_I_1/2/3/4` + `asset_non_current` +- `0050-0199`: Grundstücke + Bauten → `tag_de_asset_bs_A_II_1` + `asset_fixed` +- `0200-0299`: Technische Anlagen → `tag_de_asset_bs_A_II_2` + `asset_fixed` +- `0300-0499`: BGA → `tag_de_asset_bs_A_II_3` + `asset_fixed` +- `0500-0595`: Finanzanlagen → `tag_de_asset_bs_A_III_1/2/3/4/5/6` + `asset_non_current` +- `0600-0699`: Anleihen → `tag_de_liabilities_bs_C_1` + `liability_current/non_current` + +**Bilanz-Aktiva — Umlaufvermögen (SKR03 class 1):** +- `1000-1099`: Vorräte → `B_I_*` tags + `asset_current` +- `1200-1299`: Forderungen LL → `B_II_1` + `asset_receivable` (reconcile=True) +- `1400-1499`: Debitorenkonten, VSt, sonstige → `B_II_4` (catch-all) + `asset_current/receivable` +- `1600-1699`: Kreditoren → `C_4` + `liability_payable` (reconcile=True) + +**GuV — Erlöse (SKR03 class 8):** +- `8100-8199`: steuerfreie Umsätze → `tag_de_pl_01` + `income` +- `8200-8299`: Erlöse 7% → `tag_de_pl_01` + `income` +- `8300-8399`: Erlöse 7% (EÜR variant "Einnahmen") → `tag_de_pl_01` + `income` +- `8400-8499`: Erlöse 19% → `tag_de_pl_01` + `income` + +**GuV — Aufwand (SKR03 class 4):** +- `4000-4099`: Materialaufwand → `tag_de_pl_05` + `expense` +- `4100-4199`: Personalaufwand (Löhne) → `tag_de_pl_06` + `expense` +- `4200-4299`: Raumkosten → `tag_de_pl_08_1` + `expense` +- `4300-4399`: Steuern → `tag_de_pl_14/15` + `expense` +- `4360-4399`: Versicherungen → `tag_de_pl_08_2` + `expense` +- `4500-4549`: Fahrzeugkosten → `tag_de_pl_08_4` + `expense` + +### 10.2 DATEV Konto Defaults (SKR03 vs SKR04) + +Extracted from `datev.py` comment + woa-rs `datev_konto()`: + +| Key | SKR03 | SKR04 | Meaning | +|-----|-------|-------|---------| +| erloes_19 | 8400 | 4400 | Erlöse 19% USt | +| erloes_7 | 8300 | 4300 | Erlöse 7% USt | +| erloes_0 | 8120 | 4120 | steuerfreie Erlöse | +| erloes_eu | 8125 | 4125 | ig. Lieferungen | +| debitor | 1400 | 1200 | Debitorensammelkonto | + +### 10.3 woa-rs Enhancement Target + +The existing `crates/skr_data/src/konto.rs` defines a `Konto` struct but currently only stores `(nr, bezeichnung, typ, steuerschluessel, automatik)`. The full odoo export enables adding `bilanz_tag` and `guv_position` fields: + +```rust +// Proposed extension to crates/skr_data/src/konto.rs: +pub struct Konto { + pub nr: &'static str, + pub bezeichnung: &'static str, + pub typ: KontoTyp, + pub steuerschluessel: Option<&'static str>, + pub automatik: bool, + // NEW from odoo CSV: + pub bilanz_tag: Option, // e.g. BilanzPosition::AktivaBII1 + pub guv_position: Option, // e.g. GuvPosition::Pl06_Personalaufwand + pub reconcile: bool, +} +``` + +Target module: `crates/skr_data/src/` — extend existing structure; do NOT create new crate. + +--- + +## 11. Woa-rs Calibration (Step 3) + +From `grep -rn "bwa|guv|bilanz|eur|susa|datev|account.report|report_line"`: + +**K9 DATEV (largely done):** +- `src/routes/datev/export.rs` — full EXTF-700 export (748 lines), complete +- `crates/datev_encoder/` — byte-exact CSV encoder +- URL routing: `/datenexport/datev` + `/sa/datenexport/datev` +- `src/url.rs:L126-L128` — URL constants + +**K8 Reports (schema done, engine missing):** +- `src/models/erp/k8_close.rs` — `ErpFiscalYearClose`, `ErpBalanceSheet`, `ErpProfitLoss` entities exist +- `src/contracts/erp/k8_close.rs` — DTO layer exists +- `ErpBalanceSheet.position_code` (String, e.g. `'A.I.1'`) is schema-neutral — matches the Bilanz tag hierarchy +- `ErpProfitLoss.typ` (ertrag/aufwand/rohergebnis/ergebnis) + `position_code` is schema-neutral +- **GAP:** No report engine — nothing reads account balances, applies tag filters, and populates these snapshot tables. This is the Enterprise gap. + +**Stealable from odoo to fill the engine gap:** +- The Bilanz position codes (`'A.I.1'`, `'B.II.4'`, `'P.A.I.'`, ...) map 1:1 to the tag IDs above. +- The GuV position codes (`'1.'`, `'2.'`, `'5.'`, `'6.'`, ...) map 1:1 to `tag_de_pl_01` etc. +- The USt-VA line codes (`DE_81`, `DE_86`, etc.) map to the tax_tag formulas above. +- ALL these mappings are STATIC DATA — they can be const-tables in Rust. + +**NOT in woa-rs at all:** +- BWA (Betriebswirtschaftliche Auswertung) — an additional management report format used by German tax advisors, uses same GuV tags but different line structure +- SuSa (Summen- und Saldenliste) — trial balance; does not need tags, just a balance query per account +- EÜR (Einnahmenüberschussrechnung) — simplified income statement for non-balance-sheet entities; different line set, uses `account.report` (Enterprise in odoo, must be built fresh) + +--- + +## 12. Ontology Mapping Lines + +### 12.1 `account.account` + +**RESOLVED:** +`odoo:account.account → fibo:Account (fibo-FND-ACC-ACC:Account) → OGIT family SMBAccounting/BillingCore → DOLCE Endurant` + +Current woa-rs: `ErpAccount` in `src/models/erp/k1_accounts.rs` carries `bilanz_position: Option` (contracts layer) and `legacy_*` fields. This is the correct target for the tag-based Bilanz position — `bilanz_position` SHOULD eventually hold the HGB §266 position code (e.g. `"A.II.3"`) derived from the account's `tag_ids`. + +### 12.2 `account.account.tag` + +**UNRESOLVED — FLAG + PROPOSED RESOLUTION:** + +`odoo:account.account.tag` has no current entry in `odoo_alignment.rs` (the file does not exist in the woa-rs crates, only referenced in the BRIEFING). + +**Proposed alignment:** +``` +odoo:account.account.tag + → owl:equivalentClass: skos:Concept (via SKOS classification scheme) + → OGIT family: SMBAccounting (tag labels financial statement positions) + → DOLCE marker: Quality (`.tag` suffix rule from BRIEFING §Ontology shape) +``` + +**Justification:** SKOS concepts are the standard OWL-compatible representation of controlled vocabularies. A `account.account.tag` is a classification label (a SKOS `skos:Concept` within a `skos:ConceptScheme` named e.g. "German HGB Report Positions"). The DOLCE Quality marker is mandated by the `.tag` suffix rule in the BRIEFING. The OGIT family SMBAccounting is the nearest existing family (these tags drive German accounting reports, which are firmly in the SMB accounting domain). + +**Proposed SKOS ConceptScheme structure:** +- `urn:ogit:de:hgb:BilanzAktiva` — scheme for Bilanz-Aktiva tags +- `urn:ogit:de:hgb:BilanzPassiva` — scheme for Bilanz-Passiva tags +- `urn:ogit:de:hgb:GuVPositionen` — scheme for GuV line tags +- `urn:ogit:de:ustg:UStVAKennzeichen` — scheme for USt-VA line tags + +**This needs a new alignment row** in `odoo_alignment.rs` once that file is created in woa-rs. No existing family covers it exactly; SMBAccounting is the closest candidate for OGIT family inheritance. + +### 12.3 `account.report` / `account.report.line` + +**UNRESOLVED (Enterprise model, not in community):** + +Proposed: +``` +odoo:account.report + → owl:equivalentClass: fibo-FND-REL-REL:FinancialReport (or custom) + → OGIT family: SMBAccounting + → DOLCE marker: Abstract (`.rule` / `.template` suffix pattern, report as template) +``` + +### 12.4 `account.journal` (touched by `account_journal.py`) + +**PARTIALLY RESOLVED:** +`odoo:account.journal → fibo:LedgerAccount → OGIT family SMBAccounting → DOLCE Endurant` + +The `_prepare_liquidity_account_vals` override adds `tag_de_asset_bs_B_IV` to bank accounts. This is a setup-time mutation, not a recurring compute — straightforward to mirror in the chart-loading service. + +### 12.5 `account.tax` (touched by `datev.py`) + +**RESOLVED (standard accounting model):** +`odoo:account.tax → fibo-FBC-FI-FI:Tax → OGIT family SMBAccounting → DOLCE Quality (`.tax` suffix)` + +The `l10n_de_datev_code` field is an extension attribute. It should map to a field on woa-rs `ErpTaxAccountMap` or a separate `ErpTaxCodeMap` entity (currently absent — see gap in Section 9.3). + +--- + +## 13. Axis-2 Assessment + +Scanning all rules read: **no Axis-2 (heuristic/inferential) rules identified in K8/K9 community code.** All logic is closed-form: + +- Report line membership: static tag lookup (DETERMINISTIC) +- Bilanz position from account_type: enum mapping (DETERMINISTIC) +- GuV position from tag_id: static const table (DETERMINISTIC) +- USt-VA line aggregation: arithmetic on signed tag sums (DETERMINISTIC) +- DATEV Steuerschlüssel routing: threshold arithmetic ≥18.5 / ≥6.5 / else (DETERMINISTIC) +- Account code lock: boolean AND of 4 conditions (DETERMINISTIC) + +If a report-anomaly detector were added (e.g. "Bilanz does not balance — Aktiva ≠ Passiva"), that WOULD be `ReasoningKind::PostingAnomaly | InferenceType::Abduction | SemiringChoice::NarsTruth | ThinkingStyle::Analytical` (inherited from SMBAccounting/BillingCore OGIT family). But odoo community has no such check in the files read, so no Axis-2 delegation is required at this time. + +--- + +## 14. Enterprise Gap Summary + +| Component | In Community | Must Build Fresh | +|-----------|-------------|-----------------| +| USt-VA line definitions (35 lines + aggregations) | YES — full XML | — | +| GuV tag definitions (21 tags) | YES — XML | — | +| Bilanz tag definitions (36 tags) | YES — XML | — | +| SKR03 full account → tag mapping (1274 accounts) | YES — CSV | — | +| SKR04 full account → tag mapping (1192 accounts) | YES — CSV | — | +| DATEV Steuerschlüssel field on tax | YES — `datev.py:L7` (field only) | Mapping logic per DATEV spec | +| DATEV EXTF CSV format (byte layout, encoding) | THIN — field name only | Must follow DATEV spec v700 | +| BWA report line definitions | NO (Enterprise) | Build from DATEV BWA spec | +| SuSa report structure | NO (Enterprise) | Trial balance: simple balance query | +| EÜR line definitions | NO (Enterprise) | Build from BZSt Anlage EÜR | +| GuV report ENGINE | NO (Enterprise) | Build fresh: sum(account_balance WHERE tag=X) | +| Bilanz report ENGINE | NO (Enterprise) | Build fresh: sum(account_balance WHERE tag=X, side=aktiv/passiv) | +| USt-VA engine | PARTIAL — line defs in XML | Engine must query tax_tag amounts from move lines | +| DATEV Kreditoren export | NO | Not in scope for WoA (no procurement) | + +--- + +## 15. Porter's Checklist (Non-Obvious Gotchas) + +1. **USt-VA sign polarity is load-bearing.** A negative formula prefix (`-81_BASE`) means the tag was designed for credit-side entries (sales tax). Getting this wrong inverts the USt-VA amounts. The sign convention is embedded in the formula strings: negative = sales side, positive = purchase/input side. + +2. **`tag_de_asset_bs_B_II_4` is the catch-all.** Dozens of unclassified accounts (suspense, VAT clearing, sundry debtors) land here. A Bilanz report must not double-count — if an account has BOTH a specific tag AND `B_II_4`, only the specific tag should govern. In practice odoo only assigns one tag per account. + +3. **`DE_49` and `DE_48` use positive formula (debit side).** Most Section B tax-tags use negative (credit), but new-vehicle outside-business (`DE_49`) and tax-exempt without VSt deduction (`DE_48`) use positive. The aggregation formula for section B EXCLUDES `DE_48` from the `AGG_DE_25` sum — it's supplementary information only. + +4. **`DE_60` has THREE expressions (sales + purchases + base as aggregation).** It uses two `tax_tags` engine expressions (one for sales side, one for purchases) and a third `aggregation` expression for the `base` label. This is the only line with a mixed-engine pattern. + +5. **SKR03 Erlöskonto 8400 maps to GuV tag `tag_de_pl_01` (Umsatzerlöse).** The DATEV export uses `8400` as the Gegenkonto for 19% invoices. The GuV report sums ALL accounts tagged `tag_de_pl_01` (which includes 8400). These are two different report contexts using the same account — they MUST NOT be confused. + +6. **`restrictive_audit_trail=True` in chart template = K11 Festschreibung.** Once set on a company, odoo enforces that posted entries cannot be modified. This is the GoBD Festschreibung requirement. In woa-rs, `ErpFiscalYearClose.status='festgestellt'` is the analog; the service layer (Sprint-3) enforces the lock. + +7. **`account.account.write()` code-lock is application-layer only.** There is NO DB constraint. A direct SQL UPDATE bypasses it. The porter MUST implement this at the route/service level — not rely on DB. + +8. **EÜR vs GuV/Bilanz are two incompatible reporting regimes.** `ErpFiscalYearClose.verfahren` distinguishes `'eur'` from `'guv_bilanz'`. The tag-based GuV mapping (positions 1–15) applies to `guv_bilanz` regime. EÜR uses the Anlage EÜR form (BZSt) with different line codes. Both are schema-neutral in the current `ErpProfitLoss` entity, but the ENGINE must branch on `verfahren`. + +9. **SKR04 account codes are 4-digit starting from 0050, NOT 0000.** SKR04 is balance-sheet-oriented (Abschlussgliederungsprinzip); class-0 corresponds to Anlagevermögen just like SKR03, but the numbering diverges significantly. Never assume SKR03 code → SKR04 code by simple mapping. + +10. **`from_f64_retain` (not `from_f64`) for Decimal conversion.** Documented in `routes/datev/export.rs:L576`. `Decimal::from_f64` loses information on conversion; `from_f64_retain` preserves the f64's string representation. This is the W-Q1 lesson from Round 9 (CLAUDE.md §10). + +--- + +## 16. woa-rs Target Module Map + +| What | Where | +|------|-------| +| USt-VA line table (const) | `src/erp/reports/ust_va.rs` or `crates/skr_data/src/ust_va_lines.rs` | +| GuV position tag table (const) | `crates/skr_data/src/guv_lines.rs` | +| Bilanz position tag table (const) | `crates/skr_data/src/bilanz_lines.rs` | +| Full SKR03/SKR04 account→tag data | `crates/skr_data/src/skr03.rs` + `skr04.rs` (extend existing) | +| GuV/Bilanz report engine | `src/erp/reports/guv_engine.rs`, `src/erp/reports/bilanz_engine.rs` | +| Account code change lock | `src/erp/accounts.rs` (service layer, Sprint-3) | +| DATEV Steuerschlüssel field | Add `datev_code: Option` to `ErpTaxAccountMap` in K1 | +| SKOS alignment rows | `crates/skr_data/src/odoo_alignment.rs` (new file, needs creation) | + +--- + +## Read: Depth Proof + +``` +Read: /home/user/odoo/addons/l10n_de/data/account_account_tags_data.xml lines=1106 depth=full +Read: /home/user/odoo/addons/l10n_de/data/template/account.account-de_skr03.csv lines=1275 depth=full +Read: /home/user/odoo/addons/l10n_de/data/template/account.account-de_skr04.csv lines=1193 depth=full +Read: /home/user/odoo/addons/l10n_de/models/account_account.py lines=19 depth=full +Read: /home/user/odoo/addons/l10n_de/models/chart_template.py lines=25 depth=full +Read: /home/user/odoo/addons/l10n_de/models/datev.py lines=37 depth=full +Read: /home/user/odoo/addons/l10n_de/models/account_journal.py lines=18 depth=full +Read: /home/user/woa-rs/src/routes/datev/export.rs lines=748 depth=full +Read: /home/user/woa-rs/src/models/erp/k8_close.rs lines=394 depth=full +Read: /home/user/woa-rs/src/models/erp/k1_accounts.rs lines=80 depth=thorough +Read: /home/user/woa-rs/crates/skr_data/src/lib.rs lines=84 depth=full +Read: /home/user/woa-rs/crates/skr_data/src/konto.rs lines=56 depth=full +Read: /home/user/woa-rs/.claude/board/odoo-richness/BRIEFING.md lines=124 depth=thorough +``` diff --git a/.claude/odoo/L5-PAY-TERMS-MATCH.md b/.claude/odoo/L5-PAY-TERMS-MATCH.md new file mode 100644 index 00000000..7faa9587 --- /dev/null +++ b/.claude/odoo/L5-PAY-TERMS-MATCH.md @@ -0,0 +1,691 @@ +RICHNESS-LANE-OK + +# L5 — Payments · Payment Terms · Reconcile-Model Matching Rules + +**Lane:** L5 — Payments + Payment Terms + Reconcile-model matching +**Analyst:** Claude Sonnet 4.6, read-only pass, 2026-05-26 +**K-steps covered:** K3 (double-entry posting), K5 (bank matching), Mahnwesen-precursor + +--- + +## 1. Scope and files read + +| File | Lines | Depth | +|---|---|---| +| `addons/account/models/account_payment.py` | 1247 | full | +| `addons/account/models/account_payment_term.py` | 368 | full | +| `addons/account/models/account_reconcile_model.py` | 201 | full | + +woa-rs calibration targets examined: + +- `src/erp/engine/bank_match.rs` (303 LoC, existing implementation) +- `src/models/erp/k3_debitors.rs` (debtors + Mahnwesen model layer) +- `src/models/erp/k4_creditors.rs` (creditors + payment run model layer) +- `src/contracts/erp/k3_debitors.rs` (contract DTOs) + +--- + +## 2. Rule Sections + +--- + +### RULE P1 — Payment move-line generation: `_prepare_move_lines_per_type` + `_generate_journal_entry` + +**File:** `account_payment.py:L311-L408, L1069-L1101` + +#### Axis-1 Rich-AST Spec + +**Entry point chain:** +`action_post (L1126)` sets `state='in_process'` (or `'paid'` for cash accounts, L1141). +`write(vals)` override (L947) detects `state in ('in_process','paid')`. If `not pay.move_id`: +calls `_generate_journal_entry()` then posts the move with `action_post()`. + +**`_generate_journal_entry` (L1069-L1079):** + +``` +need_move = self.filtered(lambda p: not p.move_id and p.outstanding_account_id) +assert len(self)==1 or (not write_off_line_vals and not force_balance and not line_ids) +move_vals = [pay._generate_move_vals(...) for pay in need_move] +moves = env['account.move'].create(move_vals) +for pay, move in zip(need_move, moves): + pay.write({'move_id': move.id, 'state': 'in_process'}) +``` + +**`_generate_move_vals` (L1081-L1101):** Returns: + +```python +{ + 'move_type': 'entry', # ALWAYS 'entry', not invoice type + 'ref': self.memo, + 'date': self.date, + 'journal_id': self.journal_id.id, + 'company_id': self.company_id.id, + 'partner_id': self.partner_id.id, + 'currency_id': self.currency_id.id, + 'partner_bank_id': self.partner_bank_id.id, + 'line_ids': line_ids or [Command.create(lv) for lv in self._prepare_move_line_default_vals(...)], + 'origin_payment_id': self.id, +} +``` + +**`_prepare_move_lines_per_type` (L311-L391) - core journal-line factory:** + +1. Guard: if `not outstanding_account_id` raise UserError (L323-L326). +2. Label: `line_name = ''.join(x[1] for x in _get_aml_default_display_name_list())`. Built from `payment_method_line_id.name` + optional `": " + memo` (L329). +3. Withholding lines: `_prepare_move_withholding_lines({})` returns `[]` in community (L283-L285). Enterprise-only hook. +4. Mutual exclusion: if both `withholding_lines` and `write_off_lines` non-empty, `write_off_lines` is silently cleared (L342-L346). +5. Liquidity amount: + - `inbound`: `liquidity_amount_currency = +self.amount` + - `outbound`: `liquidity_amount_currency = -self.amount` + - other: `0.0` +6. Balance (company-currency): + - If `force_balance` set AND no `write_off_line_vals`: `sign = 1 if liq_amt > 0 else -1; liquidity_balance = sign * abs(force_balance)` (L358-L360) + - Otherwise: `liquidity_balance = currency._convert(liq_amount_currency, company_currency, company, date)` (L362-L367) +7. Withholding subtraction from liquidity (L368-L369): + `liquidity_amount_currency -= withholding_amount_currency` + `liquidity_balance -= withholding_balance` +8. Counterpart (always residual, closed-form double-entry): + `counterpart_amount_currency = -liquidity_amount_currency - write_off_amount_currency - withholding_amount_currency` + `counterpart_balance = -liquidity_balance - write_off_balance - withholding_balance` + +**Line dictionaries (L287-L309):** Both liquidity and counterpart lines share: + +```python +{ + 'name': line_name, + 'date_maturity': self.date, + 'partner_id': self.partner_id.id, + 'account_id': , + 'currency_id': self.currency_id.id, + 'balance': , + 'amount_currency': , +} +``` + +**`_seek_for_lines` helper (L214-L242) - dispatcher for existing move lines:** + +Iterates `move_id.line_ids` and classifies into three buckets: + +- liquidity_lines: `line.account_id in _get_valid_liquidity_accounts()` (L227) + Valid liquidity = `journal.default_account_id | payment_method_line.payment_account_id | inbound_method_lines.payment_account_id | outbound_method_lines.payment_account_id | outstanding_account_id` (L244-L252) +- counterpart_lines: `line.account_id.account_type in ['asset_receivable','liability_payable']` or `== company.transfer_account_id` (L229) +- writeoff_lines: everything else + +Edge case (L236-L240): if exactly one writeoff line exists and liquidity or counterpart bucket is empty, the writeoff line is promoted into the missing slot. + +**`_synchronize_to_moves` (L995-L1060) - Payment to Move sync:** + +Trigger fields (L1063-L1067): `date, amount, payment_type, partner_type, payment_reference, currency_id, partner_id, destination_account_id, partner_bank_id, journal_id`. + +Flow: +1. Skip if `move_id.state == 'posted'` (L1004) - posted entries are immutable. +2. Preserve existing write-off amounts (L1012-L1022). +3. `zip_longest` over liquidity_lines vs new vals: Command.update / .create / .delete (L1027-L1033). +4. Counterpart: single line, always update-or-create (L1035-L1040). +5. Write-off lines: delete old (`(2, id, 0)` = ORM unlink), create new (L1042-L1045). +6. Write to move: `date, partner_id, currency_id, partner_bank_id, line_ids` always; + `journal_id + name='/'` only if `journal_id` changed (L1048-L1060). +7. Context `skip_invoice_sync=True` prevents recursion. + +**`_compute_destination_account_id` (L625-L647):** + +- `partner_type='customer'`: `partner.property_account_receivable_id` (or search `asset_receivable`). +- `partner_type='supplier'`: `partner.property_account_payable_id` (or search `liability_payable`). + +**`_compute_outstanding_account_id` (L621-L623):** +`outstanding_account_id = payment_method_line_id.payment_account_id` + +**`_compute_currency_id` (L616-L618):** +`currency = journal.currency_id or journal.company_id.currency_id` + +**State machine (L36-L48, L1126-L1163):** + +``` +draft --[action_post]--> in_process (general) +draft --[action_post]--> paid (asset_cash journal, L1141) +in_process --[_compute_state: liq_residual==0 or no reconcile flag]--> paid +in_process --[all linked invoices paid]--> paid +any --[action_cancel]--> canceled (draft moves unlinked, posted moves button_cancel) +any --[action_reject]--> rejected +canceled/in_process --[action_draft]--> draft +``` + +**`_compute_state` (L453-L467):** + +```python +if state in ('paid','in_process') and move_id: + liquidity = _seek_for_lines()[0] + state = ('paid' + if company_currency.is_zero(sum(liquidity.amount_residual)) + or not any(liquidity.account_id.reconcile) + else 'in_process') +if state=='in_process' and all linked invoices paid: + state = 'paid' +``` + +Critical: `or not any(liquidity.account_id.reconcile)` means if the liquidity account has `reconcile=False`, payment goes to 'paid' even with non-zero residual (direct bank account path, no statement needed). + +**`_compute_reconciliation_status` (L469-L497):** + +``` +is_matched = liquidity lines' residual == 0 + OR journal uses its own default_account_id directly (L490-L493) +is_reconciled = counterpart+writeoff lines with account.reconcile==True have residual == 0 +``` + +Currency selector (L488): `amount_residual` if pay.currency == company.currency; else `amount_residual_currency`. + +**Constraints (L863-L882):** + +- `@api.constrains('payment_method_line_id')`: must not be null; must match journal (L863-L872). +- `@api.constrains('state', 'move_id')`: posted payment with outstanding_account_id must have move (L874-L882). +- SQL: `CHECK(amount >= 0.0)` (L199-L202). + +**Axis classification:** DETERMINISTIC +**K-step:** K3 (double-entry posting), K5 (outstanding account / bank matching via `is_matched`) +**woa-rs target:** `src/erp/engine/payment.rs` (new), `src/models/erp/k5_bank.rs` (outstanding account linkage) + +**Ontology mapping:** +`odoo:account.payment` -- FLAG: UNRESOLVED in odoo_alignment.rs -- +Proposed: `odoo:account.payment` -> `fibo-FBC-PAS-FPAS:Payment` -> OGIT family SMBAccounting/BillingCore -> DOLCE Perdurant (a payment is an event with date + state transitions; .payment suffix -> Perdurant by analogy with .move). + +--- + +### RULE P2 — Payment term computation: `_compute_terms` + +**File:** `account_payment_term.py:L171-L256` + +#### Axis-1 Rich-AST Spec + +**Signature:** + +```python +def _compute_terms(self, date_ref, currency, company, tax_amount, tax_amount_currency, + sign, untaxed_amount, untaxed_amount_currency, cash_rounding=None) -> dict +``` + +**Output structure:** + +```python +{ + 'total_amount': float, # company-currency total + 'discount_percentage': float, # 0.0 if no early_discount + 'discount_date': date | False, # date_ref + relativedelta(days=discount_days) + 'discount_balance': float, # company-currency amount after Skonto + 'discount_amount_currency': float, # move-currency amount after Skonto + 'line_ids': [ + { + 'date': date, # due date + 'company_amount': float, # balance in company currency + 'foreign_amount': float, # balance in move currency + }, ... + ] +} +``` + +**Step 1 - totals (L187-L190):** + +```python +total_amount = tax_amount + untaxed_amount +total_amount_currency = tax_amount_currency + untaxed_amount_currency +rate = abs(total_amount_currency / total_amount) if total_amount else 0.0 +``` + +The exchange rate is embedded from move amounts, NOT from a live currency lookup. +This means any fixed invoice rate is preserved through all term calculations. + +**Step 2 - early discount (Skonto) computation (L200-L214):** + +Guard: `early_discount == True`. Constraint ensures only single-line 100% terms can have early_discount. + +Three computation modes (controlled by `early_pay_discount_computation`): + +| Mode | Formula for `discount_balance` (company currency) | Country default | +|---|---|---| +| `'included'` | `company_currency.round(total_amount * (1 - pct))` | DE, AT, CH, most | +| `'excluded'` | `company_currency.round(total_amount - untaxed_amount * pct)` | NL | +| `'mixed'` | same formula as 'excluded' | BE | + +Where `pct = self.discount_percentage / 100.0`. + +Parallel computation for `discount_amount_currency` using `currency.round(...)`. + +Cash-rounding adjustment for discount (L210-L214): + +```python +if cash_rounding: + diff = cash_rounding.compute_difference(currency, discount_amount_currency) + if not currency.is_zero(diff): + discount_amount_currency += diff + discount_balance = company_currency.round(discount_amount_currency / rate) if rate else 0.0 +``` + +The company-currency discount balance is re-derived from the cash-rounded move-currency value. + +**Step 3 - term lines loop (L219-L254):** + +``` +residual_amount = total_amount +residual_amount_currency = total_amount_currency + +for i, line in enumerate(self.line_ids): # ordered by id (insertion order) + on_balance_line = (i == len(self.line_ids) - 1) # LAST line always = residual +``` + +Per-line computation: + +- Balance line (last): `company_amount = residual_amount; foreign_amount = residual_amount_currency` +- Fixed line: `company_amount = sign * company_currency.round(line.value_amount / rate) if rate else 0.0` + `foreign_amount = sign * currency.round(line.value_amount)` +- Percent line: `company_amount = company_currency.round(total_amount * (line.value_amount / 100.0))` + `foreign_amount = currency.round(total_amount_currency * (line.value_amount / 100.0))` + +Cash rounding for non-balance lines (L242-L250): same diff pattern; company_amount re-derived via rate. + +Residual tracking: `residual_amount -= term_vals['company_amount']` after each non-balance line. The last line absorbs all rounding differences. + +**Rounding:** `company_currency.round(...)` delegates to `res.currency` precision. `float_round` is used ONLY in `_check_lines` constraint, not in `_compute_terms`. + +**`_check_lines` constraint (L156-L169):** + +- Sum of percent lines must equal 100 (using `float_round` with `Payment Terms` decimal precision). +- If `early_discount=True` and `len(line_ids) > 1`: ValidationError. +- `discount_percentage` must be > 0.0. +- `discount_days` must be > 0. + +**Due-date computation `_get_due_date` (L310-L327) on AccountPaymentTermLine:** + +| delay_type | Formula | +|---|---| +| `'days_after'` | `date_ref + relativedelta(days=nb_days)` | +| `'days_after_end_of_month'` | `end_of(date_ref, 'month') + relativedelta(days=nb_days)` | +| `'days_after_end_of_next_month'` | `end_of(date_ref + relativedelta(months=1), 'month') + relativedelta(days=nb_days)` | +| `'days_end_of_month_on_the'` | see below | + +For `'days_end_of_month_on_the'` (L318-L326): +- `days_next_month` is a string field (size=2), parsed with `int()`. `ValueError` -> default to 1. +- If `days_next_month == 0` after int(): returns `end_of(date_ref + relativedelta(days=nb_days), 'month')`. +- Otherwise: `date_ref + relativedelta(days=nb_days) + relativedelta(months=1, day=days_next_month)`. + +**`_compute_discount_computation` (L82-L90):** Country-specific defaults at payment term creation: + +- `'BE'` -> `'mixed'` +- `'NL'` -> `'excluded'` +- everything else -> `'included'` + +**`_get_amount_due_after_discount` (L61-L79):** Simplified display version: + +```python +if early_pay_discount_computation in ('excluded', 'mixed'): + discount_amount_currency = (total - untaxed) * pct # tax-only portion +else: + discount_amount_currency = total * pct # full total +amount_due = currency.round(total - discount_amount_currency) +# optional cash_rounding adjustment follows same pattern +``` + +**`_compute_value_amount` (L359-L367):** Auto-fills percent value_amount so all lines sum to 100. + +**`_compute_days` (L350-L356):** Auto-fills `nb_days` for new lines to `last_line.nb_days + 30`. +Only fires when `not line.nb_days and len(payment_id.line_ids) > 1`. + +**Axis classification:** DETERMINISTIC +**K-step:** K3 (payment terms feed invoice due dates -> Mahnwesen escalation timing + Skonto) +**woa-rs target:** `src/erp/engine/payment_term.rs` (new module - no current equivalent found). +The flat fields `ErpDebtor.skonto_prozent / skonto_tage` (k3_debitors.rs) are snapshots; this module is the structured source. + +**Ontology mapping:** +`odoo:account.payment.term` -- FLAG: UNRESOLVED in odoo_alignment.rs -- +Proposed: `odoo:account.payment.term` -> `fibo-FBC-PAS-FPAS:PaymentObligationTerms` (preferred) or `ubl:PaymentTerms` -> OGIT family SMBAccounting/BillingCore -> DOLCE Abstract (.term suffix -> Abstract by briefing rule). + +`odoo:account.payment.term.line` -> sub-component of PaymentObligationTerms, no independent alignment row. DOLCE Abstract. + +--- + +### RULE P3 — Early Payment Discount (Skonto) mode selection + +**File:** `account_payment_term.py:L41-L46, L82-L90` + +#### Axis-1 Rich-AST Spec + +`early_pay_discount_computation` is a stored computed Selection, re-evaluated when `company_id` changes. +Three modes affect only the Skonto base (whether VAT is discounted or not): + +| Mode | German accounting concept | Discount base | +|---|---|---| +| `'included'` | Skonto auf Bruttobetrag | `total_amount * pct` | +| `'excluded'` | Skonto nur auf Nettobetrag (VAT stays full) | `untaxed_amount * pct` | +| `'mixed'` | Skonto auf Nettobetrag (BE) | same as 'excluded' | + +For Germany (DE): always `'included'` - Skonto reduces both net and VAT proportionally. +This is the canonical "14 Tage netto, 2% Skonto bei 8 Tagen" case. + +The `early_discount` boolean is a user toggle. Defaults: `discount_percentage=2.0`, `discount_days=10`. + +Constraint: `early_discount=True` is only valid with single-line 100% terms (L163-L165). + +**Axis classification:** DETERMINISTIC +**K-step:** K3 + K7 (Skonto affects VAT base in 'excluded'/'mixed' modes) +**woa-rs target:** `src/erp/engine/payment_term.rs`, enum `SkontoMode { Included, Excluded, Mixed }`. + +--- + +### RULE P4 — Payment reconciliation status and Mahnwesen integration + +**File:** `account_payment.py:L453-L497` + +#### Axis-1 Rich-AST Spec + +Two distinct boolean flags serve different purposes: + +**`is_matched` (bank reconciliation, K5):** +True when liquidity lines' residual is zero, OR when the journal uses its own default_account_id directly (direct bank path without statement lines, L490-L493). + +**`is_reconciled` (invoice clearance, K3):** +True when counterpart+writeoff lines that have `account_id.reconcile=True` all have zero residual. + +These are INDEPENDENT. A payment can be `is_matched=True, is_reconciled=False` (bank acknowledged but invoice not yet cleared). Mahnwesen MUST check `is_reconciled`, not `is_matched`, before escalating dunning. + +Currency selector (L488): `amount_residual` when pay.currency == company.currency; else `amount_residual_currency`. + +**@api.depends (L469):** `move_id.line_ids.amount_residual`, `amount_residual_currency`, `account_id`, `state` - all four must be fresh. + +**Axis classification:** DETERMINISTIC +**K-step:** K3 (open-item clearance), K5 (bank match) +**woa-rs target:** feeds `src/erp/engine/debitor.rs` Mahnwesen logic (already exists). The `ErpOpenItemAR.offen_betrag` open-item balance in woa-rs is the equivalent of `amount_residual`. + +--- + +### RULE P5 — Reconcile-model matching rules (NARS-HEAVY, Axis-2) + +**File:** `account_reconcile_model.py:L91-L200` (model), `L8-L89` (line sub-model) + +**THIS RULE IS NARS-HEAVY.** The reconcile.model matching is a textbook Axis-2 heuristic: multi-dimensional filter-and-score over incoming bank statement lines against a set of configured rules. The outcome is not deterministic from the data alone — it depends on which models exist, their sequence order, and how multiple evidence dimensions combine. + +#### Axis-1 Rich-AST Spec + +**Match dimensions defined on `AccountReconcileModel`:** + +**Dimension 1 - Journal filter (`match_journal_ids`, L123-L126):** +Many2many of `account.journal`. Model only applies when the statement line's journal is in this set. +Empty set = applies to all journals. Hard in/out filter, no score contribution. + +**Dimension 2 - Amount filter (`match_amount` + `match_amount_min/max`, L127-L134):** +Selection: `lower | greater | between`. Compared against statement line amount. +Hard filter: if set, model only fires for amounts in range. + +**Dimension 3 - Label/communication filter (`match_label` + `match_label_param`, L135-L143):** +Selection: `contains | not_contains | match_regex`. +- `contains`: case-insensitive substring of statement label/narration/transaction details. +- `not_contains`: negation of contains. +- `match_regex`: compiled Python regex against the same fields. +Validated at save via `_check_match_label_param` (L149-L156). This is the primary textual evidence dimension. + +**Dimension 4 - Partner filter (`match_partner_ids`, L144-L145):** +Many2many of `res.partner`. Hard filter on statement line's partner. + +**Trigger (`trigger`, L107-L108):** +`manual` (proposed, user confirms) vs `auto_reconcile` (applied automatically). +Controls enforcement level, not matching. + +**`can_be_proposed` computed field (L158-L161):** + +```python +can_be_proposed = (not mapped_partner_id + and (match_label or match_amount or match_partner_ids + or trigger == 'auto_reconcile')) +``` + +Depends on: `mapped_partner_id, match_label, match_amount, match_partner_ids, trigger`. + +**`mapped_partner_id` computed field (L163-L167):** + +```python +is_partner_mapping = (match_label + and len(line_ids) == 1 + and line_ids[0].partner_id + and not line_ids[0].account_id) +mapped_partner_id = line_ids[0].partner_id if is_partner_mapping else False +``` + +Depends on: `match_label, line_ids.partner_id, line_ids.account_id`. +A model with exactly one line having partner but no account = partner-mapping rule (not a reconcile candidate). + +**Model sequencing:** `_order = 'sequence, id'` (L94). Odoo applies models in sequence order, takes the first match. This greedy first-match is what makes the community implementation heuristic-shaped even if individual dimensions are deterministic. + +**Write-off line sub-model (`AccountReconcileModelLine`, L8-L89):** + +When a model matches, it generates journal entries via `line_ids`. Each line: + +- `account_id`: contra-account for write-off/categorisation. +- `amount_type` (L25-L34): `fixed | percentage | percentage_st_line | regex`. + - `fixed`: hard-coded value (validated non-zero, L78-L79). + - `percentage`: percentage of matched open item's balance (validated non-zero, L82-L83). + - `percentage_st_line`: percentage of statement line's own amount (validated non-zero, L80-L81). + - `regex` (L36-L51): extracts amount from statement label. First capturing group = integer part, optional second = decimal part (two-digit). Regex validated at save (L84-L88). Default pattern: `([\d,]+)`. +- `tax_ids`: taxes on the write-off amount. +- `partner_id` + `label`: for the generated line. +- `amount` (L36): `float` computed from `amount_string` via `float(amount_string)` with fallback 0 on ValueError (L68-L73). + +**`action_reconcile_stat` (L175-L188):** +SQL: `SELECT ARRAY_AGG(DISTINCT move_id) FROM account_move_line WHERE reconcile_model_id = %s`. +Returns journal entries that were created by this model. Not a matching method, just a stat. + +--- + +#### Axis-2 Classification: HEURISTIC -> NARS delegation + +**NARS contract tuple:** + +``` +ReasoningKind: Other("BankStatementMatch") +InferenceType: Induction (primary) with Abduction fallback +SemiringChoice: NarsTruth +ThinkingStyle: Analytical (INHERITED from SMBAccounting/BillingCore OGIT family) +``` + +**Justification - `ReasoningKind: Other("BankStatementMatch")`:** + +None of the four named kinds fit precisely: +- `CustomerCategory`: partner classification. Not this. +- `PostingAnomaly`: anomaly detection on existing postings. Closest alternative (unmatched statement lines are anomalous open items), but the primary operation is identification not anomaly detection. +- `NextBestAction`: forward-looking recommendation. Not this. +- `InvoiceCompleteness`: document completeness. Not this. + +Bank statement matching is identification of what a statement line IS from its properties. Use `Other` with proposed name `BankStatementMatch`. Flag: propose adding to the enum in `lance_graph_contract`. + +**Justification - `InferenceType: Induction` (primary) with `Abduction` fallback:** + +Induction: "Things with label containing 'RG-2024-0078' and amount 1234.56 are usually payment for invoice RG-2024-0078." This is pattern generalisation - the model learned (from user configuration) that these evidence properties co-occur with a particular open-item type. The reconcile model configuration IS the inductive knowledge base. + +Abduction fallback: when no single model matches with high confidence (ambiguous label, multiple open items with matching amount), the reasoner should switch to abduction - "what is the most likely explanation for this statement line?" - generating hypotheses from open items and evaluating them against evidence. This handles the multi-candidate ranking case that odoo's greedy first-match cannot. + +**Justification - `SemiringChoice: NarsTruth`:** + +Each match dimension contributes independent evidence. Odoo community applies all dimensions as hard AND-filters, but the underlying problem structure is multi-dimensional evidential: a label match raises frequency, a partner match raises confidence, an amount match in range raises both. NarsTruth (frequency f, confidence c) allows evidence fusion across dimensions. A high-frequency (many similar past matches) high-confidence (multiple independent dimensions agree) match gets the highest truth value. Boolean would collapse all nuance; HammingMin would be too harsh on partial matches; NarsTruth is the right semiring for graded multi-dimensional evidence fusion. + +**Justification - `ThinkingStyle: Analytical` (inherited from SMBAccounting/BillingCore):** + +`account.reconcile.model` maps to the SMBAccounting/BillingCore OGIT family (bank statement + open-item reconciliation is core accounting infrastructure). The briefing states SMBAccounting/BillingCore posting-anomaly checks inherit Analytical/Critical. Statement matching is fundamentally Analytical: decompose the statement line into evidence dimensions, compare against known candidates, select by rule-based pattern. Not Creative (no novel generation), not Empathic (no partner intent modelling), not Exploratory (domain is fixed). Analytical is correct. + +**ReasoningContext shape for woa-rs:** + +```rust +ReasoningContext { + namespace: "erp.bank_statement.match", + kind: ReasoningKind::Other(/* BankStatementMatch id */), + evidence: &[ + // statement line properties + ("st_line.amount", "1234.56"), + ("st_line.currency", "EUR"), + ("st_line.label", "SVWZ+RG-2024-0078 Mustermann GmbH"), + ("st_line.partner_iban", "DE89370400440532013000"), + ("st_line.date", "2026-05-15"), + ("st_line.journal_id", "42"), + // candidate open items (from ErpOpenItemAR / ErpOpenItemAP) + ("candidate[0].id", "881"), + ("candidate[0].amount", "1234.56"), + ("candidate[0].belegnummer","RG-2024-0078"), + ("candidate[0].partner", "Mustermann GmbH"), + // reconcile model rule propositions (from DB) + ("model[0].seq", "10"), + ("model[0].label_match", "contains:RG-"), + ("model[0].amount_range", "between:1000:2000"), + // ... additional candidates and models + ], + budget: Budget { steps: 50, confidence_threshold: 0.75 }, +} +// Returns: Conclusion { match_id: Option, confidence: f32, write_off_lines: Vec<...> } +``` + +woa-rs then applies the match (updating `match_status`, `match_confidence`, FK) using the existing `bank_match.rs` engine infrastructure. + +**Mapping to existing woa-rs `bank_match.rs`:** + +`src/erp/engine/bank_match.rs` (Sprint-3b) already implements a deterministic scoring engine with hard confidence levels (100/80/60/40/0), mirroring `../WoA/woa/erp/engine/bank_match.py`. This covers the community equivalent of `trigger='manual'` with fixed match criteria. + +The odoo reconcile-model adds on top of this: +- Configurable label regex/contains patterns (richer than `extract_belegnummern`). +- Partner-mapping rules (absent from bank_match.rs). +- Write-off line generation with amount extraction from label regex (absent). +- Auto-reconcile trigger (bank_match.rs only proposes, never auto-posts). + +Gap: woa-rs `bank_match.rs` covers approximately 40% of odoo's reconcile-model surface. The NARS delegation handles the configurable-pattern matching and write-off line selection; the deterministic hard match (belegnummer, IBAN, exact amount) stays in Rust. + +**K-step:** K3 (open-item clearance), K5 (bank statement matching) +**woa-rs target:** `src/erp/engine/bank_match.rs` (extend existing) + `src/erp/engine/reconcile_match.rs` (new, NARS-delegating layer). + +**Ontology mapping:** +`odoo:account.reconcile.model` -- FLAG: UNRESOLVED in odoo_alignment.rs -- +Proposed: `odoo:account.reconcile.model` -> `sh:NodeShape` / `sh:rule` pattern (a reconcile model is a declarative rule shape over statement line properties; when the shape is satisfied, a particular accounting action fires) -> OGIT family SMBAccounting -> DOLCE Abstract (.model suffix -> Abstract by briefing rule). +Note: No FIBO class covers reconciliation rules (FIBO focuses on instruments and obligations). SHACL sh:rule is the closest W3C standard. The `can_be_proposed` / `trigger` fields parallel `sh:condition` / `sh:action` patterns. + +`odoo:account.reconcile.model.line` -> sub-component of sh:NodeShape rule, no independent alignment row. DOLCE Abstract. + +--- + +## 3. Enterprise / Unresolved Flags + +### Enterprise Boundary + +**E1 - `_prepare_move_withholding_lines` (account_payment.py:L283-L285):** +Community returns `[]`. Enterprise modules override to generate WHT lines. Hook is called at L337; always `[]` in community. Porter: implement hook with empty default; override is future extension point. + +**E2 - `_valid_payment_states` (account_payment.py:L254-L258):** +Enterprise: returns `['in_process', 'paid']`. Community: `['in_process']`. +`account.move._get_invoice_in_payment_state()` returns `'paid'` in Enterprise, `'in_payment'` in community. +Affects when a payment is considered "valid" for open-item matching. + +**E3 - Auto-reconcile trigger:** +`trigger='auto_reconcile'` field stored in community, but the auto-application engine is in Enterprise bank statement widget. Community stores the flag but does not execute it. Flag: woa-rs can store and read `trigger` but auto-application requires NARS delegation (RULE P5). + +### UNRESOLVED Ontology Alignments + +| odoo class | Status | Proposed owl pivot | Proposed OGIT family | DOLCE | +|---|---|---|---|---| +| `account.payment` | UNRESOLVED | `fibo-FBC-PAS-FPAS:Payment` | SMBAccounting/BillingCore | Perdurant | +| `account.payment.term` | UNRESOLVED | `fibo-FBC-PAS-FPAS:PaymentObligationTerms` or `ubl:PaymentTerms` | SMBAccounting/BillingCore | Abstract | +| `account.reconcile.model` | UNRESOLVED | `sh:NodeShape` / `sh:rule` | SMBAccounting | Abstract | + +All three are confirmed absent: no `odoo_alignment.rs` file exists in woa-rs (the alignment infrastructure is in `lance-graph`, not yet mirrored into the woa-rs crate tree). + +--- + +## 4. Ontology Mapping Lines (summary) + +``` +odoo:account.payment + -> owl:equivalentClass fibo-FBC-PAS-FPAS:Payment + -> OGIT family SMBAccounting/BillingCore + -> DOLCE Perdurant (payment is a temporal event with date + state transitions) + [UNRESOLVED - FLAG: needs alignment row in odoo_alignment.rs] + +odoo:account.payment.term + -> owl:equivalentClass fibo-FBC-PAS-FPAS:PaymentObligationTerms + alt: ubl:PaymentTerms + -> OGIT family SMBAccounting/BillingCore + -> DOLCE Abstract (.term suffix -> Abstract by briefing rule) + [UNRESOLVED - FLAG: needs alignment row] + +odoo:account.payment.term.line + -> sub-component of PaymentObligationTerms + -> DOLCE Abstract + [no independent row needed] + +odoo:account.reconcile.model + -> owl:equivalentClass sh:NodeShape with sh:rule action pattern + -> OGIT family SMBAccounting + -> DOLCE Abstract (.model suffix -> Abstract by briefing rule) + [UNRESOLVED - FLAG: needs alignment row] + +odoo:account.reconcile.model.line + -> sub-component of sh:NodeShape + -> DOLCE Abstract + [no independent row needed] +``` + +--- + +## 5. K-step Map + +| Rule | K-step | Description | +|---|---|---| +| P1 (payment move gen) | K3 | Double-entry journal entries for payment | +| P1 (outstanding account) | K5 | Bank / outstanding receipts-payments linkage | +| P2 (payment term compute) | K3 | Due-date splits feed invoice aging | +| P2 (Skonto) | K3+K7 | Skonto reduces tax base in excluded/mixed modes | +| P3 (discount mode) | K3+K7 | Country-specific Skonto-on-VAT behaviour | +| P4 (reconciliation status) | K3+K5 | Open-item clearance + bank match status | +| P5 (reconcile model) | K3+K5 | Bank statement to open item matching + write-off generation | + +Mahnwesen (K3) dependency: escalation timing reads `ErpOpenItemAR.skonto_bis` +(computed from `zahlungsziel_tage + skonto_tage`, mirroring `discount_date = date_ref + relativedelta(days=discount_days)`) +and checks the open-item residual (equivalent of `is_reconciled`) before promoting to the next dunning stage. +The K-adjacent graph: payment terms (K3) -> due dates -> open items (K3) -> Mahnwesen (K3) -> bank matching (K5) -> final clearance (K3). + +--- + +## 6. Porter's Checklist - Non-obvious Gotchas + +1. **Last line is always the balance, regardless of type.** `_compute_terms` treats `i == len(line_ids) - 1` as balance line. Even a `'percent'`-typed last line gets `residual_amount`, not a percentage. This is the primary rounding-absorption mechanism. Porter must use index-based guard, not type-based. + +2. **Exchange rate is embedded, not live.** `rate = abs(total_amount_currency / total_amount)`. A live FX lookup gives a different result. All rounding in the term computation uses this embedded rate. Do NOT substitute a currency-table rate in the port. + +3. **Skonto country-mode is company-level, not per-term.** `early_pay_discount_computation` is recomputed from `company_id.country_code`. A German company always gets `'included'`. This cannot be overridden per-term in community. The flat `ErpDebtor.skonto_prozent` in woa-rs does not encode this mode; the mode must be derived from company at runtime. + +4. **`amount_residual` vs `amount_residual_currency` selector** in `_compute_reconciliation_status` (L488): if pay.currency == company.currency use `amount_residual`; else use `amount_residual_currency`. Mixing produces wrong `is_reconciled` results. This matters for EUR-company paying a USD invoice. + +5. **`not any(liquidity.account_id.reconcile)` in `_compute_state`**: if the liquidity account has `reconcile=False`, payment goes to 'paid' with non-zero residual. This is the direct bank account path. woa-rs Mahnwesen must not treat this path as unpaid. + +6. **Write-off lines are silently dropped when withholding lines exist** (L342-L346). Community always has empty withholding lines; Enterprise may not. The port's hook must signal this conflict rather than silently discarding. + +7. **`_synchronize_to_moves` skips posted moves** (L1004): changes to a payment after posting do NOT update the journal entry. Only draft moves are synchronised. Do not attempt to sync posted moves. + +8. **Journal name reset on journal change** (L1056-L1059): `'name': '/'` resets the sequence number when `journal_id` changes on a draft payment. The target journal will re-sequence it. Only reachable on draft moves (see gotcha 7). + +9. **`days_end_of_month_on_the` with `days_next_month=0`** (L323-L324): returns end of the month, not day 0 of next month. String-to-int with `except ValueError: days_next_month = 1` means invalid strings silently become day 1 of the following month. + +10. **Reconcile model sequence is the greedy tie-breaker.** Odoo takes the first model in sequence order that matches. The NARS reasoner should receive all candidate models as evidence and return the highest truth-value match, which may differ from the first-in-sequence match. This is an intentional improvement over greedy matching for ambiguous lines. + +11. **`can_be_proposed` excludes partner-mapping models** (L161: `not model.mapped_partner_id`). A model whose sole purpose is partner assignment (one line with partner, no account) is a lookup table, not a reconcile candidate. woa-rs should treat these as a separate `PartnerMapping` evidence source. + +12. **Amount field on reconcile model line is a stored compute of `amount_string`** (L67-L73): `float(amount_string)` with fallback 0 on ValueError. The `amount_string` is the authoritative field; `amount` is a cached float. For regex-type lines, `amount_string` IS the regex, and `amount` = 0 always. + +--- + +## 7. woa-rs Gap Summary + +| Capability | odoo has | woa-rs has | Gap | +|---|---|---|---| +| Payment journal entry (K3) | Full (P1) | No `engine/payment.rs` | New: `engine/payment.rs` | +| Payment terms / due-date splits | Full (P2) | Flat fields (zahlungsziel_tage, skonto_tage) | New: `engine/payment_term.rs` | +| Skonto computation modes | Full (P2, P3) | `skonto_prozent` field only | Enrich: add SkontoMode enum + computation | +| Bank statement matching | Partial (P5) | `engine/bank_match.rs` (confidence 100/80/60/40) | Extend: label regex, partner mapping, write-off gen | +| NARS delegation for match | Odoo: heuristic | None | New: `engine/reconcile_match.rs` + NARS contract call | +| Ontology alignment rows | Needed (3 classes) | No odoo_alignment.rs in woa-rs | Action: add when skr_data grows | + +--- + +Read: /home/user/odoo/addons/account/models/account_payment.py lines=1247 depth=full +Read: /home/user/odoo/addons/account/models/account_payment_term.py lines=368 depth=full +Read: /home/user/odoo/addons/account/models/account_reconcile_model.py lines=201 depth=full diff --git a/.claude/odoo/L6-SALE-PURCHASE.md b/.claude/odoo/L6-SALE-PURCHASE.md new file mode 100644 index 00000000..95eab511 --- /dev/null +++ b/.claude/odoo/L6-SALE-PURCHASE.md @@ -0,0 +1,855 @@ +RICHNESS-LANE-OK + +# L6 — Sales + Purchase Order Flow → Invoice Creation (Vorgang Lifecycle) + +**Lane:** Sales + Purchase order flow → invoice creation (quote → order → invoice in odoo terms; +Vorgang lifecycle: Angebot → Auftrag → Rechnung) + +**Written:** 2026-05-26 + +--- + +## 1. Scope + Odoo Files Read + +| File | Lines | Depth | +|---|---|---| +| `/home/user/odoo/addons/sale/models/sale_order.py` | 2301 | full | +| `/home/user/odoo/addons/sale/models/sale_order_line.py` | 1819 | full | +| `/home/user/odoo/addons/purchase/models/purchase_order.py` | 1418 | full | + +**Also read (calibration):** +- `/home/user/woa-rs/src/models/work_order.rs` (full, for woa-rs Vorgang shape) +- `/home/user/lance-graph/crates/lance-graph-callcenter/src/odoo_alignment.rs` (full, for existing alignment table) +- `/home/user/woa-rs/.claude/board/odoo-richness/BRIEFING.md` (full, output contract) + +--- + +## 2. Per-Rule Sections + +--- + +### Rule S-1: Sale Order State Machine + +**Odoo source:** `sale_order.py:L26-31, L70-76, L1058-1065, L1156-1196, L1231-1233, L1318-1333` + +#### Axis-1 Rich-AST Spec + +**State values (SALE_ORDER_STATE constant, L26-31):** +```python +SALE_ORDER_STATE = [ + ('draft', "Quotation"), + ('sent', "Quotation Sent"), + ('sale', "Sales Order"), + ('cancel', "Cancelled"), +] +``` +Field declaration (L70-76): `state = fields.Selection(SALE_ORDER_STATE, default='draft', readonly=True, copy=False, index=True, tracking=3, group_expand=True)` + +**Note: there is NO `'done'` state in community odoo sale module.** The briefing mentioned +`done` — that state existed in older versions. Community odoo 17 only has `draft/sent/sale/cancel`. +The `locked` boolean (L77-81) replaces what was previously a separate `done` state in older versions. + +**Transitions:** + +| From | To | Method | Guard | +|---|---|---|---| +| `draft` | `sent` | `action_quotation_sent()` L1156-1164 | state must be `draft`; raises UserError otherwise | +| `draft`/`sent` | `sent` (implicit) | `message_post()` L1714-1718 | context flag `mark_so_as_sent=True` | +| `draft`/`sent` | `sale` | `action_confirm()` L1166-1196 | calls `_confirmation_error_message()` L1203-1216; guards: state ∈ {`draft`,`sent`}; all non-display, non-downpayment lines must have `product_id` | +| `cancel`/`sent` | `draft` | `action_draft()` L1058-1065 | only processes orders in `cancel` or `sent`; also clears `signature`, `signed_by`, `signed_on` | +| `sale`/`draft`/`sent` | `cancel` | `action_cancel()` L1324-1328 | raises UserError if any order is `locked`; delegates to `_action_cancel()` | +| — | `locked` | `action_lock()` L1318-1319 | sets `locked=True` on the record | + +**`action_confirm()` full control flow (L1166-1196):** +1. For each order: call `_confirmation_error_message()`. If non-falsy, raise UserError. +2. Call `self.order_line._validate_analytic_distribution()` (validates analytics before confirming). +3. Call `self.write(self._prepare_confirmation_values())` which sets `{'state': 'sale', 'date_order': fields.Datetime.now()}` — date_order is OVERWRITTEN to confirmation time. +4. Strip `default_name` and `default_user_id` from context (avoids propagation to linked record creation). +5. Call `self.with_context(context)._action_confirm()` — extensibility hook (empty in base sale module; overridden in sale_stock to create pickings etc.). +6. For each SO that `_should_be_locked()` returns True: call `action_lock()`. Lock guard: checks `sale.group_auto_done_setting` feature flag (L1198-1201). +7. If `send_email` in context: call `_send_order_confirmation_mail()`. + +**`_action_cancel()` full flow (L1330-1333):** +1. Find all draft invoices linked to the order lines: `self.invoice_ids.filtered(lambda inv: inv.state == 'draft')`. +2. Cancel those draft invoices: `inv.button_cancel()`. +3. Write `{'state': 'cancel'}` on all orders. + +**Deletion guard (L1032-1038):** `_unlink_except_draft_or_cancel()` — raises UserError if state not in `('draft', 'cancel')`. + +**Write guard (L1040-1043):** `write()` raises UserError if `pricelist_id` is being changed on a confirmed (`sale`) order. + +**SQL constraint (L41-44):** `_date_order_conditional_required` — DB-level CHECK: `(state = 'sale' AND date_order IS NOT NULL) OR state != 'sale'`. Confirmed orders must have `date_order`. + +#### Axis Classification +**DETERMINISTIC** — pure state machine, all transitions are guarded/unguarded writes. No scoring or ranking. Port directly to Rust enum + transition table. + +#### Ontology Mapping +`odoo:sale.order` — **UNRESOLVED** (not in ODOO_ALIGNMENTS table, `resolve_odoo("odoo:sale.order")` returns `None`). + +**FLAG: Missing alignment row — proposal:** +- OWL pivot: `ubl:Order` (UBL 2.1 Order document, the canonical commercial order standard) +- OGIT family: `SmbFoundryInvoice` (0x81) — the document/transaction basin; a sale order is a pre-invoice commercial document in the same document lifecycle as `account.move` +- DOLCE: `Perdurant` (it is a process/event document with temporal extent, analogous to `.move`) +- Proposed row: `odoo:sale.order → ubl:Order → OGIT SmbFoundryInvoice (0x81) → DOLCE Perdurant` +- This aligns with woa-rs Vorgang which is the closest analog (see §woa-rs calibration) + +#### K-Step +This is the **core ERP Vorgang lifecycle**, not directly a K3/K7/K8 step. It is the prerequisite document flow that gates K3 (double-entry posting happens when the invoice created from this order is posted) and K7 (USt triggers when invoice lines carry tax_ids). + +#### woa-rs Target Module +`src/models/work_order.rs` — the existing `Model` with `doc_type` and `status` fields IS the woa-rs Vorgang. The odoo state machine maps as: +- odoo `draft` → woa-rs `status = 'draft'` +- odoo `sent` → woa-rs `status = 'open'` (Angebot verschickt) +- odoo `sale` → woa-rs `status = 'in_progress'` (Auftrag bestätigt) +- odoo `cancel` → woa-rs `status = 'cancelled'` +- odoo `locked` flag → woa-rs GoBD lock (`is_wo_gobd_locked()` in `src/gobd.rs`) + +The Rust state machine logic belongs in a new `src/erp/sale_order_fsm.rs` or as methods on `work_order::Model`. + +--- + +### Rule S-2: Amount Computation (_compute_amounts / _amount_all) + +**Odoo source:** `sale_order.py:L512-528` (`_compute_amounts`); `purchase_order.py:L29-44` (`_amount_all`) + +#### Axis-1 Rich-AST Spec + +**`_compute_amounts` (sale, L512-528):** + +`@api.depends('order_line.price_subtotal', 'currency_id', 'company_id', 'payment_term_id')` + +Full control flow for each order: +1. `order_lines = order._get_priced_lines()` — returns `order_line.filtered(lambda x: not x.display_type)` (L509-510). Excludes section/note lines. +2. Build base_lines: `[line._prepare_base_line_for_taxes_computation() for line in order_lines]` +3. Append EPD (Early Payment Discount) lines: `base_lines += order._add_base_lines_for_early_payment_discount()` — only adds lines if `payment_term_id.early_discount AND early_pay_discount_computation == 'mixed' AND discount_percentage` (L530-567). These lines represent the discounted amount for tax computation. +4. `AccountTax._add_tax_details_in_base_lines(base_lines, order.company_id)` — computes tax breakdown per line using the company's tax engine. +5. `AccountTax._round_base_lines_tax_details(base_lines, order.company_id)` — applies rounding per `company.tax_calculation_rounding_method` (round_per_line vs round_globally). +6. `tax_totals = AccountTax._get_tax_totals_summary(base_lines, currency, company)` — returns dict with `base_amount_currency`, `tax_amount_currency`, `total_amount_currency`. +7. Assign: `order.amount_untaxed = tax_totals['base_amount_currency']`, `order.amount_tax = tax_totals['tax_amount_currency']`, `order.amount_total = tax_totals['total_amount_currency']`. + +**`_amount_all` (purchase, L29-44):** +Identical pipeline to sale's `_compute_amounts` but: +- No EPD lines +- Adds `order.amount_total_cc = tax_totals['total_amount']` (company-currency total, not order-currency) +- `@api.depends('order_line.price_subtotal', 'company_id', 'currency_id')` + +**Rounding critical notes:** +- All currency rounding goes through `company_id.currency_id` or `order.currency_id` as appropriate. +- The `company.tax_calculation_rounding_method` field (`tax_calculation_rounding_method`, L308-310 in sale_order) controls whether rounding is per-line or global. This is a **company-level setting** — porter must respect it. +- The actual arithmetic is inside `account.tax._add_tax_details_in_base_line` (lane L3 territory); from sale_order's perspective it is a black-box call that takes `price_unit * qty * (1-discount/100)` and returns tax-split amounts. +- EPD (Early Payment Discount mixed mode): creates virtual negative base lines for the discount amount, then a balancing positive line. This affects `amount_tax` and `amount_total` on the SO even before invoicing. + +**`_prepare_base_line_for_taxes_computation` (sale_order_line, L816-837):** +Called per line; builds the dict: +```python +{ + 'tax_ids': self.tax_ids, + 'quantity': self.product_uom_qty, + 'partner_id': self.order_id.partner_id, + 'currency_id': self.order_id.currency_id or company.currency_id, + 'rate': self.order_id.currency_rate, + 'name': self.name, + # if _is_global_discount(): 'special_type': 'global_discount' + # elif is_downpayment: 'special_type': 'down_payment' +} +``` +This is then passed to `account.tax._prepare_base_line_for_taxes_computation(self, **base_values)` — the ORM model record (`self`) is the `record` argument so the tax engine can read `price_unit` and `discount` from it directly. + +**`_compute_amount` (sale_order_line, L843-853):** +`@api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_ids')` +Per line: +1. `base_line = line._prepare_base_line_for_taxes_computation()` +2. `AccountTax._add_tax_details_in_base_line(base_line, company)` +3. `AccountTax._round_base_lines_tax_details([base_line], company)` +4. `line.price_subtotal = base_line['tax_details']['total_excluded_currency']` +5. `line.price_total = base_line['tax_details']['total_included_currency']` +6. `line.price_tax = line.price_total - line.price_subtotal` + +Note: `price_unit` already accounts for fiscal position tax mapping (see `_compute_price_unit`/`_reset_price_unit`). The line amount formula in simplified form: `price_unit * qty * (1 - discount/100)` before tax engine, but this is computed inside the tax engine not in sale_order_line. + +#### Axis Classification +**DETERMINISTIC** — all arithmetic is deterministic given inputs. The tax engine is a black box from this lane's perspective (belongs to lane L3). Currency rounding is deterministic (company currency round). EPD mixed logic is a conditional computation, deterministic once payment_term_id is set. + +#### Ontology Mapping +`odoo:sale.order` — see Rule S-1 (UNRESOLVED, propose ubl:Order → SmbFoundryInvoice 0x81 → Perdurant). + +#### K-Step +Core ERP amount arithmetic. Feeds K3 (posting amounts), K7 (USt computation trigger), K8 (BWA line amounts). Not a standalone K-step but prerequisite to all. + +#### woa-rs Target Module +woa-rs stores `netto_summe`, `brutto_summe` on `work_order::Model` (inferred from `home.rs` references to `rechnung_nr`/`bezahlt` and `partner_commission.rs::netto_summe_der_endkundenrechnung`). The odoo pipeline (prepare_base_line → add_tax_details → round → totals) should land in `src/erp/tax_compute.rs` or as part of the ERP crate. + +**GAP:** woa-rs Vorgang has no concept of `qty_to_invoice`, partial invoicing amounts, or EPD. These are richer than what woa-rs currently tracks. The `amount_untaxed / amount_tax / amount_total` trio on the SO maps well but the EPD mixed-mode computation is entirely absent. + +--- + +### Rule S-3: Line Amount — price_unit, discount, tax_ids + +**Odoo source:** `sale_order_line.py:L162-188, L541-568, L586-633, L783-814, L843-853` + +#### Axis-1 Rich-AST Spec + +**`_compute_tax_ids` (L541-568):** +`@api.depends('product_id', 'company_id')` + +Full control flow: +1. Group lines by company. +2. For each company group, for each line `with_company(company)`: + a. If `product_type == 'combo'`: `line.tax_ids = False`, continue. + b. If `product_id`: `taxes = product_id.taxes_id._filter_taxes_by_company(company)`. + c. If no product_id or no taxes: `line.tax_ids = False`, continue. + d. Cache key: `(fiscal_position.id, company.id, tuple(taxes.ids)) + _get_custom_compute_tax_cache_key()`. + e. `result = fiscal_position.map_tax(taxes)` (maps taxes through fiscal position rules). + f. `line.tax_ids = result`. + +**`_compute_price_unit` (L586-617):** +`@api.depends('product_id', 'product_uom_id', 'product_uom_qty')` + +Guard conditions that SKIP recomputation (price stays as-is): +- Line has no `order_id` (orphan line) +- `is_downpayment` is True +- `_is_global_discount()` is True (extra_tax_data starts with 'global_discount,') +- `not force_recompute AND has_manual_price(line)` — where `has_manual_price` checks `currency.compare_amounts(technical_price_unit, price_unit) != 0`. If user manually edited price away from pricelist price, it sticks. +- `qty_invoiced > 0` — price frozen once any quantity has been invoiced +- `product_id.expense_policy == 'cost' AND is_expense` — expense cost lines + +If none of the guards match: call `line._reset_price_unit()`. + +**`_reset_price_unit` (L619-633):** +1. `line = self.with_company(self.company_id)` +2. `price = line._get_display_price()` — gets pricelist-based price (before discount, for display) +3. `product_taxes = line.product_id.taxes_id._filter_taxes_by_company(line.company_id)` +4. `price_unit = line.product_id._get_tax_included_unit_price_from_price(price, product_taxes, fiscal_position=line.order_id.fiscal_position_id)` — adjusts price if company has price-include taxes +5. `line.update({'price_unit': price_unit, 'technical_price_unit': price_unit})` + +**`technical_price_unit` (L182):** shadow field that tracks the "system-computed" price so user edits to `price_unit` can be detected. If `technical_price_unit != price_unit`, the price was manually edited. + +**`_compute_discount` (L783-814):** +`@api.depends('product_id', 'product_uom_id', 'product_uom_qty')` + +Control flow: +1. `discount_enabled = product.pricelist.item._is_discount_feature_enabled()` — requires the discount feature to be active. +2. If no product or display_type: `discount = 0.0`. +3. If no pricelist or discount feature not enabled or no product_uom_id: skip (no change). +4. If `combo_item_id`: `discount = line._get_linked_line().discount` (inherit parent combo's discount). +5. `line.discount = 0.0` (reset). +6. If `not pricelist_item_id._show_discount()`: continue (pricelist didn't specify a discount). +7. `pricelist_price = line._get_pricelist_price()` +8. `base_price = line._get_pricelist_price_before_discount()` +9. If `base_price != 0`: `discount = (base_price - pricelist_price) / base_price * 100` +10. Show discount only if: `(discount > 0 and base_price > 0) OR (discount < 0 and base_price < 0)` (negative discounts = surcharge, hidden unless price is also negative) +11. `line.discount = discount` + +#### Axis Classification +**DETERMINISTIC** — pure pricelist/fiscal-position arithmetic. No scoring. The `has_manual_price` check and the `qty_invoiced > 0` freeze are deterministic guards. + +**Axis-2 tag for pricelist selection (`_compute_pricelist_item_id`):** This resolves which pricelist rule to use. If we later need to recommend a "best pricelist" or "next-action on pricing" for a Vorgang, that is `ReasoningKind::NextBestAction, InferenceType::Synthesis, SemiringChoice::NarsTruth, ThinkingStyle cluster: Exploratory` (inherited from SmbFoundryInvoice document family → the commercial document negotiation angle is Exploratory). But pricelist rule selection within the odoo model itself (given that `pricelist_id` is already set on the order) is deterministic. + +#### Ontology Mapping +`odoo:sale.order.line` — **UNRESOLVED** in ODOO_ALIGNMENTS. + +**FLAG: Missing alignment row — proposal:** +- OWL pivot: `ubl:OrderLine` (UBL 2.1 OrderLine, line item within a commercial order) +- OGIT family: `SmbFoundryInvoice` (0x81) — line is part of the invoice/order document basin +- DOLCE: `Perdurant` (`.line` suffix → Perdurant per DOLCE suffix classifier rule in BRIEFING) +- Proposed row: `odoo:sale.order.line → ubl:OrderLine → OGIT SmbFoundryInvoice (0x81) → DOLCE Perdurant` + +#### K-Step +Price/tax computation feeds K7 (USt: `tax_ids` on the line is what gets mapped through fiscal position and applied). Discount computation feeds K3 (affects posting amounts). + +--- + +### Rule S-4: qty_to_invoice / qty_invoiced — Partial Invoicing Tracking + +**Odoo source:** `sale_order_line.py:L238-251, L972-1065` + +#### Axis-1 Rich-AST Spec + +**`_compute_qty_invoiced` (L972-1006):** +`@api.depends('invoice_lines.move_id.state', 'invoice_lines.quantity')` + +Delegates to `_prepare_qty_invoiced()`: +```python +for line in self: + for invoice_line in line._get_invoice_lines(): + if invoice_line.move_id.state != 'cancel' or invoice_line.move_id.payment_state == 'invoicing_legacy': + invoice_qty = invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_uom_id, round=False) + if invoice_line.move_id.move_type == 'out_invoice': + invoiced_qties[line] += invoice_qty + elif invoice_line.move_id.move_type == 'out_refund': + invoiced_qties[line] -= invoice_qty +``` + +Key notes: +- Cancelled invoices are NOT counted (state == 'cancel'), UNLESS `payment_state == 'invoicing_legacy'` (backward compat). +- Refunds (`out_refund`) DECREASE the invoiced qty — so the SO line knows it can be re-invoiced. +- UoM conversion is done with `round=False` (raw qty before rounding, to avoid double-rounding). + +**`_compute_qty_to_invoice` (L1036-1064):** +`@api.depends('qty_invoiced', 'qty_delivered', 'product_uom_qty', 'state')` + +Full control flow: +```python +combo_lines = set() +for line in self: + if line.state == 'sale' and not line.display_type: + if line.product_id.type == 'combo': + combo_lines.add(line) + elif line.product_id.invoice_policy == 'order': + line.qty_to_invoice = line.product_uom_qty - line.qty_invoiced + else: # 'delivery' + line.qty_to_invoice = line.qty_delivered - line.qty_invoiced + if line.combo_item_id and line.linked_line_id: + combo_lines.add(line.linked_line_id) + else: + line.qty_to_invoice = 0 +# Combo lines: only invoiceable if at least one combo item line has qty_to_invoice > 0 +for combo_line in combo_lines: + if any(line.combo_item_id and line.qty_to_invoice for line in combo_line.linked_line_ids): + combo_line.qty_to_invoice = combo_line.product_uom_qty - combo_line.qty_invoiced + else: + combo_line.qty_to_invoice = 0 +``` + +**Invoice policy `'order'`:** invoice based on ordered quantity (pre-delivery billing; typical for services). +**Invoice policy `'delivery'`:** invoice based on delivered quantity (post-delivery billing; typical for physical goods). + +**`_force_lines_to_invoice_policy_order` (sale_order.py L1797-1807):** override that forces all lines to act as `invoice_policy='order'` — used for automatic invoice creation after payment (so full SO is invoiced regardless of delivery status). + +#### Axis Classification +**DETERMINISTIC** — pure arithmetic on stored quantities. The invoice policy is a product configuration field, not a heuristic. The combo line logic is deterministic (any/all check on linked lines). + +#### Ontology Mapping +`odoo:sale.order.line` — see Rule S-3 (UNRESOLVED, propose ubl:OrderLine → SmbFoundryInvoice 0x81 → Perdurant). + +#### K-Step +This is the partial-invoicing tracking that enables K3 (only post the invoiced portion). Directly gates which lines appear in the created invoice. + +#### woa-rs Target Module +woa-rs Vorgang has NO equivalent to `qty_to_invoice` / `qty_invoiced`. The Python WoA model is simpler: one Vorgang row = one invoice-level document. There is no line-level partial invoicing state. This is a significant ERP enrichment gap. + +**GAP (Major):** woa-rs Vorgang does not track qty_invoiced/qty_to_invoice per line. If ERP functionality is needed, a new `vorgang_line` table and `qty_invoiced` column would be required, analogous to `sale.order.line`. Until then, this logic has no home in woa-rs. + +--- + +### Rule S-5: invoice_status Derivation (Line + Order) + +**Odoo source:** `sale_order_line.py:L1066-1095` (line); `sale_order.py:L618-664` (order) + +#### Axis-1 Rich-AST Spec + +**INVOICE_STATUS values (L19-24):** +```python +INVOICE_STATUS = [ + ('upselling', 'Upselling Opportunity'), + ('invoiced', 'Fully Invoiced'), + ('to invoice', 'To Invoice'), + ('no', 'Nothing to Invoice') +] +``` + +**Line `_compute_invoice_status` (L1066-1095):** +`@api.depends('state', 'product_uom_qty', 'qty_delivered', 'qty_to_invoice', 'qty_invoiced')` + +Ordered decision tree (first matching condition wins): +1. `state != 'sale'` → `'no'` +2. `is_downpayment AND untaxed_amount_to_invoice == 0` → `'invoiced'` +3. `not float_is_zero(qty_to_invoice, precision)` → `'to invoice'` +4. `state == 'sale' AND invoice_policy == 'order' AND qty >= 0.0 AND float_compare(qty_delivered, product_uom_qty) == 1` (delivered MORE than ordered) → `'upselling'` +5. `float_compare(qty_invoiced, product_uom_qty) >= 0` (invoiced >= ordered) → `'invoiced'` +6. else → `'no'` + +**Precision:** uses `decimal.precision.precision_get('Product Unit')` — this is the DB-stored precision for product quantities, NOT a hardcoded decimal place. + +**Order `_compute_invoice_status` (L618-664):** +`@api.depends('state', 'order_line.invoice_status')` + +Full control flow: +1. `confirmed_orders = self.filtered(lambda so: so.state == 'sale')` +2. `(self - confirmed_orders).invoice_status = 'no'` — non-confirmed orders always 'no'. +3. Batch query: `_read_group` on `sale.order.line` where `is_downpayment=False AND display_type=False AND order_id in confirmed_orders.ids`, grouped by `['order_id', 'invoice_status']`. +4. For each confirmed order, from the `line_invoice_status` list: + - If empty: `'no'` + - If any `'to invoice'`: + - If also any `'no'` present: check if the ONLY invoiceable lines are those that `_can_be_invoiced_alone()` returns False (i.e., discount/delivery/promo lines). If so → `'no'`. Otherwise → `'to invoice'`. + - If no `'no'` in list: → `'to invoice'` + - Elif ALL are `'invoiced'`: → `'invoiced'` + - Elif ALL are `'invoiced'` or `'upselling'`: → `'upselling'` + - else: → `'no'` + +**Upselling side effect (L1964-1975):** When `invoice_status` transitions to `'upselling'`, the system automatically creates a TODO activity for the salesperson. This is done via `_compute_field_value` override that calls `_create_upsell_activity()`. The activity creation is skipped if `mail_activity_automation_skip` is in context. + +**`_can_be_invoiced_alone` (L1097-1105):** Returns True unless the product is the company's `sale_discount_product_id` (global discount product). Discount-only invoiceable states don't count as "really invoiceable". + +#### Axis Classification +**DETERMINISTIC** — pure aggregation/comparison logic on stored quantities. The batch `_read_group` query is a performance optimization, not a heuristic. + +**Axis-2 note on upselling activity creation:** The creation of the TODO activity when `invoice_status → 'upselling'` is a **next-best-action trigger**. In woa-rs terms: `(ReasoningKind::NextBestAction, InferenceType::Induction, SemiringChoice::NarsTruth, ThinkingStyle cluster: Exploratory)`. Inherited from SmbFoundryInvoice family → a document-basin next-action recommendation has Exploratory character. But the detection logic itself (compare qty_delivered > qty_ordered) is deterministic; only the "what to do about it" (activity scheduling, sales suggestions) is Axis-2. + +#### Ontology Mapping +Both `odoo:sale.order` and `odoo:sale.order.line` — UNRESOLVED. Proposals in S-1 and S-3. + +#### K-Step +Determines when invoicing is triggered → feeds K3 (posting) and K8 (invoice reports). + +--- + +### Rule S-6: _prepare_invoice — Order→Invoice Field Mapping + +**Odoo source:** `sale_order.py:L1411-1450` + +#### Axis-1 Rich-AST Spec + +**`_prepare_invoice` (L1411-1450):** +Called per SO in `_create_invoices`. Returns a dict of `account.move` creation values. + +Full field mapping: +```python +{ + 'ref': self.client_order_ref or self.name, # customer reference or SO name + 'move_type': 'out_invoice', # sales invoice (not bill) + 'narration': self.note, # T&C text → invoice narration + 'currency_id': self.currency_id.id, + 'campaign_id': self.campaign_id.id, # UTM tracking + 'medium_id': self.medium_id.id, + 'source_id': self.source_id.id, + 'team_id': self.team_id.id, # sales team + 'partner_id': self.partner_invoice_id.id, # INVOICE address (not shipping!) + 'partner_shipping_id': self.partner_shipping_id.id, + 'fiscal_position_id': (self.fiscal_position_id or + self.fiscal_position_id._get_fiscal_position(self.partner_invoice_id)).id, + 'invoice_origin': self.name, # SO number in invoice origin field + 'invoice_payment_term_id': self.payment_term_id.id, + 'preferred_payment_method_line_id': self.preferred_payment_method_line_id.id, + 'invoice_user_id': self.user_id.id, # salesperson → invoice user + 'payment_reference': self.reference, # payment ref (Verwendungszweck) + 'transaction_ids': [Command.set(txs_to_be_linked.ids)], # link payment transactions + 'company_id': self.company_id.id, + 'invoice_line_ids': [], # populated next by _create_invoices + 'user_id': self.user_id.id, +} +# Conditional: if self.journal_id: values['journal_id'] = self.journal_id.id +``` + +**Transaction linking (L1419-1424):** Filters payment transactions to those in `pending`/`authorized` state OR `done` + `not payment_id.is_reconciled`. These unreconciled transactions are linked to the new invoice for later reconciliation. + +**Sudo context (L1547-1548):** Invoice creation happens via `self.env['account.move'].sudo()` with context `default_move_type='out_invoice'`. A salesperson can create an invoice from SO without billing access rights, but cannot create invoices from scratch. + +**`_prepare_invoice` for purchase (L925-946):** +Purchase variant creates `in_invoice` (vendor bill), maps: +```python +{ + 'move_type': move_type, # context.get('default_move_type', 'in_invoice') + 'narration': self.note, + 'currency_id': self.currency_id.id, + 'partner_id': partner_invoice.id, # vendor invoice address + 'fiscal_position_id': ..., + 'partner_bank_id': partner_bank_id.id, # vendor bank (purchase only, not in sale) + 'invoice_origin': self.name, + 'invoice_payment_term_id': self.payment_term_id.id, + 'invoice_line_ids': [], + 'company_id': self.company_id.id, +} +``` +Purchase does NOT copy `team_id`, `campaign_id`, `payment_reference`, `preferred_payment_method_line_id`, or transaction_ids to the bill. + +#### Axis Classification +**DETERMINISTIC** — static field mapping. No heuristics. + +#### Ontology Mapping +`odoo:sale.order` → see S-1; `odoo:account.move` is already resolved: `odoo:account.move → fibo:Transaction → SmbFoundryInvoice (0x81) → DOLCE Perdurant` (in ODOO_ALIGNMENTS L132-137). + +#### K-Step +K3 — the created `account.move` is the posting document. When posted, double-entry bookkeeping entries are created. This method is the bridge between the order world and the accounting world. + +#### woa-rs Target Module +In woa-rs, the Vorgang IS the invoice (`doc_type = 'invoice'`). The SO→invoice transition in woa-rs would be: update `doc_type` from `'order'` to `'invoice'`, set `rechnung_nr`, etc. There is no separate `account.move` model in woa-rs — the Vorgang row represents both the order and the invoice. This is a fundamental architectural difference. + +**GAP:** odoo separates `sale.order` (commercial document) and `account.move` (accounting document). woa-rs collapses both into `workorders`. The porter needs to decide whether to keep the collapsed model (current woa-rs architecture, simpler) or introduce a separate posting document. Given the Rust port is behaviorally-preserving (Iron Rule 7), the collapsed model is correct for now. + +--- + +### Rule S-7: _create_invoices — Invoice Creation Algorithm + +**Odoo source:** `sale_order.py:L1499-1692` + +#### Axis-1 Rich-AST Spec + +**`_get_invoiceable_lines(final=False)` (L1499-1542):** + +Iterates `self.order_line` in order. Tracks `section_line_ids` (current section), `subsection_line_ids` (current subsection). + +Line classification: +- `display_type == 'line_section'`: reset `section_line_ids = [line.id]`, reset subsection, skip. +- `display_type == 'line_subsection'`: set `subsection_line_ids = [line.id]`, skip. +- `display_type != 'line_note' AND float_is_zero(qty_to_invoice, precision)`: skip (nothing to invoice). +- `qty_to_invoice > 0 OR (qty_to_invoice < 0 AND final) OR display_type == 'line_note'`: + - If `is_downpayment`: add to `down_payment_line_ids` (put at end). + - If under subsection: collect subsection_line_ids + section_line_ids first (lazy collection of headers). + - If under section only: collect section_line_ids first. + - Add line to `invoiceable_line_ids`. + +Returns: `self.env['sale.order.line'].browse(invoiceable_line_ids + down_payment_line_ids)` — down payments always at the END. + +**`_create_invoices(grouped=False, final=False, date=None)` (L1550-1692):** + +Phase 1 — Build invoice_vals_list: +1. Access check: requires `account.move` create access OR write access on SO. If neither: return empty. +2. For each SO (with partner language + company context): + a. `invoice_vals = order._prepare_invoice()` + b. `invoiceable_lines = order._get_invoiceable_lines(final)` + c. If ALL lines are display_type (no real lines): `continue` (skip this SO). + d. Build `invoice_line_vals`: + - Track `down_payment_section_added` (create a section header for down payments once). + - For each line: if `is_downpayment` and section not yet added → insert down payment section via `_prepare_down_payment_section_line(sequence=...)`. + - For down payment lines in final invoice: `optional_values['quantity'] = -1.0` AND `optional_values['extra_tax_data'] = reversed extra_tax_data`. This NEGATES the down payment on the final invoice. + - `for vals in line._prepare_invoice_lines_vals_list(**optional_values): invoice_line_vals.append(Command.create(vals))` + e. `invoice_vals['invoice_line_ids'] += invoice_line_vals` +3. If `invoice_vals_list` is empty and `raise_if_nothing_to_invoice` context: raise UserError. + +Phase 2 — Grouping: +- If `grouped=True`: one invoice per SO (no grouping). +- If `grouped=False`: group by `_get_invoice_grouping_keys()` = `['company_id', 'partner_id', 'partner_shipping_id', 'currency_id', 'fiscal_position_id']`. Multiple SOs with same group keys → merged into one invoice. Merged refs = comma-separated (truncated to 2000 chars). payment_reference only kept if unique across the group. + +Phase 3 — Resequencing: +- If `len(invoice_vals_list) < len(self)` (grouping happened): resequence lines via `_get_invoice_line_sequence(new=seq, old=old_seq)`. Prevents duplicate sequence numbers when combining from multiple SOs. + +Phase 4 — Create invoices: +- `moves = self._create_account_invoices(invoice_vals_list, final)` (creates in sudo). + +Phase 5 — Refund conversion: +- If `final=True`: find moves with `amount_total < 0` after tax calculation → call `action_switch_move_type()` to convert to `out_refund`. Sets reversed entry relationship. + +Phase 6 — Message posting: +- Post origin link message on each invoice. + +**Purchase `action_create_invoice` (purchase_order.py L760-833):** + +Simpler than sale's `_create_invoices`: +1. No `final` parameter — purchase always invoices all quantities. +2. No `grouped` parameter by name, but still groups by `(company_id, partner_id, currency_id)`. +3. Section lines: only included if they precede a non-display line (`pending_section` pattern — section buffered, flushed when next real line appears). +4. Negative total → `action_switch_move_type()` (same as sale). +5. Optional: if `attachment_ids` provided, extend the created invoice with OCR attachment data and link message. + +#### Axis Classification +**DETERMINISTIC** — the grouping, sequencing, line inclusion, down-payment negation are all deterministic algorithms. + +#### Ontology Mapping +All UNRESOLVED classes (sale.order, sale.order.line) per proposals above. The created `account.move` is resolved. + +#### K-Step +K3 (double-entry creation is next step after invoice is posted), K11 (Festschreibung happens when invoice is posted/locked). + +--- + +### Rule S-8: Purchase Order State Machine (button_confirm + approval) + +**Odoo source:** `purchase_order.py:L105-111, L625-668, L615-619, L1252-1261` + +#### Axis-1 Rich-AST Spec + +**State values (L105-111):** +```python +[ + ('draft', 'RFQ'), + ('sent', 'RFQ Sent'), + ('to approve', 'To Approve'), + ('purchase', 'Purchase Order'), + ('cancel', 'Cancelled') +] +``` +Field: `state = fields.Selection(..., default='draft', readonly=True, index=True, copy=False, tracking=True)` + +**`button_confirm` (L625-639):** +For each order: +1. Skip if state not in `['draft', 'sent']`. +2. `_confirmation_error_message()` (L657-668): guard — any non-display, non-downpayment line missing product → error. +3. `order.order_line._validate_analytic_distribution()`. +4. `order._add_supplier_to_product()` (L682-708): adds vendor to product supplierinfo if not already there, max 10 suppliers per product. This is a SIDE EFFECT of confirming. +5. If `_approval_allowed()`: + - Call `button_approve()` → write `{'state': 'purchase', 'date_approve': now()}`. If `lock_confirmed_po == 'lock'`: also write `{'locked': True}`. +6. Else: write `{'state': 'to approve'}` (needs a second approval). + +**`_approval_allowed` (L1252-1261):** +Returns True if: +- `company.po_double_validation == 'one_step'` (no second approval needed), OR +- `company.po_double_validation == 'two_step' AND amount_total < po_double_validation_amount` (below threshold), OR +- User has `purchase.group_purchase_manager` role. + +**`button_cancel` (L641-649):** Raises UserError if: +- Any order is locked. +- Any order has non-cancelled/non-draft invoices (must cancel bills first). +Then writes `{'state': 'cancel'}`. + +**Deletion guard (L408-412):** `_unlink_if_cancelled` — can only delete cancelled POs. + +#### Axis Classification +**DETERMINISTIC** for the state machine itself. **HEURISTIC** for the double-validation amount threshold: the `po_double_validation_amount` is a configured threshold, not computed. The `_approval_allowed` check is deterministic given the company config. + +**Note on `_add_supplier_to_product`:** This is an interesting side-effect. Odoo automatically enriches product supplier info on PO confirmation. This is DETERMINISTIC business logic (not heuristic) but represents an ERP enrichment entirely absent from woa-rs. + +#### Ontology Mapping +`odoo:purchase.order` — **UNRESOLVED** in ODOO_ALIGNMENTS. + +**FLAG: Missing alignment row — proposal:** +- OWL pivot: `ubl:Order` (same UBL Order standard, just on the buy side — UBL uses same Order for both purchase and sales orders from the buyer's perspective; the type is contextual) +- OGIT family: `SmbFoundryInvoice` (0x81) — purchase order is a commercial document in the same document basin +- DOLCE: `Perdurant` +- Proposed row: `odoo:purchase.order → ubl:Order → OGIT SmbFoundryInvoice (0x81) → DOLCE Perdurant` + +Note: distinguishing sale vs purchase UBL: UBL 2.1 has `ubl:Order` (purchase order from buyer) and there is no separate "sale order" in UBL — the seller's perspective is the `ubl:OrderResponse`. If we need distinct OWL pivots: `ubl:Order` for purchase.order and `ubl:OrderResponse` for sale.order. Either way, same OGIT family. + +#### K-Step +Same as sale: core ERP Vorgang lifecycle, prerequisite to K3 (bill posting). + +#### woa-rs Target Module +woa-rs has no purchase order model. Purchase functionality is entirely absent from WoA Python source as well. Not a current target. + +--- + +### Rule S-9: invoice_status on Purchase Order + +**Odoo source:** `purchase_order.py:L46-68` + +#### Axis-1 Rich-AST Spec + +**`_get_invoiced` (L46-68):** +`@api.depends('state', 'order_line.qty_to_invoice')` + +```python +for order in self: + if order.state != 'purchase': + order.invoice_status = 'no' + continue + precision = decimal.precision.precision_get('Product Unit') + any_to_invoice = any( + not float_is_zero(line.qty_to_invoice, precision_digits=precision) + for line in order.order_line.filtered(lambda l: not l.display_type) + ) + all_invoiced_with_invoices = ( + all(float_is_zero(line.qty_to_invoice, ...) for non-display lines) + and order.invoice_ids # must have at least one invoice! + ) + if any_to_invoice: order.invoice_status = 'to invoice' + elif all_invoiced_with_invoices: order.invoice_status = 'invoiced' + else: order.invoice_status = 'no' +``` + +**Key difference from sale:** Purchase only has 3 statuses (`'no'`, `'to invoice'`, `'invoiced'`) — NO `'upselling'` status. The `all_invoiced_with_invoices` check requires `invoice_ids` to exist (can't be invoiced with zero invoices even if qty_to_invoice is 0 — prevents marking new POs as 'invoiced'). + +#### Axis Classification +**DETERMINISTIC** — same as sale. + +--- + +### Rule S-10: _prepare_invoice_line — Line→InvoiceLine Mapping + +**Odoo source:** `sale_order_line.py:L1488-1536` + +#### Axis-1 Rich-AST Spec + +**`_prepare_invoice_line` (L1491-1536):** +Standard line (not combo): +```python +{ + 'display_type': self.display_type or 'product', + 'sequence': self.sequence, + 'name': account.move.line._get_journal_items_full_name(self.name, self.product_id.display_name), + 'product_id': self.product_id.id, + 'product_uom_id': self.product_uom_id.id, + 'quantity': self.qty_to_invoice, # KEY: invoices ONLY qty_to_invoice, not full qty + 'discount': self.discount, + 'price_unit': self.price_unit, + 'tax_ids': [Command.set(self.tax_ids.ids)], + 'sale_line_ids': [Command.link(self.id)], # back-link to SO line (many2many) + 'is_downpayment': self.is_downpayment, + 'extra_tax_data': self.extra_tax_data, + 'collapse_prices': self.collapse_prices, + 'collapse_composition': self.collapse_composition, +} +``` + +Down payment lines: if `is_downpayment` and existing `downpayment_lines` exist → carry over `account_id` from previous invoice line. + +Display type lines: `account_id = False`. + +Combo product lines (L1499-1512): create a `line_section` display line instead of a product line, with `name = f'{product.name} x {qty_to_invoice}'`. + +Optional values override: any `**optional_values` passed in `_create_invoices` (e.g., `quantity=-1.0` for down payment negation in final invoice) are applied last, overriding defaults. + +**`sale_line_ids` many2many link:** This is the critical back-link. The `sale_order_line_invoice_rel` junction table links SO lines to invoice lines. This is how `qty_invoiced` is computed (via `invoice_lines.move_id.state` / `.quantity`). + +#### Axis Classification +**DETERMINISTIC** — static field mapping with one conditional (down payment account). + +#### K-Step +K3 — the invoice line `price_unit × quantity × (1-discount/100) × tax` becomes the posting amount. + +--- + +### Rule S-11: untaxed_amount_to_invoice / amount_to_invoice + +**Odoo source:** `sale_order_line.py:L1143-1200` + +#### Axis-1 Rich-AST Spec + +**`_compute_untaxed_amount_to_invoice` (L1143-1189):** +`@api.depends('state', 'product_id', 'untaxed_amount_invoiced', 'qty_delivered', 'product_uom_qty', 'price_unit')` + +Only computes for `state == 'sale'`. Otherwise `amount_to_invoice = 0.0`. + +```python +uom_qty_to_consider = (qty_delivered if invoice_policy == 'delivery' else product_uom_qty) +price_reduce = price_unit * (1 - (discount or 0.0) / 100.0) +price_subtotal = price_reduce * uom_qty_to_consider + +# If tax is price-inclusive: strip included tax from price_subtotal +if any(tax.price_include for tax in self.tax_ids): + price_subtotal = self.tax_ids.compute_all( + price_reduce, currency=currency, quantity=uom_qty_to_consider, + product=product, partner=partner_shipping_id + )['total_excluded'] + +# Check for invoice lines with different discount (re-invoicing case) +inv_lines = line._get_invoice_lines() +if any(l.discount != line.discount for l in inv_lines): + # Manual re-calculation to handle discount drift + amount = sum( + tax_ids.compute_all(converted_price * qty)['total_excluded'] # if price_include + OR converted_price * qty # otherwise + for l in inv_lines + ) + amount_to_invoice = max(price_subtotal - amount, 0) +else: + amount_to_invoice = price_subtotal - line.untaxed_amount_invoiced +``` + +**Key: `max(…, 0)`** — the remaining amount to invoice is FLOORED at 0. Can't have a negative amount to invoice (that would be a credit situation handled differently). + +**`_compute_amount_to_invoice` (L1191-1200):** Tax-inclusive version (uses `price_total`): +```python +if product_uom_qty: + uom_qty = qty_delivered if delivery_policy else product_uom_qty + qty_to_invoice = uom_qty - qty_invoiced_posted + unit_price_total = price_total / product_uom_qty # tax-included unit price + amount_to_invoice = unit_price_total * qty_to_invoice +else: 0.0 +``` + +Uses `qty_invoiced_posted` (only posted invoices, not draft) to compute the remaining tax-included amount. + +#### Axis Classification +**DETERMINISTIC** — arithmetic with max(0) floor. + +--- + +## 3. Enterprise / Unresolved Flags + +### Enterprise Boundary Flags + +1. **`account_asset` (K12 Anlagen):** Not in community clone. No odoo source for asset depreciation computation. The sale of assets via `sale.order` is visible in community (asset products can be ordered), but the depreciation schedule is Enterprise-only. This lane does not touch K12. + +2. **`sale.order` → `project.project` link (sale_project):** The `_action_confirm()` hook in base sale is empty. `sale_project` extension fills it to create project/tasks on confirmation. This is part of community odoo (not Enterprise) but NOT in the scoped files. The Vorgang lifecycle in woa-rs does have `project_id` FK — so this hook is relevant but not in scope here. + +3. **`account_analytic` distribution validation:** `order_line._validate_analytic_distribution()` is called on confirmation. The analytic module is community but not scoped in this lane. The validation may raise for missing analytic accounts. Porter should note this as a guard step. + +### ODOO_ALIGNMENTS Unresolved Classes (from this lane) + +All four classes touched in this lane are UNRESOLVED in `ODOO_ALIGNMENTS`: + +| odoo class | Current status | Proposed OWL pivot | Proposed OGIT family | Proposed DOLCE | +|---|---|---|---|---| +| `odoo:sale.order` | UNRESOLVED | `ubl:Order` | `SmbFoundryInvoice` (0x81) | Perdurant | +| `odoo:sale.order.line` | UNRESOLVED | `ubl:OrderLine` | `SmbFoundryInvoice` (0x81) | Perdurant | +| `odoo:purchase.order` | UNRESOLVED | `ubl:Order` | `SmbFoundryInvoice` (0x81) | Perdurant | +| `odoo:purchase.order.line` | UNRESOLVED | `ubl:OrderLine` | `SmbFoundryInvoice` (0x81) | Perdurant | + +**Note on UBL vs other pivot candidates:** UBL (Universal Business Language) is the canonical B2B XML standard for orders and invoices, widely used in e-invoicing. It is the natural OWL pivot for commercial order documents. Alternatives considered: +- `schema:Order` (schema.org): less formal, more e-commerce oriented +- `fibo:CommercialAgreement`: too abstract +- `ccts:OrderDocument` (UN/CEFACT): technically more precise but less commonly linked in OWL ontologies + +Recommendation: use `ubl:Order` / `ubl:OrderLine` as they are the closest standard analogs and align with the X-Rechnung/ZUGFeRD work in woa-rs (those use UBL structure). + +--- + +## 4. woa-rs Calibration Summary + +### What woa-rs Vorgang currently has (from `src/models/work_order.rs`) + +- `doc_type`: `'workorder'|'offer'|'order'|'invoice'|'craft_invoice'|'credit'|'collective_invoice'` +- `status`: `'draft'|'open'|'in_progress'|'completed'|'invoiced'|'paid'|'cancelled'` +- `angebot_nr`, `auftrags_nr`, `workorder_nr`, `rechnung_nr`, `gutschrift_nr` — separate number fields for each lifecycle stage +- `sammelrechnung_id` — self-referential FK for collective invoicing +- `zahlungsart`: `'rechnung'|'vorkasse'|'anzahlung'` +- `anzahlung_prozent`, `anzahlung_betrag` — down payment fields (partial analog to odoo's down payment lines) +- `bezahlt`, `bezahlt_am` — payment tracking +- `mahnstufe`, `letzte_mahnung` — dunning (no odoo analog in sale module) +- `kleinunternehmer_snapshot`, `zahlungsziel_tage_snapshot` — GoBD freeze fields + +### Richness Gaps (odoo richer than woa-rs) + +| Odoo feature | woa-rs status | Gap severity | +|---|---|---| +| `qty_to_invoice` / `qty_invoiced` per line | Absent (no line model at all) | MAJOR | +| Partial invoicing state machine | Absent | MAJOR | +| `invoice_status` enum (no/to invoice/invoiced/upselling) | Absent (status covers different states) | MAJOR | +| Multi-line SO with sections/subsections | Absent | MODERATE | +| Invoice grouping by partner/currency | Absent | MODERATE | +| Fiscal position → tax mapping | Absent | MODERATE | +| EPD (Early Payment Discount) mixed mode | Absent | LOW (unusual edge case) | +| Upselling activity automation | Absent | LOW (UI feature) | +| Down payment line negation on final invoice | Absent | LOW (no line model) | + +### What woa-rs can absorb without a line model + +The following odoo logic CAN be ported to woa-rs even without a `vorgang_line` table: + +1. **State machine transitions** (S-1): map to `status` field transitions with guard logic in `src/erp/sale_order_fsm.rs`. +2. **Amount computation** (S-2, simplified): `brutto_summe = netto_summe * (1 + mwst_satz)` — woa-rs already tracks these. The odoo tax engine pipeline (prepare_base_line → add_tax_details → round) is the richer version; for woa-rs the `tax_rate::Model` provides the rate. +3. **Invoice creation field mapping** (S-6): the transition from Vorgang `doc_type='order'` to `doc_type='invoice'` with `rechnung_nr` set is the woa-rs equivalent of `_prepare_invoice`. +4. **GoBD lock** (S-1 cancel guard): already implemented in `src/gobd.rs`. + +--- + +## 5. Porter's Checklist — Non-Obvious Gotchas + +1. **No `'done'` state.** Community odoo 17 `sale.order` has only `draft/sent/sale/cancel`. The `locked` boolean is separate from state. Do not implement a `done` state. + +2. **`date_order` is OVERWRITTEN on confirm.** `_prepare_confirmation_values()` sets `date_order = now()`. The original creation date is in `create_date` (read-only). In woa-rs, `datum` is the user-set date; a separate `confirmed_at` timestamp may be needed if this distinction matters. + +3. **Price is frozen once invoiced.** `_compute_price_unit` skips recompute if `qty_invoiced > 0`. In woa-rs, once a Vorgang is in `invoiced` or `paid` status, price edits should be blocked (GoBD lock already enforces this via `src/gobd.rs`). + +4. **Fiscal position maps taxes.** The `_compute_tax_ids` pipeline calls `fiscal_position.map_tax(taxes)`. A customer in a different country/VAT regime gets different taxes applied. woa-rs currently has no fiscal position concept — all tax is flat-rate via `tax_rate::Model`. This is a simplification that is acceptable for Stefan's domestic use case. + +5. **`qty_invoiced` counts refunds negatively.** An `out_refund` against a line DECREASES `qty_invoiced`, making the line invoiceable again. In woa-rs, a credit note (`doc_type='credit'`) logically reverses an invoice but there is no qty tracking to unlock re-invoicing. + +6. **Down payment negation on final invoice.** When `final=True`, down payment lines get `quantity=-1.0` and reversed `extra_tax_data`. This avoids double-counting: the customer already paid the down payment, so the final invoice shows it as a deduction. woa-rs has `anzahlung_betrag` but no line-level negation mechanism. + +7. **Invoice grouping by shipping address.** `_get_invoice_grouping_keys()` includes `partner_shipping_id`. Multiple SOs with different shipping addresses → separate invoices even if same billing partner. woa-rs has no shipping address concept. + +8. **Sudo for invoice creation.** `account.move` is created in sudo so salespersons can create invoices without full accounting access. In woa-rs, permission checks are handled at route level; this is not a direct concern but the pattern (salesperson can create invoice from SO) should be preserved. + +9. **Combo products.** When `product.type == 'combo'`, `tax_ids = False` and `qty_to_invoice` is computed differently (via linked item lines). woa-rs has no combo product concept; this is safe to ignore. + +10. **Purchase `_approval_allowed`** double-validation threshold: `po_double_validation_amount` is compared using `company_currency._convert` to the order's currency. Multi-currency comparison is deterministic but requires currency tables. woa-rs is single-currency (EUR). + +11. **`technical_price_unit` shadow field.** This is not a displayed field but is essential for detecting manual price edits. When porting, any price-edit lock mechanism needs to compare "pricelist-computed" vs "actual" price, requiring this dual-field pattern or equivalent. + +12. **Section/subsection carry-forward.** `_get_invoiceable_lines()` includes section header lines only when there is at least one invoiceable line under them. Empty sections are dropped. This prevents orphan section headers on partial invoices. + +13. **Precision from `decimal.precision`** not hardcoded. `precision_get('Product Unit')` returns a DB-stored decimal precision setting. Porter should use a configurable precision, not a compile-time constant. + +14. **`_can_be_invoiced_alone()` discount product check.** A discount-only SO (all lines are global discount lines) gets `invoice_status = 'no'` even if technically there are `to invoice` lines. This prevents creating invoices that contain nothing but discounts. + +--- + +## 6. Depth Proof + +Read: `/home/user/odoo/addons/sale/models/sale_order.py` lines=2301 depth=full +Read: `/home/user/odoo/addons/sale/models/sale_order_line.py` lines=1819 depth=full +Read: `/home/user/odoo/addons/purchase/models/purchase_order.py` lines=1418 depth=full +Read: `/home/user/woa-rs/src/models/work_order.rs` lines=~160 depth=full +Read: `/home/user/lance-graph/crates/lance-graph-callcenter/src/odoo_alignment.rs` lines=~260 depth=full +Read: `/home/user/woa-rs/.claude/board/odoo-richness/BRIEFING.md` lines=124 depth=full diff --git a/.claude/odoo/L7-STOCK.md b/.claude/odoo/L7-STOCK.md new file mode 100644 index 00000000..d91644e4 --- /dev/null +++ b/.claude/odoo/L7-STOCK.md @@ -0,0 +1,742 @@ +RICHNESS-LANE-OK + +# L7 — Inventory: Stock Moves, Picking, Quant Valuation + Reservation + +**Lane:** L7-STOCK +**Date:** 2026-05-26 +**Author:** Claude Sonnet 4.6 (read-only analysis lane) +**K-step:** K10 (Lager/Inventur) — net-new ERP inventory richness +**Sentinel:** RICHNESS-LANE-OK (first line) + +--- + +## 1. Scope + Files Read + +| File | Lines | Depth | +|---|---|---| +| `/home/user/odoo/addons/stock/models/stock_move.py` | 2683 | full | +| `/home/user/odoo/addons/stock/models/stock_quant.py` | 1563 | full | +| `/home/user/odoo/addons/stock/models/stock_picking.py` | 2149 | full | + +**woa-rs calibration grep:** `grep -rn "stock\|lager\|inventory\|bestand\|material\|quant\|reserv" /home/user/woa-rs/src/ 2>/dev/null | head` + +**Finding:** woa-rs has `src/models/erp/k10_inventory.rs` — a simple WoA-app Lager model (warehouse/inventory/stock_movement/serial). It is a flat movement ledger with no state machine, no reservation concept, no multi-step picking, no quant-level tracking, no removal strategies. The Odoo inventory subsystem is **entirely net-new richness** relative to woa-rs. No collision, pure addition. + +--- + +## 2. Rule Sections + +--- + +### R1 — Stock Move State Machine + +**File:** `stock_move.py:107–120` (state field), `L1546–1641` (`_action_confirm`), `L1901–2043` (`_action_assign`), `L2101–2169` (`_action_done`), `L2044–2084` (`_action_cancel`), `L2268–2288` (`_recompute_state`) + +#### Axis-1: Rich-AST Spec + +**State Enumeration** (`stock_move.py:L107–120`): +``` +draft → New (not confirmed) +waiting → Waiting Another Move (upstream move not done) +confirmed → Waiting (confirmed but product not reservable yet) +partially_available → Some qty reserved +assigned → Fully reserved (available) +done → Transfer completed +cancel → Cancelled +``` +Default on create: `'draft'`. Field: `copy=False, index=True, readonly=True`. + +**Transition Rules** (`_action_confirm`, `L1546–1641`): +- Entry guard: `move.state != 'draft'` → skip. +- If `move.move_orig_ids` is non-empty → `waiting` (has upstream dependency). +- Else if `procure_method == 'make_to_order'` → `waiting` + optionally create procurement. +- Else if `rule_id.procure_method == 'mts_else_mto'` → `confirmed` + optionally create procurement. +- Else → `confirmed`. +- After state write: if `reservation_method == 'at_confirm'`, set `reservation_date = today`. +- After merge: immediately call `_action_assign` on `confirmed`/`partially_available` moves that `_should_bypass_reservation()` or `_should_assign_at_confirm()`. +- Negative qty moves (`product_uom_qty < 0`) are treated as returns: locations swapped, `product_uom_qty *= -1`, `picking_type_id = return_picking_type_id`. + +**`_should_bypass_reservation`** (`L1825–1828`): bypasses if `location.should_bypass_reservation()` OR `not product_id.is_storable`. Storable = consumable/service get no quants. + +**`_should_assign_at_confirm`** (`L1830–1831`): True when bypass OR `reservation_method == 'at_confirm'` OR `reservation_date <= today`. + +**`_recompute_state`** (`L2268–2288`): Re-derives state from `quantity` vs `product_uom_qty`: +- If `quantity >= product_uom_qty` → `assigned`. +- If `0 < quantity < product_uom_qty` → `partially_available`. +- If `procure_method == 'make_to_order'` and no `move_orig_ids` → `waiting`. +- Else → `confirmed`. +- Skip if state is `cancel`, `done`, or `draft` with zero quantity. + +**`_action_cancel`** (`L2044–2084`): +- Guard: cannot cancel `done` moves where `location_dest_usage != 'inventory'`. +- Calls `_do_unreserve()` before cancelling. +- Sets state `cancel`. +- If `propagate_cancel=True` and ALL siblings are cancelled → also cancel `move_dest_ids` (chain propagation). +- Detaches `move_orig_ids` from cancelled moves (clears, sets `procure_method='make_to_stock'`). + +**`_action_done`** (`L2101–2169`): +1. Draft moves are first confirmed (merge=False). +2. Moves with `picked=False` and `quantity <= 0` → cancelled (unless `is_inventory`). +3. Unpicked move lines unlinked. +4. `_create_backorder()` called for partial fulfilment (unless `cancel_backorder=True`). +5. `move_line_ids.sorted()._action_done()` — move lines drive the actual quant update. +6. state → `'done'`, `date = now()`. +7. `move_dest_ids._action_assign()` called (downstream moves get available quants). +8. Push rules applied via `_push_apply()`. +9. Picking `_create_backorder()` called if `picking` exists. + +**`_create_backorder` (move-level)** (`L2174–2190`): For each move where `quantity < product_uom_qty` (using general Product Unit decimal precision, NOT UoM rounding), compute `qty_split = product_uom_qty - quantity`, call `_split(qty_split)`, create new move, confirm without merge and without creating procurement. + +**`_split`** (`L2216–2260`): Creates a copy of the move with `product_uom_qty = qty` (the backorder portion). Updates original move's `product_uom_qty = max(0, original_qty - qty)`. Uses `float_round` at Product Unit precision. UoM conversion: first tries to round-trip the quantity through move UoM; if round-trip fails (fractional UoM), falls back to product default UoM. + +#### Rounding / UoM / Float Handling +- `float_compare`, `float_is_zero`, `float_round` from `odoo.tools.float_utils` — NOT Python's built-in float comparison. +- Product quantity computed in `product_id.uom_id` (the product's default UoM), not the move's `product_uom`. Conversion via `product_uom._compute_quantity(..., rounding_method='HALF-UP')`. +- Backorder qty uses `precision_get('Product Unit')` (the named decimal precision, not UoM rounding). +- `product_qty` field is computed (`_compute_product_qty`): `product_uom._compute_quantity(product_uom_qty, product_id.uom_id, rounding_method='HALF-UP')`. Setting `product_qty` raises `UserError` — must write `product_uom_qty` instead. + +#### Axis Classification +**DETERMINISTIC** — Axis-1. The state machine transitions are closed-form rules with no heuristic weighting. Port directly. + +**Ontology:** +`odoo:stock.move` → UNRESOLVED (FLAG — see Section 3) +Proposed: `odoo:stock.move` → `gs1:LogisticEvent` → OGIT family needs authoring (see Section 3) +DOLCE: **Perdurant** (`.move` suffix classifier, temporal event) +K-step: **K10** (Lager/Inventur) +woa-rs target: `src/erp/inventory/stock_move.rs` + +--- + +### R2 — Availability Arithmetic: `_get_available_quantity` + +**File:** `stock_quant.py:L793–832` (quant-level), `stock_move.py:L1846–1850` (move wrapper) + +#### Axis-1: Rich-AST Spec + +**Move-level wrapper** (`stock_move.py:L1846–1850`): +```python +def _get_available_quantity(self, location_id, lot_id=None, package_id=None, owner_id=None, strict=False, allow_negative=False): + if location_id.should_bypass_reservation(): + return self.product_qty + return self.env['stock.quant']._get_available_quantity(self.product_id, location_id, ...) +``` +If location bypasses reservation (e.g. virtual/supplier/customer locations), return the full `product_qty` unconditionally. + +**Quant-level** (`stock_quant.py:L793–832`): +``` +available_quantity = quantity - reserved_quantity +``` +For untracked products (`tracking == 'none'`): +- `sum(quants.quantity) - sum(quants.reserved_quantity)`. +- If `allow_negative=False`: floor at 0.0 using `product_id.uom_id.compare(available_quantity, 0.0) >= 0`. + +For tracked products (lot/serial): +- Group by lot_id: `{lot_id: 0.0, ..., 'untracked': 0.0}`. +- Sum `quantity - reserved_quantity` per lot bucket. +- If `strict=True` and `lot_id` specified: skip quants without lot. +- If `allow_negative=False`: only sum buckets with `compare(qty, 0) > 0`. + +**`_compute_product_availability`** (`stock_move.py:L489–500`): Field `availability` on move: +- If `state == 'done'`: `availability = product_qty`. +- Else: `min(product_qty, quant._get_available_quantity(product_id, location_id))`. + +#### Axis Classification +**DETERMINISTIC** — Axis-1. Pure arithmetic: `quantity - reserved_quantity`, min-capped, lot-grouped. No heuristics. + +**Ontology:** +`odoo:stock.quant` → UNRESOLVED (FLAG — see Section 3) +Proposed: → `gs1:LogisticEvent` (instance of stock at location) OR needs new alignment row (see Section 3) +DOLCE: **Endurant** (persistent stock record) +K-step: **K10** +woa-rs target: `src/erp/inventory/quant.rs` + +--- + +### R3 — Quant `_gather`: Candidate Selection (Removal Strategy) + +**File:** `stock_quant.py:L617–791` + +#### Axis-1: Rich-AST Spec + +**`_get_removal_strategy`** (`L617–628`): +Priority order: +1. `product_id.categ_id.removal_strategy_id.method` (product category wins). +2. Walk `location_id` up the location hierarchy (`loc = loc.location_id` loop) until `loc.removal_strategy_id` found. +3. Default: `'fifo'`. + +**Available strategies:** +- `'fifo'`: order `in_date ASC, id ASC` (oldest incoming date first). +- `'lifo'`: order `in_date DESC, id DESC` (newest first). +- `'fefo'`: not in `_get_removal_strategy_order` (returns `False` for `'closest'`); FEFO is handled via `with_expiration` context key in `_get_gather_domain` — filters quants where `removal_date >= expiration_threshold` OR `removal_date IS NULL`, then sorted by lot expiry date. Note: `_get_removal_strategy_order` raises `UserError` for unknown strategies. +- `'least_packages'`: order `in_date ASC, id ASC` (same as FIFO), but domain is computed by A* search first (see below). +- `'closest'`: no SQL order (returns `False`); sorted in Python: `res.sorted(lambda q: (q.location_id.complete_name, -q.id))` — nearest location alphabetically, most recent id. + +**`_get_gather_domain`** (`L750–769`): Builds the search domain: +- Always: `product_id = product_id.id`. +- Non-strict: `lot_id IN [lot_id, False]`, `package_id = package_id`, `owner_id = owner_id`, `location_id CHILD_OF location_id.id`. +- Strict: `lot_id IN [False, lot_id]`, exact `package_id`, exact `owner_id`, exact `location_id = location_id.id` (no child_of). +- If context has `with_expiration`: adds `removal_date >= threshold OR removal_date IS NULL`. + +**`_gather`** (`L771–791`): +``` +removal_strategy = _get_removal_strategy(product_id, location_id) +domain = _get_gather_domain(...) +if removal_strategy == 'least_packages' and qty: + domain = _run_least_packages_removal_strategy_astar(domain, qty) +order = _get_removal_strategy_order(removal_strategy) +# cache bypass for strict non-least-packages +if quants_cache and strict and removal_strategy != 'least_packages': + res = from cache +else: + res = self.search(domain, order=order) +if removal_strategy == 'closest': + res = res.sorted(lambda q: (complete_name, -id)) +return res.sorted(lambda q: not q.lot_id) # ← quants WITH lot_id float to top +``` + +The final `.sorted(lambda q: not q.lot_id)` means lot-specific quants are preferred over lot-less quants when selecting for reservation. This is a tie-breaker that promotes specificity. + +**`_run_least_packages_removal_strategy_astar`** (`L630–738`): An A* search over package combinations to find the minimum number of packages that satisfy the requested quantity. Uses a priority queue with heuristic `len(taken_packages) + remaining_qty / package_size[next]`. Falls back to `best_leaf` (partial or overselecting) on `MemoryError`. + +#### Axis-2: HEURISTIC (Axis-2 NARS Candidate — STRONG) + +This is the primary NARS candidate for this lane. The allocation strategy over candidate quants is a **next-best-action** selection problem: +- Given demand qty + candidate quants (each with `in_date`, `lot_id`, `removal_date`, `package_id`, `location`). +- The system selects WHICH quants to bind to the reservation, in what order, up to the demand. +- FIFO/FEFO/LIFO/closest/least_packages are competing heuristics with different optimality criteria. +- The choice of strategy itself (product category or location hierarchy) is a policy, not a closed-form derivation. +- Partial reservation (some quants, not enough) is a natural intermediate result — the Reasoner must know when to stop. + +**Contract Tuple:** +``` +ReasoningKind: NextBestAction +InferenceType: Induction (for FIFO/FEFO pattern recognition over historical lot dates) + + Abduction (for "why can't this be fulfilled" — missing quants, wrong location) +SemiringChoice: XorBundle (multi-quant binding: we select a set of quants that jointly satisfy demand; + XOR semantics apply because each unit of demand is satisfied by exactly one quant) +ThinkingStyleCluster: Exploratory (breadth over candidate quants, scanning all options before committing) +``` + +**ThinkingStyle inheritance:** `stock.quant` maps toward logistic stock/goods tracking. In OGIT, this aligns with the `Equipment/Resource` or `Logistics` family (if it existed). The removal strategy is a resource allocation problem — Exploratory cluster is justified because the Reasoner needs to scan the candidate space (all available quants with their dates/lots/locations) before selecting the optimal bundle. + +**Evidence the Reasoner receives:** +``` +namespace: "stock.inventory.reservation" +kind: NextBestAction +evidence: { + demand_qty: f64, + product_id: str, + location_id: str, + strategy_hint: "fifo" | "fefo" | "lifo" | "closest" | "least_packages", + candidate_quants: [ + { quant_id, location, lot_id, in_date, removal_date, quantity, reserved_quantity, available_qty } + ... // pre-filtered to available (quantity - reserved_quantity > 0) + ], + strict: bool, // exact lot/package/owner match required? + packaging_uom_id: Option, // affects full-packaging rounding +} +budget: default +``` + +**Reasoner output (Conclusion):** Ordered list of `(quant_id, qty_to_reserve)` pairs summing to `min(demand, total_available)`. Includes explanation field for Abduction path ("insufficient stock at location X", "all lots reserved", etc.). + +**Justification from `_gather` ordering logic:** The fact that Odoo encodes 5 different strategies (FIFO/LIFO/FEFO/closest/least_packages) with a product-category-then-location-hierarchy fallback, plus the A* search for least_packages, confirms this is a heuristic multi-criterion optimization — not a closed-form rule. The NARS Reasoner can encode each strategy as an inductive pattern and select the optimal assignment. + +**Ontology:** (see quant entry above, Section 3) +K-step: **K10** +woa-rs target: `src/erp/inventory/reservation.rs` (stub that delegates to lance-graph reasoning contract) + +--- + +### R4 — `_action_assign`: Reservation / Assignment + +**File:** `stock_move.py:L1901–2043` + +#### Axis-1: Rich-AST Spec + +The full reservation loop. Called on moves in `['confirmed', 'waiting', 'partially_available']` state (unless `force_qty` specified). + +**Pre-loop setup:** +- `reserved_availability = {move: move.quantity}` — snapshot of already-reserved quantities (avoids cache invalidation mid-loop). +- `roundings = {move: move.product_id.uom_id.rounding}`. +- MTO moves: `quants_cache` pre-built via `_get_quants_by_products_locations`. + +**Per-move logic:** +``` +missing_reserved_uom_quantity = product_uom_qty - reserved_availability[move] +if missing <= 0: + → assigned_moves_ids.add(move.id); continue +missing_reserved_quantity = convert to product uom (HALF-UP) +``` + +**Branch A: Bypass reservation** (`_should_bypass_reservation()` = True): +- Location is virtual/supplier/customer OR product not storable. +- If `move.move_orig_ids`: pull from `_get_available_move_lines(...)` — look at what upstream done moves brought, subtract what sibling moves already took (in-loop accounting of partially committed quantities). +- If serial tracking: create one move line per unit. +- Otherwise: update existing compatible move line or create new one. +- Result: `assigned_moves_ids.add(move.id)` + `moves_to_redirect.add(move.id)`. + +**Branch B: Normal MTS (no upstream moves)**: +- If `procure_method == 'make_to_order'`: skip. +- Call `move._update_reserved_quantity(need, move.location_id, strict=False)` → calls `_get_reserve_quantity` on quant. +- If `taken_quantity == 0`: continue (no stock). +- If `taken_quantity == need`: `assigned_moves_ids.add`. +- Else: `partially_available_moves_ids.add`. + +**Branch C: MTO / chained moves** (has `move_orig_ids`, not bypass): +- `_get_available_move_lines(assigned_moves_ids, partially_available_moves_ids)` — cross-reference what upstream delivered. +- For each `(location, lot, package, owner) → qty` bucket: + - Compute `need = product_qty - sum(existing_mls) - sum(taken_quantities)`. + - Call `_update_reserved_quantity_vals(min(qty, need), ..., strict=True)`. + - Accumulate `taken_quantities`. +- If any taken: check if `need - taken_quantity ≈ 0` → assigned, else partially_available. + +**Post-loop:** +```python +self.env['stock.move.line'].create(move_line_vals_list) # batch create +StockMove.browse(partially_available_moves_ids).write({'state': 'partially_available'}) +StockMove.browse(assigned_moves_ids).write({'state': 'assigned'}) +StockMove.browse(moves_to_redirect).move_line_ids._apply_putaway_strategy() +``` + +**`_update_reserved_quantity`** (`L1763–1773`): Calls `_get_reserve_quantity` on quant, creates move lines via `_prepare_move_line_vals`. + +**`_update_reserved_quantity_vals`** (`L1775–1820`): Deduplicates quants with same `(location, lot, package, owner)` key (groups them). Updates existing move lines where UoM round-trip is exact; creates new move lines otherwise. Serial products: one move line per unit. + +**`_get_available_move_lines`** (`L1893–1899`): Available = what upstream done moves brought (`_get_available_move_lines_in`) MINUS what siblings already reserved (`_get_available_move_lines_out`). Siblings include moves already processed in this same `_action_assign` call (the `assigned_moves_ids`/`partially_available_moves_ids` sets are passed in to prevent double-counting). + +**`action_assign` (picking level)** (`stock_picking.py:L1195–1208`): Sorts moves by `(-priority, not date_deadline, date_deadline, date, id)` before calling `_action_assign()`. This means: +- High-priority moves (priority='1', Urgent) reserved first. +- Among equal priority: moves WITH deadlines before moves WITHOUT. +- Among equal priority+deadline: by deadline date ascending. +- Tie-break: by date, then by id. + +#### Axis-2: HEURISTIC (Axis-2 NARS Candidate — STRONG) + +The `action_assign` sorting is a policy (priority-first, deadline-aware), and the decision of whether a partially-reserved move should block or proceed is a judgment call (see `move_type` = 'one' vs 'direct' in picking). The inner `_action_assign` loop is deterministic given a fixed quant set, but the quant selection (via `_gather`) is heuristic (see R3). + +For the sorting policy specifically: +``` +ReasoningKind: NextBestAction +InferenceType: Induction (which move to satisfy first) +SemiringChoice: NarsTruth (confidence-weighted priority scoring) +ThinkingStyleCluster: Exploratory (scanning candidate moves and their urgency signals) +``` + +**Ontology:** (same as stock.move — see Section 3) +K-step: **K10** +woa-rs target: `src/erp/inventory/reservation.rs` + +--- + +### R5 — `_get_reserve_quantity`: Quant-Level Reservation Arithmetic + +**File:** `stock_quant.py:L834–914` + +#### Axis-1: Rich-AST Spec + +Returns `[(quant, qty_to_reserve), ...]` without mutating anything. + +**Full flow:** +1. `quants = _gather(...)` — sorted by removal strategy. +2. `available_quantity = quants._get_available_quantity(...)` — total available across all gathered quants. +3. Full-packaging check: if `packaging_uom_id` context and `categ.packaging_reserve_method == 'full'`: + `available_quantity = packaging_uom._check_qty(min(quantity, available_quantity), product_uom, 'DOWN')`. + This floors to whole-package multiples. +4. `quantity = min(quantity, available_quantity)` — cap at what's available. +5. UoM conversion (non-strict + different UoM): + `quantity_move_uom = product_uom._compute_quantity(quantity, uom_id, rounding_method='DOWN')` + `quantity = uom_id._compute_quantity(quantity_move_uom, product_uom_id, rounding_method='HALF-UP')` + (DOWN then HALF-UP guarantees never-over-reserve). +6. Serial guard: if `tracking == 'serial'` and `quantity != int(quantity)` → quantity = 0 (cannot partially reserve a serial). +7. Negative quantity path (unreservation): drain `reserved_quantity` from quants. +8. Positive reservation: + - Pre-compute `negative_reserved_quantity` dict: quants where `quantity - reserved_quantity < 0` (deficit quants). + - For each quant in strategy-order: + - `max_quantity = quant.quantity - quant.reserved_quantity`. + - Skip if `<= 0`. + - Offset by any negative-reserved at same `(location, lot, package, owner)`. + - `reserve_qty = min(max_quantity, remaining_demand)`. + - Append `(quant, reserve_qty)`. + - Decrement remaining demand. + - Break when demand = 0 or available = 0. + +#### Axis Classification +**DETERMINISTIC** (the arithmetic once quants are gathered) + **HEURISTIC** (the quant selection via `_gather` — see R3). + +The arithmetic itself is Axis-1; the quant selection delegates to R3. + +**Ontology:** (same as stock.quant — Section 3) +K-step: **K10** +woa-rs target: `src/erp/inventory/quant.rs` + +--- + +### R6 — `_update_available_quantity`: Quant Mutation (Done Move) + +**File:** `stock_quant.py:L1037–1105` + +#### Axis-1: Rich-AST Spec + +Called when a move line is done — the actual stock update. + +```python +def _update_available_quantity(product_id, location_id, quantity=False, reserved_quantity=False, + lot_id=None, package_id=None, owner_id=None, in_date=None): +``` + +**`in_date` handling:** +- Gather existing quants (strict = True for exact match). +- If `lot_id` and `quantity > 0`: keep only lot-specific quants. +- If `lot_id` and `quantity <= 0`: keep quants where `quantity > 0` OR `lot_id` set (avoid removing from negative-no-lot quants). +- `incoming_dates = [quant.in_date for quant in quants where quantity > 0]`. +- If `in_date` param provided: append it. +- `in_date = min(incoming_dates)` (oldest date is canonical for the quant group — FIFO semantics preserved even after updates). +- If no dates: `in_date = now()`. + +**Write or Create:** +- If existing quant found: `try_lock_for_update(limit=1)` (pessimistic locking, one quant at a time). + - `quantity += quantity_delta`, `reserved_quantity = max(0, reserved + reserved_qty_delta)`. + - `in_date = min(incoming_dates)`. +- If no quant: create new quant with all key fields. +- Returns `(available_quantity, in_date)`. + +**Concurrency:** `_merge_quants()` handles duplicate quants that arise from concurrent transactions creating the same (product, location, lot, package, owner) combination. Uses raw SQL `WITH dupes AS (SELECT min(id) ...) UPDATE ... DELETE ...`. + +**`_unlink_zero_quants`** (`L1122–1138`): Raw SQL query deletes quants where `round(quantity, 2*product_uom_precision) = 0` AND `reserved_quantity = 0` AND `inventory_quantity = 0` AND `user_id IS NULL`. Uses `max(6, uom_precision * 2)` decimal places to avoid rounding artifacts. + +#### Axis Classification +**DETERMINISTIC** — Axis-1. Pure accounting: add/subtract, lock, write. No heuristics. + +**Rounding note:** `reserved_quantity = max(0, reserved + delta)` — reserved can never go negative (floor at 0). This is an invariant. + +**Ontology:** (stock.quant — Section 3) +K-step: **K10** +woa-rs target: `src/erp/inventory/quant.rs` + +--- + +### R7 — Picking State Machine + +**File:** `stock_picking.py:L575–863` (state field + `_compute_state`), `L1186–1208` (action_confirm/action_assign), `L1255–1280` (`_action_done`), `L1577–1602` (`_create_backorder`) + +#### Axis-1: Rich-AST Spec + +**Picking state** (`L575–589`) is COMPUTED from move states (not stored independently): +``` +draft → Any move is draft +waiting → Waiting for another operation +confirmed → Waiting for availability +assigned → Ready +done → Done +cancel → Cancelled +``` + +**`_compute_state`** (`L816–863`): Aggregates per picking: +``` +any_draft → 'draft' +all_cancel → 'cancel' +all_cancel_done: + all_done_are_scrapped AND any_cancel_and_not_scrapped → 'cancel' + else → 'done' +else (active moves): + if location.should_bypass_reservation() AND all make_to_stock → 'assigned' + else: relevant_move_state = _get_relevant_state_among_moves() + if relevant_move_state == 'partially_available': + → 'assigned' (NOTE: partial is treated as assigned at picking level for 'as soon as possible' policy) + else: + → relevant_move_state +``` + +**`_get_relevant_state_among_moves`** (`stock_move.py:L1320–1360`): Priority sort map `{assigned:4, waiting:3, partially_available:2, confirmed:1}`. For `move_type == 'one'` (deliver all at once): uses the MOST important (highest priority) move's state. Otherwise: uses the LEAST important move's state. + +**`action_confirm`** (`L1186–1193`): +1. Call `_action_confirm` on all draft moves. +2. Trigger scheduler on non-draft, non-cancel, non-done moves. + +**`action_assign`** (`L1195–1208`): +1. If draft: first call `action_confirm`. +2. Sort moves: `(-int(priority), not bool(date_deadline), date_deadline, date, id)`. +3. Guard: if no moves to assign → `UserError('Nothing to check the availability for.')`. +4. Call `moves._action_assign()`. + +**`button_validate`** (`L1398–1458`): +1. Filter out already-done pickings. +2. Auto-confirm draft pickings. +3. For draft pickings: if move has no quantity done but has demand → set `quantity = product_uom_qty`. +4. `_sanity_check()` (lots required, no empty moves). +5. `_pre_action_done_hook()` — may show backorder wizard. +6. `_action_done()` with `cancel_backorder=True/False` based on `picking_type.create_backorder` setting. +7. Auto-print reception report if configured. + +**`_create_backorder` (picking level)** (`L1577–1602`): +1. Find `moves_to_backorder = picking._get_moves_to_backorder()` (state not in done/cancel). +2. Recompute states. +3. Create a copy of the picking (`_create_backorder_picking`) with empty moves: `copy({'name': '/', 'move_ids': [], 'move_line_ids': [], 'backorder_id': picking.id})`. +4. Move the `moves_to_backorder` to the new picking: `moves_to_backorder.write({'picking_id': backorder.id, 'picked': False})`. +5. Also move their `move_line_ids` to the new picking. +6. Set `backorder_picking.user_id = False` (no responsible, must be reassigned). +7. Post chatter message on original picking: "Backorder X created." +8. If `reservation_method == 'at_confirm'`: immediately `action_assign()` on the backorder. + +**`_check_backorder`** (`L1533–1546`): Returns pickings needing backorder (only where `create_backorder == 'ask'`). A picking needs a backorder if any move (not cancelled) has `product_uom_qty > 0 AND not picked` OR `_get_picked_quantity() < product_uom_qty` (using Product Unit decimal precision). + +#### Axis-2: HEURISTIC — Backorder decision + +The backorder creation decision involves judgment: +- `picking_type.create_backorder` = 'ask' | 'always' | 'never' is a policy. +- The wizard presented to the user (`_action_generate_backorder_wizard`) is an interactive judgment. +- Whether a partial fulfilment constitutes a valid delivery (`move_type = 'direct'` vs `'one'`) is a business judgment. + +``` +ReasoningKind: NextBestAction (should we create backorder or cancel remainder?) +InferenceType: Abduction (why is this partially fulfilled? insufficient stock? fulfil-as-possible?) +SemiringChoice: NarsTruth (confidence in whether partial delivery is acceptable) +ThinkingStyleCluster: Exploratory +``` + +**Ontology:** (stock.picking — Section 3) +K-step: **K10** +woa-rs target: `src/erp/inventory/picking.rs` + +--- + +### R8 — Inventory Adjustment via Quant + +**File:** `stock_quant.py:L996–1035` (`_apply_inventory`), `L1253–1292` (`_get_inventory_move_values`) + +#### Axis-1: Rich-AST Spec + +**`_apply_inventory`** (`L996–1035`): Converts `inventory_quantity` (counted) to a stock move: +1. Set `inventory_quantity_set = True`. +2. For each quant: check `inventory_diff_quantity = inventory_quantity - quantity`. +3. If `diff > 0`: create move from `inventory_location → quant.location_id` (stock gain). +4. If `diff < 0`: create move from `quant.location_id → inventory_location` (stock loss). +5. `inventory_location = product.property_stock_inventory` or company default. +6. Create all moves and call `moves._action_done()` (with `inventory_mode=False` context). +7. Trigger auto-assign on downstream moves. +8. Update `location.last_inventory_date`. +9. Call `action_clear_inventory_quantity()` (reset counted). + +**`_get_inventory_move_values`** (`L1253–1292`): Builds move dict with `is_inventory=True`, `picked=True`, `state='confirmed'`. Includes one move line inline. Lot, package, owner, restricted_partner preserved. + +**Skip condition:** If context has `from_inverse_qty` AND `diff == 0`: skip (no-op inventory write). + +#### Axis Classification +**DETERMINISTIC** — Axis-1. Mechanical: diff → move direction → done. No heuristics. + +**Ontology:** (stock.quant) +K-step: **K10** +woa-rs target: `src/erp/inventory/inventory_adjustment.rs` + +--- + +### R9 — `_trigger_assign` + Auto-Reservation Flow + +**File:** `stock_move.py:L2482–2503` + +#### Axis-1: Rich-AST Spec + +```python +def _trigger_assign(self): + if not self or config_param('stock.picking_no_auto_reserve'): + return + product_domains = OR([('product_id', '=', m.product_id.id), ('location_id', 'parent_of', m.location_dest_id.id)] for m in self) + static_domain = [ + ('state', 'in', ['confirmed', 'partially_available']), + ('procure_method', '=', 'make_to_stock'), + ('reservation_date', '<=', today) OR ('picking_type_id.reservation_method', '=', 'at_confirm') + ] + moves_to_reserve = StockMove.search(static_domain & product_domains, order='priority desc, date asc, id asc') + moves_to_reserve = moves_to_reserve.sorted(key=lambda m: any(r in self.reference_ids.ids for r in m.reference_ids.ids), reverse=True) + moves_to_reserve._action_assign() +``` + +The secondary sort (reference_ids intersection) prioritizes moves that share a reference with the triggering move — this is a soft heuristic to prefer related document moves. + +#### Axis Classification +**DETERMINISTIC** for the domain/filter. **HEURISTIC** for the reference_ids secondary sort (preference for related documents is a policy choice). + +K-step: **K10** +woa-rs target: `src/erp/inventory/reservation.rs` + +--- + +## 3. Enterprise/Unresolved Flags + +### FLAG-1: stock.move — UNRESOLVED in odoo_alignment + +`stock.move` currently returns `None` from `resolve_odoo_to_family` in any alignment table. + +**Proposed mapping:** +``` +odoo:stock.move → owl:equivalentClass → gs1:LogisticEvent (GS1 Digital Link ontology) + → OGIT family: needs alignment authoring + → DOLCE: Perdurant (temporal event — a movement of goods in time) +``` + +GS1 has `gs1:LogisticEvent` for "an event in the logistics process" which fits stock moves well. However no OGIT family currently covers GS1 logistics events. **Options:** +1. Map to existing OGIT `SoftwareApplicationComponent` (wrong semantic fit). +2. Map to `Organization/OrganizationUnit` (wrong). +3. **Recommendation: Declare needs new alignment row.** Do NOT invent a CAM family. Flag for alignment authoring in a dedicated pass. Interim: use `Other(7)` as the ReasoningKind tag + `"StockMove"` as the proposed name. + +### FLAG-2: stock.picking — UNRESOLVED in odoo_alignment + +`stock.picking` currently returns `None`. + +**Proposed mapping:** +``` +odoo:stock.picking → owl:equivalentClass → ubl:DespatchAdvice (UBL 2.1 — "a document sent by a Supplier to a Customer") + → OGIT family: needs alignment authoring + → DOLCE: Perdurant (temporal process — coordinating a transfer of goods) +``` + +`ubl:DespatchAdvice` is the closest UBL class (covers both inbound and outbound logistics). The picking aggregate (collection of moves + header) maps to a despatch advice header. **Recommendation: needs alignment authoring.** Interim: `Other(8)` + `"StockPicking"`. + +### FLAG-3: stock.quant — UNRESOLVED in odoo_alignment + +`stock.quant` currently returns `None`. + +**Proposed mapping:** +``` +odoo:stock.quant → owl:equivalentClass → gs1:QuantityElement (GS1 — quantity at a location) + → OGIT family: needs alignment authoring + → DOLCE: Endurant (persistent entity — stock quantity at rest at a location) +``` + +A quant is a persistent record of how much of a product exists at a specific location with specific lot/package/owner characteristics. `gs1:QuantityElement` or alternatively `gs1:PhysicalInventoryReportInventoryType` (GS1 SCB). **Recommendation: needs alignment authoring.** Interim: `Other(9)` + `"StockQuant"`. + +### FLAG-4: stock.warehouse — UNRESOLVED in odoo_alignment + +`stock.warehouse` currently returns `None`. + +**Proposed mapping:** +``` +odoo:stock.warehouse → owl:equivalentClass → gs1:Location (GS1 — "a physical location") + → OGIT family: needs alignment authoring (closest existing: SoftwareApplication? No.) + → DOLCE: Endurant (persistent physical entity) +``` + +`gs1:Location` (or `vcard:ADR` for the address component) covers the physical site. The warehouse entity also carries operational configuration (picking types, routes). **Recommendation: needs alignment authoring.** Interim: `Other(10)` + `"StockWarehouse"`. + +### FLAG-5: FEFO strategy — not fully community + +The `removal_strategy = 'fefo'` path uses `with_expiration` context and `removal_date` on quants. The community code supports it in `_get_gather_domain` (`L767–768`) but the full FEFO UI configuration (product expiration dates, auto-application) may require enterprise modules (`stock_enterprise` / `product_expiry`). The community `_get_removal_strategy_order` does NOT include 'fefo' — it falls through to `UserError`. FEFO is handled purely by domain filtering (expiry date filter + lot-date sort), not a named order. Porter must handle this carefully. + +### FLAG-6: Least-packages A* — performance boundary + +The `_run_least_packages_removal_strategy_astar` has a `MemoryError` guard and uses a Python `heapq` priority queue. For large warehouse inventories (many packages), this can be slow or fail. The `best_leaf` fallback uses a partial/overselecting package combination. This is an explicit approximation — Axis-2 by design, but the A* itself is deterministic once the package list is known. In woa-rs, the least-packages strategy should delegate to the NARS Reasoner (Axis-2, `XorBundle` semiring over package combinations). + +### No Enterprise boundary hits in this lane + +The stock module in community is complete for the core functionality. `account_valuation` (landed costs, AVCO/FIFO costing methods) is partially in community and partially enterprise. The basic `price_unit` field on stock moves (L129) covers the community valuation hook. Full standard-cost/AVCO valuation (`_generate_valuation_lines`) is in `stock_account` (separate community module, not examined in this lane). + +--- + +## 4. Ontology Mapping Summary + +| Odoo class | owl pivot | OGIT family | DOLCE | Status | +|---|---|---|---|---| +| `stock.move` | `gs1:LogisticEvent` | **needs authoring** | Perdurant | UNRESOLVED — FLAG-1 | +| `stock.picking` | `ubl:DespatchAdvice` | **needs authoring** | Perdurant | UNRESOLVED — FLAG-2 | +| `stock.quant` | `gs1:QuantityElement` | **needs authoring** | Endurant | UNRESOLVED — FLAG-3 | +| `stock.warehouse` | `gs1:Location` | **needs authoring** | Endurant | UNRESOLVED — FLAG-4 | + +All four classes need new alignment rows. Do NOT invent CAM families. Use `Other(7–10)` + proposed names as interim contract tags. + +--- + +## 5. woa-rs Integration + +### K-step +**K10 (Lager/Inventur)** — net-new. No K3/K7/K8/K9/K11/K12/K13/K15 overlap. Odoo inventory richness is entirely additive to woa-rs's existing `k10_inventory.rs` model. + +### Existing woa-rs K10 baseline +`src/models/erp/k10_inventory.rs` (432 lines) contains: +- `warehouse::Model` — Lager, flat. +- `inventory::Model` — Bestand snapshot, no state machine. +- `stock_movement::Model` — Immutable ledger row (GoBD-compliant), bewegungsart enum as string. +- `serial::Model` — Seriennummern + MHD. + +**Critical gap vs Odoo:** woa-rs has no: +- Move state machine (draft/confirmed/assigned/done). +- Reservation concept (reserved_quantity). +- Multi-step picking (picking type, backorder). +- Removal strategy (FIFO/FEFO/LIFO). +- Quant-level reservation tracking. + +### Suggested new module: `src/erp/inventory/` + +``` +src/erp/inventory/ +├── mod.rs — module root +├── stock_move.rs — state machine (Axis-1 deterministic port) +├── quant.rs — available_quantity arithmetic, _update_available_quantity (Axis-1) +├── reservation.rs — _action_assign stub + NARS delegation contract (Axis-2) +├── picking.rs — picking state machine + _create_backorder (Axis-1 + Axis-2 judgment) +└── inventory_adjustment.rs — _apply_inventory, diff → move (Axis-1) +``` + +**Reservation delegates to lance-graph reasoning:** +```rust +// src/erp/inventory/reservation.rs +pub async fn assign_quants( + context: ReasoningContext, + client: &dyn ReasonerContract, +) -> Result, WoaError> { + let conclusion = client.reason(context).await?; + // parse (quant_id, qty) pairs from conclusion +} +``` + +The `ReasonerContract` trait comes from `lance-graph-contract` (BBB-allowed). The brain (NARS planner) is NOT in the customer binary. + +--- + +## 6. Porter's Checklist — Non-Obvious Gotchas + +1. **`product_qty` is read-only.** Writing `product_qty` raises `UserError`. Always write `product_uom_qty`. The field `product_qty` is a computed field converting `product_uom_qty` to the product's default UoM via `HALF-UP` rounding. Porter must replicate this computed field, NOT store product_qty directly. + +2. **State is NOT a simple enum transition.** `_recompute_state` re-derives from `quantity` vs `product_uom_qty` at any time. After any write to `product_uom_qty` or `quantity`, state must be recomputed. This is a live invariant, not a simple FSM. + +3. **Backorder uses Product Unit precision, not UoM rounding.** `float_compare(move.quantity, move.product_uom_qty, precision_digits=rounding)` where `rounding = precision_get('Product Unit')` (a named decimal precision in Odoo settings, typically 2). This is NOT the same as `product_uom.rounding` (which is a float factor like 0.001 for UoM with 3 decimals). + +4. **`in_date` on quants uses `min()` of incoming dates.** When multiple quants at the same location are merged, `in_date = min(all_incoming_dates)` — oldest date wins. This is the FIFO invariant preserved at the quant level. + +5. **`reserved_quantity = max(0, reserved + delta)` — can never go negative.** This is a hard floor. If a reservation is released for more than was reserved, it clamps at 0. Porter must replicate this clamp. + +6. **`_action_assign` runs in a single loop with in-memory accounting.** The `assigned_moves_ids` and `partially_available_moves_ids` sets are passed into `_get_available_move_lines_out` to account for sibling moves that were processed in the same batch call. This prevents double-reservation within a single `_action_assign` invocation. Porter must replicate this in-loop accounting. + +7. **Picking state 'partially_available' → picking state 'assigned'.** At the picking level, `partially_available` maps to `assigned` when `move_type == 'direct'` (as soon as possible). This is counter-intuitive — a picking with only partially fulfilled moves can still be `assigned` if the shipping policy allows partial delivery. See `_compute_state:L858–862`. + +8. **`_gather` final sort: lot quants float to top.** After strategy ordering, `_gather` applies `.sorted(lambda q: not q.lot_id)` — quants WITH lots sort before quants WITHOUT lots. This ensures lot-tracked quants are consumed before untracked ones. Porter must replicate this secondary sort. + +9. **`_should_bypass_reservation` short-circuits for non-storable products.** Services and consumables (`not product_id.is_storable`) never touch quants. Porter must gate all quant operations on `is_storable`. + +10. **`propagate_cancel` chain.** Cancel propagation is conditional: ALL siblings must be cancelled before the destination move is cancelled. This prevents cancelling a shared-upstream move that another branch still needs. Porter must implement the sibling-state check. + +11. **Concurrency via `try_lock_for_update`.** `_update_available_quantity` locks the first quant row for update (pessimistic locking). In Rust/sea-orm, use `SELECT ... FOR UPDATE` via raw SQL or sea-orm's `lock()` method. The `_merge_quants` cleanup must also be implemented as a scheduled task. + +12. **`_create_backorder` at picking level resets `user_id = False` and `picked = False` on moves.** The backorder is unassigned (no responsible user). If `reservation_method == 'at_confirm'`, the backorder is immediately re-assigned (action_assign called). Porter must replicate this post-creation auto-assign. + +--- + +## 7. NARS Candidates Summary + +| Rule | Kind | InferenceType | Semiring | ThinkingStyle | Strength | +|---|---|---|---|---|---| +| R3 `_gather` removal strategy | NextBestAction | Induction + Abduction | XorBundle | Exploratory | **STRONG** | +| R4 `action_assign` move priority sort | NextBestAction | Induction | NarsTruth | Exploratory | STRONG | +| R7 backorder decision | NextBestAction | Abduction | NarsTruth | Exploratory | MODERATE | +| R9 reference_ids secondary sort | NextBestAction | Induction | NarsTruth | Exploratory | WEAK (deterministic fallback acceptable) | + +--- + +## Depth Proof + +Read: `/home/user/odoo/addons/stock/models/stock_move.py` lines=2683 depth=full +Read: `/home/user/odoo/addons/stock/models/stock_quant.py` lines=1563 depth=full +Read: `/home/user/odoo/addons/stock/models/stock_picking.py` lines=2149 depth=full +Read: `/home/user/woa-rs/.claude/board/odoo-richness/BRIEFING.md` lines=124 depth=full +Read: `/home/user/woa-rs/src/models/erp/k10_inventory.rs` lines=432 depth=full +woa-rs calibration: grep confirms inventory is thin/absent at the Odoo-richness level (no state machine, no reservation, no quant model). Richness is net-new. diff --git a/.claude/odoo/L8-PRODUCT-UOM-PRICELIST.md b/.claude/odoo/L8-PRODUCT-UOM-PRICELIST.md new file mode 100644 index 00000000..8a8f0e28 --- /dev/null +++ b/.claude/odoo/L8-PRODUCT-UOM-PRICELIST.md @@ -0,0 +1,736 @@ +RICHNESS-LANE-OK + +# Lane L8 — Product + UoM + Pricelist + Costing + +> Generated 2026-05-26. Read-only harvest lane; no cargo, no src edits, no git. + +## Sources read (file : line-range : depth) + +- `/home/user/odoo/addons/product/models/product_template.py` : L1-1598 : full +- `/home/user/odoo/addons/product/models/product_product.py` : L1-1197 : full +- `/home/user/odoo/addons/product/models/product_category.py` : L1-69 : full +- `/home/user/odoo/addons/product/models/product_pricelist.py` : L1-415 : full +- `/home/user/odoo/addons/product/models/product_pricelist_item.py` : L1-684 : full +- `/home/user/odoo/addons/product/models/uom_uom.py` : L1-30 : full (product extension only — stub) +- `/home/user/odoo/addons/account/models/uom_uom.py` : L1-59 : full (account extension — UNECE codes) +- `/home/user/odoo/addons/account/models/product.py` : L1-523 : full (account extension on product.category + product.template) +- `/home/user/odoo/addons/stock/models/product.py` : L1274-1389 : full (stock extension on product.category + uom.uom) +- `uom/models/uom_uom.py` : NOT PRESENT in this clone — core `uom` module absent; logic reconstructed from call-sites + WebFetch of raw.githubusercontent.com/odoo/odoo/17.0/addons/uom/models/uom_uom.py + +--- + +## Ontology rows + +| odoo class | owl pivot | OGIT family (or None) | DOLCE | +|---|---|---|---| +| `product.template` | `schema:Product` | None (ontology-unmapped; needs Layer-2 alignment axiom under a new `0x63 ProductCatalog` family or mapped as Endurant under `0x62 SMBAccounting`) | Endurant (persistent physical/service thing) | +| `product.product` | `schema:Product` (variant) | None (same gap; sub-type of template via `_inherits`) | Endurant | +| `product.category` | `schema:Category` / `skos:Concept` | None (unmapped) | Abstract | +| `uom.uom` | `schema:QuantitativeValue` / `qudt:Unit` | None (unmapped) | Abstract | +| `uom.category` | `schema:Enumeration` | None (unmapped) | Abstract | +| `product.pricelist` | `schema:PriceSpecification` | None (unmapped) | Abstract | +| `product.pricelist.item` | `schema:UnitPriceSpecification` | None (unmapped) | Abstract | + +All seven odoo classes resolve to `None` from `resolve_odoo_to_family()`. They are ontology-unmapped and need Layer-2 alignment axioms before Savant delegation can be wired up. Proposed mapping: `product.*` → `schema:Product` → new OGIT family `0x63 ProductCatalog` (Analytical style, matching the catalog/pricing domain). + +--- + +## Rules extracted + +--- + +### R1 — Product type enum + purchase_ok gate [AXIS-A] + +- **odoo source**: `product_template.py:54-65`, `product_template.py:117` +- **What it does**: `type` ∈ `{consu, service, combo}`. `purchase_ok` is stored+recomputable; base implementation is a no-op (`pass`) — extended by `purchase` module. `sale_ok` defaults True. `combo` type forbids `attribute_line_ids` (constraint at L490-493) and requires at least one `combo_ids` entry (L490-492). When type changes to non-combo, `combo_ids` is cleared (L604-606). +- **woa-rs target**: Data model for `product` entity (K-step: data foundation). Maps to a `ProductKind` enum in Rust. +- **Rust sketch**: + ```rust + pub enum ProductKind { Goods, Service, Combo } + pub struct Product { + kind: ProductKind, + sale_ok: bool, + purchase_ok: bool, // overridden by purchase module + combo_ids: Vec, // non-empty iff kind==Combo + } + // Constraint: kind==Combo => combo_ids.len() >= 1 + // Constraint: kind==Combo => attribute_lines.is_empty() + ``` +- **Parity notes**: `purchase_ok` is a `compute` with `store=True, readonly=False` — effectively a stored field with a no-op compute in base. Only relevant when purchase module is present. + +--- + +### R2 — lst_price / list_price / price_extra triad [AXIS-A] + +- **odoo source**: `product_product.py:25-35`, `product_product.py:319-335`, `product_product.py:308-316` +- **What it does**: `lst_price` (on `product.product`) = `list_price` (on template) + `price_extra` (sum of variant attribute PTAVs) + optional UoM conversion. Context key `uom` (int id) triggers conversion via `uom_id._compute_price(list_price, to_uom)`. Setting `lst_price` via `_set_product_lst_price` reverses: `value = convert(lst_price) - price_extra → write({'list_price': value})`. +- **woa-rs target**: Pricing layer (Vorgang line price). A product variant's sale price in a given UoM. +- **Rust sketch**: + ```rust + fn lst_price(product: &ProductVariant, to_uom: Option<&Uom>) -> Decimal { + let base = match to_uom { + Some(u) if u != &product.uom_id => + uom_compute_price(product.list_price, &product.uom_id, u), + _ => product.list_price, + }; + base + product.price_extra // price_extra = sum of ptav.price_extra + } + ``` +- **Parity notes**: `price_extra` is computed from `product_template_attribute_value_ids.mapped('price_extra')` — sum of per-variant attribute surcharges. No rounding applied at this layer; rounding deferred to pricelist or currency. + +--- + +### R3 — standard_price (cost) — company-dependent, variant-level [AXIS-A] + +- **odoo source**: `product_product.py:62-68`, `product_template.py:100-107`, `product_template.py:310-321` +- **What it does**: `standard_price` lives on `product.product` (variant) with `company_dependent=True` — each company stores its own cost per variant. On the template it is a delegated compute (`_compute_template_field_from_variant_field`) that reads from the single variant if exactly one variant exists, else returns `False`/`0.0`. Constraint: `standard_price >= 0` (validated by `_onchange_standard_price` at `product_product.py:399-402`). +- **woa-rs target**: Cost field for FIFO/AVCO/Standard costing (K3 valuation bridge; also used in pricelist formula base `standard_price`). +- **Rust sketch**: + ```rust + // company_dependent: stored as (company_id, product_id) → Decimal in ir.property or JSON column + fn standard_price(variant: &ProductVariant, company: &Company) -> Decimal { + // fetched per-company; default 0.0 + assert!(price >= Decimal::ZERO); // constraint + price + } + ``` +- **Parity notes**: `standard_price` fetch is elevated to `sudo()` when `price_type == 'standard_price'` in `_price_compute` — cost is accessible to all users through pricelist computation even if restricted directly. Note `cost_currency_id` may differ from `currency_id` when company currency differs. + +--- + +### R4 — currency_id / cost_currency_id compute [AXIS-A] + +- **odoo source**: `product_template.py:88-92`, `product_template.py:257-267` +- **What it does**: `currency_id` = `company_id.currency_id OR main_company.currency_id`. `cost_currency_id` = same logic but depends on `env.company` (context-sensitive). Both are computed, not stored. `cost_currency_id` depends on `depends_context('company')`. +- **woa-rs target**: Currency resolution for pricelist computations. +- **Rust sketch**: + ```rust + fn currency_id(product: &ProductTemplate, env: &Env) -> CurrencyId { + product.company_id + .and_then(|c| c.currency_id) + .unwrap_or(env.main_company().currency_id) + } + ``` +- **Parity notes**: `cost_currency_id` uses `env.company` (the active company from context), not `product.company_id`. These can diverge in multi-company setups. + +--- + +### R5 — UoM core model: factor, rounding, _compute_quantity, _compute_price [AXIS-A] + +- **odoo source**: `uom/models/uom_uom.py` (NOT in this clone — reconstructed from call-sites + GitHub 17.0 raw fetch) +- **What it does**: + - Fields: `name`, `category_id` (Many2one `uom.category`), `factor` (Float, non-zero SQL constraint), `factor_inv` (computed = 1/factor), `rounding` (Float, positive SQL constraint), `uom_type` ∈ `{bigger, reference, smaller}`, `active`. + - SQL constraints: `factor != 0`, `rounding > 0`, reference unit has `factor = 1.0`. + - Each category has exactly one reference unit (`uom_type == 'reference'`). + - **`_compute_quantity(qty, to_unit, round=True, rounding_method='UP', raise_if_failure=True)`**: + 1. Validate `self.category_id == to_unit.category_id` (else raise or return qty as-is if `raise_if_failure=False`). + 2. `result = (qty / self.factor) * to_unit.factor` — convert through reference unit. + 3. If `round=True`: apply `float_round(result, precision_rounding=to_unit.rounding, rounding_method=rounding_method)`. + 4. Return `result`. + - **`_compute_price(price, to_unit)`**: + 1. If `self == to_unit` or `not price`: return `price`. + 2. Validate categories match. + 3. `return (price * self.factor) / to_unit.factor` + 4. No rounding applied (prices carry full float precision until currency rounds). + - Usage confirmed in `_adjust_uom_quantities` (stock): `rounding_method='HALF-UP'`. +- **woa-rs target**: Core UoM conversion engine (data foundation; used in every pricelist + stock move + invoice line). +- **Rust sketch**: + ```rust + pub struct Uom { + pub id: UomId, + pub category_id: UomCategoryId, + pub factor: f64, // ratio vs reference; reference unit has factor=1.0 + pub rounding: f64, // precision multiplier (e.g. 0.01, 1.0, 0.001) + pub uom_type: UomType, // Bigger | Reference | Smaller + } + + pub fn compute_quantity( + from: &Uom, qty: f64, to: &Uom, + round: bool, rounding_method: RoundingMethod, + raise_if_failure: bool, + ) -> Result { + if from.category_id != to.category_id { + if raise_if_failure { return Err(UomError::CategoryMismatch); } + return Ok(qty); + } + let result = (qty / from.factor) * to.factor; + if round { + Ok(float_round(result, to.rounding, rounding_method)) + } else { + Ok(result) + } + } + + pub fn compute_price(from: &Uom, price: f64, to: &Uom) -> f64 { + if from.id == to.id || price == 0.0 { return price; } + // category check implied; porter must add guard + (price * from.factor) / to.factor + } + ``` +- **Parity notes**: + - `factor` is the ratio compared to the reference unit. Bigger UoMs have `factor < 1` (e.g. dozen: factor=1/12 ≈ 0.0833), smaller UoMs have `factor > 1`. + - Wait — the formula `(qty / self.factor) * to_unit.factor` means: convert self→reference by dividing by self.factor, then reference→to_unit by multiplying by to_unit.factor. This is consistent if `reference.factor = 1.0`. + - `_compute_price` inverts: `price * self.factor / to.factor` — price per from-unit → price per to-unit. + - `rounding_method` defaults to `'UP'` in `_compute_quantity` (ceiling toward positive infinity), but `'HALF-UP'` is used in stock procurement (`_adjust_uom_quantities`). The porter must NOT silently normalise to HALF-UP everywhere. + - Stock extension (`stock/models/product.py:L1344-1375`) blocks factor/relative_factor/relative_uom_id changes when open stock moves or non-zero quants exist — this is a hard integrity guard. + - UNECE codes (EDI/e-invoice): `account/models/uom_uom.py` maps 26 standard UoM xml_ids to UNECE Rec-20 codes (C62, KGM, HUR, etc.) via `_get_unece_code()`. Default fallback: `'C62'` (unit). + +--- + +### R6 — product.category hierarchy + account properties [AXIS-A] + +- **odoo source**: `product_category.py:L1-69` (base), `account/models/product.py:L13-29` (account extension) +- **What it does**: + - Base: `parent_id` (Many2one, cascade), `parent_path` (materialized), `_check_category_recursion` (DFS cycle guard). + - `complete_name` = recursive `parent.complete_name / name` breadcrumb. + - Account extension adds two `company_dependent` Many2one fields: + - `property_account_income_categ_id` → `account.account` (income account for customer invoices) + - `property_account_expense_categ_id` → `account.account` (expense/COGS account for vendor bills) + - Both use domain excluding `asset_receivable`, `liability_payable`, `asset_cash`, `liability_credit_card`, `off_balance`. + - Stock extension adds: `route_ids`, `removal_strategy_id`, `putaway_rule_ids`, `packaging_reserve_method`. +- **woa-rs target**: Product category table (data foundation). Account properties feed K3 GL posting. +- **Rust sketch**: + ```rust + pub struct ProductCategory { + pub id: CategoryId, + pub name: String, + pub parent_id: Option, + pub parent_path: String, // materialized closure path "1/4/7/" + // per-company, nullable: + pub property_account_income_categ_id: Option, + pub property_account_expense_categ_id: Option, + // stock: + pub removal_strategy_id: Option, + } + // Constraint: no cycles in parent_id chain (_has_cycle check) + ``` +- **Parity notes**: `company_dependent` fields are stored in `ir.property` (or modern JSON column) keyed by `(model, field, company_id, res_id)`. In woa-rs these should be modelled as per-company overrides, not flat columns. + +--- + +### R7 — Account resolution waterfall for product income/expense accounts [AXIS-A] + +- **odoo source**: `account/models/product.py:L67-97` +- **What it does**: `_get_product_accounts()` returns `{'income': ..., 'expense': ...}` following priority chain: + 1. `product.property_account_income_id` (product-level, company-dependent) + 2. Walk `categ_id → parent_id → ...` until account found (`_get_category_account`) + 3. `company.income_account_id` (company default) + Same for expense. Then `get_product_accounts(fiscal_pos)` maps through fiscal position if provided. +- **woa-rs target**: GL account resolution when posting invoice lines (K3). +- **Rust sketch**: + ```rust + fn get_income_account(product: &ProductTemplate, company: &Company) -> AccountId { + product.property_account_income_id + .or_else(|| walk_category_account(&product.categ_id, "income")) + .unwrap_or(company.income_account_id) + } + fn walk_category_account(categ: &ProductCategory, kind: &str) -> Option { + let mut c = Some(categ); + while let Some(cat) = c { + let acc = if kind == "income" { cat.property_account_income_categ_id } + else { cat.property_account_expense_categ_id }; + if acc.is_some() { return acc; } + c = cat.parent_id.as_ref(); + } + None + } + ``` +- **Parity notes**: The walk is unbounded — follows full parent chain. In Rust, guard against degenerate deep trees. Fiscal position mapping (`map_account`) is an additional substitution layer (handled in L3). + +--- + +### R8 — Pricelist structure: currency, company, country-group scoping [AXIS-A] + +- **odoo source**: `product_pricelist.py:L9-65` +- **What it does**: `product.pricelist` has: `currency_id` (required, defaults to company currency), `company_id` (optional scoping), `country_group_ids` (M2M for geo-scoping), `item_ids` (the rules). `sequence` (int, default 16) determines selection priority when multiple pricelists apply. `active` boolean for soft-delete. +- **woa-rs target**: Pricelist table (Vorgang pricing). +- **Rust sketch**: + ```rust + pub struct Pricelist { + pub id: PricelistId, + pub name: String, + pub currency_id: CurrencyId, + pub company_id: Option, + pub country_group_ids: Vec, + pub sequence: i32, // default 16; lower = higher priority + pub active: bool, + } + ``` +- **Parity notes**: Deleting a pricelist that is used as `base_pricelist_id` in another pricelist's rules is blocked (`_unlink_except_used_as_rule_base`). Recursion prevention via DFS is enforced on save. + +--- + +### R9 — Pricelist item fields: applied_on, min_quantity, date validity [AXIS-A] + +- **odoo source**: `product_pricelist_item.py:L51-153` +- **What it does**: + - `applied_on` ∈ `{3_global, 2_product_category, 1_product, 0_product_variant}` — scoping level. + - Sort order: `applied_on ASC, min_quantity DESC, categ_id DESC, id DESC` — more specific rules sort first; higher min_quantity sorts first within same specificity. + - `min_quantity` (Float, digits='Product Unit', default=0) — expressed in product's default UoM. + - `date_start` / `date_end` (Datetime) — validity window; constraint: `date_start < date_end` if both set. + - `compute_price` ∈ `{percentage, formula, fixed}`. + - `base` ∈ `{list_price, standard_price, pricelist}`. + - For `fixed`: `fixed_price` (Float). + - For `percentage`: `percent_price` (Float, the discount %). + - For `formula`: `price_discount` (Float, %), `price_round` (Float, rounding step), `price_surcharge` (Float, additive), `price_min_margin` (Float), `price_max_margin` (Float). + - `price_markup` = `-price_discount` (computed inverse, used when `base == standard_price`). +- **woa-rs target**: Pricelist rule table. +- **Rust sketch**: + ```rust + pub enum AppliedOn { Global, ProductCategory, Product, ProductVariant } + pub enum ComputePrice { Fixed, Percentage, Formula } + pub enum RuleBase { ListPrice, StandardPrice, Pricelist } + + pub struct PricelistItem { + pub pricelist_id: PricelistId, + pub applied_on: AppliedOn, + pub categ_id: Option, + pub product_tmpl_id: Option, + pub product_id: Option, + pub min_quantity: f64, // in product default UoM + pub date_start: Option>, + pub date_end: Option>, + pub compute_price: ComputePrice, + pub base: RuleBase, + pub base_pricelist_id: Option, // only when base==Pricelist + pub fixed_price: f64, + pub percent_price: f64, + pub price_discount: f64, + pub price_round: f64, + pub price_surcharge: f64, + pub price_min_margin: f64, + pub price_max_margin: f64, + } + // Constraint: min_margin <= max_margin (if both non-zero) + // Constraint: date_start < date_end (if both set) + // Constraint: base==Pricelist => base_pricelist_id must be set + // Constraint: no pricelist graph cycles (DFS enforced on write) + ``` + +--- + +### R10 — _get_applicable_rules_domain: rule filtering [AXIS-A] + +- **odoo source**: `product_pricelist.py:L239-264` +- **What it does**: Fetches ALL rules for a pricelist that could potentially apply to a set of products on a given date. Domain combines: + 1. `pricelist_id == self.id` + 2. `categ_id IS NULL OR categ_id parent_of product.categ_id` (category hierarchy) + 3. `product_tmpl_id IS NULL OR product_tmpl_id IN [template ids]` + 4. `product_id IS NULL OR product_id IN [variant ids]` + 5. `date_start IS NULL OR date_start <= date` + 6. `date_end IS NULL OR date_end >= date` + Result is ordered by `applied_on ASC, min_quantity DESC, categ_id DESC, id DESC` (from `_order`). +- **woa-rs target**: Rule candidate selection query in pricing engine. +- **Rust sketch**: + ```rust + fn get_applicable_rules( + pl: &Pricelist, products: &[Product], date: DateTime + ) -> Vec { + // SQL query equivalent — filter pricelist items by: + // pricelist_id = pl.id + // AND (categ_id IS NULL OR categ_id is ancestor-of product.categ_id) + // AND (tmpl_id IS NULL OR tmpl_id IN template_ids) + // AND (variant_id IS NULL OR variant_id IN variant_ids) + // AND (date_start IS NULL OR date_start <= date) + // AND (date_end IS NULL OR date_end >= date) + // ORDER BY applied_on ASC, min_quantity DESC, categ_id DESC, id DESC + } + ``` +- **Parity notes**: `categ_id parent_of` uses Odoo's `parent_path` materialized closure. The Porter must replicate this via a prefix-check: `product.categ.parent_path.starts_with(rule.categ.parent_path)`. + +--- + +### R11 — _is_applicable_for: rule applicability check [AXIS-A] + +- **odoo source**: `product_pricelist_item.py:L526-568` +- **What it does**: Fine-grained per-product applicability after candidate fetch: + 1. `min_quantity > 0 AND qty_in_product_uom < min_quantity` → False. + 2. `applied_on == 2_product_category`: check `product.categ_id == rule.categ_id OR product.categ.parent_path.startswith(rule.categ.parent_path)`. + 3. `applied_on == 1_product` (on template): `product.id == rule.product_tmpl_id`. + 4. `applied_on == 0_product_variant` (on template): only if template has exactly one variant AND that variant == rule.product_id. + 5. `applied_on == 1_product` (on variant): `product.product_tmpl_id == rule.product_tmpl_id`. + 6. `applied_on == 0_product_variant` (on variant): `product.id == rule.product_id`. +- **woa-rs target**: Inner loop of pricing engine. +- **Rust sketch**: + ```rust + fn is_applicable_for(rule: &PricelistItem, product: &Product, qty_in_product_uom: f64) -> bool { + if rule.min_quantity > 0.0 && qty_in_product_uom < rule.min_quantity { + return false; + } + match rule.applied_on { + AppliedOn::Global => true, + AppliedOn::ProductCategory => { + product.categ.parent_path.starts_with(&rule.categ.parent_path) + } + AppliedOn::Product => product.product_tmpl_id == rule.product_tmpl_id.unwrap(), + AppliedOn::ProductVariant => product.id == rule.product_id.unwrap(), + } + } + ``` +- **Parity notes**: `qty_in_product_uom` is already converted to product default UoM before this check (done in `_compute_price_rule`). The `min_quantity` field on items is always in product default UoM — do not apply UoM conversion a second time. + +--- + +### R12 — _compute_price_rule: main pricing engine loop [AXIS-A] + +- **odoo source**: `product_pricelist.py:L169-236` +- **What it does**: Core method — mono-pricelist, multi-product. For each product: + 1. Determine `target_uom` (passed `uom` arg or product's own `uom_id`). + 2. If `target_uom != product_uom`: convert quantity to product UoM via `target_uom._compute_quantity(quantity, product_uom, raise_if_failure=False)` — for `min_quantity` comparison. + 3. Iterate `rules` (pre-fetched, sorted) in order; take FIRST rule where `_is_applicable_for` returns True. + 4. If no rule matches, `suitable_rule` = empty recordset. + 5. Call `suitable_rule._compute_price(product, quantity, target_uom, date, currency)`. + 6. Return `{product_id: (price, rule_id)}`. +- **woa-rs target**: Central pricing function called from sale order line, invoice line. +- **Rust sketch**: + ```rust + fn compute_price_rule( + pl: &Pricelist, products: &[Product], + quantity: f64, uom: Option<&Uom>, date: DateTime, currency: &Currency, + ) -> HashMap)> { + let rules = get_applicable_rules(pl, products, date); + let mut results = HashMap::new(); + for product in products { + let product_uom = &product.uom_id; + let target_uom = uom.unwrap_or(product_uom); + let qty_in_product_uom = if target_uom != product_uom { + compute_quantity(target_uom, quantity, product_uom, false, RoundingMethod::Up, false) + .unwrap_or(quantity) + } else { quantity }; + let suitable_rule = rules.iter() + .find(|r| is_applicable_for(r, product, qty_in_product_uom)); + let price = match suitable_rule { + Some(rule) => compute_price_from_rule(rule, product, quantity, target_uom, date, currency), + None => compute_base_price_no_rule(product, target_uom, date, currency), + }; + results.insert(product.id, (price, suitable_rule.map(|r| r.id))); + } + results + } + ``` +- **Parity notes**: When no rule matches, `suitable_rule._compute_price` is called on an EMPTY recordset. The `else` branch at `product_pricelist_item.py:L623-624` handles this: `price = self._compute_base_price(...)` where `self` is empty, which uses `list_price` as base. This is a subtle fallback — the porter must handle the empty-rule case by returning the base list/cost price in the target UoM/currency. + +--- + +### R13 — _compute_price (item): fixed / percentage / formula branches [AXIS-A] + +- **odoo source**: `product_pricelist_item.py:L570-626` +- **What it does**: Three-way branch on `compute_price`: + + **A. `fixed`**: + ``` + price = uom_convert(self.fixed_price, from=product_uom, to=target_uom) + ``` + Uses `product_uom._compute_price(fixed_price, target_uom)` to convert the stored fixed price from product UoM to requested UoM. + + **B. `percentage`**: + ``` + base_price = _compute_base_price(...) + price = base_price - (base_price * (percent_price / 100)) + # i.e. percent_price=20 means 20% discount → price = 80% of base + # percent_price can be negative (markup) + price = price or 0.0 # coerce -0.0 to 0.0 + ``` + + **C. `formula`** (most complex): + ``` + base_price = _compute_base_price(...) + price_limit = base_price # saved for margin clamps + discount = price_discount if base != standard_price else -price_markup + price = base_price - (base_price * (discount / 100)) + if price_round: + price = float_round(price, precision_rounding=price_round) + if price_surcharge: + price += uom_convert(price_surcharge, product_uom, target_uom) + if price_min_margin: + price = max(price, price_limit + uom_convert(price_min_margin, product_uom, target_uom)) + if price_max_margin: + price = min(price, price_limit + uom_convert(price_max_margin, product_uom, target_uom)) + ``` + Note: `price_markup = -price_discount` (they are inverses); when `base == standard_price` the discount field label changes to "Markup" but the formula uses `-price_markup` so the arithmetic is the same sign convention. + +- **woa-rs target**: Pricing computation kernel. +- **Rust sketch**: + ```rust + fn compute_price_from_rule( + rule: &PricelistItem, product: &Product, qty: f64, + target_uom: &Uom, date: DateTime, currency: &Currency, + ) -> f64 { + let product_uom = &product.uom_id; + let uom_cvt = |p: f64| uom_compute_price(product_uom, p, target_uom); + + match rule.compute_price { + ComputePrice::Fixed => uom_cvt(rule.fixed_price), + ComputePrice::Percentage => { + let base = compute_base_price(rule, product, qty, target_uom, date, currency); + let price = base - base * (rule.percent_price / 100.0); + if price == -0.0 { 0.0 } else { price } + } + ComputePrice::Formula => { + let base = compute_base_price(rule, product, qty, target_uom, date, currency); + let price_limit = base; + let discount = if rule.base == RuleBase::StandardPrice { + -rule.price_markup + } else { + rule.price_discount + }; + let mut price = base - base * (discount / 100.0); + if rule.price_round > 0.0 { + price = float_round(price, rule.price_round, RoundingMethod::HalfUp); + // NOTE: odoo uses float_round with precision_rounding=price_round + // default rounding_method for float_round is 'HALF-UP' + } + if rule.price_surcharge != 0.0 { + price += uom_cvt(rule.price_surcharge); + } + if rule.price_min_margin != 0.0 { + price = price.max(price_limit + uom_cvt(rule.price_min_margin)); + } + if rule.price_max_margin != 0.0 { + price = price.min(price_limit + uom_cvt(rule.price_max_margin)); + } + price + } + } + } + ``` +- **Parity notes**: + - `price_surcharge` and margin fields are UoM-converted (they are stored in product UoM units). + - `price_round` in `float_round` is `precision_rounding` (step size, e.g. 0.05 means round to nearest 5 cents), NOT decimal digits. To get 9.99-style prices: `price_round=10.0, price_surcharge=-0.01`. + - The margin clamp: `price_min_margin` is added to `base_price` (not to zero) — it is a minimum MARGIN over base, not a minimum absolute price. + - When `base == standard_price`: the label is "Markup" and `price_discount` stores the negative of the markup (`price_markup = -price_discount`). But the formula uses `discount = -price_markup = price_discount` effectively. Symmetric. + +--- + +### R14 — _compute_base_price: base resolution + currency conversion [AXIS-A] + +- **odoo source**: `product_pricelist_item.py:L628-659` +- **What it does**: Resolves base price for percentage/formula rules: + 1. `base == 'pricelist'`: recursively call `base_pricelist_id._get_product_price(...)` using `base_pricelist_id.currency_id` as src currency. + 2. `base == 'standard_price'`: fetch `product._price_compute('standard_price', uom, date)` using `product.cost_currency_id` as src. + 3. `base == 'list_price'`: fetch `product._price_compute('list_price', uom, date)` using `product.currency_id` as src. + 4. If `src_currency != currency` (the target pricelist currency): convert via `src_currency._convert(price, currency, company, date, round=False)` — no rounding at this step. +- **woa-rs target**: Base price resolver in pricing kernel. +- **Rust sketch**: + ```rust + fn compute_base_price( + rule: &PricelistItem, product: &Product, qty: f64, + target_uom: &Uom, date: DateTime, currency: &Currency, + ) -> f64 { + let (price, src_currency) = match rule.base { + RuleBase::Pricelist => { + let pl = rule.base_pricelist_id.unwrap(); + let p = pl.get_product_price(product, qty, Some(&pl.currency), Some(target_uom), date); + (p, &pl.currency) + } + RuleBase::StandardPrice => { + let prices = product.price_compute("standard_price", Some(target_uom), None, None, date); + (prices[product.id], &product.cost_currency) + } + RuleBase::ListPrice => { + let prices = product.price_compute("list_price", Some(target_uom), None, None, date); + (prices[product.id], &product.currency) + } + }; + if src_currency != currency { + currency_convert(price, src_currency, currency, date, /*round=*/false) + } else { + price + } + } + ``` +- **Parity notes**: Pricelist chaining (`base == 'pricelist'`) fetches the base pricelist price using `base_pricelist_id.currency_id` explicitly — it does NOT pass the outer pricelist's currency to the inner call. Currency conversion happens AFTER the inner call returns. A recursion guard (cycle detection) exists at the constraint level but not at runtime — cycles can't exist if `_check_pricelist_recursion` is enforced. + +--- + +### R15 — Partner pricelist assignment: country-group → fallback waterfall [AXIS-B / HYBRID] + +- **odoo source**: `product_pricelist.py:L333-384` +- **What it does**: `_get_partner_pricelist_multi(partner_ids)` — determines which pricelist applies to each partner: + 1. If `group_product_pricelist` feature disabled → return empty pricelist for all. + 2. For each partner: check `specific_property_product_pricelist` (explicit property set on partner form). If active → use it. + 3. Remaining partners: group by `country_id`. For each country, find pricelist with matching `country_group_ids.country_ids`. + 4. Fallback waterfall (`_get_country_pricelist_multi`): + a. Search pricelist with `country_group_ids = False` (no geo restriction) + active + company filter. + b. `ir.config_parameter` `res.partner.property_product_pricelist_{company_id}`. + c. `ir.config_parameter` `res.partner.property_product_pricelist` (global default). + d. Any active pricelist (`search(pl_domain, limit=1)`). + - **AXIS-A part**: The lookup steps 1-4a are deterministic lookups — pure data retrieval. + - **AXIS-B part**: When no explicit property and no country match exists, the fallback is multi-factor: country group config, company config param, global param, first-available. This is heuristic assignment, not a closed formula. The "right" pricelist for a new partner in an edge case is a business judgment. + +- **woa-rs target**: Partner onboarding / sale order price selection. +- **Rust sketch (AXIS-A part)**: + ```rust + fn resolve_partner_pricelist(partner: &Partner, company: &Company) -> Option { + // 1. Feature guard + if !feature_enabled("group_product_pricelist") { return None; } + // 2. Explicit property + if let Some(pl) = partner.specific_pricelist_id.filter(|pl| pl.active) { + return Some(pl.id); + } + // 3. Country group match + if let Some(country) = &partner.country_id { + if let Some(pl) = find_pricelist_for_country(country, company) { + return Some(pl.id); + } + } + // 4. Fallback chain (heuristic — see SAVANT seed) + find_fallback_pricelist(company) + } + ``` +- **Delegation tuple (AXIS-B — fallback resolution)**: + `ReasoningKind=Other("PricelistAssignment")` `InferenceType=Deduction` (it's a lookup chain, but the ordering of fallbacks is a policy choice) — actually `InferenceType=Revision` (when partner data changes — country, segment — the pricelist should be re-evaluated against business rules). `SemiringChoice=NarsTruth` (evidence from country + company config + segment). `ThinkingStyle=Analytical` (inherited from expected `0x63 ProductCatalog` family — if unmapped, proposed Analytical as the catalog assignment domain is rule-based not creative). + +`SAVANT: name=PricelistAssignmentAgent family=None(needs_0x63_ProductCatalog) reasoning=Other("PricelistAssignment") inference=Revision semiring=NarsTruth style=Analytical — fallback pricelist chain (no country match, no explicit property) requires business-policy judgment not deterministic lookup` + +--- + +### R16 — _price_compute (template + variant): UoM + currency conversion gate [AXIS-A] + +- **odoo source**: `product_template.py:L737-768`, `product_product.py:L1101-1131` +- **What it does**: Returns `{product_id: float}` for a set of products. On template: + 1. Fetch raw price from field (`list_price` or `standard_price`). + 2. For `list_price`: add `_get_attributes_extra_price()` (context key `current_attributes_price_extra` sum). + 3. For `standard_price`: fallback to first variant's price if template price is 0. + 4. If `uom` arg: convert via `template.uom_id._compute_price(price, uom)`. + 5. If `currency` arg: convert via `price_currency._convert(price, currency, company, date)`. + On variant: same but adds `no_variant_attributes_price_extra` from context key `no_variant_attributes_price_extra`. +- **woa-rs target**: Price extraction utility called by pricelist base resolution. +- **Parity notes**: `_get_attributes_extra_price` reads from `env.context` (template version) or sums `ptav.price_extra` (variant version). The context key `current_attributes_price_extra` is a tuple of floats injected by the configurator when a partial combination is being priced. The porter must handle this context injection pattern. + +--- + +### R17 — cost_method / property_valuation: MISSING (Enterprise gap) [AXIS-A — Enterprise gap] + +- **odoo source**: NOT PRESENT in community clone. Expected location: `stock_account` module or `stock/models/product.py` (not found). Fields `cost_method` ∈ `{standard, average, fifo}` and `property_valuation` ∈ `{manual_periodic, real_time}` live in `stock_account` which requires Enterprise or `account+stock` merged module. +- **woa-rs target**: K3 inventory valuation + K13 costing base. +- **Spec from structure**: Based on odoo documentation and the `_run_fifo` / `_run_average` references in L13's scope: + - `cost_method` on `product.category` (company-dependent). + - `standard`: cost is `standard_price`, fixed until manually changed. + - `average` (AVCO): `standard_price` updated on each receipt: `new_cost = (qty_on_hand * old_cost + qty_received * unit_cost) / (qty_on_hand + qty_received)`. + - `fifo`: cost pulled from receipt layers (oldest first); `standard_price` reflects last layer cost. + - `property_valuation`: `manual_periodic` (periodic inventory; no automatic GL on move) vs `real_time` (perpetual; GL entry on every stock move). +- **Enterprise gaps flagged**: See section below. + +--- + +### R18 — Variant creation / combination matrix [AXIS-A] + +- **odoo source**: `product_template.py:L770-868` +- **What it does**: `_create_variant_ids()` — after attribute line changes, recomputes the Cartesian product of attribute values. For each possible combination: + - If variant exists → activate. + - If variant doesn't exist → create. + - Variants no longer in any possible combination → unlink or archive (`_unlink_or_archive`). + - Dynamic attributes (`create_variant='dynamic'`) skip full matrix creation; variants created on demand. + - Hard limit: `ir.config_parameter product.dynamic_variant_limit` (default 1000) variants per template. +- **woa-rs target**: Product variant management. +- **Parity notes**: The combination-index dedup key is `combination_indices = ",".join(sorted ptav ids)` stored on `product.product`. Unique index enforced: `(product_tmpl_id, combination_indices) WHERE active IS TRUE`. + +--- + +### R19 — Pricelist recursion guard (DFS cycle detection) [AXIS-A] + +- **odoo source**: `product_pricelist_item.py:L321-353` +- **What it does**: On create/write of pricelist items with `base='pricelist'`, DFS traversal starting from `base_pricelist_id` following chains of `base='pricelist'` items. If `pricelist_id` (the owning pricelist) appears in the traversal path → `ValidationError`. +- **woa-rs target**: Integrity constraint on pricelist write. +- **Rust sketch**: Topological sort or DFS on write; reject if cycle detected. O(V+E) where V=pricelists, E=pricelist-based rules. + +--- + +### R20 — UoM change guard on posted invoices [AXIS-A] + +- **odoo source**: `account/models/product.py:L130-149` +- **What it does**: `@api.constrains('uom_id')` on `product.template` — runs a SQL query checking if any `account_move_line` in `posted` state references this product with a DIFFERENT UoM. If found → `ValidationError`. Prevents silent unit-of-measure drift on locked accounting documents. +- **woa-rs target**: Constraint when updating product UoM after invoices posted. +- **Parity notes**: This is a cross-model constraint touching `account_move_line`. The porter must implement a validation hook on product UoM write that checks invoice lines. + +--- + +### R21 — UoM factor immutability guard (stock moves / quants) [AXIS-A] + +- **odoo source**: `stock/models/product.py:L1344-1375` +- **What it does**: `write()` override on `uom.uom` — blocks changes to `factor`, `relative_factor`, `relative_uom_id` if: + - Any `stock.move` in non-terminal state (`not in {cancel, done}`) uses this UoM. + - Any `stock.move.line` in non-terminal state uses this UoM. + - Any `stock.quant` with `quantity != 0` uses a product whose template's `uom_id` is this UoM. +- **woa-rs target**: UoM integrity constraint in inventory. +- **Parity notes**: This is a write-time guard, not a DB constraint. Must be implemented as a pre-write validator in woa-rs. + +--- + +### R22 — Contextual price / pricelist resolution (template) [AXIS-A] + +- **odoo source**: `product_template.py:L1534-1550` +- **What it does**: `_get_contextual_price(product)` reads from `env.context`: + - `pricelist` (int id) → resolves pricelist. + - `quantity` (float, default 1.0). + - `uom` (int id, optional). + - `date` (optional). + Then calls `pricelist._get_product_price(product, quantity, uom, date)`. + `_get_contextual_pricelist()` simply reads `env.context.get('pricelist')`. +- **woa-rs target**: UI/API entry point for contextual price display. +- **Parity notes**: In woa-rs this context pattern should be replaced with explicit function parameters rather than implicit context bag — cleaner for the Rust type system. + +--- + +### R23 — `_get_tax_included_unit_price`: UoM + fiscal-pos + currency normalisation [AXIS-A / HYBRID] + +- **odoo source**: `account/models/product.py:L222-293` +- **What it does**: Helper to get price unit for invoicing, combining: + 1. Resolve product price (`lst_price` for sale, `standard_price` for purchase). + 2. Apply UoM conversion if `product_uom != product.uom_id`. + 3. Apply fiscal position tax adaptation (`_adapt_price_unit_to_another_taxes`) if fiscal_pos given — adjusts price for included-tax differences. + 4. Apply currency conversion (no rounding at this step). +- **AXIS-A part**: Steps 1-2 and 4 are deterministic. +- **AXIS-B part**: Step 3 (fiscal position application) — choosing the right fiscal position is heuristic (handled in L3). But once chosen, adapting the price is deterministic math. +- **woa-rs target**: Invoice line unit price computation (K3 + K7). + +--- + +## Enterprise gaps flagged + +| Module | What's missing | What we spec from community data/structure | +|---|---|---| +| `stock_account` | `cost_method` ∈ `{standard, average, fifo}` on `product.category`; `property_valuation` ∈ `{manual_periodic, real_time}`; `_run_fifo`, `_run_average` logic; SVL (stock valuation layer) creation on stock moves | R17 above: fields documented from public Odoo docs; AVCO/FIFO engine built fresh in woa-rs (L13 lane handles SVL) | +| `product_margin` | Margin computation on sale order lines | Not present; woa-rs can compute from `(price - standard_price) / price` inline | +| `account_accountant` | `_predict_specific_product` (ML-based product prediction on invoice import) — referenced at `account/models/product.py:L357-371` | Flag: AXIS-B candidate if EDI import is implemented; skip for now | +| `uom` (core module) | `uom_uom.py` core model with `_compute_quantity`, `_compute_price`, `factor`, `rounding` | Reconstructed in R5 from call-sites + GitHub raw fetch; porter should verify against actual source | + +--- + +## Open questions for the Opus porter + +1. **UoM factor direction**: The formula `(qty / from.factor) * to.factor` implies reference unit has `factor=1.0` and bigger UoMs have `factor < 1` (e.g., dozen ≈ 0.0833). Verify against actual `uom` module data seeds before implementing. The alternative reading (`factor` = how many base units in this UoM, so dozen=12) would invert the formula. + +2. **`company_dependent` fields**: Odoo stores these via `ir.property` (or a new JSON-based mechanism in v17). In woa-rs, model as a separate `product_company_properties` junction table keyed by `(company_id, product_id)` with typed columns, rather than a generic key-value store. + +3. **Pricelist feature flag**: `group_product_pricelist` disables the entire pricelist system when off. woa-rs should support a `pricing_mode: Simple | Pricelist` enum at the company level. + +4. **`ir.config_parameter` for pricelist defaults**: The partner pricelist fallback uses `ir.config_parameter` keys `res.partner.property_product_pricelist_{company_id}` and `res.partner.property_product_pricelist`. Map these to a typed `CompanySettings` struct field in woa-rs. + +5. **`price_round` rounding method**: `float_round(price, precision_rounding=price_round)` — what rounding method does Odoo's `float_round` default to when only `precision_rounding` is given? From the `_compute_rule_tip` code this appears to be `HALF-UP` (Python `round()` semantics). Verify in odoo source `tools/float_utils.py`. + +6. **Combo product pricing**: `type='combo'` disables taxes and supplier taxes (`_onchange_type` in account extension). Pricing of combo products (sum of item prices with discounts) is not detailed in the community source — likely handled in `pos` or `sale` module extensions. + +7. **`no_variant_attributes_price_extra`**: Context-injected extra for no-variant attributes (attributes that affect price but don't create variants). The configurator injects this; the pricelist engine does NOT include it by default. Clarify whether woa-rs needs to handle this for the product configurator flow. + +8. **`_compute_price_before_discount`** (`product_pricelist_item.py:L661-684`): Used when the pricelist is configured to "show discount to customer" — walks the pricelist chain to find the lowest rule whose pricelist shows discount, then returns the base price at that level (so the "before discount" price shown on the order reflects the underlying list price). This is a display feature; the porter should flag it as needed only if the UX shows strikethrough pricing. + +--- + +## Depth-proof footer + +``` +Read: /home/user/woa-rs/.claude/odoo/BRIEFING.md lines=166 depth=full +Read: /home/user/woa-rs/.claude/odoo/BRIEFING-GAP.md lines=82 depth=full +Read: /home/user/odoo/addons/product/models/product_template.py lines=1598 depth=full (4 chunks) +Read: /home/user/odoo/addons/product/models/product_product.py lines=1197 depth=full (3 chunks) +Read: /home/user/odoo/addons/product/models/product_category.py lines=69 depth=full +Read: /home/user/odoo/addons/product/models/product_pricelist.py lines=415 depth=full +Read: /home/user/odoo/addons/product/models/product_pricelist_item.py lines=684 depth=full +Read: /home/user/odoo/addons/product/models/uom_uom.py lines=30 depth=full (product extension stub only) +Read: /home/user/odoo/addons/account/models/uom_uom.py lines=59 depth=full (account extension) +Read: /home/user/odoo/addons/account/models/product.py lines=523 depth=full +Read: /home/user/odoo/addons/stock/models/product.py lines=1389 depth=partial (L1274-1389 for ProductCategory+UomUom sections; remainder is stock product/template which is L7 territory) +WebFetch: https://raw.githubusercontent.com/odoo/odoo/17.0/addons/uom/models/uom_uom.py depth=full (uom core module absent from clone; reconstructed via WebFetch) +``` diff --git a/.claude/odoo/L9-PARTNER-FISCALPOS.md b/.claude/odoo/L9-PARTNER-FISCALPOS.md new file mode 100644 index 00000000..2c64e42c --- /dev/null +++ b/.claude/odoo/L9-PARTNER-FISCALPOS.md @@ -0,0 +1,580 @@ +RICHNESS-LANE-OK + +# Lane L9 — Partner Accounting Properties + Fiscal-Position Assignment + +## Sources read (file : line-range : depth) + +- `/home/user/odoo/addons/account/models/partner.py` : L1–1170 : full +- `/home/user/woa-rs/.claude/odoo/BRIEFING.md` : L1–166 : full +- `/home/user/woa-rs/.claude/odoo/BRIEFING-GAP.md` : L1–82 : full + +--- + +## Ontology rows + +| odoo class | owl pivot | OGIT family (or None) | DOLCE | +|---|---|---|---| +| `res.partner` (account extension) | `vcard:Individual` / `fibo:LegalEntity` (company=True) | `0x80 SmbFoundryCustomer` | Endurant (persistent object) | +| `account.fiscal.position` | `fibo:TaxJurisdiction` (closest; no direct fibo term for fiscal mapping) | `None` — ontology-unmapped, needs Layer-2 alignment axiom | Endurant (named configuration object) | +| `account.fiscal.position.account` | `fibo:AccountMapping` (proposed pivot) | `None` — ontology-unmapped | Endurant | +| `account.payment.term` (referenced) | `fibo:PaymentTerms` | `0x61 BillingCore` | Endurant | +| `account.account` (receivable/payable props) | `fibo:Account` | `0x62 SMBAccounting` | Endurant | + +**DOLCE notes:** +- `res.partner` is clearly Endurant: it persists through time and has properties that can change (rank, payment term, fiscal position). +- `account.fiscal.position` is also Endurant: a named configuration record that maps tax A → tax B; it does not "happen" (Perdurant) — it is consulted. +- The `_get_fiscal_position()` **resolution event** is a Perdurant (a happening/process), but the *result* (a fiscal position record) is Endurant. + +--- + +## Rules extracted + +### R1 — Per-partner property accounts (receivable / payable) [AXIS-A] + +- **odoo source**: `partner.py:537–546` +- **What it does**: Two `company_dependent` Many2one fields on `res.partner`: + - `property_account_receivable_id` → restricted to `account_type = 'asset_receivable'` + - `property_account_payable_id` → restricted to `account_type = 'liability_payable'` + Both are `company_dependent=True` (stored in `ir.property` keyed by `(partner_id, company_id)`), `check_company=True`, and `ondelete='restrict'` (cannot delete an account that is a partner default). The domain constraint means you cannot assign a non-AR/AP account here. + These fields are **inherited** by child partners through `commercial_partner_id` rollup (see R5). +- **woa-rs target**: K3 double-entry posting; data foundation for partner → AR/AP account lookup when building journal entries. +- **Rust sketch**: + ```rust + // In Partner entity (sea-orm): + // property_account_receivable_id: Option -- company-scoped + // property_account_payable_id: Option -- company-scoped + // Lookup at invoice creation: + fn get_receivable_account(partner: &CommercialPartner, company_id: CompanyId) -> Result { + partner.property_account_receivable_id + .ok_or(WoaError::MissingPartnerAccount("receivable")) + } + // Constraint: must verify account.account_type == 'asset_receivable' at write time + // ondelete=restrict: FK with RESTRICT in DB migration + ``` + Storage note: Odoo uses `ir.property` (EAV), not a direct column. In woa-rs, model as nullable foreign-key columns scoped to company (or use a separate `partner_accounting_props` table per company if multi-company K15 needed). +- **Parity notes / gotchas**: The `company_dependent` EAV storage means each company can see a *different* account for the same partner. woa-rs must decide: flat columns (single-company) or company-keyed rows. K15 (Mehrfirma) is currently missing; plan for the multi-company shape now to avoid a schema migration. + +--- + +### R2 — Per-partner payment terms (customer + supplier) [AXIS-A] + +- **odoo source**: `partner.py:551–558` +- **What it does**: Two `company_dependent` Many2one fields: + - `property_payment_term_id` → `account.payment.term` — used when issuing sales invoices TO this partner + - `property_supplier_payment_term_id` → `account.payment.term` — used when receiving vendor bills FROM this partner + Both are `check_company=True`. The customer term drives invoice due-date computation; the supplier term drives vendor bill due-date computation. +- **woa-rs target**: K3 invoice due-date computation (already partially in L5); this lane provides the *lookup source*. +- **Rust sketch**: + ```rust + fn get_customer_payment_term(partner: &CommercialPartner, company_id: CompanyId) -> Option { + partner.property_payment_term_id + } + fn get_supplier_payment_term(partner: &CommercialPartner, company_id: CompanyId) -> Option { + partner.property_supplier_payment_term_id + } + // If None, fall back to journal/company default (outside this lane) + ``` +- **Parity notes / gotchas**: When `None`, odoo falls back silently (no term → due immediately, or journal default). Document that `None` = immediate payment, not an error. + +--- + +### R3 — Manual fiscal-position field on partner [AXIS-A] + +- **odoo source**: `partner.py:547–550` +- **What it does**: `property_account_position_id` — a `company_dependent` Many2one to `account.fiscal.position`. When set, this **always wins** over auto-detection (see R8). The field is `check_company=True`, meaning only fiscal positions belonging to the same company (or a parent company via `check_company_domain_parent_of`) are valid. +- **woa-rs target**: K7 tax compute — input to fiscal-position resolution (R8). +- **Rust sketch**: + ```rust + // In Partner entity: + // property_account_position_id: Option -- company-scoped + // + // At resolution time: + fn get_manual_fiscal_position(partner: &Partner, company_id: CompanyId) -> Option { + partner.property_account_position_id + } + ``` +- **Parity notes**: This is the "override" escape hatch. Any auto-detection logic (R8) is bypassed entirely if this is set. + +--- + +### R4 — customer_rank / supplier_rank counters [AXIS-A] + +- **odoo source**: `partner.py:600–601` (field declarations) + `partner.py:800–833` (_increase_rank method) + `partner.py:773–784` (create hook) +- **What it does**: + - `customer_rank`: Integer (default=0, copy=False). Incremented each time the partner appears on a confirmed sale/invoice. Used to order partners in customer search mode. + - `supplier_rank`: Integer (default=0, copy=False). Incremented each time the partner appears on a confirmed purchase/vendor bill. + - **create hook** (L773–784): if `res_partner_search_mode == 'customer'` in context and `customer_rank` not explicitly set, initialises to 1 (and vice versa for supplier). + - **`_increase_rank(field, n=1)`** (L800–833): The increment is NOT immediate. If the partner already has rank > 0, the increment is **deferred to a post-commit hook** to avoid serialization errors (high-concurrency environment). If rank is currently 0, it increments immediately (so `customer_rank > 0` filtering works right away). The post-commit hook catches `psycopg2.errors.OperationalError` silently (just logs debug). + - **`_order` property** (L356–363): When `res_partner_search_mode == 'customer'`, partners are ordered by `customer_rank DESC` prepended to the normal order. Same for supplier. +- **woa-rs target**: Data foundation; partner search UX (L9); also determines which partners appear as "customers" vs "vendors" in filtered views. +- **Rust sketch**: + ```rust + // Fields on partner table: + // customer_rank: i32 DEFAULT 0 + // supplier_rank: i32 DEFAULT 0 + // + // On invoice confirm / sale confirm → call increase_rank("customer_rank", 1) + // Strategy: for woa-rs (single-tenant, low concurrency), immediate increment is safe. + // For high-concurrency: use a background task with retry. + async fn increase_rank(pool: &DbPool, partner_id: PartnerId, field: RankField, n: i32) -> Result<(), WoaError> { + sqlx::query!("UPDATE res_partner SET customer_rank = customer_rank + ? WHERE id = ?", n, partner_id) + .execute(pool).await?; + Ok(()) + } + // On create in customer search context: set customer_rank = 1 if not supplied + ``` +- **Parity notes / gotchas**: The deferred-post-commit strategy in odoo is a PostgreSQL-specific serialization-error mitigation. MySQL (woa-rs's DB) does not have the same serialization error pattern, so immediate increment is fine. However, note the odoo bug-in-the-open: if the post-commit fails with `OperationalError`, the rank silently stays un-incremented. woa-rs should log a warning rather than silent swallow. + +--- + +### R5 — commercial_partner_id rollup for accounting fields [AXIS-A] + +- **odoo source**: `partner.py:702–710` (_find_accounting_partner + _commercial_fields) +- **What it does**: + - `_find_accounting_partner(partner)` (L702–704): Returns `partner.commercial_partner_id`. This means all accounting entries for a contact (child partner) are posted to the *commercial* (company-level) partner, not the individual contact. Invoice lines reference the child; the AR/AP account lookup uses the commercial parent. + - `_commercial_fields()` (L706–710): Extends the base list with `['property_account_payable_id', 'property_account_receivable_id', 'property_account_position_id', 'property_payment_term_id', 'property_supplier_payment_term_id', 'credit_limit']`. This causes odoo's partner hierarchy logic to **sync these fields from parent to children** when a partner is set as a child of a company. +- **woa-rs target**: K3 journal entry partner assignment; partner data model. +- **Rust sketch**: + ```rust + fn find_accounting_partner(partner: &Partner) -> PartnerId { + partner.commercial_partner_id.unwrap_or(partner.id) + } + // commercial_partner_id: if partner.parent_id is set AND partner.is_company=False, + // commercial_partner_id = parent.commercial_partner_id (recursive up to root company) + // otherwise commercial_partner_id = self + // + // When syncing commercial fields: + // If partner gets a new parent, the five fields above are copied from parent → child. + ``` +- **Parity notes / gotchas**: + - The `write()` hook at L749–771 enforces: if you change `parent_id` on a partner that already has accounting move lines, it **re-points all move lines** to the new `commercial_partner_id`. This is a significant operation — it uses `bypass_lock_check` to write through GoBD lock. In woa-rs (K11 Festschreibung), this bypass must be replicated deliberately. + - VAT guard at L757–758: if the child partner has a different VAT than the new parent, raising `UserError` — you cannot reparent if VAT differs. + +--- + +### R6 — credit / debit computed balances [AXIS-A] + +- **odoo source**: `partner.py:365–449` (_credit_debit_get, _asset_difference_search, _credit_search, _debit_search) +- **What it does**: + - `credit` (Total Receivable): Sum of `amount_residual` on posted move lines where `account_type = 'asset_receivable'` and `reconciled IS NOT TRUE`, for all partners in the set, filtered to current company's root subtree. + - `debit` (Total Payable): Same but `account_type = 'liability_payable'`, sign negated (`-val`). + - SQL is executed directly (not ORM) for performance; uses `account_move_line`'s search query infrastructure (`_search` → `from_clause` + `where_clause`). + - Both fields are `Monetary` with `search` functions (`_credit_search`, `_debit_search`) allowing filtering partners by outstanding balance using operators `<`, `=`, `>`, `>=`, `<=`. + - The search (`_asset_difference_search`) uses raw SQL with `SPLIT_PART(line_company.parent_path, '/', 1)::int = company.root_id.id` — PostgreSQL-specific! +- **woa-rs target**: K3 partner balance views (dashboard, partner list). +- **Rust sketch**: + ```rust + // As a computed/derived value (not stored), fetch on demand: + async fn get_partner_credit(pool: &DbPool, partner_id: PartnerId, company_root_id: CompanyId) -> Decimal { + // SELECT SUM(aml.amount_residual) FROM account_move_line aml + // JOIN account_account aa ON aml.account_id = aa.id + // JOIN account_move am ON aml.move_id = am.id + // WHERE aa.account_type = 'asset_receivable' + // AND aml.partner_id = partner_id + // AND aml.reconciled = FALSE + // AND am.state = 'posted' + // AND aml.company_id IN (SELECT id FROM res_company WHERE root_id = company_root_id) + // MySQL note: no SPLIT_PART — use JOIN to res_company WHERE root_company_id = ? + } + ``` +- **Parity notes / gotchas**: The odoo SQL uses `SPLIT_PART(parent_path, '/', 1)::int` — a PostgreSQL extension. MySQL equivalent is a subquery on the company tree. woa-rs (single-company initially) can simplify to `company_id = ?`. + +--- + +### R7 — Days Sales Outstanding (DSO) compute [AXIS-A] + +- **odoo source**: `partner.py:472–490` +- **What it does**: `days_sales_outstanding = (credit / total_invoiced_tax_included) * days_since_oldest_invoice`. The formula: + 1. Find all posted sale-type invoices for `commercial_partner_id` in current company. + 2. `oldest_invoice_date` = min(`invoice_date`) across those invoices. + 3. `total_invoiced_tax_included` = sum(`amount_total_signed`). + 4. `days_since_oldest_invoice` = today − oldest_invoice_date (in days). + 5. If `total_invoiced_tax_included == 0` → DSO = 0 (no division). + 6. `credit` is the AR balance (R6). +- **woa-rs target**: K3 partner dashboard / AR aging. +- **Rust sketch**: + ```rust + fn compute_dso(credit: Decimal, total_invoiced: Decimal, oldest_invoice_date: NaiveDate, today: NaiveDate) -> Decimal { + if total_invoiced.is_zero() { return Decimal::ZERO; } + let days = (today - oldest_invoice_date).num_days(); + (credit / total_invoiced) * Decimal::from(days) + } + ``` +- **Parity notes**: Uses `amount_total_signed` (includes tax), not net. DSO = 0 when no invoices exist (not an error). + +--- + +### R8 — Fiscal-position auto-resolution: `_get_fiscal_position` [HYBRID: AXIS-A guard + AXIS-B core] + +- **odoo source**: `partner.py:246–279` (_get_fiscal_position) + `partner.py:208–244` (_get_first_matching_fpos, _get_fpos_validation_functions) + +#### AXIS-A guard — deterministic precedence + +**What it does (guard layer)**: +1. If `partner` is falsy → return empty (no fiscal position). +2. Compute `intra_eu`: both company and partner have VAT, both VAT prefixes are in the EU country codes set. +3. Compute `vat_exclusion`: both VAT prefixes are identical (same country). +4. **Delivery address selection**: if no `delivery` arg, OR if (`intra_eu AND vat_exclusion AND partner.country_id == company.country_id`), then `delivery = partner`. Otherwise keep separate delivery address. +5. **Manual override wins**: check `delivery.property_account_position_id` (company-scoped), then `partner.property_account_position_id`. If either is set → return it immediately. No auto-detection. +6. If `partner.country_id` is empty → return empty (can't match country-based rules). +7. Search all `auto_apply=True` fiscal positions for current company. +8. Call `_get_first_matching_fpos(delivery)` to find first match. + +**Rust sketch (guard)**: +```rust +fn get_fiscal_position( + partner: &Partner, + delivery: Option<&Partner>, + company: &Company, + eu_country_codes: &HashSet<&str>, + auto_apply_positions: &[FiscalPosition], +) -> Option { + // Step 1: partner must exist + // Step 2-3: intra_eu / vat_exclusion + let intra_eu = company.vat.as_ref().zip(partner.vat.as_ref()).map(|(cv, pv)| { + eu_country_codes.contains(&cv[..2]) && eu_country_codes.contains(&pv[..2]) + }).unwrap_or(false); + let vat_exclusion = company.vat.as_ref().zip(partner.vat.as_ref()) + .map(|(cv, pv)| cv[..2] == pv[..2]).unwrap_or(false); + + // Step 4: delivery selection + let effective_delivery = if delivery.is_none() + || (intra_eu && vat_exclusion && partner.country_id == Some(company.country_id)) { + partner + } else { + delivery.unwrap() + }; + + // Step 5: manual override + if let Some(fp) = effective_delivery.property_account_position_id + .or(partner.property_account_position_id) { + return Some(fp); + } + + // Step 6: no country → no match + if partner.country_id.is_none() { return None; } + + // Step 7-8: delegate to matching (AXIS-B) + get_first_matching_fpos(effective_delivery, auto_apply_positions) +} +``` + +#### AXIS-B core — heuristic matching + +**What it does (matching layer)**: `_get_first_matching_fpos(delivery)` (L208–213): +- Sort all candidate fiscal positions: **company-specific first** (longer `parent_ids` chain = more specific company → goes first), then by `sequence` ascending. +- For each fpos in sorted order, run ALL 5 validation functions; return the first fpos where all pass. + +**5 validation predicates** (L215–244): +1. `vat_required`: `not fpos.vat_required OR partner._get_vat_required_valid(company)`. The base `_get_vat_required_valid` simply returns `bool(partner.vat)` — hook for VIES in Enterprise. +2. `zip_range`: `not (fpos.zip_from AND fpos.zip_to) OR (partner.zip AND fpos.zip_from <= partner.zip <= fpos.zip_to)`. Lexicographic comparison (zip stored as Char, padded to equal length with leading zeros for digit-only zips via `_convert_zip_values`). +3. `state`: `not fpos.state_ids OR partner.state_id in fpos.state_ids`. +4. `country`: `not fpos.country_id OR partner.country_id == fpos.country_id`. +5. `country_group`: `not fpos.country_group_id OR (partner.country_id in group.country_ids AND (not partner.state_id OR partner.state_id not in group.exclude_state_ids))`. + +All 5 must pass (AND semantics). First match wins (ORDER BY company-specificity DESC, sequence ASC). + +**Why AXIS-B**: The matching is not "look up a single exact key" — it is a priority-ordered search through a variable-length list of rules, each with multi-dimensional predicates (VAT, zip range, state, country, country group). The *choice* of which rule fires is evidence-weighted (more-specific company overrides less-specific; sequence ordering is an admin-configured priority). In a live system, adding new fiscal positions changes which rule fires for all partners. This is exactly the kind of multi-factor, priority-ranked, belief-revision pattern that NARS/lance-graph handles better than brittle Rust if/else. + +- **Delegation tuple**: + - `ReasoningKind = CustomerCategory` (classifying a partner into a tax treatment category) + - `InferenceType = Deduction` (the rules are explicit lookup — but the *priority ordering* introduces induction over the rule set) + - `SemiringChoice = NarsTruth` (evidence fusion: each predicate is a partial match; the "best" fiscal position wins by highest-specificity evidence) + - `ThinkingStyle = Analytical` (inherited from `0x80 SmbFoundryCustomer` family; resolving a customer's tax category is an analytical classification task) + +`SAVANT: name=FiscalPositionResolver family=0x80 reasoning=CustomerCategory inference=Deduction semiring=NarsTruth style=Analytical — multi-predicate priority-ranked fiscal position matching is a belief-revision classification over the partner's country/VAT/zip evidence, not a single-key lookup; delegate to lance-graph so new fpos rules do not require Rust recompilation.` + +- **Parity notes / gotchas**: + - Zip comparison is **lexicographic** (string `<=`), not numeric, because German PLZ can have leading zeros (e.g., `01067`). The `_convert_zip_values` method pads digit-only zips with leading zeros to equal length before storing. woa-rs must replicate this padding on write. + - The `intra_eu + vat_exclusion + same_country` short-circuit forces `delivery = partner` (use invoicing address, not ship-to address). This matters for B2B within Germany: a DE company shipping to a DE customer uses the invoicing address's fiscal position, not the delivery address. + - The company-specificity sort (`-len(f.company_id.parent_ids)`) is a multi-company concern — in single-company woa-rs, this is a no-op but must be preserved for K15. + +--- + +### R9 — `map_tax`: fiscal-position tax remapping [AXIS-A] + +- **odoo source**: `partner.py:154–163` +- **What it does**: + ```python + def map_tax(self, taxes): + if not self: + return taxes # no fiscal position → taxes unchanged + if not self.tax_ids and taxes.fiscal_position_ids: + return self.env['account.tax'] # empty fpos with fpos-aware taxes → remove all taxes + return self.env['account.tax'].browse(unique( + tax_id + for tax in taxes + for tax_id in (self.tax_map or {}).get(tax.id, [tax.id]) + )) + ``` + - `tax_map` is a Binary computed field (dict): `{src_tax_id: [dest_tax_id, ...]}` built from the M2M `tax_ids` via `original_tax_ids` back-relation (L98–105). + - For each input tax: look up in `tax_map`; if found, replace with the mapped list; if not found, keep original (identity mapping). + - `unique()` deduplicates the output (preserving order). + - Special case: empty fiscal position (`not self.tax_ids`) + tax has `fiscal_position_ids` → **removes the tax entirely**. This handles "OSS/distance-selling" fiscal positions that nullify specific taxes. +- **woa-rs target**: K7 tax compute — called for every invoice line when a fiscal position is active. +- **Rust sketch**: + ```rust + fn map_tax( + fiscal_pos: Option<&FiscalPosition>, + taxes: &[TaxId], + tax_map: &HashMap>, + tax_has_fpos: &HashSet, // taxes that have fiscal_position_ids set + ) -> Vec { + let Some(fp) = fiscal_pos else { return taxes.to_vec(); }; + if fp.tax_ids.is_empty() { + // empty fpos: remove taxes that are fpos-aware + return taxes.iter() + .filter(|t| !tax_has_fpos.contains(t)) + .cloned().collect(); + } + // Normal mapping: src → [dest...] or identity + let mut result = Vec::new(); + let mut seen = HashSet::new(); + for &tax in taxes { + let mapped = tax_map.get(&tax).map(|v| v.as_slice()).unwrap_or(&[tax]); + for &dest in mapped { + if seen.insert(dest) { result.push(dest); } + } + } + result + } + ``` +- **Parity notes**: + - `unique()` in odoo preserves insertion order (Python `dict` semantics since 3.7). Rust `HashSet` does not preserve order; the sketch above preserves insertion order via manual `seen` set. + - The "empty fpos removes all fpos-aware taxes" branch is subtle and easy to miss — it requires knowing which taxes have `fiscal_position_ids` set (a back-reference query). + +--- + +### R10 — `map_account`: fiscal-position account remapping [AXIS-A] + +- **odoo source**: `partner.py:165–166` +- **What it does**: + ```python + def map_account(self, account): + return self.env['account.account'].browse( + (self.account_map or {}).get(account.id, account.id) + ) + ``` + `account_map` (Binary computed, L107–110): `{src_account_id: dest_account_id}` from `account_ids` One2many. Simple dict lookup; if no mapping exists for this account, return the account unchanged (identity). +- **woa-rs target**: K3 journal entry posting — account substitution per fiscal position. +- **Rust sketch**: + ```rust + fn map_account( + fiscal_pos: Option<&FiscalPosition>, + account_id: AccountId, + account_map: &HashMap, + ) -> AccountId { + fiscal_pos + .and_then(|_| account_map.get(&account_id)) + .copied() + .unwrap_or(account_id) + } + ``` +- **Parity notes**: Simpler than `map_tax` — one-to-one mapping only (no list). No "empty fpos" special case. The `account_src_dest_uniq` constraint (L320–323) ensures no duplicate src→dest pairs per fiscal position. + +--- + +### R11 — Zip value normalisation on write [AXIS-A] + +- **odoo source**: `partner.py:181–206` (_convert_zip_values, create, write overrides) +- **What it does**: Before storing `zip_from`/`zip_to`, if both are present and both are digit-only, pad both to `max(len(zip_from), len(zip_to))` with leading zeros. E.g., `('1000', '99999')` → `('01000', '99999')`. This ensures lexicographic comparison `zip_from <= partner.zip <= zip_to` works correctly for numeric German PLZ. + - Validation constraint (L112–116): `zip_from` and `zip_to` must either both be empty or both be set, AND `zip_from <= zip_to`. +- **woa-rs target**: fiscal-position table migration + write handler. +- **Rust sketch**: + ```rust + fn convert_zip_values(zip_from: &str, zip_to: &str) -> (String, String) { + if zip_from.is_empty() || zip_to.is_empty() { return (zip_from.to_string(), zip_to.to_string()); } + let max_len = zip_from.len().max(zip_to.len()); + let from = if zip_from.chars().all(|c| c.is_ascii_digit()) { + format!("{:0>width$}", zip_from, width = max_len) + } else { zip_from.to_string() }; + let to = if zip_to.chars().all(|c| c.is_ascii_digit()) { + format!("{:0>width$}", zip_to, width = max_len) + } else { zip_to.to_string() }; + (from, to) + } + // Constraint check before write: + fn validate_zip_range(zip_from: &str, zip_to: &str) -> Result<(), WoaError> { + match (zip_from.is_empty(), zip_to.is_empty()) { + (true, true) => Ok(()), + (false, false) if zip_from <= zip_to => Ok(()), + _ => Err(WoaError::Validation("Invalid zip range")), + } + } + ``` + +--- + +### R12 — `is_domestic` computed field [AXIS-A] + +- **odoo source**: `partner.py:74–77` +- **What it does**: `is_domestic = (self == self.company_id.domestic_fiscal_position_id)`. A Boolean stored field on `account.fiscal.position`. True only for the fiscal position designated as the company's domestic/default position. Used in UI to flag the home-country fiscal position. +- **woa-rs target**: fiscal-position entity, company config. +- **Rust sketch**: Boolean column on fiscal position; OR look it up dynamically by comparing `company.domestic_fiscal_position_id == self.id`. + +--- + +### R13 — VAT format check hooks [AXIS-A / partial AXIS-B] + +- **odoo source**: `partner.py:841–870` (_check_vat, _run_vat_checks, _get_vat_required_valid) +- **What it does**: + - `_run_vat_checks(country, vat, partner_name, validation)` (L848–865): In community, this is a **stub** — returns `(vat, country_code)` unchanged. Real VAT validation is in `base_vat` module (which this file imports from for `_ref_vat`). Community code cannot validate VAT syntax; Enterprise/`base_vat` does. + - `_get_vat_required_valid(company)` (L867–870): stub — returns `bool(partner.vat)`. Enterprise extends this with VIES lookup. + - `_check_vat(validation)` calls `_run_vat_checks` and may reformat the VAT. +- **woa-rs target**: Partner VAT field validation. +- **Parity notes / gotchas**: The base community `_run_vat_checks` is essentially a no-op. Real validation requires `base_vat` which IS present in the community clone (imported at L14). The actual validation logic lives in `base_vat/models/res_partner.py` — that file should be read separately if L9 needs full VAT validation coverage. Flag as **Enterprise gap partially** (VIES check is Enterprise; format check is community via `base_vat`). + +--- + +### R14 — autopost_bills field [AXIS-A with AXIS-B annotation] + +- **odoo source**: `partner.py:602–608` +- **What it does**: Selection field `autopost_bills ∈ {always, ask, never}` (default: `ask`). Controls whether vendor bills from this partner are auto-posted: + - `always`: auto-post every time. + - `ask`: prompt after 3 validations without edits. + - `never`: never auto-post. +- **woa-rs target**: K3 bill posting flow. +- **Note**: The `ask` logic (counting validations without edits) is likely implemented in the bill posting flow, not here. This field is the *configuration*, not the counter logic. +- **Rust sketch**: Enum field on Partner; consulted at bill validation time. + +--- + +### R15 — credit_limit + use_partner_credit_limit [AXIS-A guard + AXIS-B risk signal] + +- **odoo source**: `partner.py:515–524` (fields) + `partner.py:670–683` (compute/inverse) +- **What it does**: + - `credit_limit: Float` (company_dependent). Per-partner credit limit. Falls back to company-level default. + - `use_partner_credit_limit: Boolean` (computed): True if partner's credit_limit differs from the company-level fallback. + - `_compute_use_partner_credit_limit`: compares `partner.credit_limit` against `_fields['credit_limit'].get_company_dependent_fallback(self)`. + - `_inverse_use_partner_credit_limit`: if toggled off, resets partner's credit_limit to the company default. + - `show_credit_limit` (L681–683): True if `company.account_use_credit_limit` is enabled. +- **woa-rs target**: K3 / accounts-receivable risk check. +- **Parity notes**: Credit limit enforcement logic (blocking an invoice if limit exceeded) is in the sale/invoice flow, not here. This lane captures only the field definitions and compute logic. + +--- + +### R16 — `trust` field (debtor quality signal) [AXIS-B annotation] + +- **odoo source**: `partner.py:566` +- **What it does**: `trust ∈ {good, normal, bad}` — company_dependent. Used as a qualitative risk signal for this partner as a debtor. No automatic computation; manually set by accounting staff. +- **woa-rs target**: K3 AR risk / dunning escalation (Mahnwesen). +- **Delegation tuple**: This field is an input to dunning escalation heuristics. The *assignment* of trust is a human judgment; the *use* of trust in escalation decisions is AXIS-B. + - `ReasoningKind = CustomerCategory` + - `InferenceType = Induction` (pattern over payment history to suggest trust level) + - `SemiringChoice = NarsTruth` + - `ThinkingStyle = Analytical` (from `0x80 SmbFoundryCustomer`) + +`SAVANT: name=PartnerTrustAdvisor family=0x80 reasoning=CustomerCategory inference=Induction semiring=NarsTruth style=Analytical — the 'trust' rating should be inferred from payment history patterns rather than maintained manually; lance-graph can update beliefs on each payment event via Revision inference.` + +--- + +### R17 — EDI format fields [AXIS-A] + +- **odoo source**: `partner.py:576–597` (fields) + `partner.py:651–667` (compute/inverse) +- **What it does**: `invoice_edi_format` (computed, stored via `invoice_edi_format_store`) — determines the eInvoice format (ZUGFeRD, XRechnung, etc.) for this partner. Logic: + - Reads from `commercial_partner_id.invoice_edi_format_store`. + - If store = `'none'` → `invoice_edi_format = False`. + - If store is empty → falls back to `_get_suggested_invoice_edi_format()` (stub, returns False in base; overridden in l10n modules). + - Inverse: if set to the suggested value → clear store (let suggestion win); if cleared → store `'none'`; otherwise store the value. +- **woa-rs target**: K9 DATEV / eInvoice export (X-Rechnung). +- **Parity notes**: `_get_suggested_invoice_edi_format()` is a hook — in `l10n_de`, it likely returns `'xrechnung'` for German partners. Must read `l10n_de` extension to get the DE-specific suggestion. + +--- + +### R18 — Partner deletion guard [AXIS-A] + +- **odoo source**: `partner.py:786–798` (_unlink_if_partner_in_account_move) +- **What it does**: `@api.ondelete(at_uninstall=False)` — prevents deletion of any partner that appears on any `account.move` in draft or posted state. Raises `UserError`. Note: applies to ALL states (not just posted) to prevent orphaning draft invoices. +- **woa-rs target**: DELETE endpoint for partners. +- **Rust sketch**: + ```rust + async fn check_partner_deletable(pool: &DbPool, partner_id: PartnerId) -> Result<(), WoaError> { + let count = sqlx::query_scalar!( + "SELECT COUNT(*) FROM account_move WHERE partner_id = ? AND state IN ('draft', 'posted')", + partner_id + ).fetch_one(pool).await?; + if count > 0 { + return Err(WoaError::Validation("Partner cannot be deleted: used in accounting")); + } + Ok(()) + } + ``` + +--- + +### R19 — VAT-on-reparent guard [AXIS-A] + +- **odoo source**: `partner.py:749–770` +- **What it does**: When `parent_id` changes on a partner that has accounting move lines: + 1. If the new parent has a different VAT than the partner → raise `UserError` (cannot reparent if VAT differs, as this would corrupt the legal entity mapping on existing entries). + 2. If reparenting succeeds, update `partner_id` on ALL existing move lines for this partner to `partner.commercial_partner_id` (bypassing GoBD lock). + 3. Also update `commercial_partner_id` on account.move records that are "entirely" for this partner. +- **woa-rs target**: Partner write endpoint; K11 GoBD considerations. +- **Parity notes / gotchas**: The bypass_lock_check is explicit — it passes `BYPASS_LOCK_CHECK` sentinel. In woa-rs's K11 implementation, this bypass path must be replicated (and audited/logged, since GoBD requires traceability of such changes). + +--- + +### R20 — `_compute_tax_map` and `_compute_account_map` [AXIS-A] + +- **odoo source**: `partner.py:98–110` +- **What it does**: These computed Binary fields pre-build lookup dictionaries at fiscal-position load time: + - `tax_map`: `{src_tax_id: [dest_tax_id, ...]}` — built by iterating `tax_ids` (the M2M of destination taxes) and accessing `dest_tax.original_tax_ids` (the back-relation from dest to src). Multiple src taxes can map to the same dest. + - `account_map`: `{src_account_id: dest_account_id}` — simple dict from `account_ids` lines. + Both are stored as Binary (serialised dict) — effectively a denormalised cache for fast runtime lookup. +- **woa-rs target**: Cache these at fiscal-position load time; avoid re-querying on every invoice line. +- **Rust sketch**: + ```rust + struct FiscalPosition { + id: FiscalPositionId, + tax_map: HashMap>, // pre-built + account_map: HashMap, // pre-built + // ... other fields + } + // Build on load: + fn build_tax_map(tax_mappings: &[TaxMapping]) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for tm in tax_mappings { + map.entry(tm.src_tax_id).or_default().push(tm.dest_tax_id); + } + map + } + ``` + +--- + +## Enterprise gaps flagged + +| Module | What's missing | What we spec from community | +|---|---|---| +| `base_vat` (community, present) | VIES real-time VAT validation | Base `_run_vat_checks` is a stub; format validation in `base_vat` is present but `_get_vat_required_valid` is stub (no VIES). woa-rs: implement basic DE/EU format check; VIES lookup is a future K7 enhancement. | +| `account_reports` (Enterprise, absent) | Partner ledger reports, AR aging reports with fiscal-position breakdown | Not present. Reports built fresh on woa-rs side. | +| `account_asset` (Enterprise, absent) | No direct dependency in this lane | N/A | +| `sale` (community, present but separate) | `credit_to_invoice` field is "TO OVERRIDE in Sales" (L412) — always returns False in base account | woa-rs: when porting sale module, must override this compute. Flag as pending. | +| VIES / VAT validation | `_get_vat_required_valid` hook (L867–870) is community stub returning `bool(partner.vat)` | Enterprise extends with live VIES API call. woa-rs: implement as optional external service call; default = syntactic check only. | +| `l10n_de` EDI suggestion | `_get_suggested_invoice_edi_format()` stub (L697–700) | The DE-specific suggestion (XRechnung) is in `l10n_de` — read that file separately for K9. | + +--- + +## Open questions for the Opus porter + +1. **Multi-company storage shape for `property_*` fields**: Odoo uses `ir.property` (EAV table) for `company_dependent` fields — one row per `(partner, company)`. woa-rs currently has no multi-company (K15). Should we model these as nullable FK columns on `res_partner` (single-company, simple) and plan a schema migration for K15, or pre-build a `partner_accounting_props(partner_id, company_id, ...)` table now? + +2. **`commercial_partner_id` cascade on move lines**: The write hook (R19) updates move lines when reparenting — this bypasses GoBD lock. In woa-rs (K11 Festschreibung), do we allow this bypass? If yes, must it be logged in the audit trail? Stefan's GoBD obligations apply. + +3. **Zip range matching semantics**: German PLZ `01067` (Dresden) — if zip_from and zip_to are both digit-only, odoo pads them to equal length for lexicographic comparison. What about mixed (alphanumeric) postal codes? The code only pads when `isdigit()` — alphanumeric zips (UK, Canada) are compared as-is. Is woa-rs targeting DE-only (all numeric) or international? + +4. **`autopost_bills` counter**: The `ask` branch triggers after "3 validations without edits" — where is this counter? Not in this file. Likely in `account.move` posting logic. Needs a cross-reference with L1 (account.move state machine). + +5. **`trust` field vs dunning**: The `trust` value (R16) is used in dunning (Mahnwesen). Where exactly? The dunning model (Mahnwesen) is in woa-rs scope (woa-rs has partial Mahnwesen from Round 9). Should the trust field be in the initial DB schema? + +6. **`_get_first_matching_fpos` and the Savant boundary**: Should woa-rs implement the deterministic 5-predicate matching in Rust AS WELL as having the Savant, or should the Savant fully replace it? Recommendation: implement deterministic fallback in Rust (for offline/fast-path); Savant adds evidence weighting when lance-graph is available (graceful degradation). + +7. **`account_map` / `tax_map` Binary fields**: Odoo serialises these as Python dicts in a Binary column. In woa-rs: use `JSONB` (if Postgres) or `JSON` (MySQL) column, or compute from normalized join tables on each request? Given fiscal positions change rarely, an in-process cache (built at startup, invalidated on write) is sufficient. + +--- + +## Depth-proof footer + +Read: `/home/user/odoo/addons/account/models/partner.py` lines=1170 depth=full +Read: `/home/user/woa-rs/.claude/odoo/BRIEFING.md` lines=166 depth=full +Read: `/home/user/woa-rs/.claude/odoo/BRIEFING-GAP.md` lines=82 depth=full diff --git a/.claude/odoo/SAVANTS.md b/.claude/odoo/SAVANTS.md new file mode 100644 index 00000000..8a35e676 --- /dev/null +++ b/.claude/odoo/SAVANTS.md @@ -0,0 +1,158 @@ +# SAVANTS — odoo-richness reasoner roster (woa-rs ⟶ lance-graph delegation) + +> Synthesis of the AXIS-B / HYBRID rules harvested across lanes L1–L15 +> (`.claude/odoo/L*.md`). Each Savant is a **delegated reasoner**: the +> deterministic guard stays in woa-rs (AXIS-A Rust), the evidence-weighted / +> ambiguous core is delegated to lance-graph's thinking surface through the +> **BBB-allowed** contract crates (`lance-graph-contract`, +> `lance-graph-ontology`, `lance-graph-callcenter`). No brain-crate ever +> enters the customer binary (Iron Rule 1). +> +> **Per-agent documents** live under `.claude/odoo/savants/.md` +> (schema at the end of this file). This file is the index + framework. + +## The three coordinates (pinned to the real contract enums) + +1. **Ontology** — `resolve_odoo_to_family(odoo_class, &OgitFamilyTable)` + chains `odoo:` → OWL pivot → `FamilyEntry` (8-bit family + 16-bit + slot). The family carries the **default ThinkingStyle cluster**. +2. **Use case** — `lance_graph_contract::reasoning::ReasoningKind` + { CustomerCategory, PostingAnomaly, NextBestAction, InvoiceCompleteness, + MailIntent, Other(u32) }. +3. **Thinking** — + `nars::InferenceType` { Deduction, Induction, Abduction, Revision, Synthesis } + × `nars::SemiringChoice` { Boolean, HammingMin, NarsTruth, XorBundle, CamPqAdc } + × `thinking::StyleCluster` { Analytical, Creative, Empathic, Direct, + Exploratory, Meta } — inherited from the family. + +`InferenceType::default_strategy()` already maps to a `QueryStrategy` +(Deduction→CamExact, Induction→CamWide, Abduction→DnTreeFull, +Revision→BundleInto, Synthesis→BundleAcross) — so a Savant's tuple fully +determines its runtime dispatch. + +## OGIT family map (existing + proposed) and inherited style + +| Family | Basin | Seeded odoo classes | Default style cluster | Rationale | +|---|---|---|---|---| +| `0x60` WorkOrderCore | work order | (woa core) | Direct | task execution | +| `0x61` BillingCore | product/billing catalogue | product.product→schema:Product | Analytical | pricing math | +| `0x62` SMBAccounting | ledger / CoA | account.account→fibo:Account; account.move.line | Analytical | ledger reasoning | +| `0x80` SmbFoundryCustomer | partner / legal entity | res.partner.Company→fibo:LegalEntity | Empathic (fiscal sub-paths Analytical) | relationship + trust | +| `0x81` SmbFoundryInvoice | document / transaction | account.move→fibo:Transaction | Direct | transaction processing | +| **`0x63` ProductCatalog** *(PROPOSED)* | product catalogue + pricelist | product.template, product.pricelist, uom.uom | Analytical | catalogue/pricing; L8 `PricelistAssignmentAgent` needs a home (currently None) | +| **`0x90` HRFoundation** *(PROPOSED)* | employee / org | hr.employee→vcard:Individual, hr.department→org:OrganizationalUnit | Empathic | people/org; all hr.* resolve None today | + +**Unmapped (`None`) classes needing a Layer-2 alignment axiom**: +`stock.*` (stock.rule, stock.warehouse.orderpoint, stock.move), +`account.analytic.distribution.model`, `account.account.tag`. Savants on +these carry `family=None` until an alignment axiom lands (lance-graph side). + +## Roster — gap lanes L8–L15 (16 Savants) + +| # | Savant | Family | ReasoningKind | Inference | Semiring | Style | Lane | Decides | +|---|---|---|---|---|---|---|---|---| +| 1 | FiscalPositionResolver | 0x80 | CustomerCategory | Deduction | NarsTruth | Analytical | L9 | which fiscal position (tax mapping) applies to a partner | +| 2 | PartnerTrustAdvisor | 0x80 | CustomerCategory | Revision | NarsTruth | Empathic | L9 | infer partner trust/dunning-risk from payment history | +| 3 | PricelistAssignmentAgent | 0x63* | Other(PricelistAssignment) | Revision | NarsTruth | Analytical | L8 | partner pricelist when no explicit property (country-group/config fallback) | +| 4 | AnalyticDistributionSuggester | 0x62 | NextBestAction | Induction | NarsTruth | Analytical | L10 | suggested cost-centre distribution for a move line | +| 5 | AnalyticModelScorer | None | CustomerCategory | Deduction | HammingMin | Analytical | L10 | which analytic.distribution.model matches (priority-scored) | +| 6 | SequenceGapAnomalyDetector | 0x62 | PostingAnomaly | Abduction | NarsTruth | Analytical | L11 | gaps in journal sequences ⇒ deleted posted entries (GoBD) | +| 7 | ExchangeAccountSelector | 0x62 | Other(ChartAccountMapping) | Deduction | Boolean | Analytical | L12 | gain/loss account for FX diff (sign-driven; config-assist) | +| 8 | ReportRateTypeSelector | 0x62 | Other(ConsolidationRatePolicy) | Deduction | Boolean | Analytical | L12 | current/historical/average rate per report line (IFRS vs HGB) | +| 9 | CurrencySelectionAdvisor | 0x62 | NextBestAction | Induction | NarsTruth | Analytical | L12 | which currencies to enable (geography signal) | +| 10 | UserCompanyAccessAdvisor | 0x80 | CustomerCategory | Induction | NarsTruth | Empathic | L12 | branch-access subset by user role/context | +| 11 | ProcurementRuleSelector | None | NextBestAction | Induction | NarsTruth | Analytical | L13 | route among equal-sequence rules (lead/availability/reliability) | +| 12 | ReorderTimingAdvisor | None | NextBestAction | Induction | NarsTruth | Analytical | L13 | reorder timing under demand/supplier uncertainty | +| 13 | ReplenishmentReportAdvisor | None | NextBestAction | Induction | NarsTruth | Analytical | L13 | real shortfall vs demand noise in the replenishment report | +| 14 | RouteTiebreaker | None | NextBestAction | Abduction | NarsTruth | Analytical | L13 | equal-sequence route tiebreak (supplier lead/cost/capacity) | +| 15 | TaxExigibilitySuggestor | 0x62 | NextBestAction | Induction | NarsTruth | Analytical | L15 | suggest tax exigibility (on-invoice vs on-payment / cash-basis) | +\* `0x63 ProductCatalog` is the proposed family; until ratified, treat as `None`. + +## Roster — original lanes L1–L7 (9 Savants; L3/L4 are fully deterministic) + +| # | Savant | Family | ReasoningKind | Inference | Semiring | Style | Lane | Decides | +|---|---|---|---|---|---|---|---|---| +| 17 | AutopostRecommender | 0x81 | PostingAnomaly | Induction | NarsTruth | Analytical | L1 | recommend auto-posting bills after 3+ unmodified from a partner | +| 18 | LockDateAdvancer | 0x81 | PostingAnomaly | Abduction | NarsTruth | Analytical | L1 | which next open period to advance a move into when date is locked | +| 19 | ReconcileMatchSelector | None | Other(ReconcileMatch) | Induction | NarsTruth | Analytical | L2 | which open items to propose as reconciliation candidates (core) | +| 20 | BankStatementMatcher | None | Other(BankStatementMatch) | Induction | NarsTruth | Analytical | L5 | which reconcile-model rule matches a bank line + write-offs | +| 21 | PaymentToInvoiceMatcher | None | Other(ReconcileMatch) | Induction | NarsTruth | Analytical | L5 | whether a payment fully reconciles open invoices (Mahnwesen gate) | +| 22 | UpsellActivityTrigger | 0x81 | NextBestAction | Induction | NarsTruth | Exploratory | L6 | qty_delivered>ordered ⇒ upsell TODO for salesperson | +| 23 | PricelistRecommender | 0x81 | NextBestAction | Synthesis | NarsTruth | Exploratory | L6 | which pricelist rule when multiple candidates apply | +| 24 | RemovalStrategySelector | None | NextBestAction | Induction | XorBundle | Exploratory | L7 | which quants to bind to a reservation (FIFO/FEFO/LIFO/closest) | +| 25 | MoveAssignmentPrioritizer | None | NextBestAction | Induction | NarsTruth | Exploratory | L7 | which confirmed moves to satisfy first (priority/deadline/quants) | +| 26 | BackorderJudge | None | NextBestAction | Abduction | NarsTruth | Exploratory | L7 | partial fulfilment ⇒ backorder vs cancel remainder | + +**Roster total: 25 Savants** (16 from L8–L15, 9 from L1–L7). L3 (tax compute) and L4 (reports/DATEV) are fully AXIS-A — no Savants. The `Exploratory` cluster clusters on stock/sale next-best-action (L6/L7); `Analytical` dominates accounting (0x62/0x81); `Empathic` on partner/HR (0x80/0x90). + +## woa-rs ↔ lance-graph split (the delegation contract) + +Per Savant, woa-rs owns the **deterministic guard** (AXIS-A) and calls the +reasoner only for the **ambiguous core** (AXIS-B): + +``` +woa-rs handler (AXIS-A guard: balance==0, residual, sign, prefix-match, …) + └─ if ambiguous → lance_graph_contract::reasoning::Reasoner::reason( + ReasoningContext { namespace: tenant, kind: , + evidence: &[EvidenceRef…], budget }) + → conclusion (truth-weighted) → woa-rs applies it as a *suggestion*, + never an un-guarded write (Iron Rule 7 verhaltens-bewahrend). +``` + +Evidence batches are Arrow `EvidenceRef { table, schema_fingerprint, rows }`. +The Savant's `SemiringChoice` selects how evidence fuses (NarsTruth = NARS +evidence fusion, the common case); `InferenceType` selects the query strategy. + +## AXIS-A remainder (the deterministic ports) + +The large majority of harvested rules are AXIS-A — deterministic Rust ports, +**not** Savants. They land in woa-rs ERP modules (K3/K7/K8/K11/K15/skr_data) +per each lane draft's "woa-rs target" + Rust sketch. The per-lane drafts +(`L*.md`) are the porter's spec; this roster covers only the delegated set. + +## lance-graph handover boundary (FLAG for Jan) + +The **woa-rs side** defines: the Savant roster, each delegation tuple, the +`ReasoningContext` call sites (guards), and the evidence schemas. That is all +contract-level (BBB-allowed) and lives here. + +The **lance-graph side** must implement: (a) the actual `Reasoner` impls / +experts for each `ReasoningKind`, (b) the two new OGIT families +(`0x63 ProductCatalog`, `0x90 HRFoundation`) + the Layer-2 alignment axioms +for the `None` classes (stock.*, analytic.distribution.model, +account.account.tag), (c) wiring the `ThinkingStyle` clusters to those +families in `OgitFamilyTable`. + +→ **When we start (b)/(c), it needs an integration plan on the lance-graph +side** (`lance-graph/.claude/board/INTEGRATION_PLANS.md` PREPEND + +`lance-graph/.claude/plans/-v1.md`, per lance-graph CLAUDE.md +board-hygiene). **I will stop and notify you at that boundary** rather than +cross-commit into lance-graph from this woa-rs session. + +## Per-agent document schema (`.claude/odoo/savants/.md`) + +```markdown +# Savant: +- **Family / ontology**: <0x..|None> (); odoo class → OWL +- **ReasoningKind**: **Inference**: **Semiring**: + **Style cluster**: (inherited from family) +- **Source lane / odoo**: L + +## What it decides (the AXIS-B core) + + +## Deterministic guard (AXIS-A, stays in woa-rs) + + +## Evidence (Arrow EvidenceRef) +- table(s), key columns, what signal each carries + +## Delegation call site + + +## Parity / GoBD notes + + +## lance-graph dependency + +```