# Transaction-Document-Form & Product-Search Unification — big plan

> **Status: 🔬 DEEP ANALYSIS (started 2026-06-20).** Owner asked for a **manager-grade** plan (the manager will review + evaluate). Two unifications + cross-cutting settings. NOT a quick report — deep, so we don't redo/add later. The [HTML plan](https://moonui.elbaset.com/document-form-unification-plan.html) is the deliverable; **Codex reviews the plan for correctness** before implementation.

## What the owner wants (verbatim intent)
1. **Product search = ONE common widget, identical look everywhere** in the whole program; the **search character-count is a SETTING** (`products.min_search_chars`).
2. **Unify the document FORM shape** — header + line-items + totals — which repeats across **sales order/invoice, purchase order/bill/request, AND inventory issue (طلب الصرف) / receipt (طلب التوريد) + any similar form**. Same shape + same settings, **data differs per module**.
3. **Field-visibility settings** — what shows per row (e.g. treasury/cash-box, tax) — must be consistent; today some docs have a field (e.g. treasury) and some don't (inconsistency to fix).

## Scope (ALL line-item editors — 11+)
- **Sales:** orders, invoices (+ quotations, returns).
- **Purchases:** orders, bills, requests (+ returns).
- **Inventory:** stock-receipts, stock-issues, warehouse-transfers, inventory-counts, stock-adjustments, opening-balance.
- **Payments / treasury:** sales/purchases payments + the inline-pay blocks in invoices/bills + LIS cashier routing.

## Analysis (5 agents — file:line maps)
- ✅ **Sales (order/invoice):** ~80% shared core (header customer/date/currency/warehouse/terms + 8-control line group + totals getters + payload). Variation: invoice-only due_date/price_type/overall-discount/field-visibility/inline-pay/post-chain; order-only expected_delivery.
- ✅ **Purchases (order/bill/request):** same shared core. Bill = most complex (per-line warehouse, overall-discount distribution, inline-pay, post-chain, field-visibility). Request = simplest (no pricing; `estimated_price`+`preferred_supplier`; **already uses `app-product-search`**). Price field varies `unit_price`/`unit_cost`/`estimated_price`; currency `code` vs `id`; party `customer`/`supplier`/none.
- ✅ **Inventory (6 docs)** — quantity/stock-movement docs: `warehouse_id` REQUIRED (no party), transfer = `from_`/`to_warehouse_id`, **polymorphic quantity** (`quantity`/`requested_quantity`/`counted_quantity` min0), money often absent (issue/transfer no cost), `batch`/`expiry`/`serial`, **divergent lifecycles** (draft→approve · transfer ship→receive sub-dialog · count→finalize→**spawns a StockAdjustment**→confirm · `quickMode` auto-approve). All use in-grid `p-select` (byte-identical) NOT `app-product-search`.
- ✅ **Field-visibility + treasury** — 🔴 **Visibility:** only 4 docs (sales/purchase invoice+return) have a hand-rolled `fieldVisibility` gear, persisted per-COMPANY to 4 ad-hoc keys (`sales.invoice_visible_fields`…); **8 docs (orders, request, 5 inventory) have NONE** (hard-coded) → a field hideable on the invoice can't be hidden on the order feeding it. 🔴 **Treasury:** **4 divergent account-list code paths** (inline-pay from chart+3 settings · standalone settlement from PettyCash+Bank · LIS standalone from Bank only · LIS wizard-v2 from `routing()`); **branch-routed treasury exists ONLY in LIS request-wizard-v2** (`primaryBranch()` cash box) — even LIS standalone payments are flat. Orders/returns carry no treasury at all. `receiving_account_id`(sales/LIS) vs `paying_account_id`(purchases).

## FINAL DESIGN — Core + Descriptor (the "change once, not 12×" answer)
- **`<transaction-document-form>` Core** owns the shared ~80%: dialog shell + the **line-grid engine** (renders columns from a schema; add/remove; the product cell = ONE common widget + stock badge + line total), the **totals engine** (mode-driven), the **header engine** (schema), the **save engine** (build payload from schema + a save-transform + a post-save lifecycle strategy + the guard + edit-preload), and the helpers duplicated 12× today (`formatDate`, debounced search, `loadStock`, quick-add party, serial).
- Each document = a **declarative DESCRIPTOR**: `{ party, header[], line{priceField,qtyField,qtyMin,columns,productFill,batch,serial}, totals, features{barcode,overallDiscount,inlinePay,quickMode}, visibility{key}, treasury{direction}, service, lifecycle }`.
- **Product search:** `app-product-search` everywhere + `minChars` from `products.min_search_chars` (one setting, exposed in a settings screen; unify the count-screen's hardcoded 2).
- **Visibility:** ONE `DocFieldConfig` registry + `FieldVisibilityService` + a `*hasField` directive + a **backend** standardized key `fields.visible.<module>.<document>` (owner wants it general + server-side); rolled out to the 8 docs that lack it. `lockable` fields (product/unit/quantity) never hideable.
- **Treasury:** ONE `<treasury-picker [direction]>` + `BranchContextService.defaultTreasuryAccountId(method)` mirroring `defaultWarehouseId()` (`primaryBranch()` branch cash box) + promote `/payments/routing` to shared.
- **Phases (Codex per phase):** 1) product widget · 2) build Core + adopt in sales invoice+order (pilot) · 3) backend visibility registry + roll out · 4) extend to purchases + inventory · 5) unified branch treasury picker · 6) cleanup the duplication.
- 📄 [HTML plan](https://moonui.elbaset.com/document-form-unification-plan.html) published.

## Codex review of the plan — **SOUND-WITH-GAPS** (gaps MUST be in the descriptor before Phase 4, else redesign)
Codex confirmed every analysis claim (file:line) + that Core+Descriptor is feasible/idiomatic in Angular. **Variation axes the descriptor MUST carry (or it forces a redo — exactly what the owner wants to avoid):**
- **Header→line warehouse cascade** (bill/invoice carry BOTH a header default + per-line `warehouse_id` override with fallback `bills.component.ts:529,608,1373`) → Core `createItem()` must receive the header warehouse as the line default.
- **Serial/batch = a sub-dialog flow** (`stock-receipts.ts:740` serial dialog cluster), not just a column toggle → Core owns it or takes an injected `TemplateRef`.
- **count→adjustment spawn** (`inventory-counts.ts:291-328`: finalize→create adjustment→find→approve) — a multi-doc cascade, not "save returns one doc."
- **transfer ship→receive** = a second post-creation dialog (`warehouse-transfers.ts:331`).
- **Payment field NAME** `receiving_account_id`(sales/LIS) vs `paying_account_id`(purchases) → descriptor needs `paymentAccountField`, not just `direction`.
- **quickMode = a RUNTIME setting** (read from the settings API, e.g. `inventory.count_auto_approve`), not a static flag.
- **edit-mode preload** = a per-doc `forkJoin` strategy → descriptor `preloadStrategy` hook.
- Engineering: **validators in the line-schema** (not just type/display); **per-row search signal isolation** (else every keypress re-renders all dropdowns); **discriminated-union descriptor types**; **TemplateRef slots** for doc-specific cells (avoid `@if` proliferation / 400-line template).

**Codex phasing refinements (baked into the [HTML plan](https://moonui.elbaset.com/document-form-unification-plan.html)):**
- **NEW Phase 3.5 — "variation-axis design freeze":** finalize the descriptor type for ALL 12 docs (serial/batch hooks, spawn lifecycle, sub-dialog slots) BEFORE any Phase-4 code.
- **Defer inline-pay** out of the sales pilot (high risk) until the `treasury-picker` is stable.
- **Standardize the visibility key early** (Phase 2/3) before wiring new docs (else double migration).
- **Per-phase gate = a smoke-test matrix** (create→approve→post→verify JE lines), not just generic repro (production, no staging).

## ✅ OWNER DECISIONS (2026-06-20, §7 answered)
1. **Order:** start with Phase 1 (product-search widget + setting). 2. **Pilot:** sales (invoice+order) first, owner reviews before generalizing. 3. **Visibility:** server-backed **per-company** (no per-user). 4. **Inventory:** **unify** the 6 inventory docs into the same `<transaction-document-form>` (wider config).

## 🟢 PHASE 1 — IN PROGRESS (2026-06-20). Built green + deployed to moonui `/app` (hazemdev). NOT released to moontest. Awaiting owner test.
**Reality was further along than the plan assumed** (3-agent code sweep): the FE settings screen ALREADY had a Products tab + `min_search_chars` load/save, and 12 components already read `products.min_search_chars`. So Phase 1 = finish the gaps.
- **1-A setting foundation (DONE):** BE — added `products.min_search_chars` (int, def 3) + `products.show_category_in_name` (bool) defs to `Modules/Core/database/seeders/SettingDefinitionSeeder.php` (~line 778, updateOrCreate, **seeded to `moonui_dev_be`**, count=2 verified). FE — unified the **4** genuinely-hardcoded PRODUCT-search thresholds to `minSearchChars()`: `inventory-counts`, `sales/returns`, `purchases/returns`, `products`(list). **KEY CORRECTION:** most `>=2` thresholds the agents flagged were **partner (customer/supplier) search**, NOT product — left alone (separate concern; would be a future `partners.min_search_chars`).
- **1-B common widget — SALES PILOT (DONE):** swapped manual `<p-select>` product cell → `<app-product-search [minChars]="minSearchChars()" (productSelected)="onLineProductPicked(i,$event)">` in **sales invoice + sales order**. Invoice: `onLineProductPicked` pushes the emitted product into `searchedProducts()` then calls the existing `onProductSelect(i,id)` → all logic (price_type/listPrices/stock/warehouse cascade) unchanged. Order: had NO onChange before → new `onLineProductPicked` fills unit+price (consistency fix) + `productMap` for barcode row. Widget is ControlValueAccessor → `formControlName` works per-row inside FormArray; edit-mode shows label via `writeValue→getById` without touching loaded unit/price.
- **Review:** ⚠️ **Codex CANNOT run on this server** — `max_user_namespaces=0` (CloudLinux/cPanel hardening) → bwrap fails; used the harness-native **`code-reviewer`** agent instead (see [[codex-blocked-on-server]]). Verdict **DEPLOY-with-caution, 0 CRITICAL, no blockers**; invariants confirmed (row isolation, edit-mode writeValue safe, push-before-call ordering, barcode path, seeder idempotent, no partner-threshold touched). **Fixed pre-deploy:** HIGH-1 widget `searchSub` leak → added `ngOnDestroy`; MEDIUM-2 clearing a line now resets unit/price. **Deferred:** MEDIUM-4 edit-mode double `getById` (widget + prefetch) → optimize later via a preloaded-product `@Input` on the widget; LOW dead code (`onProductSearch`/`searchSubject` in pilot files) → Phase 6 cleanup.
- **ROLLOUT (DONE 2026-06-20, deployed):** widget swapped into **purchases orders+bills** + all **6 inventory docs** (stock receipt/issue/transfer/count/adjustment/opening-balance). Uniform handler `onLineProductPicked(i, product)`: inventory pushes the emitted product into `products()` then calls the existing `onProductChange(i,id)` (reuses unit/cost/variant/stock fill; receipts/issues self-`getById`, the other 4 read `products().find`); purchase order fills unit+`unit_cost`(purchase_price)+productMap; purchase bill pushes to `searchedProducts` + calls existing `onProductSelect`. All 8: ProductSearchComponent in import+imports-array. Widget `list(1,30,{search})` == old `searchForDropdown` endpoint → identical shape, no variant/unit regression. on-clear resets `unit_id`+`unit_cost`.
- **🐞 DROPDOWN-CLIPPING BUG + FIX (2026-06-20):** owner reported the results list was clipped/hidden under the table/dialog frame (the panel was `position:absolute` inside an `overflow:hidden` cell; old p-select escaped via `appendTo=body`). Fix: panel → `position: fixed` anchored from `input.getBoundingClientRect()` (signals `panelPos`/`panelMaxH`, flip-up when low space, scroll(capture)+resize listeners only while open). **Native review caught a CRITICAL:** PrimeNG v21 `.p-dialog` has `transform: scale(1)` + `will-change: transform` AT REST → a `position:fixed` child resolves against the DIALOG, not the viewport → panel offset. **Final fix:** `positionPanel()` subtracts the **fixed containing-block origin** (`fixedOrigin()` walks ancestors for transform/filter/will-change, returns its rect; viewport→0,0) so coords are correct both inside dialogs and outside — no portal/CDK needed. Widget also: `ngOnDestroy` (was leaking `searchSub`). KB rule: if this ever drifts, the bulletproof alternative is append-panel-to-`document.body`.
## 🟢 PHASE 2 — Core line-items grid STARTED (2026-06-20, deployed, awaiting owner test)
First slice of the Core = the **shared line-items GRID** (the most-duplicated part). NEW shared component `src/app/shared/components/transaction-line-items/`:
- `transaction-line.types.ts` — `TxColumn{key,type:product|select|number|percent|computed|action,labelKey,options/min/max/fractionDigits/suffix/compute}` + `TxLineDescriptor{columns,minChars,productPlaceholderKey,onProductPicked,getProductBarcode,removeDisabled}`.
- `transaction-line-items.component.ts` (`<app-transaction-line-items [items]=FormArray [descriptor]=desc>`) — renders thead+tbody from the column schema; each `<tr [formGroup]="asGroup(row)">` + per-`type` cell. Host KEEPS add-row button + totals + barcode-scan + save (this owns only the table). The row LAYOUT now lives in ONE place = "change once".
- **Adopted in sales ORDER** (pilot, simplest): replaced the inline `<table class="lines-table">` with the component + a `lineDescriptor` field; added `unitsSig`/`taxRatesSig` via `toSignal(units$/taxRates$)`.
- **Native review verdict: DEPLOY-with-caution, 0 CRITICAL.** Confirmed: FormArray binding across the component boundary is correct (`[formGroup]="row"` = same control refs, edits propagate to save); edit-mode shows labels via writeValue→getById without clobbering loaded values; add/remove reactive; toSignal valid; appendTo=body preserved on selects. **Fixed:** widget `labelOf` now prefers `name_ar` (was `name_en` — Arabic-first parity, affects all widget usages). **Deferred (Phase 6):** dead `onProductSearch`/`searchSubject`/`searchLoading` + redundant ProductSearchComponent import in the order host (harmless; `searchedProducts`/`minSearchChars` still needed for barcode-fallback + descriptor).
- **Layout/density fix (owner feedback):** the Core grid was missing the order's column widths + compact density (they lived in the order's *scoped* SCSS). Baked into the component: `table-layout:fixed` + col `width` (new `TxColumn.width`) + `font-size 0.75rem` + `::ng-deep` compact padding for p-select/p-inputnumber/`.ps-input`. Also `vertical-align: top` so the picked-product **barcode badge no longer shifts sibling cells**.
- **🆕 FIELD-VISIBILITY mechanism (Phase 3 seed, owner asked) + per-line WAREHOUSE on sales order:** added `TxColumn.visible?: () => boolean` → grid renders only visible columns (`cols()`). Sales-order model already supports item `warehouse_id`, so added a **per-line warehouse column** gated by a NEW setting `sales.show_order_line_warehouse` (bool, seeded; default OFF = current behaviour unchanged). Wired: `createItem` warehouse_id, header→line cascade (`onHeaderWarehouseChange` + on product pick), `onSave` maps item.warehouse_id, setting loaded into `showLineWarehouse()`. **Toggle surfaced in Settings → Products tab** (`toggleShowOrderLineWarehouse`, + `SETTINGS.SHOW_ORDER_LINE_WAREHOUSE` i18n ar/en). This is the first concrete instance of the owner's "field visible per-document via setting" vision — generalize to a full per-doc registry in Phase 3.
- Phase 2 sales-order grid tested by owner → layout fixed (col widths/density baked into the component; barcode no longer shifts rows via `vertical-align:top`).

## 🟢 PHASE 3 — PER-DOCUMENT FORM CONFIGURATOR (2026-06-20, deployed, owner-driven scope expansion)
Owner expanded the vision (4 messages): not just field-visibility — a **central, extensible per-document settings system** controlling EVERY field (header + line): **show/hide + required/optional + default**, plus **behaviours** (default qty, autofill price/unit, barcode-primary), all from ONE place, for all 12 docs. = a "document form configurator".
- **Framework (NEW):** `core/services/doc-config.types.ts` + `doc-config.service.ts` — `DOC_CONFIG_REGISTRY` (each doc → header+line `DocFieldDef`{locked,canRequire,defaultVisible,defaultRequired} + `DocBehaviorDef`{toggle|number} + `wired`). `DocConfigService`: one JSON setting `core.document_settings` (seeded, is_visible=false) → `fieldVisible/fieldRequired/fieldDefault(doc,field,AREA)` + `behavior(doc,key)` = override ?? registry default; `setField/setBehavior` mutate signal + persist whole JSON. **CRITICAL fix from review:** header & line can share a key (warehouse_id) → methods take an `area` arg and storage key is `area:field` (else header/line toggles collide). Locked fields always visible+required.
- **Central UI:** Settings → **"Documents" tab** renders the whole registry (per doc: header fields + line fields with Visible+Required toggles, behaviours). **Unwired docs are shown DISABLED with a "Soon" badge** (honesty — only `sales.order` is wired; the rest light up as they migrate to the Core grid).
- **Sales ORDER = full proof (wired:true):** line columns (warehouse/unit/unit_price/discount/tax) visibility via `fieldVisible(...,'line')`; header fields (warehouse/salesperson/currency/payment_terms/expected_delivery/reference/subject/notes) gated via `fieldVisible(...,'header')`; **required** wired (`applyConfiguredValidators()` on open for header warehouse/salesperson + `unit_price` in createItem); behaviours `default_qty` (createItem) + `autofill_price` (gates onLineProductPicked). The old one-off `sales.show_order_line_warehouse` setting + Products-tab toggle were removed/folded in.
- **Review:** native code-reviewer, verdict was DO-NOT-DEPLOY → **all HIGH fixed** (area key-collision, header fields ungated, default_qty, removed unwired `barcode_primary`, disabled unwired-doc toggles). Deferred/noted: default_qty pre-load race (tiny; registry default=1=prior behaviour); `fieldDefault` for header values not yet applied; required for other docs.
## 🟢 PHASE 3 — TABS-PER-DOC UI + APPLIED TO ~ALL DOCS (2026-06-20, deployed)
Owner: (1) make Settings → Documents **a tab per document** (each tab = that doc's settings), (2) **apply to all**.
- **Settings UI = nested per-document tabs:** restructured the Documents tabpanel into `<p-tabs [value]="activeDocTab()">` (one `<p-tab>`/`<p-tabpanel>` per registry doc, `[scrollable]`). Unwired docs show a **"Soon"** badge + disabled toggles (honesty).
- **DocConfigService.fieldVisible/fieldRequired/fieldDefault/setField now take an `area` arg + storage key `area:field`** — fixes a real header/line key collision (warehouse_id exists in both) found by review.
- **Wired to ALL line-item docs** (visibility via existing-template gating, `@if (docConfig.fieldVisible(docKey,field,area))` on th+td; injected docConfig+docKey+load()): sales order (full: vis+required+default+behaviours) · **purchases order** · **6 inventory** (receipt/issue/transfer/count/adjustment/opening) — done via 7 parallel agents + native review. **sales/purchase RETURN** wired by **repointing their existing `isFieldVisible`/`toggleField` gear to DocConfigService** (the inline gear checkboxes now sync with the central tab — no HTML change). Non-sales-order docs = **visibility-only** this round (behaviours emptied, required not offered → no dead toggles).
- **Still on their own gear (wired:false, "Soon"):** **sales invoice + purchase bill** (they have the same hand-rolled `isFieldVisible` gear as returns — unify next by the same repoint trick).
- **Native review:** DEPLOY-with-caution, 0 CRITICAL. Fixed: removed dead `purchases.order` line warehouse_id from registry (no such column). Accepted/noted: purchase-order `unit_id` has no `listProductUnits` fallback (only base_unit_id) → hiding unit on a product w/o base unit could strand (rare admin misconfig); inventory `unit_id`/`unit_cost` safe (auto-fill / 0 valid for required).
- **⏳ NEXT:** unify **sales invoice + purchase bill** (repoint their gear → docConfig, flip wired). Then: header **default-value** application (fieldDefault), required for the other docs, `barcode_primary` behaviour. Eventual: migrate the non-sales-order docs to the actual `<transaction-line-items>` Core grid (currently they keep their own templates, just gated).
- **✅ COMMITTED to hazemdev (2026-06-20):** FE `b4c7a08` (Phase 1+2+3) + FE `4ef4cc4` (sales invoice + purchase bill wired via gear-repoint + audit fixes) + BE `2fee8088f` (seeder). Deployed to moonui `/app` + seeded to `moonui_dev_be`. NOT released to moontest (needs a MoonStack release).
- **ALL 12 DOCS WIRED + AUDITED.** sales invoice + purchase bill wired by repointing their `isFieldVisible/toggleField` gear → DocConfigService (field+area map; inline gear syncs with the central tab). **Comprehensive audit (native) verdict: central Settings→Documents works for ALL 12 — no dead toggles, no orphan gates, th/td paired, save-safe.** Fixed 3 HIGH: (D1) Settings disables the Required toggle when a field is hidden (was: hidden+required → unsaveable order); (D3) invoice/bill inline gear read a STALE `fieldVisibility()` signal → switched to live `isFieldVisible`; the 4 gear "Save" buttons no longer write the dead `*_visible_fields` keys (auto-persist on toggle). Remaining cosmetic: the legacy inline gear panels still exist on the 4 B-docs (invoice/bill/returns) — they now work correctly + sync, but a future cleanup could remove them so the central tab is the ONLY control point (owner's "one place"). Dead ngOnInit `*_visible_fields` loads left (harmless).
- **🔴 CODEX REVIEW (owner enabled namespaces `! sudo sysctl -w user.max_user_namespaces=15000`, revert to 0 after) — found 2 SAVE-BLOCKERS the native reviewer MISSED + fixed (FE `7f0428e`):** (1) disabling the Required toggle in the UI wasn't enough — a field made required BEFORE being hidden stayed required+hidden+null=unsaveable → `DocConfigService.fieldRequired()` now returns false when the field is hidden. (2) sales order filled `unit_id` only inside the `autofill_price` behaviour block → hiding the unit column + disabling autofill stranded the required `unit_id` null → now `unit_id` ALWAYS fills on product pick (only `unit_price` is gated by autofill). **Codex 2nd pass (fresh thread) found 2 more CRITICAL save-blockers → fixed (FE `6c4b318`):** (C1) hiding the `unit_id` column could strand a required null unit (product with no base unit) → `unit_id` now **locked** (non-hideable) in sales order, purchase order + 6 inventory docs. (C2) hiding the sales-invoice warehouse with no default configured stranded the tracked-product save guard → invoice `openNew()` now falls back to the first warehouse so the header warehouse is never null even when hidden. Also fixed bill currency key `currency_code`→`currency_id`. **Open MEDIUM (accepted):** invoice/bill `'warehouse'` is one toggle that hides BOTH header+line warehouse (can't show only one) — split into header_warehouse/line_warehouse later. **Lesson: Codex kept catching what the native reviewer missed (4 save-blockers total across 2 passes) — keep using it; it needs user namespaces enabled (`[[codex-blocked-on-server]]`).**

## 🧩 SHARED-DESIGN MAP — what's common across all 12 document forms (owner: "I feel they're similar")
Quantified (2026-06-20): the 12 line-item document components total ~11,000 lines (475–1751 each) and are **~80% structurally identical** — same shape repeated 12×. **What's COMMON (the layers):**
| Layer | Shared today? | Evidence / target |
|---|---|---|
| **Product search** | ✅ `app-product-search` (all 12) | done |
| **products.min_search_chars** | ✅ (all) | done |
| **Field visibility/required/default** | ✅ `DocConfigService` + Settings→Documents (all 12) | done |
| **Line-items grid** | ◑ `app-transaction-line-items` — **sales order ONLY**; other 11 keep bespoke `<table>` | **migrate all 11 to the grid** (descriptor per doc) |
| **Header** (party+date+warehouse+currency+terms+ref/subject+notes+status) | ❌ hand-coded 12× | extract `<transaction-header [schema]>` (header engine) |
| **Totals** (subtotal/discount/tax/total + `getLineTotal`) | ❌ duplicated (orders/invoices/bills each reimplement) | extract a totals engine (compute from line schema) |
| **Save/payload + lifecycle** (`onSave`, build itemsPayload, draft→approve→post) | ❌ `onSave` in all 12 | shared save pipeline (descriptor payload-transform + lifecycle strategy) |
| **Party picker + quick-add** | ❌ customer/supplier search + add-dialog dup'd (4 docs, ~14 refs each) | `<party-picker [type]>` |
| **Barcode scan** (`onBarcodeScan`/`addProductByBarcode`/`lookupByBarcode`) | ❌ dup'd in 8 docs | fold into the grid / `<barcode-scan>` |
| **Variants / serial / batch** sub-flows | ❌ dup'd (inventory) | grid features (TemplateRef slots) |
| **Treasury/payment picker** (branch-routed) | ❌ 4 divergent code paths | `<treasury-picker [direction]>` (Phase 5) |
| **Edit-mode preload** (forkJoin product details) | ❌ dup'd | descriptor `preloadStrategy` |
| **Status badges / row actions** (approve/post/cancel) | ❌ similar 12× | shared status/actions helpers |

**The unifying target = `<transaction-document-form [descriptor]>`** = Core (header engine + the line grid + totals engine + save engine) + a small per-doc descriptor. The line-grid + the config layers are the first two slices already shipped; the header/totals/save engines are the remaining big wins ("change once, not 12×"). This is the original Core+Descriptor plan — the owner's intuition confirms it. **NEXT design step: roll `app-transaction-line-items` to the other 11 docs (each a descriptor), then extract the header + totals engines.**

## Design direction (to be finalized in the plan)
- **`<transaction-document-form>`**: reusable CORE (line-grid + totals + save + search) + **declarative config**: header field schema, line-column schema (generalize the `fieldVisibility` gear → a column schema), price/party/currency field names, pluggable product-pick fill strategy, totals mode, save-payload transform + post-save chain, injected per-module service.
- **`app-product-search` everywhere** (the type-to-search widget; owner confirmed) + `minChars` from the setting (exposed in a settings screen).
- **Unified field-visibility settings model** (one schema per doc, persisted consistently).
- **Unified treasury default** — branch-routed (mirror the warehouse default we just shipped + the LIS cash routing).

## Implementation (phased, Codex per phase)
1. Product-search widget common (independent, lowest risk).
2. Build `<transaction-document-form>` + adopt in sales invoice+order.
3. Roll out to purchases (order/bill/request) + inventory docs.
4. Treasury branch-default + unified visibility settings.
5. Cleanup (delete the 11× duplicated search/totals/quick-add).

> ⚠️ Critical transaction flows (JE + stock + payment) → incremental, behind current behavior, build + headless repro + Codex + owner test each phase.

_Related: [[branches]] (the branch-default warehouse/treasury work this builds on)._
