# Aumni AMS — Software Architecture Document

**Project:** Aumni AMS (Asset Management System)
**Company:** Aumni Techworks LLP
**Type:** Internal single-company line-of-business application
**Status:** **Approved 2026-08-06** with amendments. Phases 0–10 complete —
every module in §17 is built and shipped. **Version 1.0.0 released** after
RC1 stabilization pass (QA, security, performance, code quality, UI
consistency, documentation) — see `docs/RELEASE_NOTES_v1.0.0.md` and
`docs/CHANGELOG.md` for what RC1 found and fixed.
**Version:** 1.7
**Date:** 2026-08-07

### Revision History

| Ver | Date | Change |
|---|---|---|
| 1.7 | 2026-08-07 | **Version 1.0.0 Release Candidate 1.** Every module already built and approved; this pass is stabilization only. Two real, if minor, defects found and fixed (an unbounded QR bulk-label selection; a latent id-collision risk in `<x-filter.date-range>`), one UI layout inconsistency fixed (Warranty's 5-card KPI row), and one investigated-and-closed security concern (the `local` disk's `'serve' => true` flag was confirmed not exploitable, then removed anyway as defense-in-depth). `docs/USER_GUIDE.md`, `docs/ADMIN_GUIDE.md` and `docs/RELEASE_NOTES_v1.0.0.md` written for the first time. No schema change, no architectural change. |
| 1.0 | 2026-08-06 | Initial architecture proposal |
| 1.2 | 2026-08-06 | **Approved for build.** Testing framework fixed as **PHPUnit** (not Pest). **Asset Attachments removed from V1 entirely** — table, model, controller, routes, views, `AttachmentType` enum and the private-attachments disk all dropped. Net: 13 tables → 12, 18 controllers → 16. |
| 1.1 | 2026-08-06 | **Approved with amendments.** Lookup tables reduced to Departments, Asset Categories, Locations — Manufacturer, Vendor and Designation become plain text fields. Queue removed at launch (imports run synchronously). `ReturnService` merged into `AssignmentService`. Dropped: filter class, domain exception classes, observer classes, view composer, masters base controller, two policies. Reports trimmed 9 → 6. Blameable columns narrowed to assets + employees. Net effect: 16 tables → 13, 21 controllers → 18, 12 services → 10. |
| 1.3 | 2026-08-06 | **Roadmap reordered after Phase 5.** With Assets, Employees and Assignment/Return live, the next real-world need is getting existing company inventory into the system rather than reporting on it. Phases 6–13 resequenced: **6 Import, 7 Warranty + QR Labels (merged), 8 Reports, 9 Dashboard, 10 Settings + Audit Logs UI + Hardening (merged)** — see §18. No schema or module-capability change, ordering only. |
| 1.4 | 2026-08-06 | **Roadmap reordered after Phase 7.** Dashboard and Reports swap places: **8 Dashboard, 9 Reports, 10 Settings + Audit Logs UI + Hardening (merged)** — see §18. The dashboard is the screen every user lands on daily; with every module it summarises already live, building it next is more valuable than deferring it behind Reports. No schema or module-capability change, ordering only. |
| 1.5 | 2026-08-06 | **Reports scope expanded 6 → 22** (§17 module 11, §18 Phase 9), matching an explicit, detailed Phase 9 brief calling for a professional reporting module rather than a export screen — five categories (Inventory, Employees, Assignments, Warranty, Audit), each report with search, filters, a date range, sortable columns, pagination, Excel export, PDF export and a print-friendly layout, through one reusable report viewer (`ReportService` + `ReportController` + a generic `ReportExport`). Same reasoning as the Phase 7 warranty-documents and Phase 8 dashboard-scope amendments: the architecture's original list was the schematic floor, not a ceiling. No schema change — Reports reads existing tables through existing model scopes. |
| 1.6 | 2026-08-06 | **Phase 10 — Settings, Audit Logs, production hardening.** Settings (§17 module 15) and Audit Logs (§17 module 10) both fully built, closing out every module in the original module list. Two genuine, previously-undetected bugs found and fixed during the hardening review: `config('app.timezone')` never actually read `APP_TIMEZONE` (hardcoded to `'UTC'`), and the CSP's `style-src 'self'` was silently stripping inline styles already in production-bound use across a dozen views — both corrected, documented in §19. `docs/DEPLOYMENT.md` and `docs/BACKUP.md`, referenced by §20–21 since v1.0, written for the first time. Status line corrected — see above. No schema change. |

---

## Table of Contents

1. [Guiding Principles & Non-Goals](#1-guiding-principles--non-goals)
2. [Technology Decisions](#2-technology-decisions)
3. [Folder Structure](#3-folder-structure)
4. [Database Design](#4-database-design)
5. [ER Diagram](#5-er-diagram)
6. [Laravel Architecture (Layers)](#6-laravel-architecture-layers)
7. [Models & Relationships](#7-models--relationships)
8. [Enums (Domain Value Sets)](#8-enums-domain-value-sets)
9. [Middleware](#9-middleware)
10. [Authentication Flow](#10-authentication-flow)
11. [Authorization Model](#11-authorization-model)
12. [Route Structure](#12-route-structure)
13. [Controller Structure](#13-controller-structure)
14. [Service Structure](#14-service-structure)
15. [UI Navigation & Layout](#15-ui-navigation--layout)
16. [Dashboard Layout](#16-dashboard-layout)
17. [Module Breakdown](#17-module-breakdown)
18. [Development Roadmap](#18-development-roadmap)
19. [Security Recommendations](#19-security-recommendations)
20. [Backup Strategy](#20-backup-strategy)
21. [cPanel Deployment Strategy](#21-cpanel-deployment-strategy)
22. [Future Maintenance Strategy](#22-future-maintenance-strategy)
23. [Design Decision Log (Why)](#23-design-decision-log-why)
24. [Open Questions Requiring Your Decision](#24-open-questions-requiring-your-decision)

---

## 1. Guiding Principles & Non-Goals

### Principles

| # | Principle | What it means in practice |
|---|---|---|
| P1 | **Boring, standard Laravel** | Any Laravel developer must be productive on day one. No custom framework layer, no DDD ceremony, no CQRS, no repository interfaces over Eloquent. |
| P2 | **Thin controllers, fat-enough services** | Controllers validate (via Form Requests) and delegate. Services exist only where logic spans >1 model or has transactional/business rules. Simple CRUD goes straight to Eloquent. |
| P3 | **Server-rendered first** | Blade + Bootstrap 5. JavaScript is progressive enhancement only. No SPA, no build-time server dependency in production. |
| P4 | **Database is the source of truth** | Constraints, foreign keys, unique indexes and transactions enforce integrity — not just application validation. |
| P5 | **Everything auditable** | Every mutation of a business entity is recorded with who/what/when/before/after. Asset ownership history is legally and operationally important. |
| P6 | **History is immutable** | Assignments and audit logs are never hard-deleted or edited. Corrections are new records. |
| P7 | **Shared-hosting-safe** | No persistent processes, no websockets, no Redis assumption, no Node/Python runtime, no shell-outs that may be disabled. Cron is the only background mechanism. |
| P8 | **Configuration over code** | Asset tag format, warranty alert windows, pagination size, branding — all in the Settings module, not hardcoded. |

### Non-Goals (explicitly forbidden — will never appear in this codebase)

Multi-tenancy · workspaces · organization switching · tenant scoping · company registration · billing · subscriptions · pricing · plans · payment gateways (Stripe/Cashfree/etc.) · AI · chatbots · LLM integration · marketing site · landing page · blog · public documentation site · invite-user flow · email verification · public registration · social login.

> **Architectural consequence:** there is **no `company_id`/`tenant_id` column anywhere**, no global scopes for tenancy, and no `company` model. The application is single-instance, single-company by design. This removes an entire class of complexity and query overhead permanently.

---

## 2. Technology Decisions

| Layer | Choice | Why |
|---|---|---|
| Framework | **Laravel 12** (LTS-style maintenance) | Requested; mature ecosystem; excellent shared-hosting compatibility. |
| PHP | **8.3** (target runtime) | Widely available on cPanel MultiPHP; supports typed class constants, readonly, backed enums. |
| Database | **MySQL 8.0 / MariaDB 10.6+**, InnoDB, `utf8mb4_unicode_ci` | Standard on cPanel. InnoDB for FKs + transactions. utf8mb4 for full Unicode (names, symbols). |
| Templating | **Blade** + Blade Components | Server-rendered, zero client build dependency, fastest to maintain. |
| CSS/UI | **Bootstrap 5.3** (compiled locally, shipped as static assets) | Requested; corporate look; no design system to invent; responsive out of the box. |
| Icons | **Bootstrap Icons** (self-hosted SVG sprite/font) | No CDN dependency, works offline/on intranet. |
| JS | **Vanilla JS + minimal libs** (Chart.js for dashboard, Select2-alternative `Tom Select` for searchable dropdowns) | No framework. Each lib is a single self-hosted file. |
| Asset build | **Vite, built locally, compiled output committed/uploaded** | Node never runs on the server. `public/build/` is a deploy artifact. |
| Auth | **Laravel's native `Auth` facade + hand-written controllers** — no Breeze/Jetstream/Fortify | Starter kits ship registration, email verification, password-reset-by-email, profile-photo, teams — all forbidden features we'd have to strip. Hand-rolled login is ~80 lines and exactly matches the spec. |
| Authorization | **Gates + Policies** (native) — no `spatie/laravel-permission` | Only two roles exist (`is_master` boolean). A roles/permissions package would add 5 tables and complexity for a boolean. |
| Excel | **`maatwebsite/excel`** (PhpSpreadsheet) | De-facto standard; supports chunked reading/writing for shared hosting memory limits. Requires `ext-zip`, `ext-gd`. |
| PDF | **`barryvdh/laravel-dompdf`** | Pure PHP, no wkhtmltopdf/Chromium binary — critical for cPanel. |
| QR codes | **`simplesoftwareio/simple-qrcode`** (bacon/bacon-qr-code) rendered as **SVG** | Pure PHP; SVG mode needs **no `ext-imagick`**, which is often unavailable on shared hosting. |
| Auditing | **Custom lightweight `Auditable` trait + model observer** — no `owen-it/laravel-auditing` | We need full control over what's logged, human-readable field labels, and a UI. The custom version is ~150 lines and has zero upgrade risk. |
| Sessions | **`database` driver** | Survives across cPanel's multiple PHP-FPM pools; enables "force logout on user disable"; avoids file-permission issues. |
| Cache | **`database` driver** (or `file`) | Redis/Memcached not guaranteed on shared hosting. |
| Queue | **None at launch** — `QUEUE_CONNECTION=sync` | *(Amended v1.1)* Nothing in the app actually needs deferred work: the warranty digest already runs inside a cron command, and imports are capped and chunked to finish inside a request. Shipping a queue means a `queue:work` cron, two more tables, failed-job monitoring and a second failure mode — for zero current benefit. If import volume later demands it, switching to the `database` driver is a one-line `.env` change plus one cron entry (documented in §21.5). |
| Mail | **SMTP** (cPanel mail or Google Workspace relay) | Warranty alerts + credential delivery only. |
| Dev tooling | **Laravel Pint** (PSR-12), **Larastan** level 5, **PHPUnit** feature tests | Enforces standards without ceremony. Dev-only dependencies (`--no-dev` on production). |

### ⚠️ Version parity warning (found on your machine)

Your local machine currently has **PHP 8.5.8** and **MySQL 9.7**. Production cPanel will almost certainly be **PHP 8.3** and **MySQL 8.0 / MariaDB 10.6**. Developing on newer versions than production is how "works on my machine" bugs ship.

**Recommendation:** install PHP 8.3 locally (Herd or `brew install php@8.3`) and pin `"php": "^8.3"` in `composer.json` with `config.platform.php = "8.3.x"` so Composer resolves dependencies against the production runtime. Confirm the actual cPanel PHP/MySQL versions before Phase 0 starts.

---

## 3. Folder Structure

```
aumni-ams/
├── app/
│   ├── Console/
│   │   └── Commands/
│   │       ├── SendWarrantyExpiryDigest.php     # scheduled: emails upcoming expiries
│   │       ├── PruneAuditLogs.php               # scheduled: retention policy
│   │       └── CreateMasterUser.php             # one-off bootstrap command (no seeder in prod)
│   │
│   ├── Enums/
│   │   ├── AssetStatus.php
│   │   ├── AssetCondition.php
│   │   ├── AssignmentStatus.php
│   │   ├── EmployeeStatus.php
│   │   ├── WarrantyType.php
│   │   ├── AuditEvent.php
│   │   └── ImportStatus.php
│   │
│   ├── Exports/
│   │   ├── AssetsExport.php
│   │   ├── EmployeesExport.php
│   │   ├── AssignmentsExport.php
│   │   ├── WarrantyExpiryExport.php
│   │   └── AuditLogsExport.php
│   │
│   ├── Imports/
│   │   ├── AssetsImport.php
│   │   ├── EmployeesImport.php
│   │   └── Concerns/
│   │       └── RecordsImportErrors.php
│   │
│   ├── Http/
│   │   ├── Controllers/
│   │   │   ├── Auth/
│   │   │   │   ├── LoginController.php
│   │   │   │   ├── PasswordChangeController.php     # self-service change + forced change
│   │   │   │   └── ProfileController.php
│   │   │   ├── Masters/                              # lookup-table CRUD (3 only)
│   │   │   │   ├── DepartmentController.php
│   │   │   │   ├── LocationController.php
│   │   │   │   └── AssetCategoryController.php
│   │   │   ├── DashboardController.php
│   │   │   ├── AssetController.php
│   │   │   ├── AssetWarrantyController.php
│   │   │   ├── AssetQrController.php                 # single + bulk label sheet
│   │   │   ├── EmployeeController.php
│   │   │   ├── AssignmentController.php              # issue asset
│   │   │   ├── ReturnController.php                  # receive asset back (own screens,
│   │   │   │                                         #  shares AssignmentService)
│   │   │   ├── WarrantyController.php                # tracking dashboard/list
│   │   │   ├── AuditLogController.php
│   │   │   ├── ReportController.php
│   │   │   ├── ImportController.php
│   │   │   ├── ExportController.php
│   │   │   ├── SettingController.php
│   │   │   └── UserController.php                    # master-only
│   │   │
│   │   ├── Middleware/
│   │   │   ├── EnsureUserIsActive.php
│   │   │   ├── EnsureUserIsMaster.php
│   │   │   ├── ForcePasswordChange.php
│   │   │   └── SecurityHeaders.php
│   │   │
│   │   └── Requests/
│   │       ├── Auth/LoginRequest.php
│   │       ├── Asset/StoreAssetRequest.php
│   │       ├── Asset/UpdateAssetRequest.php
│   │       ├── Assignment/StoreAssignmentRequest.php
│   │       ├── Assignment/StoreReturnRequest.php
│   │       ├── Employee/StoreEmployeeRequest.php
│   │       ├── Employee/UpdateEmployeeRequest.php
│   │       ├── User/StoreUserRequest.php
│   │       ├── User/UpdateUserRequest.php
│   │       ├── User/ResetUserPasswordRequest.php
│   │       ├── Masters/…Request.php
│   │       └── Setting/UpdateSettingsRequest.php
│   │
│   ├── Models/
│   │   ├── Concerns/
│   │   │   ├── Auditable.php                         # trait → writes audit_logs
│   │   │   └── TracksBlameable.php                   # trait → created_by / updated_by
│   │   ├── User.php
│   │   ├── Employee.php
│   │   ├── Department.php
│   │   ├── Location.php
│   │   ├── AssetCategory.php
│   │   ├── Asset.php
│   │   ├── AssetWarranty.php
│   │   ├── AssetAssignment.php
│   │   ├── AuditLog.php
│   │   ├── ImportBatch.php
│   │   └── Setting.php
│   │
│   ├── Policies/
│   │   ├── AssetPolicy.php
│   │   ├── EmployeePolicy.php
│   │   ├── AssignmentPolicy.php
│   │   └── UserPolicy.php
│   │
│   ├── Providers/
│   │   ├── AppServiceProvider.php
│   │   └── AuthServiceProvider.php
│   │
│   ├── Services/
│   │   ├── AssetTagGenerator.php
│   │   ├── AssetService.php
│   │   ├── AssignmentService.php                     # assign() + receiveReturn()
│   │   ├── WarrantyService.php
│   │   ├── QrCodeService.php
│   │   ├── AuditLogger.php
│   │   ├── DashboardService.php
│   │   ├── ReportService.php
│   │   ├── ImportService.php
│   │   └── AppSettings.php                           # cached key/value accessor
│   │
│   └── View/
│       └── Components/
│           ├── PageHeader.php
│           ├── StatusBadge.php
│           ├── StatCard.php
│           └── ConfirmModal.php
│
├── bootstrap/
│   └── app.php                                       # Laravel 12 middleware/route/exception config
│
├── config/
│   ├── ams.php                                       # app-specific defaults (tag format, alert days…)
│   └── … (framework configs)
│
├── database/
│   ├── migrations/
│   ├── factories/                                    # test data only
│   └── seeders/
│       ├── DatabaseSeeder.php
│       ├── SettingSeeder.php                         # required baseline settings
│       ├── AssetCategorySeeder.php                   # starter categories
│       └── MasterUserSeeder.php                      # first master account (dev only)
│
├── docs/
│   ├── ARCHITECTURE.md                               # this document
│   ├── DEPLOYMENT.md
│   ├── BACKUP.md
│   └── CHANGELOG.md
│
├── public/
│   ├── index.php
│   ├── .htaccess
│   ├── build/                                        # compiled Vite output (deploy artifact)
│   └── storage → ../storage/app/public (symlink)
│
├── resources/
│   ├── css/app.css                                   # bootstrap import + ams overrides
│   ├── js/
│   │   ├── app.js
│   │   └── modules/{charts.js, datatable.js, confirm.js, asset-form.js}
│   └── views/
│       ├── layouts/
│       │   ├── app.blade.php                         # authenticated shell
│       │   └── guest.blade.php                       # login shell
│       ├── partials/
│       │   ├── sidebar.blade.php
│       │   ├── topbar.blade.php
│       │   ├── alerts.blade.php
│       │   └── pagination.blade.php
│       ├── components/                               # anonymous blade components
│       │   ├── page-header.blade.php
│       │   ├── stat-card.blade.php
│       │   ├── status-badge.blade.php
│       │   ├── form/{input,select,textarea,date,checkbox,datalist}.blade.php
│       │   ├── table/{wrapper,empty-state}.blade.php
│       │   └── confirm-modal.blade.php
│       ├── auth/login.blade.php
│       ├── auth/password-change.blade.php
│       ├── dashboard/index.blade.php
│       ├── assets/{index,create,edit,show,_form,_filters}.blade.php
│       ├── employees/{index,create,edit,show,_form}.blade.php
│       ├── assignments/{index,create,show}.blade.php
│       ├── returns/{index,create}.blade.php
│       ├── warranty/{index,expiring}.blade.php
│       ├── audit-logs/{index,show}.blade.php
│       ├── reports/{index, …individual reports}.blade.php
│       ├── imports/{index,create,show}.blade.php
│       ├── masters/…/{index,_form}.blade.php
│       ├── settings/index.blade.php
│       ├── users/{index,create,edit}.blade.php
│       ├── pdf/{asset-labels,report}.blade.php
│       ├── mail/warranty-digest.blade.php
│       └── errors/{403,404,419,500,503}.blade.php
│
├── routes/
│   ├── web.php                                       # includes the module files below
│   ├── auth.php
│   └── modules/
│       ├── assets.php
│       ├── employees.php
│       ├── assignments.php
│       ├── warranty.php
│       ├── reports.php
│       ├── masters.php
│       ├── audit.php
│       └── admin.php                                 # users + settings (master-only)
│
├── storage/
│   └── app/
│       ├── private/
│       │   ├── imports/                              # uploaded source files
│       │   └── import-errors/                        # generated error reports
│       └── public/
│           └── branding/                             # company logo
│
├── tests/
│   ├── Feature/{Auth,Asset,Assignment,User,Import}Test.php
│   └── Unit/{AssetTagGenerator,WarrantyService}Test.php
│   (PHPUnit — phpunit.xml at project root)
│
├── .env.example
├── .editorconfig
├── composer.json
├── package.json
├── pint.json
├── phpstan.neon
└── vite.config.js
```

### Why this structure

- **No `Repositories/` layer.** Eloquent *is* the data-access layer. Wrapping it in interfaces adds indirection with no testability benefit here (we test against SQLite/MySQL, not mocks).
- **`Enums/` as first-class.** Statuses appear in DB, validation, badges, filters and reports. A backed enum centralises the label, colour and allowed transitions in one file.
- **`Masters/` controller namespace.** Three lookup CRUDs grouped together keeps the top-level controller list readable.
- **No `Support/Filters/`, no `Observers/`, no `Exceptions/Domain/`, no `ViewComposers/`.** *(Amended v1.1)* Asset list filtering is a `scopeFilter()` on the model — Laravel-native, testable, reusable by exports. Auditing is booted by the `Auditable` trait itself, so a separate observer class adds a file without adding behaviour. Business-rule failures throw `ValidationException` so the message lands on the form field where the user is looking, instead of needing custom exception classes plus a handler mapping. Settings reach views via one `View::share()` in `AppServiceProvider`.
- **`routes/modules/`.** One 400-line `web.php` becomes unreadable. Splitting by module means a developer opens exactly one file.
- **Private import storage.** Uploaded spreadsheets contain employee PII; they live on a non-web-accessible disk and are never served by URL.

---

## 4. Database Design

**Conventions used throughout**
- Table names: plural snake_case. PK: `id` (`BIGINT UNSIGNED AUTO_INCREMENT`).
- All FKs are real `FOREIGN KEY` constraints with an explicit `ON DELETE` rule.
- `created_by` / `updated_by` → `users.id`, `ON DELETE SET NULL`. *(Amended v1.1)* **Only on `assets` and `employees`**, where "who added this / who last touched it" is shown in the UI. Everywhere else `audit_logs` already answers the same question, and duplicating it in two columns per table is redundant storage that can silently disagree with the log.
- Soft deletes (`deleted_at`) on master data and business entities; **never** on history tables.
- Money: `DECIMAL(14,2)`. Never float.
- Dates without time: `DATE`. Points in time: `TIMESTAMP` / `DATETIME`.
- Every column that appears in a `WHERE`, `ORDER BY` or `JOIN` is indexed.
- Enum values are stored as `VARCHAR(30)` (not MySQL `ENUM`) and validated by PHP backed enums — adding a value never requires an `ALTER TABLE` lock.

---

### 4.1 `users` — application login accounts

| Column | Type | Null | Default | Notes |
|---|---|---|---|---|
| id | bigint unsigned PK | | | |
| name | varchar(120) | no | | |
| email | varchar(160) | no | | **UNIQUE** |
| password | varchar(255) | no | | bcrypt/argon2id hash |
| is_master | boolean | no | 0 | the *only* role flag |
| is_active | boolean | no | 1 | disabled users cannot log in |
| must_change_password | boolean | no | 0 | set when master resets a password |
| employee_id | bigint unsigned FK→employees | yes | null | optional link to HR record |
| last_login_at | timestamp | yes | | |
| last_login_ip | varchar(45) | yes | | IPv6-safe |
| password_changed_at | timestamp | yes | | |
| remember_token | varchar(100) | yes | | |
| created_at / updated_at | timestamp | yes | | |
| deleted_at | timestamp | yes | | soft delete |

**Indexes:** `unique(email)`, `index(is_active, is_master)`, `index(deleted_at)`
**FK:** `employee_id → employees.id ON DELETE SET NULL`

> **Why no `role` column / roles table?** The spec defines exactly two user types differing in one capability (user management). `is_master` is the honest representation. Adding a roles table would be modelling a requirement that has been explicitly ruled out.
>
> **Why `must_change_password`?** When a master resets someone's password, that master knows the temporary password. Forcing a change on next login restores the "only the user knows their password" property — without needing an email-based reset flow (forbidden).
>
> **Why `employee_id` on users?** Not every employee needs a login (most won't), and a login could theoretically exist for a service account. So the relationship is optional 1:1, and `employees` is *not* subordinate to `users`.

---

### 4.2 `departments`

| Column | Type | Notes |
|---|---|---|
| id | bigint unsigned PK | |
| name | varchar(100) | **UNIQUE** |
| code | varchar(20) | UNIQUE, nullable — e.g. `ENG`, `HR` |
| head_employee_id | bigint unsigned FK→employees | nullable, `ON DELETE SET NULL` |
| is_active | boolean | default 1 |
| timestamps, deleted_at | | |

### 4.3 `locations` — offices / sites where assets physically live

| Column | Type | Notes |
|---|---|---|
| id | bigint unsigned PK | |
| name | varchar(120) | UNIQUE — "Pune HQ", "Bangalore Office", "Remote — WFH" |
| code | varchar(20) | UNIQUE nullable — used in asset tag |
| address_line1/2, city, state, country, postal_code | varchar | nullable |
| is_active | boolean | default 1 |
| timestamps, deleted_at | | |

### 4.4 `asset_categories` — self-referencing hierarchy (max 2 levels enforced in app)

| Column | Type | Notes |
|---|---|---|
| id | bigint unsigned PK | |
| parent_id | bigint unsigned FK→asset_categories | nullable, `ON DELETE RESTRICT` |
| name | varchar(100) | |
| code | varchar(10) | **UNIQUE** — used as asset-tag prefix, e.g. `LAP`, `MON`, `MOB` |
| depreciation_years | tinyint unsigned | nullable — informational |
| default_warranty_months | smallint unsigned | nullable — pre-fills asset form |
| is_active | boolean | default 1 |
| timestamps, deleted_at | | |

**Indexes:** `unique(code)`, `index(parent_id)`, `unique(parent_id, name)`

> **Why a parent_id instead of a separate subcategory table?** Categories and subcategories have identical attributes. A self-reference avoids duplicating a table and lets the hierarchy deepen later without migration. Depth is capped at 2 in validation to keep the UI simple.

### 4.5 Manufacturer, Vendor and Designation — **text fields, not tables** *(Amended v1.1)*

These three were originally modelled as lookup tables. They are now plain `VARCHAR` columns:

| Field | Lives on | Type |
|---|---|---|
| `manufacturer` | `assets` | varchar(100) nullable |
| `vendor` | `assets` | varchar(150) nullable |
| `warranty_provider` | `asset_warranties` | varchar(150) nullable |
| `designation` | `employees` | varchar(120) nullable |

**Why text is the right call here:** a lookup table earns its keep when the entity has *attributes of its own* that you query, report on, or maintain. Departments have a head and drive reporting. Locations have addresses and hold assets. Categories drive asset-tag prefixes, default warranty periods and the entire category hierarchy. Manufacturer, vendor and designation have none of that — they are labels. Making them tables adds three migrations, three models, three controllers, three sets of views and three more "you can't delete this, it's in use" edge cases, in exchange for a dropdown.

**How data quality is kept without a table:**
- Each text input is backed by an HTML `<datalist>` populated from `SELECT DISTINCT manufacturer FROM assets ORDER BY manufacturer` (cached 1 hour). The user gets autocomplete from what already exists and types freely when it's new — native HTML, no JS library, no table.
- Values are normalised on save: `trim()` + collapse internal whitespace. Casing is preserved as typed.
- Reports group by the raw value, so a typo shows up immediately as a near-duplicate row in the Category/Vendor summary — self-correcting in practice.

**Migration path if you normalise later:** create the table, `INSERT … SELECT DISTINCT`, add a nullable FK, backfill by matching on name, then drop the text column. Additive and reversible — no data loss, no rewrite. This is exactly why the decision is safe to defer.

**Indexes:** `index(manufacturer)` and `index(vendor)` on `assets` — they appear in filters and report `GROUP BY`s.

---

### 4.6 `employees` — the people assets are issued to

| Column | Type | Null | Notes |
|---|---|---|---|
| id | bigint unsigned PK | | |
| employee_code | varchar(30) | no | **UNIQUE** — company payroll ID |
| first_name | varchar(80) | no | |
| last_name | varchar(80) | yes | |
| email | varchar(160) | yes | UNIQUE (nullable) — official email |
| personal_email | varchar(160) | yes | for exit correspondence |
| phone | varchar(20) | yes | |
| department_id | bigint unsigned FK | yes | `ON DELETE RESTRICT` |
| designation | varchar(120) | yes | free text + datalist autocomplete |
| location_id | bigint unsigned FK | yes | `ON DELETE RESTRICT` |
| reporting_to_id | bigint unsigned FK→employees | yes | `ON DELETE SET NULL` |
| status | varchar(20) | no | `active` \| `on_notice` \| `exited` \| `inactive` |
| date_of_joining | date | yes | |
| date_of_exit | date | yes | |
| notes | text | yes | |
| timestamps, deleted_at | | | |

**Indexes:** `unique(employee_code)`, `unique(email)`, `index(status)`, `index(department_id)`, `index(location_id)`, `index(first_name, last_name)`, `index(deleted_at)`

> **Why `RESTRICT` on department/location?** Deleting a department that still has employees is a data-integrity accident, not a user intention. The UI will say "This department has 14 employees and cannot be deleted — deactivate it instead."
>
> **Why `on_notice` status?** Asset clearance starts at resignation, not at exit. The dashboard surfaces "employees on notice still holding assets" — the single highest-value operational alert in an AMS.

---

### 4.7 `assets` — the core table

| Column | Type | Null | Notes |
|---|---|---|---|
| id | bigint unsigned PK | | |
| uuid | char(36) | no | **UNIQUE** — used in QR codes / public-facing URLs |
| asset_tag | varchar(40) | no | **UNIQUE** — human ID, e.g. `AUM-LAP-0042` |
| serial_number | varchar(100) | yes | **UNIQUE** (nullable) — manufacturer serial |
| name | varchar(150) | no | e.g. "Dell Latitude 5440" |
| category_id | bigint unsigned FK | no | `ON DELETE RESTRICT` |
| manufacturer | varchar(100) | yes | free text + datalist — "Dell", "Lenovo" |
| model | varchar(120) | yes | "Latitude 5440" |
| vendor | varchar(150) | yes | free text + datalist — purchased from |
| location_id | bigint unsigned FK | yes | `ON DELETE RESTRICT` — where it physically sits |
| status | varchar(30) | no | `in_stock` \| `assigned` \| `under_repair` \| `retired` \| `lost` \| `damaged` \| `disposed` |
| condition | varchar(20) | no | `new` \| `good` \| `fair` \| `poor` \| `unusable` |
| current_assignment_id | bigint unsigned FK→asset_assignments | yes | **denormalised pointer**, `ON DELETE SET NULL` |
| current_employee_id | bigint unsigned FK→employees | yes | **denormalised pointer**, `ON DELETE SET NULL` |
| purchase_date | date | yes | |
| purchase_cost | decimal(14,2) | yes | |
| currency | char(3) | no | default `INR` |
| invoice_number | varchar(60) | yes | |
| po_number | varchar(60) | yes | |
| specifications | json | yes | flexible per-category attrs (RAM, CPU, storage, IMEI…) |
| notes | text | yes | |
| is_active | boolean | no | default 1 — hides from pickers without deleting |
| timestamps, deleted_at | | | |

**Indexes**
```
unique(uuid), unique(asset_tag), unique(serial_number)
index(status)
index(category_id, status)
index(current_employee_id)
index(location_id)
index(manufacturer)
index(vendor)
index(purchase_date)
index(deleted_at)
fulltext(name, model, serial_number, asset_tag)   -- optional, for global search
```

> **Why both `uuid` and `asset_tag`?** `asset_tag` is sequential and guessable — perfect for humans and labels, dangerous as the sole identifier in a QR URL (someone could enumerate `/a/AUM-LAP-0001..9999`). The QR encodes the UUID, so scanning is safe even before the auth check.
>
> **Why denormalise `current_assignment_id` / `current_employee_id`?** The asset list is the most-visited screen and always shows "assigned to". Deriving it requires a correlated subquery on `asset_assignments WHERE returned_at IS NULL` for every row. Two pointer columns, maintained *only* inside `AssignmentService` within a DB transaction, turn that into a simple join. The `asset_assignments` table remains the authoritative history; these columns are a cache with exactly one writer. A `php artisan ams:reconcile-assets` command can rebuild them if they ever drift.
>
> **Why `specifications` as JSON?** A laptop has RAM/CPU/storage; a monitor has size/resolution; a phone has IMEI. Modelling this relationally means an EAV table (slow, ugly) or 40 mostly-null columns. MySQL 8 JSON with a category-driven field definition in Settings gives flexibility without schema churn. We never filter on JSON in hot paths — anything that needs filtering gets a real column.
>
> **Why no `assigned_to` free-text?** Assets are assigned to employees only, always via `asset_assignments`. No exceptions, no shadow state.

---

### 4.8 `asset_assignments` — issue + return history (immutable)

| Column | Type | Null | Notes |
|---|---|---|---|
| id | bigint unsigned PK | | |
| asset_id | bigint unsigned FK | no | `ON DELETE RESTRICT` |
| employee_id | bigint unsigned FK | no | `ON DELETE RESTRICT` |
| status | varchar(20) | no | `active` \| `returned` |
| assigned_at | datetime | no | |
| assigned_by | bigint unsigned FK→users | yes | `ON DELETE SET NULL` |
| expected_return_at | date | yes | for temporary issues |
| assignment_location_id | bigint unsigned FK→locations | yes | |
| condition_on_issue | varchar(20) | no | snapshot of asset condition |
| issue_notes | text | yes | |
| acknowledgement_ref | varchar(80) | yes | signed handover form reference |
| returned_at | datetime | yes | null ⇒ still held |
| received_by | bigint unsigned FK→users | yes | `ON DELETE SET NULL` |
| condition_on_return | varchar(20) | yes | |
| return_notes | text | yes | |
| **no** `deleted_at` | | | history is never deleted |

**Indexes**
```
index(asset_id, status)
index(employee_id, status)
index(assigned_at)
index(returned_at)
UNIQUE INDEX uniq_active_assignment (asset_id, status)  -- ⚠ see note
```

> **The one-active-assignment guarantee.** An asset must never be issued to two people at once. Two enforcement layers:
> 1. **Application:** `AssignmentService` runs inside a transaction with `SELECT … FOR UPDATE` on the asset row, and rejects if `status !== in_stock`.
> 2. **Database:** a generated column `active_flag` = `1` when `returned_at IS NULL`, else `NULL`, with `UNIQUE(asset_id, active_flag)`. Because MySQL treats `NULL` as distinct in unique indexes, any number of returned rows are allowed but only one open row per asset. This makes double-assignment *physically impossible* even under a race or a bad manual query.
>
> **Why one table instead of `assignments` + `returns`?** A return is the closing event of an assignment, not an independent fact. One row per custody period gives "who had this, when, and in what condition" as a single readable record, makes the "currently held" query a single `WHERE returned_at IS NULL`, and eliminates the possibility of an orphaned return. The Asset Return *module* still has its own screens and controller — the separation is at the UI/workflow layer, which is where the user experiences it, not at the storage layer.
>
> **Why `condition_on_issue` / `condition_on_return`?** Damage disputes. Snapshotting condition at both ends is the entire point of an asset handover record.

---

### 4.9 `asset_warranties`

| Column | Type | Null | Notes |
|---|---|---|---|
| id | bigint unsigned PK | | |
| asset_id | bigint unsigned FK | no | `ON DELETE CASCADE` |
| type | varchar(20) | no | `manufacturer` \| `extended` \| `amc` \| `insurance` |
| provider | varchar(150) | yes | free text + datalist *(Amended v1.1)* |
| reference_number | varchar(80) | yes | contract / policy number |
| start_date | date | no | |
| end_date | date | no | |
| cost | decimal(14,2) | yes | |
| is_primary | boolean | no | default 0 — the one shown in asset lists |
| notes | text | yes | |
| timestamps | | | |

**Indexes:** `index(asset_id)`, `index(end_date)`, `index(type, end_date)`, `unique(asset_id, is_primary)` *(partial-unique via generated column, same trick as above)*

> **Why a separate table instead of `warranty_end` on `assets`?** Assets routinely carry more than one coverage period — a 1-year manufacturer warranty plus a 3-year AMC, or an insurance policy. Flattening them into the asset row means either losing information or adding `warranty2_start`, `warranty3_start`… The `is_primary` flag preserves the fast common case ("show me the main warranty") while the indexed `end_date` makes "expiring in the next 30 days" a cheap indexed range scan across all coverage types.
>
> **`ON DELETE CASCADE` here** because a warranty has no meaning without its asset — unlike assignments, which are historical record.

---

### 4.10 `audit_logs` — polymorphic, append-only

| Column | Type | Notes |
|---|---|---|
| id | bigint unsigned PK | |
| user_id | bigint unsigned FK→users | nullable (`ON DELETE SET NULL`) — null for console/system events |
| event | varchar(40) | `created` \| `updated` \| `deleted` \| `restored` \| `login` \| `login_failed` \| `logout` \| `assigned` \| `returned` \| `password_reset` \| `exported` \| `imported` \| `setting_changed` |
| auditable_type | varchar(120) | model class (or null for auth events) |
| auditable_id | bigint unsigned | nullable |
| description | varchar(255) | pre-rendered human sentence: *"Assigned AUM-LAP-0042 to Priya Sharma"* |
| old_values | json | changed attributes only |
| new_values | json | changed attributes only |
| ip_address | varchar(45) | |
| user_agent | varchar(255) | |
| url | varchar(255) | |
| created_at | timestamp | **no `updated_at`, no `deleted_at`** |

**Indexes**
```
index(auditable_type, auditable_id)
index(user_id, created_at)
index(event, created_at)
index(created_at)          -- for pruning + date-range reports
```

> **Why store only changed attributes?** Full before/after snapshots of every row make this table the biggest in the database within a year. Diffing at write time keeps rows small and makes the UI ("Status: in_stock → assigned") trivial to render.
>
> **Why the pre-rendered `description`?** Reconstructing a human sentence from JSON at read time requires knowing the model's field labels forever, including for models that later change. Writing the sentence once, at the moment we have full context, makes the audit log permanently readable.
>
> **Sensitive fields are never logged:** `password`, `remember_token` are on a global blocklist in the `Auditable` trait.
>
> **Retention:** `ams:prune-audit-logs` keeps N months (a Setting, default 24) — but never prunes `assigned`/`returned` events, which are custody records.

---

### 4.11 `settings` — key/value application configuration

| Column | Type | Notes |
|---|---|---|
| id | bigint unsigned PK | |
| group | varchar(40) | `general` \| `assets` \| `notifications` \| `branding` |
| key | varchar(80) | **UNIQUE** |
| value | text | nullable |
| type | varchar(20) | `string` \| `integer` \| `boolean` \| `json` \| `file` — drives casting + form widget |
| label | varchar(120) | shown in the settings UI |
| description | varchar(255) | help text |
| is_editable | boolean | default 1 — protects system keys |
| updated_by | bigint unsigned FK→users | |
| timestamps | | |

Baseline keys: `company_name`, `company_logo`, `asset_tag_prefix` (`AUM`), `asset_tag_format` (`{prefix}-{category}-{seq:4}`), `asset_tag_sequence_scope` (`global`\|`per_category`), `warranty_alert_days` (`30,15,7`), `warranty_alert_recipients`, `default_currency`, `date_format`, `pagination_per_page`, `audit_retention_months`, `session_timeout_minutes`, `password_min_length`, `enable_warranty_emails`.

> **Why a settings table rather than `.env`?** `.env` changes require SSH/File-Manager access and a `config:cache` rebuild. These are business settings that a master user must change from the UI. Infrastructure secrets (DB credentials, mail password, `APP_KEY`) stay in `.env` — the split is "who owns the value".
>
> The whole table is loaded once per request and **cached forever**, invalidated on write. Cost: one cached array lookup.

### 4.12 `asset_tag_sequences` — gapless, race-free tag numbering

| Column | Type | Notes |
|---|---|---|
| id | bigint unsigned PK | |
| scope_key | varchar(40) | `global`, or the category code |
| last_number | int unsigned | |
| timestamps | | |

**Index:** `unique(scope_key)`

> **Why a table and not `MAX(asset_tag)+1`?** Under concurrent creation (or a bulk Excel import), `MAX()+1` produces duplicates. A single row locked with `SELECT … FOR UPDATE` inside the creating transaction is the standard, correct solution and costs one extra query.

### 4.13 `import_batches` — Excel import tracking

| Column | Type | Notes |
|---|---|---|
| id | bigint unsigned PK | |
| user_id | bigint unsigned FK→users | `ON DELETE SET NULL` |
| module | varchar(30) | `assets` \| `employees` |
| original_filename | varchar(255) | |
| stored_path | varchar(255) | private disk |
| status | varchar(30) | `pending` \| `processing` \| `completed` \| `completed_with_errors` \| `failed` — *(corrected in Phase 6; `varchar(20)` is too narrow for `completed_with_errors`, 21 characters)* |
| total_rows / success_rows / failed_rows | int unsigned | default 0 |
| error_report_path | varchar(255) | generated xlsx of failed rows + reasons |
| error_summary | text | |
| started_at / finished_at | datetime | |
| timestamps | | |

**Indexes:** `index(user_id, created_at)`, `index(status)`

### 4.14 Framework tables

`sessions` (database driver) · `cache` + `cache_locks` · `migrations`.

**Dropped:**
- `password_reset_tokens` — there is no email-based password reset; masters reset passwords directly.
- `jobs` / `job_batches` / `failed_jobs` *(Amended v1.1)* — no queue at launch. These migrations are simply not created; if a queue is introduced later, `php artisan make:queue-table` regenerates them.

### 4.15 Table count

**12 business tables + 4 framework tables.** Down from 16 + 6 in v1.0.

```
users · employees · departments · locations · asset_categories
assets · asset_assignments · asset_warranties
audit_logs · settings · asset_tag_sequences · import_batches
```

---

## 5. ER Diagram

```mermaid
erDiagram
    USERS ||--o{ ASSET_ASSIGNMENTS : "assigned_by"
    USERS ||--o{ ASSET_ASSIGNMENTS : "received_by"
    USERS ||--o{ AUDIT_LOGS : performs
    USERS ||--o{ IMPORT_BATCHES : uploads
    EMPLOYEES ||--o| USERS : "may have login"

    DEPARTMENTS ||--o{ EMPLOYEES : has
    LOCATIONS   ||--o{ EMPLOYEES : "based at"
    LOCATIONS   ||--o{ ASSETS : "located at"
    EMPLOYEES   ||--o{ EMPLOYEES : "reports to"
    EMPLOYEES   ||--o| DEPARTMENTS : "heads"

    ASSET_CATEGORIES ||--o{ ASSET_CATEGORIES : "parent of"
    ASSET_CATEGORIES ||--o{ ASSETS : classifies

    ASSETS ||--o{ ASSET_ASSIGNMENTS : "custody history"
    ASSETS ||--o{ ASSET_WARRANTIES : "covered by"
    ASSETS ||--o| ASSET_ASSIGNMENTS : "current (cache)"

    EMPLOYEES ||--o{ ASSET_ASSIGNMENTS : holds

    ASSET_TAG_SEQUENCES ||..|| ASSETS : "numbers"

    USERS {
        bigint id PK
        string name
        string email UK
        string password
        bool   is_master
        bool   is_active
        bool   must_change_password
        bigint employee_id FK
        timestamp last_login_at
    }
    EMPLOYEES {
        bigint id PK
        string employee_code UK
        string first_name
        string last_name
        string email UK
        bigint department_id FK
        string designation
        bigint location_id FK
        bigint reporting_to_id FK
        string status
        date   date_of_joining
        date   date_of_exit
    }
    ASSETS {
        bigint  id PK
        char    uuid UK
        string  asset_tag UK
        string  serial_number UK
        string  name
        bigint  category_id FK
        string  manufacturer
        string  model
        string  vendor
        bigint  location_id FK
        string  status
        string  condition
        bigint  current_assignment_id FK
        bigint  current_employee_id FK
        date    purchase_date
        decimal purchase_cost
        json    specifications
    }
    ASSET_ASSIGNMENTS {
        bigint   id PK
        bigint   asset_id FK
        bigint   employee_id FK
        string   status
        datetime assigned_at
        bigint   assigned_by FK
        date     expected_return_at
        string   condition_on_issue
        datetime returned_at
        bigint   received_by FK
        string   condition_on_return
    }
    ASSET_WARRANTIES {
        bigint  id PK
        bigint  asset_id FK
        string  type
        string  provider
        string  reference_number
        date    start_date
        date    end_date
        decimal cost
        bool    is_primary
    }
    AUDIT_LOGS {
        bigint id PK
        bigint user_id FK
        string event
        string auditable_type
        bigint auditable_id
        string description
        json   old_values
        json   new_values
        string ip_address
    }
    SETTINGS {
        bigint id PK
        string group
        string key UK
        text   value
        string type
    }
    IMPORT_BATCHES {
        bigint id PK
        bigint user_id FK
        string module
        string status
        int    total_rows
        int    success_rows
        int    failed_rows
    }
    ASSET_TAG_SEQUENCES {
        bigint id PK
        string scope_key UK
        int    last_number
    }
```

### Relationship cardinality summary

| From | To | Cardinality | Delete rule |
|---|---|---|---|
| Employee → Assignments | 1 : N | history | RESTRICT |
| Asset → Assignments | 1 : N (max **1 open**) | history | RESTRICT |
| Asset → Warranties | 1 : N (max 1 primary) | dependent | CASCADE |
| Category → Category | 1 : N (2 levels) | hierarchy | RESTRICT |
| Employee → Employee | 1 : N (reporting) | hierarchy | SET NULL |
| Employee → User | 1 : 0..1 | optional link | SET NULL |
| Any model → AuditLogs | 1 : N polymorphic | log | (no FK on auditable) |

---

## 6. Laravel Architecture (Layers)

```
HTTP Request
   │
   ▼
[ Global middleware ]  TrustProxies → HandleCors → ValidatePostSize → SecurityHeaders
   │
   ▼
[ web group ]  EncryptCookies → StartSession → ShareErrorsFromSession → VerifyCsrfToken → SubstituteBindings
   │
   ▼
[ route middleware ]  auth → active → password.change → master (where applicable)
   │
   ▼
[ Route ]  → implicit model binding resolves {asset}, {employee}, …
   │
   ▼
[ Form Request ]  authorize() → Policy check     rules() → validation
   │
   ▼
[ Controller ]  ≤ 15 lines: delegate, flash, redirect
   │
   ├──► [ Service ]   multi-model / transactional business rules
   │        │
   │        ├──► [ Model / Eloquent ]  ──► Database
   │        └──► [ AuditLogger ]
   │
   └──► [ Model ] directly, for simple CRUD
   │
   ▼
[ View: Blade ]  layout → page → components
   │
   ▼
HTTP Response
```

### Layer responsibilities (strict)

| Layer | Does | Never does |
|---|---|---|
| **Route** | Maps URL → controller, applies middleware, model binding | Contains logic |
| **Middleware** | Cross-cutting request gates | Business rules |
| **Form Request** | Authorization + validation + normalisation of input | DB writes |
| **Controller** | Orchestrate: call service/model, flash message, redirect/render | Query building, business rules, transactions |
| **Policy** | "Can *this user* do *this* to *this record*" | Validation |
| **Service** | Business rules spanning multiple models, transactions, invariants | HTTP concerns (`request()`, `redirect()`), view rendering |
| **Model** | Relationships, casts, accessors, **scopes** (reusable query fragments) | Cross-model orchestration |
| **Observer** | Side effects on lifecycle events (audit, cache bust) | Business decisions that could fail validation |
| **Enum** | Value set + label + colour + allowed transitions | Persistence |
| **View** | Presentation | Queries (no `Model::` calls in Blade — ever) |

**Query rules:** every list endpoint uses `paginate()` (never `get()` on unbounded sets), and every list eager-loads its display relations (`with(['category','currentEmployee','location'])`). N+1 queries are treated as bugs; Larastan + a dev-only `Model::preventLazyLoading()` in `AppServiceProvider` catches them during development.

---

## 7. Models & Relationships

### `User`
```
belongsTo      employee                → Employee (nullable)
hasMany        assignmentsIssued       → AssetAssignment (assigned_by)
hasMany        assignmentsReceived     → AssetAssignment (received_by)
hasMany        auditLogs               → AuditLog
hasMany        importBatches           → ImportBatch

casts:   is_master:bool, is_active:bool, must_change_password:bool,
         password:hashed, last_login_at:datetime, password_changed_at:datetime
hidden:  password, remember_token
scopes:  active(), masters()
methods: isMaster(): bool
traits:  Notifiable, SoftDeletes, Auditable, TracksBlameable
```

### `Employee`
```
belongsTo  department, location
belongsTo  reportingTo             → Employee
hasMany    directReports           → Employee (reporting_to_id)
hasOne     user
hasMany    assignments             → AssetAssignment
hasMany    activeAssignments       → AssetAssignment (status = active)
hasManyThrough currentAssets       → Asset via active assignments
hasMany    departmentsHeaded       → Department (head_employee_id)

casts:   status:EmployeeStatus, date_of_joining:date, date_of_exit:date
accessor: full_name
scopes:  active(), onNotice(), holdingAssets(), search($term)
traits:  SoftDeletes, Auditable, TracksBlameable
```

### `Asset`
```
belongsTo  category          → AssetCategory
belongsTo  location          → Location
belongsTo  currentAssignment → AssetAssignment
belongsTo  currentEmployee   → Employee
hasMany    assignments       → AssetAssignment (latest first)
hasMany    warranties        → AssetWarranty
hasOne     primaryWarranty   → AssetWarranty (is_primary)
morphMany  auditLogs         → AuditLog

casts:   status:AssetStatus, condition:AssetCondition,
         specifications:array, purchase_date:date, purchase_cost:decimal:2
route key: uuid  (for QR-facing routes only; internal routes use id)
scopes:  available(), assigned(), byStatus(), byCategory(),
         warrantyExpiringWithin($days), search($term),
         filter(array $filters)   ← drives the asset list + exports (replaces the
                                    dropped AssetFilter class; one when()-chain)
accessors: is_under_warranty, warranty_days_remaining, age_in_months
traits:  SoftDeletes, Auditable, TracksBlameable
```

### `AssetAssignment`
```
belongsTo  asset, employee
belongsTo  assignedBy  → User
belongsTo  receivedBy  → User
belongsTo  assignmentLocation → Location

casts:   status:AssignmentStatus, assigned_at:datetime, returned_at:datetime,
         expected_return_at:date
scopes:  active(), returned(), overdue()
accessors: is_active, duration_in_days
traits:  Auditable   ← NO SoftDeletes (immutable history)
```

### `AssetWarranty`
```
belongsTo  asset
casts:   type:WarrantyType, start_date:date, end_date:date, cost:decimal:2, is_primary:bool
scopes:  active(), expired(), expiringWithin($days)
accessors: days_remaining, is_expired
```

### `AssetCategory`
```
belongsTo  parent    → AssetCategory
hasMany    children  → AssetCategory
hasMany    assets
scopes:  roots(), active()
```

### `AuditLog`
```
morphTo    auditable
belongsTo  user
casts:   old_values:array, new_values:array, created_at:datetime
scopes:  forModel($model), byEvent(), byUser(), betweenDates()
public $timestamps = false;  // created_at only, set manually
```

### `Setting`
```
casts:   is_editable:bool
static:  get($key, $default), set($key, $value), all()  → via AppSettings (cached)
```

Remaining models (`Department`, `Location`, `ImportBatch`) follow the same conventions: `SoftDeletes` + `Auditable` on master data, a plain model for import batches.

**Model count: 10.** `User` · `Employee` · `Department` · `Location` · `AssetCategory` · `Asset` · `AssetAssignment` · `AssetWarranty` · `AuditLog` · `Setting` (+ `ImportBatch`).

### Shared traits

**`Auditable`** — boots a model observer that, on `created`/`updated`/`deleted`/`restored`, computes the changed-attribute diff (minus a blocklist), renders a description via the model's `auditDescription()` method, and inserts an `audit_logs` row with the current user + request context.

**`TracksBlameable`** — sets `created_by` on creating and `updated_by` on updating from `auth()->id()`; no-ops in console context. *(Amended v1.1)* Applied to `Asset` and `Employee` only.

---

## 8. Enums (Domain Value Sets)

Each is a PHP 8.1 backed string enum implementing `label(): string` and `color(): string` (Bootstrap contextual class), so a `<x-status-badge>` component works for every status in the app.

| Enum | Values | Notes |
|---|---|---|
| `AssetStatus` | `in_stock`, `assigned`, `under_repair`, `retired`, `lost`, `damaged`, `disposed` | `canBeAssigned()` returns true only for `in_stock`. Legal transitions defined in the enum. |
| `AssetCondition` | `new`, `good`, `fair`, `poor`, `unusable` | |
| `AssignmentStatus` | `active`, `returned` | |
| `EmployeeStatus` | `active`, `on_notice`, `exited`, `inactive` | `canReceiveAssets()` false for `exited` |
| `WarrantyType` | `manufacturer`, `extended`, `amc`, `insurance` | |
| `AuditEvent` | `created`, `updated`, `deleted`, `restored`, `login`, `login_failed`, `logout`, `assigned`, `returned`, `password_reset`, `user_disabled`, `exported`, `imported`, `setting_changed` | |
| `ImportStatus` | `pending`, `processing`, `completed`, `completed_with_errors`, `failed` | |

**Asset status transition matrix** (enforced in `AssetService::changeStatus()`):

```
in_stock     → assigned, under_repair, damaged, lost, retired, disposed
assigned     → in_stock (via return only), lost, damaged
under_repair → in_stock, damaged, disposed
damaged      → under_repair, disposed, retired
lost         → in_stock (found), disposed
retired      → disposed
disposed     → (terminal)
```

> **Why encode transitions?** Without it, a UI dropdown eventually lets someone move a disposed asset back to "in stock", and the audit trail becomes fiction. The matrix lives in one file and the UI simply renders `$asset->status->allowedTransitions()`.

---

## 9. Middleware

### Global (all requests)
| Middleware | Purpose |
|---|---|
| `TrustProxies` | Correct client IP behind cPanel's proxy/CDN — audit logs must record real IPs |
| `ValidatePostSize` | Reject oversized uploads early |
| `SecurityHeaders` *(custom)* | `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, `Permissions-Policy`, `Content-Security-Policy`, HSTS in production |

### `web` group (framework defaults)
`EncryptCookies` · `AddQueuedCookiesToResponse` · `StartSession` · `ShareErrorsFromSession` · `ValidateCsrfToken` · `SubstituteBindings`

### Route middleware (aliases registered in `bootstrap/app.php`)

| Alias | Class | Behaviour |
|---|---|---|
| `auth` | Laravel `Authenticate` | Redirects guests to `/login` |
| `guest` | `RedirectIfAuthenticated` | Sends logged-in users away from `/login` |
| `active` | `EnsureUserIsActive` | If `!auth()->user()->is_active` → logout, invalidate session, redirect to login with "Your account has been disabled." Runs on **every** authenticated request so a master disabling a user takes effect immediately, not at next login. |
| `password.change` | `ForcePasswordChange` | If `must_change_password` and the request isn't the change-password route or logout → redirect to `/password/change` |
| `master` | `EnsureUserIsMaster` | `abort(403)` unless `is_master`. Guards User Management + Settings. |
| `throttle:…` | Laravel | `throttle:5,1` on login POST; `throttle:10,1` on exports/imports |

**Standard authenticated stack:** `['auth', 'active', 'password.change']`
**Master-only stack:** the above `+ 'master'`

> **Order matters.** `active` must run *after* `auth` (needs a user) and *before* `password.change` (a disabled user shouldn't be asked to change a password). `master` runs last — the cheapest check goes last because it only applies to a few routes.
>
> **Why `active` on every request and not just login?** If a departing employee's account is disabled while they're logged in, waiting for their session to expire is a security hole. One extra boolean read from an already-loaded user model costs nothing.

---

## 10. Authentication Flow

### 10.1 Login

```
GET  /login  ──► guest middleware ──► auth/login.blade.php
                                       (email, password, remember-me, CSRF)

POST /login  ──► throttle:5,1 (per email+IP) ──► LoginRequest
   │
   ├─ 1. Validate: email required|email, password required|string
   ├─ 2. RateLimiter::tooManyAttempts(key)  → 429 + "Too many attempts. Try again in N seconds."
   ├─ 3. Auth::attempt(['email'=>…, 'password'=>…, 'is_active'=>1], $remember)
   │        ↳ the is_active condition means a disabled account fails at the DB query —
   │          it never even reaches password verification for a state check
   │
   ├─ FAIL ─► RateLimiter::hit(key)
   │          AuditLogger::loginFailed($email, $ip)
   │          back()->withErrors(['email' => 'These credentials do not match our records.'])
   │          ↳ deliberately generic: never reveals whether the email exists or the
   │            account is disabled (user-enumeration protection)
   │
   └─ PASS ─► RateLimiter::clear(key)
              $request->session()->regenerate()          ← session-fixation defence
              $user->update(last_login_at, last_login_ip)
              AuditLogger::login($user)
              │
              ├─ if must_change_password → redirect('/password/change')
              └─ else → redirect()->intended('/dashboard')
```

### 10.2 Forced password change

```
GET  /password/change   (auth, active)   → form: new password + confirmation
POST /password/change
   ├─ validate: current_password (skipped when forced), 
   │            password → Password::min(10)->mixedCase()->numbers()->symbols()->uncompromised()
   ├─ update password (hashed), must_change_password = false, password_changed_at = now()
   ├─ Auth::logoutOtherDevices($newPassword)     ← invalidates the compromised temp password everywhere
   ├─ AuditLogger::passwordChanged($user)
   └─ redirect('/dashboard') with success flash
```

### 10.3 Logout
```
POST /logout  →  AuditLogger::logout()  →  Auth::logout()
                 →  session()->invalidate()  →  session()->regenerateToken()
                 →  redirect('/login')
```

### 10.4 Master-driven password reset (replaces email reset)
```
Master → Users → [Reset Password]
   ├─ Option A: master types a temporary password
   ├─ Option B: system generates a 12-char random password and displays it ONCE
   ├─ user.password = Hash::make(temp);  must_change_password = true
   ├─ DB::table('sessions')->where('user_id', $id)->delete()   ← kills active sessions
   ├─ AuditLogger::passwordReset(actor: master, subject: user)
   └─ Master communicates the password out-of-band (in person / chat)
```

> **Why no "forgot password" link?** Email-based reset is explicitly excluded, and in a ~100-person internal tool the master user is reachable in minutes. Removing the flow removes a token table, a mail template, a token-expiry job, and an entire attack surface (reset-token interception, host-header poisoning).

### 10.5 Session policy
- Driver: `database`; lifetime from Settings (default **480 min** = one working day), `expire_on_close = false`.
- `SESSION_SECURE_COOKIE=true`, `SESSION_HTTP_ONLY=true`, `SESSION_SAME_SITE=lax`, `SESSION_ENCRYPT=true`.
- "Remember me" issues a 5-year token — **disabled by default**; enable only if you want it (see Open Questions).
- Session records carry `user_id`, enabling both the forced-logout-on-disable feature and a future "active sessions" view.

---

## 11. Authorization Model

Two mechanisms, no package:

**1. `master` middleware** — coarse, route-level. Everything under User Management and Settings.

**2. Policies** — fine, record-level, invoked from Form Requests (`authorize()`) and Blade (`@can`).

| Policy | viewAny | view | create | update | delete | Special |
|---|---|---|---|---|---|---|
| `AssetPolicy` | all | all | all | all | master only | `assign`: all, if `status->canBeAssigned()` |
| `EmployeePolicy` | all | all | all | all | master only | `delete` blocked if employee holds assets |
| `AssignmentPolicy` | all | all | all | — | **never** | `return`: all, if assignment active |
| `UserPolicy` | master | master | master | master | master | `update`/`delete` blocked on **self** and on the **last remaining master** |

*(Amended v1.1)* **`SettingPolicy` and `AuditLogPolicy` dropped.** Settings are already fully protected by the `master` middleware on `/admin/*` — a policy that repeats the same boolean is duplicated logic with two places to get it wrong. Audit logs are immutable because **no update or delete route exists** for them, which is a stronger guarantee than a policy that returns `false`. Master-data deletes (departments, locations, categories) are guarded by the `master` middleware on those DELETE routes rather than by three near-identical policy classes.

Plus a global `Gate::before` that does **not** blanket-allow masters — because `UserPolicy::delete(self)` must fail even for a master. Explicit is safer than a super-admin bypass.

> **Why can regular users delete nothing?** Deletion is the only irreversible action in the system. Restricting hard/soft deletes to masters means an ordinary mistake is always recoverable, while everyday work (create, edit, assign, return) is unrestricted — matching the spec's "apart from user management, both have the same access". *This is a deliberate slight narrowing of "same access" for safety; flag it if you'd rather users could delete too.*
>
> **Why "last master" protection?** Without it, a master can delete or demote themselves and permanently lock the organisation out of user management. The check lives in the policy so it applies to every path (UI, future console command, bulk action).

---

## 12. Route Structure

`routes/web.php` requires the module files; every route is named, every parameter is model-bound.

```php
// routes/web.php  (structure only — no implementation)
Route::redirect('/', '/dashboard');
require __DIR__.'/auth.php';

Route::middleware(['auth', 'active', 'password.change'])->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');

    require __DIR__.'/modules/assets.php';
    require __DIR__.'/modules/employees.php';
    require __DIR__.'/modules/assignments.php';
    require __DIR__.'/modules/warranty.php';
    require __DIR__.'/modules/reports.php';
    require __DIR__.'/modules/masters.php';
    require __DIR__.'/modules/audit.php';
    require __DIR__.'/modules/admin.php';   // internally applies 'master'
});
```

### Route map

| Method | URI | Name | Controller@action | Middleware |
|---|---|---|---|---|
| **Auth** |
| GET | `/login` | `login` | `Auth\LoginController@show` | guest |
| POST | `/login` | `login.store` | `Auth\LoginController@store` | guest, throttle:5,1 |
| POST | `/logout` | `logout` | `Auth\LoginController@destroy` | auth |
| GET | `/password/change` | `password.change` | `Auth\PasswordChangeController@show` | auth, active |
| PUT | `/password/change` | `password.update` | `Auth\PasswordChangeController@update` | auth, active |
| GET | `/profile` | `profile.edit` | `Auth\ProfileController@edit` | std |
| PUT | `/profile` | `profile.update` | `Auth\ProfileController@update` | std |
| **Dashboard** |
| GET | `/dashboard` | `dashboard` | `DashboardController@index` | std |
| **Assets** |
| GET | `/assets` | `assets.index` | `AssetController@index` | std |
| GET | `/assets/create` | `assets.create` | `AssetController@create` | std |
| POST | `/assets` | `assets.store` | `AssetController@store` | std |
| GET | `/assets/{asset}` | `assets.show` | `AssetController@show` | std |
| GET | `/assets/{asset}/edit` | `assets.edit` | `AssetController@edit` | std |
| PUT | `/assets/{asset}` | `assets.update` | `AssetController@update` | std |
| DELETE | `/assets/{asset}` | `assets.destroy` | `AssetController@destroy` | std + can:delete |
| PATCH | `/assets/{asset}/status` | `assets.status` | `AssetController@updateStatus` | std |
| GET | `/assets/{asset}/history` | `assets.history` | `AssetController@history` | std |
| GET/POST/PUT/DELETE | `/assets/{asset}/warranties/…` | `assets.warranties.*` | `AssetWarrantyController` | std |
| GET | `/assets/{asset}/qr` | `assets.qr` | `AssetQrController@show` | std |
| GET | `/assets/qr/bulk` | `assets.qr.bulk` | `AssetQrController@bulk` | std |
| GET | `/a/{uuid}` | `assets.scan` | `AssetQrController@scan` | auth, active *(QR landing → redirects to assets.show)* |
| **Employees** |
| resource | `/employees` | `employees.*` | `EmployeeController` | std |
| GET | `/employees/{employee}/assets` | `employees.assets` | `EmployeeController@assets` | std |
| GET | `/employees/{employee}/clearance` | `employees.clearance` | `EmployeeController@clearance` | std |
| **Assignments & Returns** |
| GET | `/assignments` | `assignments.index` | `AssignmentController@index` | std |
| GET | `/assignments/create` | `assignments.create` | `AssignmentController@create` | std |
| POST | `/assignments` | `assignments.store` | `AssignmentController@store` | std |
| GET | `/assignments/{assignment}` | `assignments.show` | `AssignmentController@show` | std |
| GET | `/returns` | `returns.index` | `ReturnController@index` | std |
| GET | `/returns/create` | `returns.create` | `ReturnController@create` | std |
| POST | `/returns/{assignment}` | `returns.store` | `ReturnController@store` | std |
| **Warranty** |
| GET | `/warranty` | `warranty.index` | `WarrantyController@index` | std |
| GET | `/warranty/expiring` | `warranty.expiring` | `WarrantyController@expiring` | std |
| **Audit** |
| GET | `/audit-logs` | `audit.index` | `AuditLogController@index` | std |
| GET | `/audit-logs/{log}` | `audit.show` | `AuditLogController@show` | std |
| **Reports** |
| GET | `/reports` | `reports.index` | `ReportController@index` | std |
| GET | `/reports/{report}` | `reports.show` | `ReportController@show` | std |
| GET | `/reports/{report}/export/excel` | `reports.export.excel` | `ReportController@exportExcel` | std |
| GET | `/reports/{report}/export/pdf` | `reports.export.pdf` | `ReportController@exportPdf` | std |
| **Export / Import** |
| GET | `/export/{resource}` | `export` | `ExportController@download` | std, throttle:10,1 |
| GET | `/imports` | `imports.index` | `ImportController@index` | std |
| GET | `/imports/create` | `imports.create` | `ImportController@create` | std |
| POST | `/imports` | `imports.store` | `ImportController@store` | std, throttle:5,1 |
| GET | `/imports/{batch}` | `imports.show` | `ImportController@show` | std |
| GET | `/imports/{batch}/errors` | `imports.errors` | `ImportController@errors` | std |
| GET | `/imports/template/{module}` | `imports.template` | `ImportController@template` | std |
| **Masters** (prefix `/masters`) |
| resource ×3 | `/masters/departments`, `/masters/locations`, `/masters/categories` | `masters.*.*` | `Masters\*Controller` | std *(DELETE also `master`)* |
| **Admin** (prefix `/admin`, middleware `master`) |
| resource | `/admin/users` | `users.*` | `UserController` | master |
| PATCH | `/admin/users/{user}/toggle-status` | `users.toggle` | `UserController@toggleStatus` | master |
| PATCH | `/admin/users/{user}/reset-password` | `users.reset-password` | `UserController@resetPassword` | master |
| GET | `/admin/settings` | `settings.index` | `SettingController@index` | master |
| PUT | `/admin/settings` | `settings.update` | `SettingController@update` | master |

*(std = `auth, active, password.change`)*

> **Why `/a/{uuid}` as a separate short route?** QR codes encode a URL, and shorter URLs produce lower-density, more scannable codes. It still requires authentication — an unauthenticated scan lands on login and then redirects through, which is the correct behaviour for an internal tool.
>
> **Why is Return not a nested resource under assignments?** Because operationally it's its own task ("someone is at my desk handing back a laptop"), with its own list of outstanding items and its own form. Giving it top-level URLs and a sidebar entry matches how it's used, even though it writes to the same table.

---

## 13. Controller Structure

**Rules:** resource-controller naming (`index/create/store/show/edit/update/destroy`); constructor property promotion for service injection; no `DB::` calls; no `Model::where()` chains longer than a scope call; every action ≤ ~15 lines.

**Canonical shape**

```
AssetController
  __construct(AssetService $assets)
  index(Request $request)      → Asset::filter($request->validated())->paginate()
  create()                     → form with lookup collections
  store(StoreAssetRequest)     → $this->assets->create($request->validated()) → redirect
  show(Asset $asset)           → eager-loads assignments, warranties, audit trail
  edit(Asset $asset)
  update(UpdateAssetRequest, Asset $asset)
  destroy(Asset $asset)        → authorize('delete') → soft delete → redirect
  updateStatus(Request, Asset) → $this->assets->changeStatus(...) with transition validation
  history(Asset $asset)        → custody timeline
```

### Controller inventory

| Controller | Actions | Notes |
|---|---|---|
| `Auth\LoginController` | show, store, destroy | |
| `Auth\PasswordChangeController` | show, update | handles both forced and voluntary |
| `Auth\ProfileController` | edit, update | name/email only |
| `DashboardController` | index | delegates entirely to `DashboardService` |
| `AssetController` | 7 resource + updateStatus, history | |
| `AssetWarrantyController` | store, update, destroy | nested under asset |
| `AssetQrController` | show, bulk, scan | SVG inline, PDF label sheet, UUID landing |
| `EmployeeController` | 7 resource + assets, clearance | |
| `AssignmentController` | index, create, store, show | no edit/destroy — immutable |
| `ReturnController` | index, create, store | `index` = outstanding assignments |
| `WarrantyController` | index, expiring | read-only views over `asset_warranties` |
| `AuditLogController` | index, show | read-only, filterable, exportable |
| `ReportController` | index, show, exportExcel, exportPdf | all four dispatch to `ReportService` by report key |
| `ImportController` | index, create, store, show, errors, template | |
| `ExportController` | download | switch on resource → Export class |
| `SettingController` | index, update | master only |
| `UserController` | 7 resource + toggleStatus, resetPassword | master only |
| `Masters\*Controller` ×3 | 7 resource each | plain resource controllers, no shared base |

**Controller count: 16.**

> *(Amended v1.1)* **No abstract base for the Masters controllers.** With six near-identical lookup CRUDs a base class paid for itself. With three it doesn't — and they aren't identical anyway: departments have a head-of-department relation, locations have address fields, categories have a parent/child hierarchy and asset-tag codes. Three plain resource controllers of ~40 lines each are easier to read and change than one base class with three sets of overrides.
>
> **`ReturnController` has no `ReturnService`.** *(Amended v1.1)* It calls `AssignmentService::receiveReturn()`. Assignment and return are two halves of one invariant on one table; splitting them across two services meant the locking and pointer-maintenance logic lived in two files that must always agree. One service, two methods.

---

## 14. Service Structure

**Services exist only where at least one is true:** the operation spans multiple models, needs a transaction, enforces an invariant, or is reused by more than one entry point (web + import + command). Simple CRUD (masters, employees, users) has **no service** — the controller talks to Eloquent directly.

| Service | Why it must exist | Key operations |
|---|---|---|
| `AssignmentService` | Transaction + the "one active assignment" invariant + 3 table writes, on **both** sides of custody | `assign(Asset, Employee, array $data)`: lock asset row → verify `status->canBeAssigned()` and employee `canReceiveAssets()` → create assignment → set asset `status=assigned`, `current_*` pointers → audit → commit.<br>`receiveReturn(AssetAssignment, array $data)`: lock → verify still active → set `returned_at`, `condition_on_return`, `status=returned` → asset back to `in_stock` (or `under_repair`/`damaged` per returned condition) → null the `current_*` pointers → audit → commit |
| `AssetTagGenerator` | Race-free sequence allocation, format from Settings | `next(AssetCategory): string` using `asset_tag_sequences` row lock |
| `AssetService` | Status-transition rules + tag generation | `create()`, `update()`, `changeStatus()`, `delete()` |
| `WarrantyService` | Shared by dashboard, warranty module, report, and the email digest | `expiringWithin($days)`, `expired()`, `coverageFor(Asset)`, `digestData()` |
| `QrCodeService` | Encapsulates the QR library + label layout | `svgFor(Asset)`, `labelSheet(Collection): PDF` |
| `AuditLogger` | Single write path for the audit table | `log($event, $model, $old, $new, $description)` + auth-event helpers |
| `DashboardService` | ~10 aggregate queries; keeps the controller at 2 lines; cacheable in one place | `metrics()`, `assetsByCategory()`, `assetsByStatus()`, `recentActivity()`, `alerts()` |
| `ReportService` | Report definitions + query builders shared by HTML view, Excel export and PDF | `catalog()` (lightweight metadata for the landing page), `definition($key)`, `run($key, array $filters)` (applies the report's query, then the one shared sort/whitelist step) |
| `ImportService` | Batch lifecycle, chunked reading, per-row validation, error-report generation | `process(UploadedFile, $module, User): ImportBatch` — runs **synchronously** inside the request, chunked at 200 rows, capped at 2,000 rows per file |
| `AppSettings` | Cached read/write of the settings table | `get()`, `set()`, `all()`, `flush()` |

**Service count: 10.**

**Explicitly no service for:** Departments, Locations, Categories, Employees, Users. Their controllers are ~40 lines of standard Eloquent. Creating `DepartmentService::create()` that only calls `Department::create()` is exactly the abstraction the brief asks us to avoid.

> *(Amended v1.1)* **`AppSettings`, not `SettingsRepository`.** Same class, honest name. Calling it a repository invited exactly the pattern we've ruled out; it is a cached key/value accessor and now says so.
>
> **Imports run synchronously.** A 2,000-row asset import with chunked reading completes in a few seconds well inside a 120s `max_execution_time`. Files above the cap are rejected with "split this into smaller files" — a message a user can act on, unlike a silent timeout. This removes the queue, the `queue:work` cron, two tables and failed-job monitoring. `import_batches` is kept regardless: it is the permanent record of what was imported, by whom, and which rows failed.

---

## 15. UI Navigation & Layout

### Shell (`layouts/app.blade.php`)

```
┌────────────────────────────────────────────────────────────────────────────┐
│ TOPBAR  [☰] Aumni AMS            [ global asset search 🔍 ]   🔔3  👤 Name ▾│
├──────────────┬─────────────────────────────────────────────────────────────┤
│              │  Breadcrumb: Assets / AUM-LAP-0042                          │
│  SIDEBAR     │  ┌───────────────────────────────────────────────────────┐  │
│              │  │ PAGE HEADER      title + subtitle    [+ Primary CTA]  │  │
│ ▪ Dashboard  │  └───────────────────────────────────────────────────────┘  │
│              │  ┌───────────────────────────────────────────────────────┐  │
│ ASSETS       │  │ FLASH ALERT (success / error) — dismissible           │  │
│ ▪ All Assets │  └───────────────────────────────────────────────────────┘  │
│ ▪ Assign     │  ┌───────────────────────────────────────────────────────┐  │
│ ▪ Returns    │  │ FILTER BAR (collapsible)                              │  │
│ ▪ Warranty   │  ├───────────────────────────────────────────────────────┤  │
│ ▪ QR Labels  │  │ CONTENT — table / form / detail                       │  │
│              │  │                                                       │  │
│ PEOPLE       │  ├───────────────────────────────────────────────────────┤  │
│ ▪ Employees  │  │ Showing 1–25 of 412        [‹ 1 2 3 … ›]              │  │
│              │  └───────────────────────────────────────────────────────┘  │
│ INSIGHTS     │                                                             │
│ ▪ Reports    │                                                             │
│ ▪ Audit Logs │                                                             │
│              │                                                             │
│ DATA         │                                                             │
│ ▪ Import     │                                                             │
│ ▪ Export     │                                                             │
│              │                                                             │
│ CONFIGURE    │                                                             │
│ ▪ Masters ▾  │                                                             │
│   ├ Categories                                                             │
│   ├ Departments                                                            │
│   └ Locations│                                                             │
│ ▪ Users 🔒   │  ← rendered only when is_master                             │
│ ▪ Settings 🔒│  ← rendered only when is_master                             │
├──────────────┴─────────────────────────────────────────────────────────────┤
│ FOOTER   Aumni Techworks LLP · Aumni AMS v1.0                              │
└────────────────────────────────────────────────────────────────────────────┘
```

### Navigation rules
- **Fixed 260px sidebar** on ≥`lg`; collapses to an off-canvas drawer below `lg`. Bootstrap Offcanvas — no custom JS.
- Sections are **grouped with labels** (Assets / People / Insights / Data / Configure) rather than a flat 14-item list. Grouping is what makes a 14-module sidebar navigable.
- Master-only items are `@can`-wrapped — a regular user sees a **shorter menu**, not disabled items. Never advertise what someone can't use.
- Active state is derived from `Route::is('assets.*')` — one helper, no per-page conditionals.
- **Topbar global search** hits assets by tag/serial/name — the single most common action ("who has AUM-LAP-0042?").
- **Bell** shows warranty-expiry and overdue-return counts, linking to the filtered list. Computed from cached dashboard metrics; no notifications table.
- Breadcrumbs on every page except the dashboard.

### Component library (Blade components)
`<x-page-header>` · `<x-stat-card>` · `<x-status-badge :status>` · `<x-form.input/select/date/textarea/checkbox>` (label + error + help text in one tag) · **`<x-form.datalist>`** *(v1.1 — text input with autocomplete from existing values; used for manufacturer, vendor, warranty provider, designation)* · `<x-table.wrapper>` + `<x-table.empty-state>` · `<x-confirm-modal>` (one modal handles every destructive action via data attributes) · `<x-filter-bar>`.

### UI conventions
- Tables: server-side pagination + sortable headers + a persistent filter bar. **No DataTables client-side rendering** on large lists — it loads every row into the browser and dies at ~5,000 assets on a shared host.
- Destructive actions: always a POST/DELETE form + confirm modal. Never a bare `<a href="…/delete">`.
- Forms: Bootstrap floating labels or standard stacked labels (pick one, apply everywhere); inline validation errors under each field; old input repopulated on failure.
- Feedback: one flash-alert region, `success`/`error`/`warning` only.
- Empty states: every table has a "No assets match these filters" state with a CTA — never a blank box.
- Colour: Bootstrap defaults with a single brand primary override in `app.css`. Status colours are owned by the enums so they're consistent everywhere.
- No animations beyond Bootstrap's built-in collapse/modal transitions.
- Accessibility: proper labels, `aria-current` on active nav, ≥4.5:1 contrast on badges, keyboard-reachable everything.

---

## 16. Dashboard Layout

```
┌────────────────────────────────────────────────────────────────────────────┐
│  Dashboard                                    Last updated: 06 Aug, 10:42  │
├────────────────────────────────────────────────────────────────────────────┤
│  ROW 1 — KPI CARDS (6 × col-md-4 col-lg-2)                                  │
│  ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐         │
│  │ TOTAL  │ │ASSIGNED│ │IN STOCK│ │ REPAIR │ │WARRANTY│ │ VALUE  │         │
│  │  412   │ │  318   │ │   71   │ │   12   │ │EXPIRING│ │ ₹1.2Cr │         │
│  │ assets │ │  77%   │ │        │ │        │ │  9     │ │        │         │
│  └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘         │
│   each card links to the corresponding pre-filtered list                    │
├────────────────────────────────────────────────────────────────────────────┤
│  ROW 2 — ⚠ ACTION REQUIRED  (only rendered when non-empty)                  │
│  ┌──────────────────────────────────────────────────────────────────────┐  │
│  │ ⚠ 3 employees on notice still holding 7 assets     → View clearance   │  │
│  │ ⚠ 4 assignments past expected return date          → View overdue     │  │
│  │ ⚠ 9 warranties expire within 30 days               → View warranties  │  │
│  └──────────────────────────────────────────────────────────────────────┘  │
├────────────────────────────────────────────────────────────────────────────┤
│  ROW 3 — CHARTS                                                            │
│  ┌───────────────────────────────┐ ┌────────────────────────────────────┐  │
│  │ Assets by Category (bar)      │ │ Assets by Status (doughnut)        │  │
│  │ col-lg-7                      │ │ col-lg-5                           │  │
│  └───────────────────────────────┘ └────────────────────────────────────┘  │
├────────────────────────────────────────────────────────────────────────────┤
│  ROW 4 — TABLES                                                            │
│  ┌───────────────────────────────┐ ┌────────────────────────────────────┐  │
│  │ Recent Activity (10)          │ │ Warranties Expiring Soon (10)      │  │
│  │ from audit_logs               │ │ tag · asset · provider · days left │  │
│  └───────────────────────────────┘ └────────────────────────────────────┘  │
├────────────────────────────────────────────────────────────────────────────┤
│  ROW 5 — QUICK ACTIONS                                                     │
│  [+ Add Asset] [→ Assign Asset] [← Record Return] [+ Add Employee] [Reports]│
└────────────────────────────────────────────────────────────────────────────┘
```

**Design rationale**
- **Alerts sit above charts.** Charts are for browsing; alerts are for acting. An asset manager opening this screen needs "what's wrong today" before "how are things distributed".
- **The "on notice holding assets" alert** is the highest-value widget in the entire system — it prevents the single most common and most expensive AMS failure: an employee leaving with a laptop.
- **KPI cards are links,** not decoration. Every number answers "show me those".
- **Charts are Chart.js fed by a JSON blob** rendered server-side into the page (no AJAX endpoint, no extra request).
- **All dashboard aggregates are cached for 10 minutes** (`cache` driver = database) and busted on asset/assignment writes. On shared hosting, running ~10 `COUNT`/`GROUP BY` queries on every dashboard load is the first thing that will feel slow.
- The dashboard is **identical for master and regular users** — per the spec, the only difference between the two is user management.

---

## 17. Module Breakdown

| # | Module | Core capability | Depends on | Master-only |
|---|---|---|---|---|
| 1 | **Authentication** | Login, logout, forced/self password change, profile | — | no |
| 2 | **User Management** | CRUD users, enable/disable, reset password, master flag, last-master protection | Auth | **yes** |
| 3 | **Masters** | Departments, Locations, Asset Categories (2-level). *Manufacturer / vendor / designation are text fields with autocomplete — no CRUD screens* | Auth | no |
| 4 | **Employee Management** | CRUD, reporting hierarchy, status lifecycle (active→on notice→exited), assets-held view, clearance checklist | Masters | no |
| 5 | **Asset Management** | CRUD, auto asset tag, per-category specs, status lifecycle with transition rules, condition, custody history | Masters | no |
| 6 | **Asset Assignment** | Issue to employee, condition snapshot, expected return date, acknowledgement ref, one-active-assignment guarantee | Assets, Employees | no |
| 7 | **Asset Return** | Outstanding-assignments worklist, receive with condition, auto-route to in_stock / under_repair / damaged, overdue tracking | Assignment | no |
| 8 | **Warranty Tracking** | Multiple coverages per asset (manufacturer/extended/AMC/insurance), expiry list with day-buckets, scheduled email digest | Assets | no |
| 9 | **QR Code Generation** | Per-asset SVG QR, printable A4 label sheet (PDF), UUID scan landing → asset detail | Assets | no |
| 10 | **Audit Logs** | Every mutation + auth event, filter by user/model/event/date, per-record timeline on asset & employee pages, export, retention pruning | All | no |
| 11 | **Reports** | **22 reports** *(expanded from 6 in v1.5)* across five categories — Inventory, Employees, Assignments, Warranty, Audit — each with search, filters, a date range, sortable columns, pagination, Excel export, PDF export and a print-friendly layout, through one reusable report viewer | All | no |
| 12 | **Export Excel** | One-click export of any filtered list, honouring active filters | Reports | no |
| 13 | **Import Excel** | Template download, upload, chunked row-by-row validation (synchronous, 2,000-row cap), per-row error report download, batch history | Assets, Employees | no |
| 14 | **Dashboard** | KPIs, action alerts, charts, recent activity | All | no |
| 15 | **Application Settings** | Branding, asset tag format, warranty alert windows & recipients, pagination, date format, retention, session timeout | Auth | **yes** |

---

## 18. Development Roadmap

Each phase ships **production-ready**: migrations + models + policies + requests + controller + views + audit integration + feature tests + a manual QA pass. Nothing is "finished later".

*(Amended v1.3)* Phases 6–13 were reordered after Phase 5 shipped. The original
sequence read Warranty → QR → Audit → Dashboard → Reports → Import → Settings →
Hardening, on the reasoning that dashboard/report modules should come last
because they aggregate data that must already be correct. That reasoning still
held for Dashboard and Reports — but Import moved *earlier* than either,
because with Assets, Employees and Assignment/Return all live, the next
real-world need is getting an existing company's inventory into the system,
not reporting on data nobody has entered yet. Warranty and QR Labels — both
small, both read from `assets` alone, neither depended on by anything else —
were merged into one phase. Audit Logs UI and Hardening, similarly small and
similarly late-stage, were folded into the Settings phase.

*(Amended v1.4, after Phase 7 shipped)* Dashboard and Reports swapped places.
With Auth, Masters, Employees, Assets, Assignment/Return, Import and Warranty
all live, every module the dashboard needs to summarise already exists and
already holds real data — the "needs a populated, correct system to be worth
building" condition that originally justified running Dashboard *after*
Reports is now equally true of Dashboard *before* Reports. Meanwhile the
dashboard is the screen every user lands on after signing in, several times a
day, on day one of go-live — it is operationally more urgent than the
reporting screens, which are consulted periodically, not lived in. Reports
still needs nothing Dashboard doesn't already have available once Phase 8
ships, so nothing about its scope or design changes — only its position.

| Phase | Scope | Deliverables | Est. |
|---|---|---|---|
| **0 — Foundation** | Laravel 12 install, PHP 8.3 pin, `.env` conventions, Pint + Larastan + PHPUnit config, Bootstrap 5 + Vite pipeline, base layouts (`app`, `guest`), sidebar/topbar, Blade component library, error pages, `Auditable`/`TracksBlameable` traits, `AuditLogger`, `AppSettings`, enums, `settings` + `audit_logs` migrations, `ams:create-master-user` command | A styled, empty, logged-out shell that boots | 2–3 d |
| **1 — Auth + Users** | Login/logout/throttle, `EnsureUserIsActive`, `ForcePasswordChange`, `EnsureUserIsMaster`, profile, User CRUD, enable/disable, reset password, last-master protection, session kill on disable | Modules 1 & 2 complete + tests | 3 d |
| **2 — Masters** | 3 lookup CRUDs (departments, locations, categories), category hierarchy, active/inactive, delete-protection with friendly messages, `<x-form.datalist>` component + cached distinct-value helper | Module 3 complete | **1–2 d** |
| **3 — Employees** | Employee CRUD, hierarchy, status lifecycle, search/filter, detail page (with an empty "assets held" section wired for Phase 5) | Module 4 complete | 3 d |
| **4 — Assets** | Asset CRUD, `AssetTagGenerator` + sequences table, category-driven specs JSON, status transitions, condition, filter bar, detail page | Module 5 complete | 5–6 d |
| **5 — Assignment + Return** | `AssignmentService` (`assign` + `receiveReturn`), generated-column unique index, issue/return screens, outstanding worklist, overdue logic, custody timeline on asset & employee pages | Modules 6 & 7 complete | 4 d |
| **6 — Import** *(was Phase 11)* | Excel/CSV template download, `ImportService`, preview-before-commit, row-by-row validation with a downloadable error report, duplicate detection, `import_batches` history, re-import of failed rows only, rollback on critical (whole-file) failure, progress feedback on large files, chunked synchronous processing with a row cap | Module 13 complete | 3–4 d |
| **7 — Warranty + QR Labels** *(was Phases 6 & 7, merged)* | `asset_warranties` CRUD nested in asset, warranty list + expiry buckets, `WarrantyService`, `SendWarrantyExpiryDigest` command; bulk, multi-asset A4 QR label sheet (PDF) — per-asset QR itself shipped in Phase 4 | Modules 8 & 9 complete | 4–5 d |
| **8 — Dashboard** *(was Phase 9; moved ahead of Reports in v1.4)* | `DashboardService`, executive KPI cards, charts (category/department/location/status/warranty/procurement trend), operational widgets (recent assignments/returns/activity, upcoming warranty expiry, awaiting return, top holders), quick actions, short-TTL caching | Module 14 complete | 2–3 d |
| **9 — Reports + Export** *(was Phase 8/10)* | `ReportService` + **22 reports** across Inventory/Employees/Assignments/Warranty/Audit, one reusable report viewer (search, filters, date range, sort, pagination), a generic `ReportExport`, PDF rendering with a row cap, landing page grouped by category | Modules 11 & 12 complete | **3 d** |
| **10 — Settings + Final Polish** *(was Phases 8, 12 & 13, merged)* | Settings UI grouped by tab (Company/System Preferences/Assets & Imports/Notifications/Security), logo upload, `timezone` + mail-from runtime overrides, `import_max_rows` made configurable; Audit Logs UI (dashboard cards, search, user/module/action/date filters, detail diff view, export, `PruneAuditLogs` command); CSP review (found + fixed the `style-src` gap and the dormant `APP_TIMEZONE` config bug), dead-code and unused-asset sweep, index review, production cache verification, `docs/DEPLOYMENT.md` + `docs/BACKUP.md` runbooks | Modules 10 & 15 complete | 6–7 d |

**Total: ~35–40 working days** for a single developer at production quality *(revised down from 40–45 by the v1.1 simplifications; the v1.3/v1.4 reorders change sequence, not total scope or effort)*.

**Sequencing rationale:** the dependency chain is Foundation → Auth → Masters → Employees → Assets → Assignment. Everything from Phase 6 onward *reads* from that core; the current order optimises for "make the system usable with real, existing inventory as soon as possible" (Import next), then the two small satellite features hanging off Assets alone (Warranty + QR), then the screen every user actually lives in day to day (Dashboard), then the periodically-consulted reporting screens (Reports), then the cross-cutting, late-stage concerns that benefit from everything else already existing (Settings, Audit UI, Hardening).

---

## 19. Security Recommendations

### Authentication & session
- Argon2id or bcrypt cost 12 (`config/hashing.php`).
- Login throttle: 5 attempts / minute keyed on `email|ip`; progressive lockout after 10.
- `Auth::attempt` includes `is_active => 1` so disabled accounts fail at the query.
- Generic failure message — no user enumeration.
- Session regeneration on login; invalidation + token regeneration on logout.
- Encrypted, `Secure`, `HttpOnly`, `SameSite=Lax` session cookies; database driver with `user_id` for forced logout.
- Password policy: min 10 chars, mixed case + number + symbol, `uncompromised()` (HIBP k-anonymity check — degrade gracefully if the host blocks outbound HTTP).
- `must_change_password` after any master-initiated reset; `logoutOtherDevices` on change.
- Log every `login`, `login_failed`, `logout`, `password_reset` to `audit_logs`.
- **Optional future:** TOTP 2FA for master accounts only. Not in scope now; the schema (`two_factor_secret`, `two_factor_confirmed_at`) can be added without disruption.

### Authorization
- Every mutating route guarded by a Policy invoked from the Form Request's `authorize()` — not just by middleware. Middleware protects URLs; policies protect records.
- No `Gate::before` super-admin bypass (see §11).
- Blade `@can` controls *visibility*; the server-side policy is the real gate. Never rely on a hidden button.

### Input & output
- **All** input through Form Requests. No `$request->all()` into `create()`/`update()` — ever.
- `$fillable` (allow-list) on every model. Never `$guarded = []`.
- `{{ }}` everywhere in Blade; `{!! !!}` requires a written justification and `Purifier`-style sanitisation.
- File uploads: the Excel importer (`xlsx,xls,csv`, 10 MB cap), warranty documents (`pdf,jpg,jpeg,png`, 10 MB cap) and the company logo in Settings (`png,jpg,jpeg,svg`, **2 MB cap** — a logo has no reason to need more, and a smaller cap is a smaller attack surface). MIME **and** extension allow-list, randomised stored filename. Import files and warranty documents go to the **private** disk and are never served by URL; the logo is the one upload on the **public** disk, since it must be renderable in the sidebar without an authenticated download route.
- Excel imports: treat every cell as untrusted; validate row-by-row; guard against CSV formula injection (`=`, `+`, `-`, `@` prefixes) on **export** by prefixing with `'`.

### Transport & headers
- Force HTTPS (`.htaccess` + `URL::forceScheme('https')` in production).
- HSTS `max-age=31536000; includeSubDomains` once HTTPS is confirmed stable.
- CSP: `default-src 'self'`; no CDN means a strict policy is actually achievable. `frame-ancestors 'none'`. `script-src 'self'` with **no** exception — there is not one inline `<script>` block anywhere in the application (Chart.js's config travels as an inert `type="application/json"` data island, never executable). `style-src 'self' 'unsafe-inline'` *(amended in Phase 10)* — several views use a `style` attribute for a one-off, data-driven value (a chart's configured height, a dynamically sized table column), and the QR label sheet's `<style>` block is embedded directly because dompdf never loads the compiled stylesheet at all. CSS injection is a materially lower-severity risk than script injection, which is why only this directive relaxes.
- `X-Content-Type-Options: nosniff`, `Referrer-Policy: same-origin`, `Permissions-Policy` denying camera/mic/geolocation.

### Application & data
- `APP_DEBUG=false` and `APP_ENV=production` in production — non-negotiable; a debug stack trace exposes `.env` contents.
- `APP_TIMEZONE` actually reaches `config('app.timezone')` *(fixed in Phase 10 — it was hardcoded to `'UTC'` regardless of the env value, a latent bug nothing had caught because local development and the test suite never exercise wall-clock display)*. The `timezone` application setting (Settings → System Preferences) is the layer that actually governs `now()` at runtime, applied once per request in `AppServiceProvider::configureTimezone()`; keep the two in agreement.
- `APP_KEY` generated once, backed up separately (losing it breaks encrypted columns and sessions).
- DB user with `SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP, REFERENCES` on **one** database. Never `root`, never `GRANT ALL ON *.*`.
- Eloquent/query-builder bindings only; any raw SQL uses parameter binding, never string interpolation.
- Audit log is append-only: no update/delete routes, no model `update()` path, policies deny both.
- Rate-limit exports and imports (they're the expensive endpoints).
- Log channel: daily files, 14-day retention, `LOG_LEVEL=error` in production.

### Filesystem (cPanel-specific)
- Application root **outside** `public_html`. Only `public/` contents are web-reachable.
- `.env`, `storage/`, `vendor/`, `bootstrap/cache/` must be unreachable by URL — verified by an explicit post-deploy check (`curl https://host/.env` must 403/404).
- Permissions: directories `755`, files `644`, `.env` `600`, `storage/` + `bootstrap/cache/` writable by the PHP user only.
- Disable directory indexing (`Options -Indexes`), block dotfiles in `.htaccess`.

### Process
- `composer audit` in the release checklist.
- Dependency review quarterly; Laravel patch releases applied promptly.
- No secrets in git. `.env` is gitignored; `.env.example` documents keys with empty values.

---

## 20. Backup Strategy

**Threat model:** shared hosting fails in three ways — accidental data deletion by a user, host-side disk/account loss, and a bad deployment. The strategy must cover all three, and backups must live **off the cPanel account**.

### What is backed up
| Item | Contains | Frequency | Retention |
|---|---|---|---|
| MySQL database | all business data | **daily 02:00** + weekly full | 7 daily · 4 weekly · 6 monthly |
| `storage/app/private` | import files + generated error reports | daily | 7 daily · 4 weekly |
| `storage/app/public` | branding/logo | weekly | 4 |
| `.env` + `APP_KEY` | secrets | on change, **manually, to a password manager** | permanent |
| Source code | git repository (GitHub/Bitbucket private) | every commit | permanent |

### Mechanism
1. **Primary — `spatie/laravel-backup`**, scheduled via the single cPanel cron that runs `schedule:run`.
   - Verify `mysqldump` exists on the host during Phase 0; if it's disabled, fall back to a pure-PHP dumper command.
   - Archives are **password-encrypted** (they contain employee PII).
   - Destinations: local `storage/backups` (short retention) **+ a remote disk**.
2. **Off-site destination** — one of: an S3-compatible bucket (Backblaze B2 / Wasabi / AWS), Google Drive via a Flysystem adapter, or an SFTP target on a different host. *Backups stored only on the same cPanel account are not backups.*
3. **Secondary — cPanel's own JetBackup/Backup Wizard**, weekly full account backup downloaded to company storage. Independent of the application, so it survives an application-level failure.
4. **Monitoring** — `spatie/laravel-backup`'s health-check notification emails on failure **and** a weekly "backup healthy" email. A silent backup system is an unverified backup system.
5. **Application-level safety net** — soft deletes on all business entities plus the immutable audit log mean most "oops" incidents are recoverable in-app without touching a backup.

### Restore procedure (documented in `docs/BACKUP.md`, rehearsed before go-live)
1. Put app in maintenance mode (`php artisan down`).
2. Create a fresh empty database via cPanel.
3. Import the decrypted SQL dump via `mysql` CLI or phpMyAdmin.
4. Restore `storage/app/private` from the archive.
5. Point `.env` at the restored database; `php artisan config:cache`.
6. Smoke-test: login, asset list, one asset detail, dashboard.
7. `php artisan up`.

**Restore drill: quarterly, on the staging subdomain.** A backup that has never been restored is a hypothesis.

---

## 21. cPanel Deployment Strategy

### 21.1 Pre-flight host requirements (verify before Phase 0 ends)

| Requirement | Check |
|---|---|
| PHP 8.3 available via MultiPHP | ✅ / adjust target |
| Extensions: `bcmath, ctype, curl, dom, fileinfo, gd, json, mbstring, openssl, pcre, pdo_mysql, tokenizer, xml, zip` | `phpinfo()` |
| `memory_limit` ≥ 256M, `upload_max_filesize` ≥ 16M, `post_max_size` ≥ 20M, `max_execution_time` ≥ 120 | MultiPHP INI Editor |
| SSH / Terminal access | strongly preferred |
| Composer available (or installable to `~/bin`) | |
| Cron jobs allowed | required |
| `symlink()` enabled | affects `storage:link` |
| `proc_open`/`exec` enabled | needed by Composer & `mysqldump` |
| MySQL 8.0 / MariaDB 10.6+ | required for generated columns + JSON |
| Free SSL (AutoSSL/Let's Encrypt) | required |

### 21.2 Directory layout

```
/home/aumni/
├── ams/                        ← application root (NOT web-accessible)
│   ├── app/ bootstrap/ config/ database/ resources/ routes/ storage/ vendor/
│   ├── .env                    (chmod 600)
│   └── public/                 ← contents deployed to public_html
│
└── public_html/                ← document root (or a subdomain's docroot)
    ├── index.php               (paths edited to point at ../ams)
    ├── .htaccess
    ├── build/                  (Vite output)
    ├── favicon.ico, robots.txt
    └── storage/                (symlink → /home/aumni/ams/storage/app/public)
```

Two `index.php` lines change:
```php
require __DIR__.'/../ams/vendor/autoload.php';
$app = require_once __DIR__.'/../ams/bootstrap/app.php';
```

> **Why not put the whole app in `public_html`?** Because then `https://host/.env`, `https://host/storage/logs/laravel.log` and `https://host/database/` are one URL away. This layout makes source exposure structurally impossible rather than dependent on a correct `.htaccess`.
>
> **Alternative if the host forbids editing `index.php` paths:** deploy to a subdomain whose document root is set directly to `/home/aumni/ams/public` in cPanel's Domains → Document Root field. This is cleaner and preferred when available.

### 21.3 Deployment method

**Preferred: cPanel Git™ Version Control**, pulling from a private repo, with a `.cpanel.yml` deployment task.
**Fallback: SSH + `git pull`.**
**Last resort: zip upload via File Manager** (documented, but manual and error-prone).

### 21.4 Release procedure (`docs/DEPLOYMENT.md`)

```
LOCAL
 1. npm run build                    # Vite → public/build (Node never runs on server)
 2. composer install --no-dev --optimize-autoloader --classmap-authoritative  (verify lock)
 3. php artisan test                 # must be green
 4. git tag vX.Y.Z && git push --tags

SERVER
 5. php artisan down --secret=<token>          # maintenance mode, bypass link for QA
 6. git pull origin main
 7. composer install --no-dev --optimize-autoloader
 8. php artisan migrate --force                # forward-only
 9. php artisan config:cache
10. php artisan route:cache
11. php artisan view:cache
12. php artisan event:cache
13. php artisan storage:link                   # first deploy only
14. php artisan up
15. Smoke test: login → dashboard → asset list → one detail page
16. Verify https://host/.env returns 403/404
```

Rollback: `git checkout <previous-tag>` → re-run steps 7 & 9–12 → restore DB from the pre-deploy backup **only if a migration was destructive** (which is why migrations are additive — see §22).

### 21.5 The single cron job

cPanel → Cron Jobs → every minute:
```
* * * * * /usr/local/bin/php /home/aumni/ams/artisan schedule:run >> /dev/null 2>&1
```

Everything else hangs off Laravel's scheduler in `routes/console.php` / `bootstrap/app.php`:

| Task | Schedule |
|---|---|
| `ams:warranty-digest` | daily 09:00 (sends mail inline — no queue) |
| `backup:run` | daily 02:00 |
| `backup:clean` | daily 03:00 |
| `backup:monitor` | daily 08:00 |
| `ams:prune-audit-logs` | weekly Sunday 04:00 |
| `model:prune` (soft-deleted records past retention) | weekly |

> *(Amended v1.1)* **No `queue:work` entry — there is no queue.** The warranty digest sends its mail inline inside the scheduled command, and imports run synchronously in the request. Every scheduled command uses `withoutOverlapping()` so a slow run never stacks.
>
> **If a queue is needed later** (large imports, bulk mail), it is one `.env` change (`QUEUE_CONNECTION=database`), one `make:queue-table` migration, and one scheduler line: `queue:work --stop-when-empty --max-time=50 --tries=3` every minute. That pattern — start, drain, exit within the minute — is the shared-hosting-safe way to run a worker without a daemon. Documented here so the option stays open; not built now.

### 21.6 Performance on shared hosting
- All four caches (`config`, `route`, `view`, `event`) built at deploy — cuts bootstrap time substantially.
- OPcache enabled (`opcache.enable=1`, `validate_timestamps=0` in production; clear on deploy).
- `optimize-autoloader` + `classmap-authoritative`.
- Static assets: long `Cache-Control` + Vite content hashing in `.htaccess`; gzip/brotli via `mod_deflate`.
- Pagination everywhere; dashboard aggregates cached; no unbounded queries.
- Images/logos resized on upload, not on render.

### 21.7 Environments
- **Production:** `ams.aumnitechworks.com` (or a subfolder) — real data, `APP_DEBUG=false`.
- **Staging:** `ams-staging.…` on the same cPanel, separate DB, separate `.env`, HTTP-auth protected, seeded with anonymised data. Every release is rehearsed here first.
- **Local:** Herd/Valet/Docker with PHP 8.3 + MySQL 8, matching production versions.

---

## 22. Future Maintenance Strategy

### Code standards
- **Laravel Pint** (PSR-12) — run before every commit; optionally a git pre-commit hook.
- **Larastan level 5** — catches undefined relations, wrong types, typos in `->where()` columns.
- Naming: models singular · tables plural · controllers `XController` · Form Requests `VerbModelRequest` · routes dot-named · Blade files kebab-case.
- Every public method on a Service has a docblock stating what it guarantees (invariants), not what it does.
- No business logic in Blade. No queries in Blade. Enforced at review.

### Testing (proportionate, not exhaustive)
Feature tests for the flows where a bug is expensive:
- login / lockout / disabled account / forced password change
- master-only route protection + last-master protection
- assign → the same asset cannot be assigned twice (concurrency test)
- return → asset status and pointers are correct
- asset tag uniqueness under bulk creation
- import with a deliberately malformed row → batch reports it, doesn't abort
- audit log written for create/update/delete/assign/return

Unit tests for `AssetTagGenerator`, `WarrantyService` date maths, enum transition matrix.
**Not tested:** simple master CRUD, Blade rendering details. Test what breaks silently.

### Database evolution
- Migrations are **forward-only and additive**. Never edit a migration that has run in production.
- Column drops happen in two releases: (1) stop writing/reading it, (2) drop it next release — so a rollback never loses data.
- Every new query pattern gets an `EXPLAIN` review before merge.
- Index review annually against real row counts.

### Operational routine
| Cadence | Task |
|---|---|
| Daily (automated) | Backups, warranty digest |
| Weekly | Skim `laravel.log` for errors; check backup-health email |
| Monthly | Review audit log volume; check disk usage; review import batches that failed |
| Quarterly | `composer outdated` + `composer audit`; apply patches; **restore drill on staging**; review user list and disable leavers |
| Annually | Laravel major-version upgrade assessment; index/performance review; permissions & access audit |

### Documentation to keep current
`docs/ARCHITECTURE.md` (this file, updated when a decision changes) · `docs/DEPLOYMENT.md` (the runbook) · `docs/BACKUP.md` (backup + restore) · `docs/CHANGELOG.md` (per release, human-readable) · `README.md` (local setup in 10 steps).

### Extension points already designed in (so future work doesn't need rework)
- **Normalising manufacturer / vendor / designation into tables** → create table, `INSERT … SELECT DISTINCT`, add nullable FK, backfill by name match, drop the text column. Additive, reversible, no data loss (§4.5).
- **A queue** → `QUEUE_CONNECTION=database` + `make:queue-table` + one scheduler line (§21.5).
- **Asset document attachments** (invoices, warranty cards) → an `asset_attachments` table, a private disk and a streamed download controller guarded by `AssetPolicy`. Deliberately deferred out of V1.
- **Asset maintenance/repair tickets** → new `asset_maintenances` table hanging off `assets`; the `under_repair` status already exists.
- **Depreciation** → `depreciation_years` already on categories; add a computed report.
- **Software licences / consumables** → a category flag + a small subtype table; `specifications` JSON already handles differing attributes.
- **2FA for masters** → additive columns on `users`.
- **REST API** (e.g. a mobile scanner app) → Sanctum + `routes/api.php`; services already contain the business logic, so controllers would be thin wrappers with no duplication.
- **Notifications table** → currently derived; a real table can be added without touching existing modules.

Each of these is *anticipated but not built* — the schema and layering leave room, without paying for features nobody has asked for.

---

## 23. Design Decision Log (Why)

| # | Decision | Alternatives considered | Why this choice |
|---|---|---|---|
| 1 | No multi-tenancy anywhere, no `company_id` | Add "just in case" | Explicitly forbidden. A tenant column touches every query, index and policy forever. Removing it later is a rewrite; adding it later (never) would be a rewrite too — but we've been told it will never happen. Build for the stated requirement. |
| 2 | `is_master` boolean, no roles package | `spatie/laravel-permission` | Two roles = one boolean. The package adds 5 tables, a cache layer and permission-name string-typing to model a single yes/no. |
| 3 | Hand-written auth instead of Breeze/Fortify | Breeze | Starter kits ship registration, email verification and email password reset — all forbidden. Stripping them leaves less code than writing login from scratch, plus a maintenance burden. |
| 4 | Blade + Bootstrap, no SPA | Livewire, Inertia+Vue, Alpine-heavy | Requested, and correct: no Node in production, no API layer to secure separately, one language, fastest page loads on shared hosting, and any Laravel dev can maintain it. |
| 5 | Assignment + return in one table | Separate `assignments` and `returns` tables | A return is the closing of a custody period, not an independent event. One row per custody period makes "who has it now" a single indexed predicate and makes orphaned returns impossible. UI still presents them as two modules. |
| 6 | Generated-column unique index for one-active-assignment | Application check only | Application checks lose races and don't survive a bad manual SQL edit. The database guarantee is free and absolute. |
| 7 | Denormalised `current_assignment_id` / `current_employee_id` on assets | Always derive via subquery | The asset list is the hottest screen; deriving costs a correlated subquery per row. One writer (the services), inside a transaction, plus a reconcile command, makes the cache safe. |
| 8 | `asset_warranties` as its own table | `warranty_start`/`warranty_end` columns on assets | Real assets carry manufacturer warranty + AMC + sometimes insurance. Flat columns force data loss. `is_primary` preserves the simple case; indexed `end_date` makes expiry queries fast. |
| 9 | `specifications` as JSON | EAV table · 40 nullable columns | Attributes differ per category and change over time. JSON avoids schema churn; nothing hot filters on it. Anything that *needs* filtering gets promoted to a real column. |
| 10 | Both `uuid` and `asset_tag` on assets | Only `asset_tag` in the QR | Sequential tags are enumerable. UUID in the QR URL prevents scanning/guessing your way through the asset register; the tag stays human-friendly on the label. |
| 11 | `asset_tag_sequences` table | `MAX(asset_tag)+1` | `MAX+1` duplicates under concurrent creation and bulk import. A locked counter row is the standard correct answer. |
| 12 | Custom audit trait, not a package | `owen-it/laravel-auditing` | We need pre-rendered human descriptions, a blocklist for secrets, a UI, an export and a retention policy. ~150 lines of our own beats a dependency we'd fight. |
| 13 | Audit stores only changed attributes + a description | Full row snapshots | Keeps the largest table small and makes the UI diff trivial. Full snapshots would dominate the database within a year. |
| 14 | Settings in DB, secrets in `.env` | Everything in `.env` · everything in DB | Split by ownership: business settings must be editable by a master from the UI; infrastructure secrets must never be. |
| 15 | `database` session/cache/queue drivers | Redis · file | Redis isn't guaranteed on shared hosting. Database sessions additionally enable "kill this user's sessions now". |
| 16 | Cron-drained queue, no daemon | Sync (no queue) · Supervisor daemon | Daemons are forbidden/killed on shared hosting; sync processing makes a 5,000-row import time out. `--stop-when-empty --max-time=50` fits inside the host's tolerance. |
| 17 | `active` middleware on every request | Check only at login | A disabled account must lose access immediately, not at session expiry. Cost: one boolean on an already-loaded model. |
| 18 | Enum-encoded status transition matrix | Free-form status dropdown | Without it, disposed assets return to stock and the audit trail becomes fiction. One file defines legality; the UI just renders what's allowed. |
| 19 | Deletes restricted to masters | All users can delete | Deletion is the only irreversible action. Everything else stays unrestricted, matching "same access apart from user management". *Flagged for your confirmation in §24.* |
| 20 | `RESTRICT` on master-data foreign keys | `CASCADE` | Cascading a department delete into employees is a data-loss accident wearing a convenience costume. RESTRICT + a clear UI message ("deactivate instead") is correct. |
| 21 | Uploaded import files on a private disk | `public/` storage | Import spreadsheets contain employee PII. Public storage means one guessed URL away from exposure, with no auth check possible. *(v1.2: asset attachments, the original driver for this rule, are out of V1 — the rule still governs import files and any future upload.)* |
| 22 | App root outside `public_html` | Everything in `public_html` + `.htaccess` rules | Makes `.env`/log/source exposure structurally impossible instead of configuration-dependent. |
| 23 | SVG QR codes (`bacon/bacon-qr-code`) | PNG via Imagick | `ext-imagick` is frequently unavailable on shared hosting. SVG needs nothing, scales perfectly for print, and is smaller. |
| 24 | dompdf, not a headless-browser PDF | Browsershot/wkhtmltopdf | Both require a binary that shared hosting won't run. dompdf is pure PHP and sufficient for labels and tabular reports. |
| 25 | Server-side pagination, no client-side DataTables | DataTables full-table load | Loading 5,000+ assets into the browser is slow at best and OOM at worst — especially against a shared-host MySQL. |
| 26 | Services only where invariants/transactions exist | A service per model | The brief says don't over-engineer. `DepartmentService::create()` calling `Department::create()` is noise. |
| 27 | No `Repositories/` layer | Repository interfaces over Eloquent | Eloquent is already the abstraction. We test against a real database, so the mockability argument doesn't apply. |
| 28 | Dashboard aggregates cached 10 min | Live every request | ~10 aggregate queries per dashboard load is the first thing that will feel slow on shared hosting. Bust on write; 10 minutes is well within acceptable freshness for asset counts. |
| 29 | Enum values as `VARCHAR`, not MySQL `ENUM` | MySQL `ENUM` | Adding a status to a MySQL ENUM needs an `ALTER TABLE` (table lock on large tables). A VARCHAR + PHP backed enum gives the same safety in code with zero migration cost. |
| 30 | No email-based password reset | Laravel's password broker | Explicitly forbidden, and unnecessary in a ~100-person internal tool where a master is reachable in minutes. Removes a table, a mail flow and a whole attack surface. |

### v1.1 amendments

| # | Decision | Alternatives considered | Why this choice |
|---|---|---|---|
| 31 | Manufacturer, vendor, designation as **text + datalist** | Lookup tables (v1.0) | They are labels with no attributes of their own. Three tables, models, controllers and view sets bought a dropdown. `<datalist>` gives the same autocomplete from existing values with native HTML and zero schema. Normalising later is additive (§4.5). |
| 32 | Lookup tables kept **only** for Departments, Locations, Categories | Normalise everything · normalise nothing | Each of these three has real attributes the app reads: departments have a head and drive reporting; locations have addresses and hold assets; categories drive tag prefixes, default warranty months and the hierarchy. That is the test a lookup table has to pass. |
| 33 | **No queue at launch** (`sync`) | `database` queue + cron worker (v1.0) | Nothing needed it. The digest runs inside a cron command; imports are capped and chunked. A queue would have added two tables, a cron entry, failed-job monitoring and a silent-failure mode for zero benefit. One-line reversal if volume changes. |
| 34 | `AssignmentService` handles both assign and return | Separate `ReturnService` (v1.0) | They are two halves of one invariant on one table. Two services meant the row-locking and pointer-maintenance rules lived in two files that must never disagree. The *modules* stay separate where the user sees them — controllers, routes, screens. |
| 35 | Dropped `AssetFilter`, `Observers/`, `Exceptions/Domain/`, `ViewComposers/`, masters base controller, `SettingPolicy`, `AuditLogPolicy` | Keep them (v1.0) | Each was a class whose job a Laravel-native mechanism already does: a model scope, the `Auditable` trait's own observer, `ValidationException`, `View::share()`, three plain controllers, and route-level `master` middleware. Every one removed is a file a future maintainer doesn't have to read to understand the flow. |
| 36 | Blameable columns only on `assets` and `employees` | On every table (v1.0) | `audit_logs` already records who created and changed everything. Duplicating that into two columns on eleven tables is redundant storage that can silently disagree with the log. Kept on the two models where the UI actually displays it. |
| 37 | 6 reports, not 9 | 9 reports (v1.0) | Asset Ageing is a column on the Asset Register, not a report. Location-wise folds into the Category & Location Summary. The Audit Report duplicated the Audit Logs module's own filtered export. Three fewer screens, no lost capability. |

---

## 24. Open Questions Requiring Your Decision

**None of these block Phase 0.** Each has a working default; the "Needed by" column says when an answer actually has to exist.

| # | Question | My default | Needed by |
|---|---|---|---|
| 1 | **Host versions** — actual PHP and MySQL/MariaDB on the cPanel account? | PHP 8.3 + MySQL 8.0. If it turns out to be MariaDB < 10.6 or MySQL 5.7, the generated-column unique index (§4.8) needs a trigger-based equivalent and `specifications` JSON needs review. | **Phase 5** |
| 2 | **Can regular (non-master) users delete records?** | **No** — masters only. Everything else unrestricted for both. | Phase 3 |
| 3 | **Asset tag format** — global (`AUM-0001`) or per-category (`AUM-LAP-0042`)? | **Per-category**; configurable in Settings either way. | Phase 4 |
| 4 | **Consumables** (mouse, cable, headset) — tracked individually, or as bulk quantities? | **Individually**, like every other asset. Quantity tracking is a different data model and would be a later addition. | Phase 4 |
| 5 | **Assets assigned to a location** (e.g. a meeting-room TV) rather than a person? | **Not supported** — an asset is either in stock at a location or held by a person. `location_id` already exists, so this is addable later. | Phase 5 |
| 6 | **"Remember me"** on login? | **Off** — a long-lived cookie on an internal asset register is a poor trade. | Phase 1 |
| 7 | **Warranty alert recipients** — fixed list, or all masters? | **Configurable email list** in Settings. | Phase 6 |
| 8 | **Domain/subdomain** for production and staging? | — | Phase 13 |
| 9 | **Anticipated volume** — employees and assets in year one? | ≤ 500 employees, ≤ 5,000 assets. Above ~50,000 assets I'd revisit the indexing plan. | Phase 13 |
| 10 | **Company logo / brand colour**? | Bootstrap primary blue until assets are provided. | Phase 12 |

---

## Approval

**Architecture approved 2026-08-06** with the amendments recorded in the Revision History and marked *(Amended v1.1)* throughout.

Work begins with **Phase 0 — Foundation**, delivered as a single reviewable module before Phase 1 starts, per the development rules.

### Standing rules for every phase

1. One module at a time. A phase is finished — migrations, models, policies, requests, controller, views, audit integration, tests, manual QA — before the next begins.
2. No feature from the forbidden list (§1) ever enters the codebase, in any form, however small.
3. Every choice is checked against shared hosting: no persistent process, no unavailable binary, no extension that cPanel may not have.
4. When a Laravel-native mechanism and an enterprise pattern both solve a problem, the Laravel-native one wins.
5. Abstractions are added when a second caller appears, not in anticipation of one.
