# PCM BR Compliance Audit — Rounds 1–5

Independent audit of `docs/pcm-requirements-map.md` claims against the BR wording (`docs/pcm-br.txt`, v1.0 02.03.2026) and the actual code. Read-only: no code was modified. Evidence cites `file:line` or grep output. All greps were run across `app/`, `resources/js/`, `routes/`, `database/migrations/`, `tests/`.

Verified: **2026-08-21**. DB state checked live (`pricelists`: 2 rows, status `Current`).

---

## 1. Verification table

| # | BR item | Claimed in map | Verified | Evidence | Recommended map correction |
|---|---|---|---|---|---|
| 1 | **P014** Status Draft/Current/Past/**Archive** + "possibility to add more" | EXISTING+, documented label mapping (BR *Archive* → DB verb *Archived*) | **OVERSTATED (core OK, extensibility missing)** | CHECK constraints hard-code `'Current','Draft','Past','Archived'` on all 6 tables (`database/migrations/2026_08_10_000005…000010_*`, e.g. `_000008_create_engines_table.php:34`); single vocabulary `CatalogStatus::ALL` (`app/Services/Catalog/CatalogStatus.php:27-32`); PATCH validation `Rule::in(CatalogStatus::ALL)` (`app/Http/Requests/Catalog/StatusUpdateRequest.php:17`) → literal "Archive" gets a 422, asserted by test `tests/Feature/Catalog/TrimEditingTest.php:123-128`; Excel import silently normalizes `archive`→`Archived` (`app/Services/Catalog/ImportExport/PricelistImporter.php:282`); UI offers a fixed Select, never free text (`resources/js/components/shared/entity-editing-actions.tsx:25`, filter chips `pricelists-index-view.tsx:216`) | Keep EXISTING+ for the four statuses + mapping, but add: *"extensibility NOT implemented — adding a status requires a migration per table + code change"* and record the open business decisions (label, Past↔Archive semantics) |
| 2 | **P009** "publish **all pricelists** (all languages) at once … reduce clicks" | EXISTING+ ("jeden publish pokrywa wszystkie języki") | **OVERSTATED → PARTIAL** | Publish is strictly per-pricelist: `PricelistService::publish(Pricelist $pricelist, …)` (`app/Services/Catalog/PricelistService.php:65`), route `pricelists/{pricelist}/publish` (`routes/web.php:198`), one Publish button on the show page only (`resources/js/components/views/pricelist-show-view.tsx:373-389`); pricelists index has **no** publish action at all. Grep `bulk|publish_all|publishAll` over `app/` → **0 matches**. Trilingual-data argument covers "all languages", not "all pricelists" | Downgrade to PARTIAL: gap = one-click publish of all pricelists (per brand) |
| 3 | **P010** Excel export; "Download all Pricelists (all languages) at once" | EXISTING+ (xlsx per pricelist + export-all zip) | **OK (with defects)** | `PricelistFilesController::exportAll()` streams a zip with one xlsx per pricelist (`app/Http/Controllers/Catalog/PricelistFilesController.php:68-86`), route `pricelists/export-all` (`routes/web.php:69`), UI button "Export all" (`pricelist-show-view.tsx:469-479`); features carry DE/FR/IT columns (`app/Services/Catalog/ImportExport/PricelistExporter.php:116,128-130`). One click satisfies "reduce clicks". Defects found: **Trims sheet Name_DE/FR/IT cells are empty** — `$trim['name']` is an array `{de,fr,it}` written into a single cell (`PricelistExporter.php:66-74`); verified live via tinker: row = `[A=>key, B=>NULL, C=>NULL, D=>NULL, E=>'Current', F=>1]`. Engines sheet is DE-only (`PricelistExporter.php:82`); Variants sheet omits disclaimers (`:98`) | Keep EXISTING+; add defect note: trim names dropped in xlsx Trims sheet (fix required), engine names DE-only |
| 4 | **P013** "–", ●, "1'000 CHF", ○, **P**, exploded-columns setting (default yes) | PARTIAL ("exploded-columns setting — do zrobienia"); Packages row additionally claims "**UI**: P marker + PackageManager" | **Map P013 row: OK · Packages-row claim: OVERSTATED** | Symbols: `PricelistService::symbol()` Standard→● Optional→○ else – (`app/Services/Catalog/PricelistService.php:280-287`, test `tests/Feature/Catalog/PricelistTest.php:93`); rendered in HTML (`pricelist-show-view.tsx:602-604`) and PDF (`resources/views/catalog/pricelists/pdf.blade.php:78`). Price: `formatPrice` = `Intl.NumberFormat('de-CH',{style:'currency',currency:'CHF'})` → "1'000 CHF" (`resources/js/lib/catalog.ts:88-94`). **"P" marker is rendered ONLY in the PDF** (`pdf.blade.php:80-82`); grep `in_package` over `resources/js/` → only the type declaration `types/catalog.ts:651`; the HTML feature table renders symbol+name+code, no P (`pricelist-show-view.tsx:594-618`). Exploded-columns setting: grep `exploded` everywhere → **0 matches**; view is grouped-by-category only. Also: missing price renders **"0 CHF"**, not "–" (HTML `variant.price ?? 0` `pricelist-show-view.tsx:572-575`; PDF `(float)$variant['price']` → `0` `pdf.blade.php:57`) | Fix Packages-row claim to "P marker in PDF only"; add to P013 gap list: no exploded/grouped PM setting (BR default = exploded), null price shows 0 instead of – |
| 5 | **P019** Model name per DWS; 3 languages "**not in tabs** but next or under each other" | PARTIAL ("locale switch UI. Trims lack localized names."); related P006 row claims "side-by-side name_de/fr/it in views" | **P019 row: OK · P006 row claim: OVERSTATED** | Model detail shows ONE locale-picked name + `LocaleSwitch` (`resources/js/components/views/model-detail-view.tsx:32,54-56,77`); `LocaleCoverageChips` are ✓/— coverage flags, **not** names (`resources/js/components/shared/locale-chips.tsx:15-40`); entity detail same pattern (`entity-detail-view.tsx:142-151,290`). No surface displays three names next/under each other; PDF header prints the model **key**, no localized name (`pdf.blade.php:33`). All three names coexist only inside data payloads/xlsx (e.g. `PricelistService::names()` `:268-275`; exporter meta JSON `:43-47`). A locale switch is exactly what BR excludes ("not in tabs") | Sharpen P019 gap: "trilingual side-by-side display implemented nowhere; current UX = locale switch, which BR explicitly rules out". Correct P006 "side-by-side … in views" claim |
| 6 | **P023** Inheritance of changed information (example: trim/engine status → variants) | EXISTING+ (status propagation trim/engine → variants) | **OVERSTATED → PARTIAL** | Implemented exactly once: `TrimEngineService::changeStatus()` updates `variants.status` via `trim_id`/`engine_id` (`app/Services/Catalog/TrimEngineService.php:97-108`, tests `TrimEditingTest.php:107-121`). Nothing else propagates: name/rename, prices, disclaimers, sort_order, model/body-type-level changes have no cascade (no other propagation code exists in `app/Services/Catalog/`). BR asks generally: "changed information … adapted accordingly in other (connected) places" — only the canonical example is covered | Relabel EXISTING+ → PARTIAL: "status-only (the BR example); general info inheritance not implemented" |
| 7 | **P007** Modular brand access ("PMs have access to their own brand(s)") | PARTIAL ("modular per-brand PM access nadal MISSING") | **OK (claim honest)** | Gates: `view-catalog` = every authenticated user, `manage-imports` = admin, `edit-catalog` = admin; `Gate::before` admins bypass everything (`app/Providers/AppServiceProvider.php:66-74`). Roles = `admin`/`viewer` only. Grep for brand-scoped gate/policy over `app/` → none; comment confirms deferral (`AppServiceProvider.php:71`) | None |
| 8 | **Trims "New categories"**: add/delete + "sequence should follow the feature structure" | EXISTING+ ("kolejność = struktura features") | **OVERSTATED (CRUD OK, sequence claim not true as stated)** | CRUD exists: store/destroy routes (`routes/web.php:194-195`), `StructureCategoriesController` + `CategoryManagementService::create/delete` (trilingual create, unique-name guard, empty-only delete; `app/Services/Catalog/CategoryManagementService.php:19-47`, tests `CategoryManagementTest.php:28-64`). But: `structure_categories` has **no sort_order column** (`database/migrations/2026_08_10_000014_create_structure_categories_table.php:14-34`); pricelist structure orders categories by `id` (`PricelistService.php:141`) so user-created categories always land LAST; the structure browser orders them alphabetically by `code` (`app/Services/Catalog/StructureQuery.php:45,86,135`) — two views disagree, neither exposes a controllable structure sequence. Additionally a non-empty category can never be deleted: there is **no endpoint/UI to remove or move `structure_category_items`** (only category routes exist in `web.php`) | Change to PARTIAL: "create/delete exist; delete blocked for any category containing items (no item-removal flow); category ordering = insertion id in pricelist / alphabetical in browser — does not follow an editable feature-structure sequence" |
| 9 | **P022** Selection of important standard equipment (per trim/engine, "additional to engine xy", configurator reuse) | MISSING ("feature_preview is a read-only aggregate, not PM-managed") | **OK (claim honest)** | Grep `important|standard equip|key_features|highlight` over `app/`, `database/migrations/`, `tests/`, `resources/js/` → **0 relevant matches**. `ModelFeaturePreview` is an availability-count aggregate (`app/Services/Catalog/ModelFeaturePreview.php`), not a selection store; no migration/table/column exists for it | None |
| 10 | **P016** Test Drive **and** Offer Request flags, settable by PM, connected to DWS | PARTIAL ("test_drive imported + displayed; no write flow; Offer Request flag not modeled") | **OK (claim honest)** | `body_types.test_drive` bool cast + fillable (`app/Models/BodyType.php:31,93`), displayed as chip (`model-detail-view.tsx:194-201`), exposed in resources (`VehicleModelDetailResource.php:66`). Grep `offer_request|offered` over `app/` + `resources/js/` → **0 matches**: flag completely unmodeled. Neither flag has any write route (nothing under `can:edit-catalog` touches it, `routes/web.php:182-209`) | None (optionally stress that even test_drive is read-only, so "PMs can SET" is 100 % missing) |

**Summary:** 5 claims verified OK/honest (#3, #7, #9, #10, P013 main row), 5 contain overstated wording (#1 extensibility, #2 P009, #4 Packages "UI: P marker", #5/#6 via P006+P023 rows, #8 sequence). No UNDERSTATED claims found — where the map says MISSING/BLOCKED, the code agrees.

---

## 2. Real divergences from BR wording (need code change or business decision)

### Business decisions
1. **P014 label & semantics.** DB/DB-API enforce `Archived`; BR says `Archive`. UI/import shield users today, but any direct API consumer typing "Archive" gets a 422, and the importer silently renames it. Decide: rename stored value to match BR wording (migration + constraint rebuild), or bless the mapping formally. Related: the operational difference between `Past` and `Archive(d)` is undefined anywhere (`CatalogStatus.php:8-10`) yet drives badges/filters — must be defined before P024-style archive flows can be trusted.
2. **P014 extensibility.** BR: "Possibility to add (if needed) more." Statuses are baked into six CHECK constraints + a PHP const. Accept a closed vocabulary, or move to a lookup table if extensibility is a real requirement.
3. **P023 inheritance scope.** Status-only propagation (trim/engine → variants) matches the BR example but not the general rule. Confirm whether name/price/disclaimer/order changes must also cascade before more rounds build on this.
4. **Category deletion restriction.** Empty-only delete + no item-removal UI means most real categories are permanently locked. Either ship item management or relax deletion (cascade/unlink) — business call.
5. **Null price display.** BR P013 defines "–" for unavailable; implementation renders "0 CHF"/"0". Confirm "–" is wanted for price-less variants (likely yes) — trivial fix afterwards.

### Code changes
6. **P009:** no bulk publish. Add e.g. `POST pricelists/publish-all?brand=…` reusing `PricelistService::publish` in a transaction; surface on the pricelists index (which currently has no publish control at all).
7. **P013:** implement the PM setting "exploded columns yes/no" (BR default: exploded=yes); render the "P" marker in the HTML preview (payload already carries `in_package` — `types/catalog.ts:651` — the view just ignores it).
8. **P019/P006:** provide a genuine side-by-side (or stacked) DE/FR/IT name display on model detail pages; the locale switch contradicts the BR's explicit "not in tabs" requirement.
9. **P010 exporter bug:** `sheetTrims()` writes the `{de,fr,it}` array into a single cell so trim names are lost (verified: B/C/D = NULL). Write three cells. While there: engines sheet DE-only, Variants sheet without disclaimers.
10. **P016:** model `offer_request`; add write flows for both flags (currently read-only from legacy import).
11. **Categories sequence:** add an explicit sequence (sort_order or position-by-structure) honored consistently by both the browser (currently alphabetical) and the pricelist renderer (currently id order).

### Already-blocked / documented elsewhere (no action here)
- P008 Accessories, P012 Connectivity, P021 QR target URL, P026 Salesforce contract, decimal separator — external dependencies, correctly listed as blockers in the map.
