---
name: lis-sample-generation
description: How lab_samples (tubes/barcodes) are created in LIS — the trigger is the FE wizard (not request creation), and the barcode-print gate only checks that sample rows exist. Why some pending requests print barcodes and others fail "No collected samples". + 2026-06-27 barcode UNIFIED into one builder (LisBarcodeBuilderService) + panel-member/English-only/extra-tube/shadow-specimen heal. + 2026-06-25 formula extra-tube fix.
updated: 2026-06-29
---

# LIS Sample Generation & the Barcode-Print Gate

## ✅ IMPLEMENTED (2026-06-29) — section name/code on the label + configurable serial-reset cadence (owner ask)
**Status: BUILT + native-code-reviewed (all HIGH/MEDIUM applied) + tested + deployed to moonui `/app`; pushed `hazemdev` (FE `ff57ca6` / BE `bb9778012`). ⏳ NOT on `main`/clients yet (owner release).** Plan/owner report: `public_html/barcode-section-and-serial-plan.html`.

**Two owner asks on the Barcode settings card:**

**① Section on the label** — print the test's lab SECTION on the sticker, as full NAME or short CODE (e.g. `HEM`/`MICRO`), with an **independent show/hide toggle**. Before: section was bundled with the specimen under `show_specimen_section` AND `tubesFromSamples()` never even populated it, so it effectively never showed; the horizontal layout never drew it.
- New `BarcodeConfig` keys (FE, in the `barcode_config` JSON — no migration/seeder, same precedent as `test_label_mode`): `show_section` (default **false** = opt-in), `section_label_mode` (`'code'` default | `'name'`), `section_max_chars` (0 = no cap). `show_specimen_section` now governs the specimen line ONLY.
- `BarcodeTube.sectionCode` + exported `SectionLookup` type; `tubesFromSamples(samples, investigations, sectionLookup?)` stamps section name+code per `sample.labSectionId`. Callers resolve sections **English-first** (label font is Latin → Arabic mojibakes): wizard (`sectionNames` map gained `code`), `lis-requests` (from the sample's eager `lab_section`, `name_en||code||name_ar`), `lis-samples` (`buildLabelData` from `labSections()` English-first).
- 🔑 **THE single section-text decision = `pickSectionLabel(name, code, mode, maxChars)` in `core/services/lis-label-row.util.ts`** — picks code/name per mode, **falls back to the (Latin) code when the picked text is Arabic** (anti-mojibake guard), caps to maxChars. Used by BOTH the renderer (`lis-barcode-label.service.ts sectionLabel()`) AND the settings live-preview (`barcode-label-preview.component.ts`) so they can't drift. Section drawn in BOTH layouts (classic + horizontal) under `show_section`.
- Settings UI: "Section on label" toggle + format dropdown (code/name) + max-chars, live preview reflects it.

**② Serial-reset cadence** — the numeric barcode serial was hardcoded **yearly** (`reset_frequency='yearly'`, resets Jan 1; NOT "forever" as the owner assumed). Now choosable: **daily** (restarts at 1 each day → "request N of today"), **yearly** (default = unchanged), **never** (counts up forever).
- BE: `ResetFrequency::Daily` + `SequenceService::resolveFiscalPeriod` `Daily => [(int) now()->format('Ymd'), 0]` — encodes the whole date in `fiscal_year` (`unsignedInteger` holds `20260629`, rolls over per day). **No schema change** (`reset_frequency` is `string(20)`).
- FE key `serial_reset` in `barcode_config` (default `'yearly'`). `LabSampleService::ensureBarcodeSequence($companyId, $cfg)` reads it, **self-heals** the sequence's `reset_frequency` to match (firstOrCreate won't update existing), and **on a mode switch advances the new counter past the max serial ever issued** (a mid-day switch could re-issue a serial already used today → today's barcodes share the date prefix → UNIQUE-`barcode`-index collision, no retry → 500).
- 🔴 **Uniqueness guard:** daily/never stay unique only while a **date segment** is in the number. `barcodeResetFrequency()` downgrades daily→yearly + `Log::warning` when no base-level date segment exists; the settings screen warns too (`barcodeHasDateSegment` getter checks BASE segments). `never` is monotonic → always unique, no date needed. Default preset C has a date → default path safe.
- Tests: `SequenceServiceTest` daily-reset (crosses a day boundary via `Carbon::setTestNow`) + fixed a stale self-heal test; LIS sample suites green.

**Review (native `code-reviewer`, Codex blocked on server):** applied — Arabic-fallback in the section callers + the shared `pickSectionLabel` guard, the same-day-switch collision advance, the `barcodeHasDateSegment` base-segment filter. 🔴 HIGH was `src/assets/config.json` (a local dev URL moonui2→moonui) — **deliberately NOT committed** (correct for moonui `/app`, wrong to ship). See [[barcode-number-format]].

## ✅ IMPLEMENTED (2026-06-29) — configurable barcode NUMBER format + one-group-per-patient (owner ask)
**Status: BUILT + reviewed (native `code-reviewer`, all HIGH/MEDIUM applied) + tested on `hazemdev`. ⏳ NOT pushed/deployed yet (owner gate); reaches clients via MoonStack update.** Study/owner report: `public_html/barcode-format-study.html`.

**Owner problem:** one patient's tubes looked unrelated — variable LENGTH (`260627001822` 12-digit vs `2606270018252` 13-digit), section id buried in the middle, and sometimes a DIFFERENT serial per tube (req `LR-2026-00443` → tubes `…00183` + `…00184`; the "120037 vs 140037" confusion). Owner wanted the number arrangeable from Settings, with the per-patient running part recognisable and the per-tube differentiator in a consistent, fixed-width position.

**Decision (owner):** build a configurable **Format Builder** (Lab Settings → Barcode), default preset **"C"** = `[date ymd][serial 5][tube# 2]` → `26062900182` + `01/02/03`. One patient's tubes share an 11-char head, fixed 13-char length, trailing sequential tube number; section name shows as TEXT on the label (not in the number).

**Architecture (key constraint):** the number = **request-level BASE** (date/serial/branch/literal — freely orderable) **+ a trailing per-tube SUFFIX** (tube#/section/specimen). Tube-level parts are ALWAYS the suffix because the **standalone `aliquot()` path** (samples-page button + `bulkAliquot`) only has the parent and must derive `child.barcode = parent.barcode + suffix` WITHOUT re-drawing the request serial. So section-in-the-MIDDLE arrangements aren't supported (and weren't chosen).

**BE (`/home/moonui/moon-erp-be`):**
- NEW `Modules/LIS/app/Services/SampleBarcodeBuilder.php` — pure builder. `buildBase()` renders non-tube segments; `buildTubeSuffix()` renders tube-level segments (with a fallback padded tube-index so a mis-config can never yield an empty suffix → collision). Reads `barcode_segments` from `lis.barcode_config`, falls back to default preset C.
- `LabSampleService.php`: `generateBarcode($companyId, $branchCode=null)` → BASE via builder (+ new `nextBarcodeSerial()` draws the serial ONCE); `aliquot()` suffix = running **tube index** via builder (not section id); `generateForRequest()` draws **one base for the whole request** and shares it across the internal parent + section children + external tubes with a continuous tube counter → **fixes the LR-00443 different-serial bug**.
- **No migration / no seeder** — `barcode_segments` is a key inside the existing `barcode_config` JSON (new keys default in for old labs; same pattern as `test_label_mode`).
- Barcode lookups are all **exact-match** (no parsing) → old printed barcodes keep working; the `/-R\d+$/` recollect-marker regex is unaffected (no `-R` in the new suffix).

**FE (`/home/moonui/public_html/moon-erp`):**
- `core/services/lis-lab-info.service.ts` — `BarcodeSegment` type + `barcode_segments` on `BarcodeConfig` (default preset C) + `normalizeBarcodeSegments` + preview renderers `renderBarcodeBase`/`renderBarcodeTubeSuffix` (mirror the PHP builder, incl. the multi-specimen guard + date-token formatting).
- `features/lis/lab-info/lis-lab-info.component.*` — **Format Builder UI** in the Barcode card (numeric mode): presets dropdown · orderable base segments (▲▼ + width + add/remove) · per-tube differentiator select · **live number preview** (one patient's 3 tubes + another patient). `ng build` green.

**Code-review fixes applied (native `code-reviewer`, Codex blocked on server):** **HIGH-1** branch segment was always `null` (preset "BR" = dead) → now resolved from `$labRequest->branch?->code` / `auth user primaryBranch`. **HIGH-2** "Section #" mode collided for a section with >1 specimen type → `buildTubeSuffix` now auto-appends the specimen disambiguator when a section segment is the only differentiator (preserves the pre-builder guarantee; preset C/tube unaffected). **MEDIUM** TS preview now honours `seg.format` date tokens. 4 LOW accepted (in-place form mutation [consistent w/ ngModel], width-cap UI-only, triple cfg read, wasted serial on all-external request).

**Tests:** `tests/Unit/SampleBarcodeBuilderTest.php` (13 cases — base/suffix/padding/alpha/section-multispecimen/fallback/reorder) + `tests/Feature/LabSampleApiTest.php` `generateForRequest shares ONE base…` (internal+external share base, indices 01/02/03) + updated the stale aliquot assertion. Full LIS sample/aliquot/formula/external/rollup suites green (sqlite). CHANGELOG `[Unreleased]` bilingual bullet added.

**⏳ NEXT:** owner reviews `barcode-format-study.html` → push `hazemdev`→`main` → build FE → deploy `/app` → MoonStack release → update staging → verify → promote to elmadina/clients.


## ✅ FIXED (2026-06-27) — barcode UNIFIED into one builder + panel/English/extra-tube fixes (elmadina LR-2026-00006)
**Status: SHIPPED on `hazemdev` + `main` (FF), FE deployed to `/app`. ⏳ NOT yet on elmadina — the two data migrations reach it only via a MoonStack update (owner-run; A+C must ship together).** Commits: FE `c321fca` (unification) · BE `2ab43d7ad` (data integrity) + `3d141d59f` (CHANGELOG). Owner report HTML: `public_html/lab-barcode-bug-analysis.html`. Client-facing explainer: `public_html/lab-barcode-guide.html` (how a tube = one specimen × one section; why "2 Semen/2 Urine" = analysis + culture, not a dup).

### ✅ FIXED (2026-06-27, part 2) — auto-print (wizard) now identical to reprint (requests list); LR-00008 "dup" explained
The **label RENDERING** was unified in part 1 (one `build()`), but each path still built its **tubes** (which tests ride which tube) differently → owner saw auto-print ≠ reprint. Root cause: the **requests-list reprint filtered a tube's investigations by SECTION ONLY** (so two tubes sharing a section — urine-culture + semen-culture, both Microbiology sec 39 — showed each other's tests) + an all-tests fallback; the **wizard filtered by specimen + section** (correct). **Fix:** new `LisBarcodeBuilderService.tubesFromSamples(samples, investigations)` = THE single tube-builder (tube = one specimen × one section, string-coerced; skip a parent that has children; external samples → external investigations). Both paths map their data + call it → identical. FE `e84cbf7` + CHANGELOG BE `855c60d06` (Unreleased). Native code-reviewer applied: unified `labSectionId` resolution order (nested relation first) in both BarcodeSample maps, `per_page` 50→100, error handler on requests fetch. **HIGH-1 (panel-member leak if list omits `panel_members`) = NON-issue here** — verified BE `LabRequestController::index()` eager-loads `requestInvestigations.investigation.panelMembers` + `LabInvestigationResource` emits it via `whenLoaded` (same resource as the create response the wizard relies on). **LR-2026-00008 "still duplicated"** = NOT a real dup: every (specimen+section) tube is unique; the "2 Semen / 2 Urine" are **analysis tube + culture tube** (SECU semen-culture sec39 + SEMEN analysis sec54; URCU urine-culture sec39 + urinalysis sec45) — cultures keep their own sterile tube by the owner's earlier decision. MEATFIB confirmed healed (now on the STL tube). ⚠️ Note: `SECU` is typed `memo` not `culture` — optional catalog cleanup to type it `culture` like `URCU` (won't change that it's a separate tube; just consistency).

**Symptom (owner, with image+PDF, req LR-2026-00006 on elmadina):** panel internal members printed on the barcode (CBC/STOOL/SEMEN showed every component), garbled Arabic text (`þòþËþŽþ¨þç…` = Latin font can't shape Arabic), unrequested tests (StoolCysts, MEATFIB) appeared as their own tubes, and `STCU` showed its code. Auto-print (after request) behaved differently from re-print (from Requests).

**Root cause — FOUR independent print paths, each re-deriving label rows differently:**
1. **Panel members leaked** — the wizard built its "members to hide" set from the searchable test rows (`testRows()`), which do NOT carry `panel_members` → empty set → members printed. The authoritative set is the created request's `investigations[].investigation.panel_members` (LIS-062) — but `LabRequestController::store()` returned the resource **without** `->load(...panelMembers)`, so even that was absent in the create response.
2. **Arabic mojibake** — 4 paths used the localized specimen name (`name_ar`/`name`) on a Latin-only barcode font.
3. **Extra tubes (StoolCysts/MEATFIB)** — catalog data: panel members whose `specimen_type_id`/`lab_section_id` diverged from their panel → the aliquot key `{section}:{specimen}` split each into its own tube (same root family as the formula extra-tube bug below).
4. **Duplicate "shadow" specimen types** — a second set (codes `SPT-*`, `name_en=NULL`) duplicating the canonical specimens; members pointing at a shadow "Stool" split off.

**The unification (owner's #1 ask — "one place, all identical"):**
- **NEW `core/services/lis-barcode-builder.service.ts` (`LisBarcodeBuilderService`)** — THE single source: takes a patient context + `BarcodeTube[]` and emits `LabelDataV2[]`, applying the canonical rules ONCE (panel-name-only, English specimen, code dedup via a `seen` Set, member + non-searchable exclusion).
- **NEW `core/services/lis-label-row.util.ts`** — shared helpers: `resolveSpecimenName(st)` (English-first: `name_en` → Latin name → Arabic), `collectPanelMemberIds(requestInvestigations)` (reads BOTH `panel_members` AND `panelMembers` casings), `isLabelVisibleInvestigation`. `ARABIC` regex includes Presentation Forms A/B.
- **All 3 FE paths now build `BarcodeTube[]` and call the one builder:** `request-wizard-v2` (`printReceptionLabels`), `lis-requests` (`printLabels`), `lis-samples` (`buildLabelData`). Dead code removed (`buildLabelsBySectionForReception`, the `codeList` computation+param). `lis-requests` section match coerces ids with `String()` (mirrors the wizard) so number/string drift can't drop all matches.

**BE data heal (two migrations, additive/idempotent/up-only, reach clients on update):**
- `2026_06_27_120000_align_panel_member_specimen_to_panel.php` — realigns a **non-culture** panel member's specimen (and section) to its panel when they differ. **History-guarded** (skip members with an entered result), **transactional**, **deterministic** (a member shared across panels with conflicting specimens is **skipped + logged**, never aligned arbitrarily), section overwritten only when it actually differs. 🔑 The pivot sub-section cleanup targets **`lab_panel_sections`** (the real FK of `lab_investigation_panel_members.section_id` — panel sub-groupings like "RBC Parameters"), **NOT `lab_sections`** (a code-reviewer catch — the wrong table would have nulled valid sub-section assignments).
- `2026_06_27_120001_cleanup_duplicate_specimen_types.php` — maps each shadow (`SPT-<canonicalCode>`) to its canonical by stripping the prefix, repoints `lab_investigations`/`lab_samples`/`lab_retention_policies`, then **soft-deletes** the shadow (per-shadow transaction). Shadows with no canonical (`SPT-WBE/PLC/PLH/URR/U24/SPT/TIS/ASP`) are **left intact** (repointing would orphan them).
- `LabSampleService::aliquot()` **safeguard B** — a member row (`source_panel_id` set, non-culture, same specimen as its panel) folds onto the panel's group key (defense-in-depth vs future catalog drift).
- `LabRequestController::store()` now eager-loads `requestInvestigations.investigation.panelMembers` (the leak's BE half); `LabRequestService::addInvestigation()` stamps `source_panel_id` on member rows (mirrors `CreateLabRequest`) so the safeguard also covers panels added via the `+` button.

**Cultures stay separate** (owner decision): `STCU`/`URCU` (`result_type='culture'`) are excluded from the fold and keep their own sterile tube. Dry-run on elmadina proved the STOOL panel's 15 members collapse to ONE tube (key `30:30`) while cultures stay distinct.

**Verification:** FE `ng build` green + deployed to `/app` (clean, 540 js = build). LIS suites green for formula/aliquot/sample/external/rollup. Native `code-reviewer` ran on BOTH FE and BE passes — **all findings applied** (FE: dead method, dead param, String-coercion, regex, model types, English-external; BE: wrong-table pivot cleanup, missing transactions, multi-panel non-determinism, unconditional section overwrite, `addInvestigation` `source_panel_id` gap). **Codex unavailable on this server (`max_user_namespaces=0`) → native code-reviewer used as the documented substitute.**
- ⚠️ **3 PRE-EXISTING `LabRequestApiTest` failures are NOT from this work** (confirmed failing on the clean baseline via `git stash`): two assert "request creates 0 samples" (stale since the 2026-06-18 server-side sample-gen) + one is a **SQLite-only** `GROUP BY before HAVING` quirk in `LabRequestController::index()` (`paginate(25)`, line ~203) that MySQL tolerates. Worth a separate cleanup; left untouched here for scope discipline.

### 🔎 VERIFIED on elmadina (2026-06-27, live DB read-only via tinker) — migrations already ran; + 2 residual cases
Both migrations are present in elmadina's `migrations` table and the catalog is healed: shadow specimen **id 45 `SPT-STL`** (name_en NULL, "Stool") soft-deleted; all 14 STOOL panel members now at **(specimen 30 STL, section 30)**; even req **LR-2026-00006** (id 6)'s already-aliquoted sample row was repointed 45→30. So a NEW STOOL request now makes ONE tube. The owner's PDF showed two "Stool" tubes (barcodes `…630**30**` + `…630**45**` — suffix = specimen id) because that request was **collected before the heal**; historical tubes are intentionally NOT retro-merged. **Two residuals found by a full read-only scan:**
1. **`MEATFIB`** (STOOL member) was still at specimen **32 (CSF)** / section 37 — migration A's **history guard** skipped it (it has 2 entered results). **FIXED manually (owner-authorized 2026-06-27):** targeted `UPDATE lab_investigations` → specimen 30 + section 30 (results untouched; they link by `investigation_id`). Rollback values: spec 32 / sec 37.
2. **`WATER-t.bact23`** (WATER-bacteriology member): same specimen as its panel (38) but **wrong SECTION** (39 vs panel 55), 0 results → would split into an extra tube. ⚠️ **NOT fixed** (only MEATFIB was authorized; the live-DB write was classifier-denied — needs explicit owner OK). 🔑 **Reveals a migration-A gap: it only triggers on `specimen_type_id` mismatch, NOT a section-only mismatch** — yet the aliquot key is `{section}:{specimen}`, so a same-specimen/diff-section member still splits. A future migration A revision should trigger on EITHER axis. Also 8 leftover `SPT-*` shadow specimens remain (0 refs each, no canonical → migration C correctly left them) — harmless list clutter, optionally soft-deletable.

**Resume / NEXT:** (1) owner runs a MoonStack release + updates **elmadina** → both migrations apply (A+C together) → verify LR-2026-00006 re-collect prints clean. (2) Planned follow-up feature below (owner chose to PARK it 2026-06-27 until the elmadina fix is verified).

### ✅ SHIPPED (2026-06-27) — barcode shows test NAME instead of code, char-limited
**Status: BUILT + reviewed + SHIPPED on `hazemdev`+`main`+`/app` (FE `46750f7`, CHANGELOG BE `d432f02f3`). ⏳ elmadina via update.** Lab Setting (Barcode → "Tests on label" = Code [default] / Test name) + "Test name max chars" (0 = no limit). `barcode_config` is a single JSON blob (FE merges `{...DEFAULT_BARCODE_CONFIG, ...stored}` on read) so **NO migration, NO setting-definition seeder, NO seeder-gap** — new keys default in for existing labs. Mirrors the existing `name_max_chars` (patient-name cap) precedent. Default 'code' → zero change until opted in. UI at `/lab/lab-info` (⚙️ Settings) → Barcode card, with a live preview that reflects mode/cap.

🔑 **Key lesson — there are 7 barcode-print paths, NOT 3.** The "unification" (`LisBarcodeBuilderService`) only covers the wizard + requests-list. The code-reviewer caught that the OTHER paths build `LabelData(V2)` BY HAND and silently showed codes in name mode: **samples** (`buildLabelData`, V1), **collection-worklist** (`buildLabelsBySection` + `printDeferredLabel` + auto-print-deferred), **specimen-receiving** (`print_at_reception`), **requests deferred** (V1), and the **B2B portal** (`portal-barcode-builder.ts`). All now emit `testNames[]` in lockstep with `testCodes[]`. The renderer's `testLabelItems()` is the single code-vs-name+truncate decision. (`reception.component` builds tubes for DISPLAY only — no print — so it was skipped.) Future real unification = migrate all 7 onto the builder. **✅ UPDATE 2026-06-29: the B2B portal path (`portal-barcode-builder.ts`) is now ON the unified `LisBarcodeBuilderService.build()`** (FE `ba6d352`) — it maps the portal request/samples → `BarcodeTube[]` + `BarcodePatientCtx` and inherits panel-name-only + English specimen (`resolveSpecimenName`) + code de-dup + non-searchable exclusion. It was the last hand-built path AND the only one that passed Arabic specimen/section names (font-dependent mojibake risk) — now English-only/font-independent. The 2 portal components inject the builder and pass it `(builder, request, samples)`. Reviewer also caught: Arabic-name leakage guard (`hasArabic` → fall back to code; label font is Latin) must be replicated on every hand-built path; numeric coercion of the char-limit (settings store may serialise as string).

Implementation touch-points (for reference):
1. **`core/services/lis-lab-info.service.ts`** — add to `BarcodeConfig` + `DEFAULT_BARCODE_CONFIG`: `test_label_mode: 'code' | 'name'` (default `'code'` → zero change unless opted in) + `test_name_max_chars: number` (default ~16).
2. **`core/services/lis-barcode-builder.service.ts`** — also emit `testNames: string[]` in lockstep with `testCodes` (English `name_en`; panel → panel name; same dedup; **fall back to code when `name_en` empty**). Add `nameEn?` to `BarcodeInvestigation`.
3. **3 callers** (request-wizard-v2 `printReceptionLabels`, lis-requests `printLabels`, lis-samples `buildLabelData`) — populate `nameEn` when mapping each `BarcodeInvestigation` (the name is already on the test row / request investigation).
4. **`core/services/lis-barcode-label.service.ts`** (renderer, already owns `BarcodeConfig` + width/font) — in the `show_tests` draw block, if `cfg.test_label_mode==='name'` draw `testNames` truncated to `cfg.test_name_max_chars`; else `testCodes`. Add `testNames?: string[]` to `LabelDataV2`.
5. **`features/lis/lab-info/`** barcode-settings form + `barcode-label-preview.component` — a Code/Name dropdown + a max-chars number input bound into `barcode_config`; the live preview already re-renders via the same builder/renderer so the owner tunes the limit visually.
**Constraint:** name is **English-only** (Latin barcode font; Arabic mojibakes — the bug just fixed). **BE:** none — `LabInfoController` stores `barcode_config` as opaque JSON; the 2 keys pass through. Effort ≈ one small FE change + build/deploy; verifiable in the live preview.

---

## ✅ FIXED (2026-06-25) — calculated (`formula`) tests no longer spawn a spurious extra tube/barcode (+ now compute)
**Status: FIXED + verified + committed on `hazemdev` (BE `271e19177`). Reaches clients/elmadina with the next release.** Owner's chosen rule: **a calculated test must live in its inputs' section+specimen** ("المحسوب من قسم واحد"). Full study HTML: `public_html/lis-barcode-formula-study.html`. (History below kept for context.)

**What shipped (BE `271e19177`):**
- **Enforce (validation):** `Modules/LIS/app/Http/Requests/Concerns/ValidatesFormulaCohesion.php` trait on Store/UpdateLabInvestigationRequest → rejects saving a `formula` test whose deps (resolved from `formula_dependencies` + `{CODE}` tokens in the formula text, company-scoped) sit in a different `lab_section_id`/`specimen_type_id`. Skips the check on an UPDATE that touches no formula field (so a legacy misconfigured row can still be renamed); `formula_dependencies.*` exists rule now company-scoped.
- **Align existing data (reaches all clients on update):** migration `2026_06_25_221000_align_formula_investigations_to_dependency_section.php` realigns any cross-section formula investigation to its inputs' section (idempotent, conservative — only when inputs agree on one section). + `house_catalog.json` eAG `section_code` BIO→HEM (fresh installs).
- **Defense-in-depth (aliquot safeguard):** `LabSampleService::aliquot()` now folds a formula onto its `formula_dependencies[0]`'s `(section:specimen)` group via a recursive `resolveKey`, and uses a NON-formula group representative (`$rep`) for the tube's section/specimen/barcode. So even a formula that slips past validation (import/seed/direct-DB) rides its dependency's tube → no spurious tube + shared `sample_id`.
- **Verified:** 6 new tests (`LabFormulaTubeTest`) + 55 LIS tests green, 0 regressions. Native code-reviewer applied (2 HIGH: company-scoped exists rule; skip cohesion on unrelated update).
- **moonui_dev_be data-fixed:** eAG → section 1 (HEM); the **8** existing spurious eAG tubes cleaned (pivots+results moved to the HbA1c sibling tube, spurious tubes soft-deleted); backup `db-backups/moonui_dev_be_pre-formula-fix_20260625_*.sql.gz`. **elmadina had 0** spurious tubes → just gets the catalog-align migration on update.
- 🔴 **CRITICAL secondary finding (was hidden):** the spurious tube ALSO broke the formula's own value — formula auto-calc matches `lab_results.sample_id`, so eAG on a different tube than HbA1c never found its source → eAG computed to null. The fix (co-locate on one tube/sample_id) repairs this too.

<details><summary>Original diagnosis (2026-06-21, parked) — kept for context</summary>

🔁 **Reproduced on a REAL request (2026-06-25):** `LR-2026-00408` (req id 98, `moonui_dev_be`) — panel HBA1C602 → HBA1C0 (numeric, sec1) + eAG (formula, sec2) + VITB12 (Serum, sec5) → 4 samples (parent #258 + #259 HBA1C0 + #260 VITB12 + #261 **eAG spurious**). Owner PDF: `c99c90b3-…pdf`.

🔴 **NEW CRITICAL finding — the spurious tube ALSO silently breaks the formula's own result.** Formula auto-calc is keyed by `lab_results.sample_id` (created at `GenerateResultsOnSampleReceived.php:94`; looked up at `LabResultService.php:361-365` + `:529-531`, NO request-level fallback). eAG lands on its own tube (#261) while HBA1C0 is on #259 → different `sample_id` → the formula lookup never finds HBA1C0's value → **eAG computes to null TODAY**. So the fix must make eAG SHARE its dependency's `sample_id` — which removes the extra tube AND repairs the calc in one change.

✅ **CHOSEN FIX (owner decision 2026-06-25) = enforce "a calculated test lives in the same section+specimen as its inputs".** A `formula` investigation must have ALL its dependencies in the SAME `lab_section_id` + `specimen_type_id` as itself, and the system must REJECT otherwise. This is the root fix: with eAG in HBA1C0's section+specimen, the existing `aliquot()` grouping (by `{section}:{specimen}`) co-locates them in ONE tube → no spurious tube **and** shared `sample_id` (formula computes) **and** RI-status/kanban advance naturally (eAG's section == its tube's section) — NO special-casing anywhere. Implementation: **(a)** validation in `Store/UpdateLabInvestigationRequest` (resolve deps from `formula_dependencies` + codes parsed from the `formula` text; 422 if any dep's section/specimen ≠ the formula's); **(b)** catalog data-alignment — set eAG's section+specimen = HBA1C0's (moonui sec 2→1, elmadina sec 37→HBA1C0's) + audit all 13 formula tests; **(c)** OPTIONAL defense-in-depth — keep a thin `aliquot()` safeguard that folds any formula onto its `formula_dependencies[0]`'s group (so a cross-section formula slipping past validation via import/seed/direct-DB still can't spawn a tube). The older "Variant A" (fold in aliquot, leave eAG in section 2) is now just (c); it alone needed an RI-status nudge — the owner's section-alignment makes that unnecessary. Variant B (drop formula → rides parent) is UNSAFE (eAG gets a different `sample_id`/no result). Study HTML has the full plan.

📊 **Scope:** `moonui_dev_be` has **8** existing spurious eAG tubes (reqs 52,57,64,75,78,80,82,98) needing a data-fix; `elmadina_db` has **0** (but imminent — analyzers repointed there). 13 formula investigations in each catalog.

🏷️ **Label "HBA1C602, VITB12" anomaly explained:** the requests-list print path `lis-requests.component.ts:994` (printLabels) is pivot-blind — it re-derives label codes from the REQUEST's investigations grouped by section, and the eAG tube's section (Biochemistry) has only a panel-member → no `sectionTestMap` entry → falls back to ALL request codes (`:1070`). Disappears once the spurious tube is gone; the pivot-blind print path is a separate latent FE issue.

✅ **No double-generation** — `generateForRequest` (idempotent guard `LabSampleService.php:537`) + `LabSampleController::store` idempotent (committed `5a5be2cd9`). The 4 tubes come from ONE aliquot pass, not two calls.

⚠️ Do **not** implement until owner says go (the design + diffs are in the study HTML + Agent reports).

**Symptom (owner, with PDF):** request **LR-2026-00374** prints **3** barcode labels when it should print **2**. Each individual barcode is valid, but there's an extra one "with the two tests together". (PDF: `cd9cfb39-26a5-4852-aa75-7abb3f8204aa.pdf`.)

**Root cause (confirmed on data — present in BOTH `moonui_dev_be` and the clone `moonui2_dev_be`, request id=64):**
The request contains the panel **`HBA1C602`** (inv 2764) which expands to two members in **different lab sections**:
| inv | code | result_type | section | specimen |
|----|------|-------------|---------|----------|
| 9    | HbA1c | numeric | 1 Hematology | 1 Whole-Blood-EDTA |
| 3924 | **eAG** ("mean blood glucose 3mo") | **`formula`** = `(28.7*{HBA1C0})-46.7`, deps `[9]` | **2 Biochemistry** | 1 Whole-Blood-EDTA |
| 151  | VIT_D | numeric | 5 Hormones | 2 Serum |

`eAG` is a **calculated** test (no physical specimen — its value is derived from HbA1c). But `LabSampleService::aliquot()` groups billable investigations by **`(lab_section_id, specimen_type_id)`** and its filter only drops **outsourced** + **deferred** — it does **NOT** drop `result_type='formula'`. Because eAG sits in a *different section* (Biochemistry) than its source HbA1c (Hematology), it forms its own group → its own child tube, for the same blood draw.

**Sample tree actually created (req 64):**
```
parent 143 (no specimen/section — NOT printed)
 ├─ 144  sec1 Hematology   spec1 WB-EDTA → HbA1c  barcode …461  ✅
 ├─ 145  sec5 Hormones     spec2 Serum   → VIT_D  barcode …465  ✅
 └─ 146  sec2 Biochemistry spec1 WB-EDTA → eAG    barcode …462  ❌ spurious (calculated test)
```
Scope: 3 formula-only child tubes exist in `moonui_dev_be` so far (recent). 13 `formula` investigations in the catalog.

**Where it lives (code):** `Modules/LIS/app/Services/LabSampleService.php` → `aliquot()` (method at ~`:602`), the `$investigations = LabRequestInvestigation::…->filter(...)` block — add an exclusion / regroup for `result_type === ResultType::Formula`.

**Proposed fix (recommended = "ride the dependency's tube"):** a `formula` investigation must NOT spawn its own `(section,specimen)` group; instead fold it into the group of its **formula dependency** (`formula_dependencies[0]` → eAG rides HbA1c's tube 144). Result = 2 tubes; eAG still gets a real child tube so the result lifecycle is unchanged.
- ✅ **Verified safe for computation:** formula results recompute at the **request** level — `LabResultService::recomputeFormulasForRequest($requestId)` / `evaluateFormula($inv,$requestId,$sampleId)` operate on `LabResult` rows by `lab_request_id`, not tied to a specific child tube (`LabResultService.php:387,459`). So removing eAG's *own* tube doesn't break its value.
- ⚠️ **Open risk to check before coding the simplest variant:** if we instead leave the formula test on the **parent** sample (simplest exclusion), confirm the result-seeding path creates a `LabResult` for parent-pivot rows — otherwise eAG could vanish from the report. (Riding the dependency's child tube avoids this entirely.) Did NOT finish tracing where `LabResult` rows are first materialised from `lab_sample_investigations`.
- Alt framing: it may also be a **catalog mis-config** — eAG is in Biochemistry but its panel/source HbA1c is Hematology; aligning eAG's section to Hematology would also collapse the tube. Best to do BOTH (robust code + fix the data), so config drift can't reintroduce it.

**Label-text anomaly to re-verify when we return:** on the PDF, the eAG tube's label (barcode …462) printed the test line **"HBA1C602, VIT_D"**, but that sample's pivot (`lab_sample_investigations`) holds only **eAG**. The FE label "tests" line did not match the tube's pivot — investigate `printLabelsV2` / the samples API label rendering separately (it disappears once the spurious tube is removed, but the rendering mismatch is worth understanding).

**Resume checklist:** (1) get client's answer on calculated-test tubes; (2) implement the regroup in `aliquot()` + a phpunit (sqlite) test asserting a panel with a cross-section formula member yields N−(formula count) tubes; (3) finish tracing `LabResult` materialisation; (4) data-fix the 3 existing spurious tubes + optionally re-section eAG; (5) native `code-reviewer` + Codex review before commit on `hazemdev`. Owner test users: `hazem@gt4it.com`/123456789.
</details>

---

## ✅ FIXED (2026-06-18) — root cause confirmed + 4 changes shipped
**Root cause (confirmed on data):** the FE fire-and-forget tube-creation call failed for **branch-less users**. `LR-00356/00357` were created by **Aghsan adel (user 19)** who has **NO branch** → requests stamped `branch_id=NULL` → the FE post-save steps (sample-gen call + payment routing, both branch-dependent) failed → 0 samples → "No collected samples". `LR-00358` was created by Ahmed (user 3, has branches 5/8/13) → worked. Payment does NOT gate sample-gen (`recordPayment` always calls `onOrderSuccess`); the missing branch is the common root of both "unpaid" and "no samples".
**Fixes (BE in live tree + FE rebuilt to /app; uncommitted → ship next build):**
1. **Server-side generation** — `LabSampleService::generateForRequest()` (parent tube + `aliquot()` section children + external tubes, idempotent) called from `CreateLabRequest` before the final load → every request gets tubes regardless of client/branch/permission. `LabSampleController::store` made idempotent (returns existing tubes for the request) so the FE's `onOrderSuccess` call can't duplicate. **Verified:** backfilled the 2 stuck requests → 4 samples each.
2. **Branch fallback** — `User::primaryBranch()` now falls back to the company **main branch** (`is_main`, else first branch) when the user has none → no more null `branch_id`. Verified: Aghsan → `#8 Elrayad is_main=1`.
3. **User-add requires a branch** — `StoreUserRequest.branch_ids` `nullable`→`required|array|min:1`; FE `users.component` form `branch_ids` gets `Validators.required`.
4. **Install seeds a main branch** — `Installer::createCompanyAndAdmin()` find-or-creates the company's main branch + assigns the admin (every install has ≥1 branch from day one).
5. **Barcode lumps per USER (data-scope visibility)** — separate from the no-samples bug. `aghsan@a.com` (user 19) has the **`own` data scope** (sees only `created_by = self`). `LabSampleService::aliquot()` created the section CHILD tubes with `created_by = auth()->id()`, which is **NULL when aliquot runs without an auth user** (server-side `generateForRequest` / backfill / job). So children had `created_by=NULL` → invisible to an `own`-scoped user → her barcode print saw only the parent → **lumped every section onto one label**; admin (`all` scope) saw the children → split. **Fix:** `aliquot()` → `created_by = auth()->id() ?? $sample->created_by` (children inherit the parent's creator) + a one-time data fix (`UPDATE lab_samples c JOIN lab_samples p ON p.id=c.parent_id SET c.created_by=p.created_by WHERE c.created_by IS NULL`). NOTE: the barcode "rules" (per specimen×section split, what shows, format) all live in ONE config (`BarcodeConfig` in lab-info, applied uniformly by `printLabelsV2`) — the lumping was NEVER a settings issue; it was the child samples being invisible to the printing user.

Data note: the moonui company has TWO `is_main=1` branches (#2, #8) — should be one. All logged in `docs/moonstack/CHANGELOG.md`.

## TL;DR
`lab_samples` rows are **NOT auto-created when a `lab_request` is created.** Sample generation is a
**separate, front-end-triggered** step (fire-and-forget in the browser). If that step never runs (alt
creation path, browser error/navigation, missing permission), the request is committed `pending` with
**zero samples**, and barcode print fails with **"No collected samples"** (`LIS.SAMPLES.NO_SAMPLES`).

## What creates `lab_samples` rows (Backend, explicit)
- **Parent tube** — `LabSampleController::store()`
  - `Modules/LIS/app/Http/Controllers/LabSampleController.php:178` (auto_split parent) / `:207` (plain).
  - With `auto_split=true`: one pending parent (`parent_id`/`lab_section_id` NULL) + barcode via
    `LabSampleService::generateBarcode()` (`LabSampleService.php:726`). Then `SampleInvestigationService::createForSample()` fills the per-test pivot.
- **Child (sectional) tubes** — `LabSampleService::aliquot()`
  - `Modules/LIS/app/Services/LabSampleService.php:533-700` (child create at `:635`).
  - Groups the request's billable investigations by `(lab_section_id, specimen_type_id)`, one child per group.
  - Child barcode = parent barcode + section suffix.
- **Backend does NOT auto-generate samples on request creation:**
  - `Modules/LIS/app/Actions/CreateLabRequest.php` has **zero** `LabSample` refs (only optional `auto_generate_invoice`, lines 294-300).
  - `Modules/LIS/app/Providers/EventServiceProvider.php` — no listener on request-created; all sample listeners fire *after* a sample exists.

## What TRIGGERS it (the crux)
The **active** request wizard is **request-wizard-v2** (wired in `app.routes.ts:204-205` and
`lis-standalone.routes.ts:142-152`; v1 `lis-request-wizard` is no longer routed).
After a successful order, `RequestWizardV2Component.onOrderSuccess()`
(`src/app/features/lis/request-wizard-v2/request-wizard-v2.component.ts:1714-1779`) calls:
1. `sampleSvc.create({ lab_request_id, patient_id, auto_split:true, investigation_ids })` (line 1737) → parent.
2. `sampleSvc.aliquot(s.id)` per non-external parent → children.

This chain is **decoupled from the committed request** and **fire-and-forget**: the error branch still
`navigateAfterSuccess()`. So the request can be saved with no tubes.

## The barcode-print gate (only checks row existence)
`printLabels()` in `src/app/features/lis/requests/lis-requests.component.ts:994-1005`:
- `sampleService.listByFilter({ lab_request_id, per_page:50 })` →
  if `allSamples.length === 0` → warn toast `LIS.SAMPLES.NO_SAMPLES` and `return`. It **reads only**, never creates.
- Gate keys on **row existence only** — NOT on `status` or `collected_at`. "collected" in the message is a misnomer.

## Lifecycle
1. Create request → `CreateLabRequest` (request + optional invoice). **No samples.**
2. Generate tubes (FE `onOrderSuccess`) → `POST /lis/samples (auto_split)` + `aliquot`. Rows written `status=pending, collected_at=NULL`.
3. Print barcode → `printLabels()` reads existing rows.
4. Collect (later, independent) → `LabSampleService::collect()` flips `status→Collected`, stamps `collected_at`.

**Normal intended state:** a `pending` request whose tubes exist but are not yet collected
(`collected_at=NULL`). A pending request with NO tubes is the anomaly.

## Evidence case (moonui_dev_be, 2026-06-18)
| req | created_by | n investigations | n lab_samples | barcode print |
|-----|-----------|------------------|---------------|---------------|
| LR-2026-00356 (id46) | 19 Aghsan adel | 24 | 0 | FAIL |
| LR-2026-00357 (id47) | 19 Aghsan adel | 8  | 0 | FAIL |
| LR-2026-00358 (id48) | 3 Ahmed | 3 | 2 (parent 93 + child 94/sec5) | WORKS |
- All three `pending`, `walk_in`, **all investigations fully configured** (specimen + section non-null on all).
  Failing ones are even richer in sections → config is NOT the cause.
- 00358 samples: parent `26061800128` + child `260618001285`, both `collected_at=NULL`, created 3s after the request.
- with-trashed = 0 for 46/47 → samples never created (not deleted).

## No setting governs this
No `lis.auto_generate_samples` (or equivalent) exists. `lis.auto_generate_invoice` = invoice only.
`lis.require_payment_before_sample` gates *collection*, not generation.

## Fix direction (recommended)
Move sample generation to the **Backend on request creation** (in `CreateLabRequest` or an event/listener,
same transaction), reusing `LabSampleService::aliquot()`. Guarantees tubes exist for every request
regardless of creation path / browser failure; keeps `printLabels()` a pure reader.
Immediate workaround: run `POST /lis/samples (auto_split)` + `aliquot` for the stuck requests.

## Full report
`knowledge-base/plans/lab-barcode-no-samples-investigation.html`
