# Customers CRUD

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

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

## Overview

`customers-crud` is the back-office CRUD for the customer master data (the CRM records the POS, pricing, orders, debt and loyalty flows all read). It is the Laravel resource controller mounted at `/customers` (`CustomerController`), exposing a paginated/searchable/birthday-filterable listing, create and edit forms, persistence, and a soft-delete. The same `store` action doubles as the **POS quick-add** endpoint: when the request expects JSON it returns the created customer as JSON instead of redirecting. 🟢 (`routes/web.php:62`, `app/Http/Controllers/CustomerController.php`)

> **Scope boundary.** The nine extra `CustomerController` routes declared *before* the resource (`/customers/scan`, `/customers/{customer}/orders`, `/check-gift`, `/gift-received`, `/statistic`, `/debt`, `POST /redeem-points`, `POST /debts`, `POST /repayments`) are **separate units** (`customers-scan`, `customers-purchase-history`, `customers-statistics`, `customers-debt-actions`, `customers-loyalty`) and are **not** covered here, even though their action methods live on the same controller. 🟢 (`routes/web.php:53-62`)

## Responsibilities

- List customers with pagination (30/page), newest-first, with a `phone`/`fullname` search and an optional birthday-month filter. 🟢 (`CustomerController::index` `:27-49`)
- Serve the create form and the edit form (the edit form also loads `Customer::$types` for the customer-tier selector). 🟢 (`create` `:56-65`, `edit` `:124-137`)
- Validate and persist a customer on create/update, parsing the display `birthday` (`d/m/Y`) into the stored `birthday2` date. 🟢 (`store` `:73-105`, `update` `:146-167`)
- Serve the POS quick-add path: when `store` is called with a JSON-expecting request, return the created customer as JSON. 🟢 (`:93-98`)
- Soft-delete a customer, returning a JSON status. 🟢 (`destroy` `:175-188`)

## Business Rules

- **`phone` is unique among live rows only.** Create validates `unique:customers,phone,NULL,id,deleted_at,NULL`; update validates `unique:customers,phone,{id},id,deleted_at,NULL`. Soft-deleted customers' phone numbers are reusable. 🟢 (`:78`, `:150`)
- **`phone` is the customer's identity key.** It is the search token, the POS `select2` value (the POS terminal remaps `id` → `phone`), and the `scopeCode` lookup column — not `id`. 🟢 (`:39`, `Customer::scopeCode` `app/Models/Customer.php:58-61`; cross-ref `pos-terminal`, `products-pricing`)
- **`type` is not settable on create; it is settable on update.** The `store` validator does not accept `type`, so a new customer's tier defaults to `khach_le` via the model accessor; only `update` validates and persists `type` (`in: khach_le, si_1, si_2`). 🟢 (`:76-84`, `:156`, `Customer::getTypeAttribute` `app/Models/Customer.php:49-51`)
- **`birthday` is a display string; `birthday2` is the parsed date.** `birthday` is validated against the regex `d/m/yyyy` and stored as typed; `store` parses it via `Carbon::createFromFormat('d/m/Y', …)->format('Y-m-d')` into `birthday2`. On a parse failure `birthday` is nulled (and `birthday2` is left unset — not persisted). 🟡 (`:82`, `:86-90`)
- **`update` does not recompute `birthday2`.** Only `store` derives `birthday2`; editing `birthday` on `update` leaves `birthday2` stale unless it is submitted directly. 🟡 (`:146-160` — no re-parse block)
- **`points` and `debt_total` are not maintained here.** Though both are `fillable`, neither the `store` nor the `update` validator accepts them; the loyalty and debt units own those balances. 🟢 (`:76-84`, `:148-157`; cross-ref `customers-loyalty`, `customers-debt-actions`)
- **Delete is a plain soft-delete, no rename.** `Customer::destroy($id)` sets `deleted_at`; unlike `products`, there is no `deleted` model event, so the name is left unchanged. Deleted customers vanish from every default-scoped query. 🟢 (`:175-188`, `app/Models/Customer.php:9-12`)
- **`type_label` is a derived, appended attribute.** It maps `type` through `Customer::$types`; it is never stored. 🟢 (`app/Models/Customer.php:25,53-55`)
- **Search is a grouped OR that stays AND-scoped to the month filter.** ✅ Fixed (2026-09-21): `q` matches `phone = q OR fullname LIKE %q%` inside a closure, so a phone match can no longer escape the `month` filter (previously ungrouped). 🟢 (`:38-42`)
- **Listing order depends on the filter.** Default is `id` descending; when `month` is present the query's ordering is reset and rows are ordered by day-of-month ascending. 🟢 (`:35-47`)

## Functional Requirements

| ID | Requirement | Priority | Acceptance criterion |
|----|-------------|----------|----------------------|
| RF-01 | List customers paginated 30/page, newest-first by default | Must | `GET /customers` renders `pages.customers` with ≤30 rows ordered by `id` desc |
| RF-02 | Search the listing by `q` over `phone` (exact) OR `fullname` (substring) | Should | `?q=090…` matches an exact phone or a name containing the term |
| RF-03 | Filter the listing by birthday `month` | Could | `?month=9` shows customers whose `birthday2` month is 9, ordered by day ascending |
| RF-04 | Serve create/edit forms; the edit form exposes the customer-tier list | Must | `GET /customers/create` and `/customers/{id}/edit` render; edit passes `Customer::$types` |
| RF-05 | Validate and persist a customer, parsing `birthday` → `birthday2` | Must | `POST /customers` with valid data creates a row; a valid `d/m/Y` birthday sets `birthday2` |
| RF-06 | Enforce `phone` uniqueness among non-deleted rows only | Must | Reusing a live customer's `phone` fails; reusing a soft-deleted one's `phone` is allowed |
| RF-07 | Serve POS quick-add: return the created customer as JSON when JSON is expected | Must | `POST /customers` with `Accept: application/json` returns the customer JSON (or `400` on failure) |
| RF-08 | Update a customer, including the `type` tier | Must | `PUT /customers/{id}` with a valid `type` persists it; `404` if the customer does not exist |
| RF-09 | Soft-delete a customer, returning a JSON status | Must | `DELETE /customers/{id}` returns `{status:true,…}` and the row is soft-deleted |
| RF-10 | Require an authenticated admin session for every action | Must | Anonymous requests are redirected to `auth/login` |

## Non-Functional Requirements

| Type | Inferred requirement | Evidence in code | Confidence |
|------|----------------------|------------------|-----------|
| Performance | Listing is bounded by `paginate(30)`; `phone` and `fullname` are indexed, but the `fullname LIKE %q%` leading wildcard cannot use the index | `CustomerController.php:39,46`, `data-dictionary.md` (`customers` indexes) | 🟡 |
| Security | Authentication required via the `['web','admin']` route group; no per-record authorization (any admin edits/deletes any customer) | `routes/web.php:24-28`, `permissions.md` | 🟢 |
| Data integrity | `phone` unique index over live rows only; soft-delete via `deleted_at`; `gender` constrained by an `in` rule | `:78,150,156`, `app/Models/Customer.php:12` | 🟢 |
| Interoperability | `store` content-negotiates: JSON body for API/POS callers, redirect-back for web forms | `:93-104` | 🟢 |

> Inferred from code. Validate listing latency at CRM scale with operations.

## Acceptance Criteria

```gherkin
Given an authenticated admin and a base of 45 live customers
When the admin opens GET /customers
Then the response is 200 rendering pages.customers with 30 rows ordered by id descending
And a second page (?page=2) shows the remaining 15

Given customers named "Anna" and "Hanna" and a customer with phone "0900000001"
When the admin opens GET /customers?q=0900000001
Then the exact-phone match is returned (plus any fullname containing "0900000001")

Given the create form
When the admin submits POST /customers with fullname="Le", phone="0912345678", birthday="5/9/1990"
Then a customer is created with birthday2 = 1990-09-05 and type defaulting to khach_le

Given a create request whose phone equals an existing LIVE customer's phone
When the admin submits POST /customers
Then validation fails on the unique rule and no customer is created

Given a POS quick-add request with Accept: application/json
When the terminal submits POST /customers with a valid fullname + phone
Then the response is the created customer as JSON (HTTP 200), not a redirect

Given an existing customer
When the admin submits PUT /customers/{id} with type="si_1"
Then the customer's type is updated to si_1 and the response redirects back with a success toastr

Given an existing customer
When the admin submits DELETE /customers/{id}
Then the response JSON is {status:true, message:<delete_succeeded>} and the row is soft-deleted

Given an unauthenticated client
When any /customers action is requested
Then the request is redirected (302) to auth/login
```

## Priority (MoSCoW)

| Requirement | MoSCoW | Justification |
|-------------|--------|---------------|
| List / create / update / soft-delete customers (RF-01/05/08/09) | Must | Customer master data backs POS, pricing, orders, debt and loyalty |
| POS quick-add JSON path (RF-07) | Must | The POS terminal creates walk-in customers inline via this action |
| `phone` uniqueness among live rows (RF-06) | Must | Phone is the identity key used by POS/pricing/scan |
| Customer-tier update (RF-08 `type`) | Must | Wholesale pricing (`si_1`/`si_2`) depends on the persisted tier |
| Auth (RF-10) | Must | Whole app is behind the admin guard |
| Search (RF-02) | Should | Operationally useful; listing still functions without it |
| Birthday-month filter (RF-03) | Could | Niche marketing use; rarely on the critical path |

> Priority inferred from downstream dependency (POS/pricing/orders/debt read customers) and call frequency.

## Identified Gaps (🔴 / 🟡)

- ✅ **`type` intentionally not settable at create.** `store` never accepts `type`, so every new customer — including POS quick-adds — starts as `khach_le`; a wholesale customer is created then edited to set their tier. Confirmed with the team (2026-09-21) this is intentional, not a gap. (`:76-84`)
- 🟡 **`update` does not recompute `birthday2` from `birthday`.** Editing the display `birthday` on `update` leaves the stored `birthday2` date stale (the month filter reads `birthday2`), unless `birthday2` is submitted directly. (`:146-160` vs `:86-90`)
- ✅ **Fixed (2026-09-21): ungrouped `orWhere` in the search.** `->where('phone',$q)->orWhere('fullname','like',…)` used to be un-grouped, letting a phone match escape the `month` filter via SQL precedence. Now wrapped in a closure, matching the `products.index`/`debts.index` fix. (`:38-42`)
- 🟡 **`phone` validation is inconsistent between create and update.** `store` uses `numeric`, `update` uses `string`; a value accepted on edit could be rejected on create. (`:78` vs `:150`)
- 🟡 **`update` `gender` rule lacks `nullable`.** It is `string|in:male,female,other` (no `nullable`), so submitting an empty `gender` string fails validation, though an absent field passes. (`:153`)
- 🟡 **`store` parse-failure silently nulls `birthday`.** On a `birthday` that `Carbon` cannot parse, the catch sets `birthday = null` (and `birthday2` is never set), so an invalid-but-regex-passing date is dropped without a user-facing error. (`:86-90`)
- 🟡 **`create` breadcrumb label is mislabeled.** The parent crumb reads `__("Sản phẩm")` ("Product") instead of "Customer" — a copy-paste artifact, cosmetic only. (`:60`)

## Code Traceability

| File | Function / Class | Coverage |
|------|------------------|----------|
| `app/Http/Controllers/CustomerController.php:27-49` | `index` (list, search, month filter) | 🟢 |
| `app/Http/Controllers/CustomerController.php:56-65` | `create` (form) | 🟢 |
| `app/Http/Controllers/CustomerController.php:73-105` | `store` (validate, birthday parse, create, JSON/redirect) | 🟢 |
| `app/Http/Controllers/CustomerController.php:113-116` | `show` — empty stub, not implemented | 🟢 |
| `app/Http/Controllers/CustomerController.php:124-137` | `edit` (form + `Customer::$types`) | 🟢 |
| `app/Http/Controllers/CustomerController.php:146-167` | `update` (validate, fill, save) | 🟢 |
| `app/Http/Controllers/CustomerController.php:175-188` | `destroy` (soft-delete, JSON status) | 🟢 |
| `app/Models/Customer.php:9-31,49-55,58-61` | `Customer` (SoftDeletes, `$fillable`, `$types`, `type`/`type_label` accessors, `scopeCode`) | 🟢 |
| `routes/web.php:62` | `resource('/customers', 'CustomerController')` | 🟢 |
