# Phase 5 implementation report

**Status:** Implemented against `docs/PHASE5_PLAN.md` and binding decisions **P5-D1–P5-D17**.  
**Baseline:** `phase-4-final-approved` on `7e6a00d2dd022d5b533d007c2f9c0cbb2fea66dc` (233 tests / 7,234 assertions / 0 failures).  
**Suite after Phase 5:** **263 tests / 9,002 assertions / 0 failures**.  
**Phase 6:** not started.

Stack unchanged: CodeIgniter 4, PHP 8.2+, MySQL 8 InnoDB, server-rendered Views. No React, Next.js, NestJS, PostgreSQL, SPA conversion, or `AUDIT.EXPORT` on RPT-SCR-013.

---

## 1. Files changed

### Added

- `app/Config/Export.php`
- `app/Database/Migrations/2026-09-13-500001_AddExportJobPhase5Columns.php`
- `app/Commands/CleanupExports.php`
- `app/Controllers/ReportController.php`
- `app/Controllers/ExportDownloadController.php`
- `app/Modules/Report/Domain/{EmptyCopy,ReportCatalog,ReportFilter}.php`
- `app/Modules/Report/Exceptions/UnknownMovementType.php`
- `app/Modules/Report/Services/{CsvSafe,DashboardQuery,DocumentTax,ExportBranding,ExportThreshold,GstSummaryQuery,InventoryPositionQuery,MovementClassifier,ReportDateRange,ReportQuery}.php`
- `app/Modules/Ui/Phase5Frames.php`
- `app/Views/pages/dashboard/index.php`
- `app/Views/pages/report/{hub,show}.php`
- `app/Views/components/report/empty.php`
- `docs/PHASE5_IMPLEMENTATION_REPORT.md` (this file)
- Tests under `tests/unit`, `tests/integration`, `tests/feature`, plus `tests/_support/Phase5Support.php`

### Changed

- `app/Config/Routes.php` — health `phase => 5`; reports hub; slug reports; opaque export download
- `app/Controllers/HomeController.php` — authenticated `/` is Dashboard
- `app/Controllers/LedgerController.php` — VIEW 403, EXPORT POST 403, P5-D13 empty copy, half-open dates
- `app/Modules/Report/Services/ExportJobService.php` — server-side count, sync/async, branding snapshot, chunked write, download auth, cleanup
- `app/Modules/Report/Services/LedgerQuery.php` — explicit classification, chunked scan, SQL count, presentation columns
- `app/Modules/Ui/FrameCatalog.php`, `FrameSimulation.php`
- `app/Views/pages/report/ledger.php`, `app/Views/layouts/app.php`, nav sidebar, `public/css/app.css`
- `app/Common/DisplayTime.php` — `formatQty`
- `app/Commands/ProcessExports.php`
- `README.md`, `docs/PHASE5_PLAN.md`
- `tests/feature/AuthHttpTest.php`, `tests/integration/LedgerExportTest.php`

---

## 2. Migrations added

`2026-09-13-500001_AddExportJobPhase5Columns` — additive on `export_job`:

| Column | Purpose |
|---|---|
| `branding_snapshot` JSON | P5-D11 immutable branding |
| `download_token` CHAR(64) UNIQUE | P5-D17 opaque download id |
| `expires_at_utc` DATETIME(6) | P5-D17 expiry (default 30 days) |
| `delivery_mode` VARCHAR(16) | SYNCHRONOUS / ASYNCHRONOUS |

`down()` drops only these columns. No Phase 0–4 tables rebuilt.

---

## 3. Routes added/changed

| Method | Path | Notes |
|---|---|---|
| GET | `/` | Dashboard when authenticated; login redirect when not (unchanged guest behaviour) |
| GET | `/reports` | Report hub |
| GET | `/reports/(:segment)` | RPT-SCR-001/002/004–013 |
| POST | `/reports/(:segment)/export` | `REPORT.EXPORT`; 403 if missing |
| GET | `/reports/material-ledger` | Existing RPT-SCR-003 (registered first) |
| POST | `/reports/material-ledger/export` | Existing; unauthorised POST now **403** |
| GET | `/exports/(:segment)/download` | Opaque token; `REPORT.VIEW` + live snapshotted Company/Plant |
| GET | `/health` | `phase: 5` |

Direct module routes (`/challans`, `/invoices`, `/audit`, …) are unchanged.

---

## 4. Controllers / services / models / views

| Area | Classes |
|---|---|
| Controllers | `HomeController`, `ReportController`, `LedgerController`, `ExportDownloadController` |
| Services | `DashboardQuery`, `ReportQuery`, `GstSummaryQuery`, `InventoryPositionQuery`, `LedgerQuery` (kept), `ExportJobService`, `MovementClassifier`, `DocumentTax`, `ExportBranding`, `ExportThreshold`, `ReportDateRange`, `CsvSafe` |
| Domain | `ReportCatalog`, `ReportFilter`, `EmptyCopy` |
| Views | Dashboard, report hub/show, ledger, shared empty |
| Frames | `Phase5Frames` (92 new) + existing seven RPT-SCR-003 names in `FrameCatalog` |

`LedgerQuery` is not replaced. Permission catalogue and seeders are not modified.

---

## 5. Thirteen-report status matrix

| Screen | Slug | Status |
|---|---|---|
| RPT-SCR-001 | `challan-register` | Working |
| RPT-SCR-002 | `invoice-register` | Working — INV-119 remains ₹177,592.00 |
| RPT-SCR-003 | `material-ledger` | Integration only — closing **116.000 MT** |
| RPT-SCR-004 | `client-billing` | Working |
| RPT-SCR-005 | `vendor-inward` | Working |
| RPT-SCR-006 | `inventory-position` | Working — closing **116.000 MT**; physical qty only |
| RPT-SCR-007 | `wastage-analysis` | Working |
| RPT-SCR-008 | `rejection-disposition` | Working |
| RPT-SCR-009 | `gst-summary` | Working — IRN GENERATED only; CRN-031 golden |
| RPT-SCR-010 | `credit-note-register` | Working |
| RPT-SCR-011 | `invoice-reservations` | Working |
| RPT-SCR-012 | `statutory-deadlines` | Working |
| RPT-SCR-013 | `audit-activity` | Working — `REPORT.VIEW` / `REPORT.EXPORT` only; no Edit/Delete |

All 13 share Empty copy:  
Headline `No data available for the selected filters`  
Supporting `Adjust the date range or filters and try again.`  
Action `Clear all filters`

---

## 6. Six Dashboard KPI definitions

| # | Card | Definition |
|---|---|---|
| 1 | Today’s Challans | Count and quantity for today’s Asia/Kolkata interval; exclude `CANCELLED_VOID` |
| 2 | Today’s Invoices | Count and `formatMoney(SUM(grand_total))`; exclude `CANCELLED` |
| 3 | Unbilled Challans | Eligible rows from existing `UnbilledChallanQuery` |
| 4 | Reserved Invoice Numbers | `reservation_status = RESERVED` in scope |
| 5 | Credit Notes Awaiting IRN | Distinct CNs with **APPROVED** or **PENDING_IRN** only (P5-D8) |
| 6 | IRN Recording Failures | **Documents** (Invoice or Credit Note) whose latest attempt is FAILURE, still Pending IRN, no later SUCCESS (P5-D7) |

Quick Actions (permission codes only, never role names): `CHALLAN.ADD` → Create Challan; `INVOICE.ADD` → Create Invoice; `REPORT.VIEW` → View Reports. Missing actions are omitted (no empty gaps).

---

## 7. Export sync/async behaviour

- Count is server-side from the same normalised filters and Company/Plant/role scope. Client `row_count` is ignored.
- `export.sync_max_rows` default **5000**. 1–5000 inclusive → synchronous (exactly 5000 sync). 5001+ → asynchronous ExportJob (exactly 5001 async).
- Async/ledger writes stream in `chunkSize` (500). Registers page by chunk. Inventory/GST aggregate without holding every movement row for classification beyond the current chunk.
- Existing ExportJob retry remains: FAILED stays FAILED until the job is REQUESTED again and `php spark exports:process` runs. Copy never uses IRN retry language.
- CSV formula injection: leading `= + - @` prefixed with `'`, except signed money (`+₹` / `−₹`) and signed quantities.
- Files in `writable/exports/` (outside public root). Opaque `download_token`. No public URLs. No scheduled email.
- Request needs `REPORT.EXPORT`. Download needs current auth, `REPORT.VIEW`, and **current** access to the snapshotted Company and Plant.
- Expiry default 30 days (`export.expiryDays`). `php spark exports:cleanup` deletes expired files and nulls `file_reference`; job and audit rows remain.
- Audit: `REPORT_EXPORT_REQUESTED`, `REPORT_EXPORT_COMPLETED`, `REPORT_EXPORT_FAILED`, `REPORT_EXPORT_DOWNLOADED`.

---

## 8. Permission matrix verification

| Role | DASHBOARD.VIEW | REPORT.VIEW | REPORT.EXPORT | Notes |
|---|---|---|---|---|
| Super Admin | Yes | Yes | Yes | All Quick Actions |
| Plant Manager | Yes | Yes | Yes | Challan + Reports; no Invoice add |
| Accounts Manager | Yes | Yes | Yes | Invoice + Reports; no Challan add |
| Billing Clerk | Yes | Yes | **No** | Export control removed; POST 403 |
| Store Keeper | Yes | **No** | **No** | Report routes 403; Dashboard without report Quick Actions |

Active role only. Missing `REPORT.VIEW` → HTTP 403 and no report payload. RPT-SCR-013 does not use `AUDIT.EXPORT`. `AUD-SCR-001` is unchanged.

---

## 9. GST golden-case verification

IRN status **GENERATED** only. Invoice GST is positive from snapshots. Credit Note GST is negative from CN snapshots (intra CGST+SGST, inter IGST).

**CRN-2026-000031** (intra-state):

| Line | Signed amount |
|---|---|
| Taxable Value | **−₹63,634.50** |
| CGST | **−₹5,727.11** |
| SGST | **−₹5,727.11** |
| IGST | **₹0.00** |
| Total Tax | **−₹11,454.22** |

**INV-2026-000119** stored/displayed **₹177,592.00** is not mutated. Invoice Count counts IRN-generated Invoices only (Credit Notes excluded).

---

## 10. Inventory Position movement-classification matrix

| `movement_type` | Column | Physical Closing |
|---|---|---|
| `OPENING_STOCK_INWARD` | Opening | + qty |
| `VENDOR_INWARD` | Inward | + qty |
| `PLANT_PRODUCTION_INWARD` | Production | + qty |
| *(none)* | Consumed | 0 in Phase 5 |
| `ALLOCATION_DISPATCH` | Despatched | − qty |
| `RETURNED_TO_AVAILABLE` | Returned | + qty |
| `RETURN_RECOVERY_INWARD` | Returned | + qty |
| `WASTAGE_FROM_*` (3 types) | Wastage | − qty |
| `REVERSAL` | Adjustments | signed reverse of original physical effect |
| `REJECTION_RECOVERY` | Bucket transfer | 0 |
| `REASSIGNMENT_RESERVATION` | Bucket transfer | 0 |
| `REASSIGNED_DISPATCH` | Bucket transfer | 0 |
| Unknown | Fail | `UNKNOWN_MOVEMENT_TYPE:…` |

Closing = Opening + Inward + Production − Consumed − Despatched + Returned − Wastage + Adjustments.

Adjustment drill-down lists adjustment `movement_id` and `linked_original_movement_id`.

---

## 11. RPT-SCR-003 and RPT-SCR-006 closing values

Both close at **116.000 MT** on Apex RMC / Chakan / M30 (unfiltered).

RPT-003 five-card summary (existing grouping, P5-D12): Opening 120 / Inward 25 (includes plant production) / Despatch −30 / Return 3 / Wastage −2 / Adjustments 0.

RPT-006 columns on the same seed (P5-D5 map): Opening 120 / **Inward 0** / **Production 25** / Consumed 0 / Despatched 30 / Returned 3 / Wastage 2 / Adjustments 0 / Closing **116.000**.

A `VENDOR_INWARD` fixture increments Inward. A `REVERSAL` of dispatch increments Adjustments and Closing. Bucket transfers leave Closing unchanged.

---

## 12. Exact 99-frame implementation/test summary

`Phase5Frames::phase5Canonical99()` is FR-0470–FR-0568 in plan order (83 desktop, 14 mobile, 2 tablet). RPT-SCR-003’s seven names remain in `FrameCatalog`. HTTP test asserts each frame name, viewport class/size, and state (Empty / Loading / Error / Filtered / PermissionRestricted / Default). Production ignores `?frame=`.

---

## 13. Responsive verification

- 14 mobile frames asserted at **390×844** (`viewport-mobile`, `data-viewport="390x844"`, drawer shell).
- 2 tablet frames asserted at **1024×768** (`T-DSH-SCR-001-View-Default`, `T-RPT-SCR-006-Report-Default`).
- RPT-006 tablet: Material column pinned; remaining columns in `inventory-scroll`.
- Dashboard KPI grid collapses; Quick Actions wrap with no empty stubs.
- Reports: desktop table + mobile cards (or shared empty state when the plant has no rows).

---

## 14. Security / download verification

- Files under `writable/exports/`, not under `public/`.
- URL uses opaque token only.
- Unauthenticated `/` → login.
- Store Keeper download → 403 (`REPORT.VIEW` missing).
- Expired `expires_at_utc` → `EXPORT_EXPIRED` / 403.
- Plant grant removed after generation → `EXPORT_SCOPE_DENIED`.
- Branding snapshot unchanged after Company `legal_name` edit.
- Cleanup deletes the file, keeps `export_job` + `audit_event`.

---

## 15. Test count and assertion count

| | Tests | Assertions | Failures |
|---|---:|---:|---:|
| Phase 0–4 baseline | 233 | 7,234 | 0 |
| **Phase 0–5 complete** | **263** | **9,002** | **0** |

Mandatory coverage includes threshold 5000/5001, filter/scope count, CSV injection, storage, download authz/expiry/scope, branding immutability, date validation, GST inclusion/CRN-031/Invoice Count, classifier/reversal/bucket/unknown, 003/006 116.000, IRN KPI document counting, six KPIs, Quick Actions, `/` Dashboard vs login, REPORT.VIEW/EXPORT, RPT-013 without `AUDIT.EXPORT`, shared empty copy, no valuation columns, 99 frames, 14 mobile, 2 tablet.

---

## 16. Phase 0–4 regression result

Green. Frozen figures unchanged: ledger **116.000 MT**, allocation 18+12=**30.000**, LOT-2026-00230 remaining **13.000**, disposition 3+7+2=**12.000**, WST-2026-000067 Approved, INV-119 **₹177,592.00**, CRN-031 **₹75,089.00**. `LedgerQuery` retained. Permission seeders unchanged. No `INWARD.APPROVE`. No Phase 6 routes.

Auth home for Store Keeper now shows Dashboard (P5-D14) instead of listing `INWARD.ADD` on `/`. `session/context` still returns active-role permissions including `INWARD.ADD`. Small golden ledger exports complete synchronously (P5-D1) instead of staying REQUESTED until spark.

---

## 17. Deviations or risks

**Seed vs RPT-006 golden column split (documented, not weakened).**  
P5-D5 maps `VENDOR_INWARD` → Inward and `PLANT_PRODUCTION_INWARD` → Production. Plan §12’s M30 table listed Inward 25 / Production 0. Seeded LOT-2026-00230 (25 MT) is `PLANT_PRODUCTION_INWARD` in `TransactionSeeder` (`app/Database/Seeds/TransactionSeeder.php`). Changing the seed or reclassifying production as Inward would violate Phase 0–4 or P5-D5. Implementation keeps the classifier and reports **Inward 0 / Production 25 / Closing 116.000**. A fixture proves `VENDOR_INWARD` increments Inward. Closing matches both reports.

Company/Plant address and legal name are snapshotted from existing master columns (`legal_name`, `address_line_*`). There is no separate “registered address” column on `company`.

Feature-test HTTP download bodies are not always populated by CI4 `DownloadResponse`; file bytes and path are asserted via `ExportJobService::authorizeDownload` plus HTTP 403 for Store Keeper.

---

## 18. Phase 6 confirmation

Phase 6 was **not** started. No Phase 6 plan, screens, routes, or permissions were added.
