# User Stories — Customer Management (CRM)

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

**Confidence scale:** 🟢 CONFIRMED · 🟡 INFERRED · 🔴 GAP

**Actor:** Store Administrator.
**Owning units:** `customers-crud`, `customers-scan`, `customers-purchase-history`, `customers-statistics`.

Customers are CRM records — never accounts (no login). A customer carries a pricing tier (`type`), a live loyalty `points` balance, and a live `debt_total`. **Phone** (not id) is the business identity used for search, POS selection, and lookups. 🟢

---

### US-CUST-1 — Browse and search customers

**As a** Store Administrator, **I want** to list and search customers, **so that** I can find a shopper's record.

- **Given** the customer list
- **When** I open `GET /customers`
- **Then** it orders by id desc, paginates 30/page; `q` matches phone (exact) OR fullname (`LIKE %q%`); an optional `month` filter selects by birthday month 🟢

Notes / gaps:
- The `q` search is a grouped closure, so `month` stays ANDed with the phone/fullname match. ✅ Fixed 2026-09-21 (was ungrouped, letting a phone match escape the `month` filter). 🟢

Traces to: `customers-crud/` (`index`)

---

### US-CUST-2 — Register a new customer

**As a** Store Administrator, **I want** to create a customer, **so that** I can attach them to sales and track points/debt.

- **Given** the create form (or the POS quick-add)
- **When** I submit `POST /customers` (validating fullname / phone numeric+live-unique / email / address / gender / birthday `d/m/yyyy` / dependant)
- **Then** the customer is created; birthday is parsed into `birthday2` (nulled silently on a parse failure); if the request `expectsJson()` (POS quick-add) the created customer returns as JSON, otherwise a web redirect + toastr 🟢

Notes / gaps:
- `type` is **not** accepted at create — every new/quick-add customer defaults to `khach_le` until edited. 🔴 (confirm intended)
- An unparseable birthday is silently nulled with no user feedback. 🟡
- The create breadcrumb is mislabeled "Sản phẩm"/Product. 🟡

Traces to: `customers-crud/` (`store`, content-negotiated)

---

### US-CUST-3 — Edit a customer and set their price tier

**As a** Store Administrator, **I want** to edit a customer and assign a wholesale tier, **so that** they get the right prices at checkout.

- **Given** an existing customer
- **When** I submit `PUT /customers/{id}` (phone as string, `type` nullable|in `khach_le`/`si_1`/`si_2`)
- **Then** the record updates; **this is the only action that sets the tier** 🟢

Notes / gaps:
- `update` does **not** re-derive `birthday2`, so an edited birthday goes stale. 🟡
- Phone rule differs create (numeric) vs update (string); update gender lacks `nullable`. 🟡

Traces to: `customers-crud/` (`edit` / `update`)

---

### US-CUST-4 — Soft-delete a customer

**As a** Store Administrator, **I want** to remove a customer, **so that** obsolete records stop appearing while history survives.

- **Given** a customer
- **When** I request `DELETE /customers/{id}`
- **Then** it is soft-deleted (no rename side effect, unlike products) with a `{ status, message }` JSON response 🟢

Traces to: `customers-crud/` (`destroy`)

---

### US-CUST-5 — Autocomplete a customer at the terminal (system-facing)

**As the** POS terminal, **I want** to search customers as the cashier types, **so that** an existing shopper can be attached quickly.

- **Given** a typed query
- **When** the client calls `GET /customers/scan?q=<text>`
- **Then** it returns up to 10 customers matching phone OR fullname (`LIKE %q%`) as a JSON **array** (empty ⇒ `[]`), each with the appended `type` + `type_label` so the client binds identity and tier in one round-trip 🟢

Notes / gaps:
- 🟢 **Verified not a bug:** although the two `LIKE`s are ungrouped, Laravel's `Builder::callScope()` isolates the `SoftDeletes` global scope's `deleted_at IS NULL` into its own nested `AND` group, so a soft-deleted customer cannot leak via either branch (confirmed against the framework source). Only hand-written filters need explicit grouping.
- Empty/missing `q` becomes `LIKE '%%'`, returning the first 10 live rows rather than `[]` — contract unconfirmed. 🔴
- The `else json(null, 204)` branch is dead code (a Collection is always truthy). 🟡

Traces to: `customers-scan/` (`CustomerController::scan`)

---

### US-CUST-6 — Review a customer's purchase history

**As a** Store Administrator, **I want** to see everything a customer has bought, **so that** I can answer questions and understand their spend.

- **Given** a customer
- **When** I open `GET /customers/{customer}/orders`
- **Then** it lists BOTH draft and done orders newest-first (20/page), with an optional category filter (`whereHas('products')` EXISTS), and a **lifetime spend total** computed as a separate unfiltered `SUM(total)` 🟢

Notes / gaps:
- The header total ignores the category filter, so a filtered page's total can exceed the visible rows. 🟡
- The dropdown lists only **child** categories (`whereNotNull('parent_id')`), matching the product forms. ✅ Fixed 2026-09-21 (was every category including parents, so selecting a parent yielded an empty dead-end list). 🟢
- Per-row `debt_locked` can trigger up to 20 queries/page (N+1) if the view reads it. 🟡

Traces to: `customers-purchase-history/` (`CustomerController::orders`)

---

### US-CUST-7 — Review a customer's statistics summary

**As a** Store Administrator, **I want** a quick per-customer purchasing summary, **so that** I can gauge their value and category mix.

- **Given** a customer
- **When** I open `GET /customers/{customer}/statistic`
- **Then** headline totals (orders_count / amount_total / points_total) come from the **nightly** `customer_order_summary` read model, while `debt_total` and current `points` are read **live** off the customer 🟢
- **And** category breakdowns split milk (id 1) and medicine (id 8) into their own buckets, aggregating the rest as "other" 🟢
- **And** the annotated-orders panel lists only orders with a note (`whereNotNull('notes')`, 10/page), each editable inline via `PUT /orders/{id}/note` 🟢

Notes / gaps:
- Numbers are stale up to ~24h; the only freshness cue is `summary.updated_at`. 🟡
- A null summary row (job not yet run) renders a `"chưa cập nhật"` placeholder instead of a date, distinguishing it from a genuine all-zero customer with a real summary. ✅ Corrected 2026-09-23 (was flagged as indistinguishable — contradicted the page's own RF-07). Residual: a customer with an existing-but-stale summary (a silently-failed nightly refresh) shows an old date, not a prominent warning. 🟢
- Medicine category id 8 is a code-marked placeholder — confirm the production id. 🟡

Traces to: `customers-statistics/` (`CustomerController::statis`), fed by `Order::summaryLogging` (Scheduler, ADR-0005)
