# Customers CRUD — Technical Design

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

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

## Interface

HTTP resource controller under the admin group (`['web','admin']`, empty admin prefix). Controller: `App\Http\Controllers\CustomerController`. All actions render Blade views (`pages.customer*`) except `store` (JSON when JSON is expected, otherwise redirect) and `destroy` (always JSON). 🟢 (`routes/web.php:62`, `CustomerController.php`)

| Method | Path | Action | Input | Output |
|--------|------|--------|-------|--------|
| GET | `/customers` | `index` | `q?`, `month?`, `page?` (query) | HTML `pages.customers` (paginated) |
| GET | `/customers/create` | `create` | — | HTML `pages.customer-add` |
| POST | `/customers` | `store` | customer fields (form or JSON) | JSON customer *(if JSON expected)* or 302 redirect back |
| GET | `/customers/{id}` | `show` | — | **empty stub** (no body) |
| GET | `/customers/{id}/edit` | `edit` | `{id}` | HTML `pages.customer-edit` (+ `types`) |
| PUT/PATCH | `/customers/{id}` | `update` | `{id}` + customer fields | 302 redirect back (toastr) or back-with-errors |
| DELETE | `/customers/{id}` | `destroy` | `{id}` | JSON `{status, message}` |

> `show` is an empty method (`:113-116`); the resource route exists but has no behaviour — a bare `GET /customers/{id}` returns an empty response. 🟢

**Store / update request fields** (validated):

| Field | Rule (store) | Rule (update) | Notes |
|-------|--------------|---------------|-------|
| `fullname` | `required\|string` | `required\|string` | 🟢 |
| `phone` | `required\|numeric\|unique:customers,phone,NULL,id,deleted_at,NULL` | `required\|string\|unique:customers,phone,{id},id,deleted_at,NULL` | live-only uniqueness; `numeric` vs `string` inconsistency 🟡 (`:78,150`) |
| `email` | `nullable\|string` | `nullable\|string` | not enforced unique at validator level 🟢 |
| `address` | `nullable\|string` | `nullable\|string` | 🟢 |
| `gender` | `nullable\|string\|in:male,female,other` | `string\|in:male,female,other` | update rule lacks `nullable` 🟡 (`:81,153`) |
| `birthday` | `nullable\|string\|regex:/^([1-9]\|[1-2][0-9]\|3[0-1])\/([1-9]\|1[0-2])\/[0-9]{4}$/` | `nullable\|string` | display `d/m/yyyy`; parsed to `birthday2` only on store 🟢 (`:82,86-90,154`) |
| `dependant` | `nullable\|string` | `nullable\|string` | 🟢 |
| `type` | *(not accepted)* | `nullable\|in:khach_le,si_1,si_2` | tier set only on update; defaults `khach_le` via accessor 🟢 (`:156`) |

> `points` and `debt_total` are `fillable` on the model but are **not** in either validator, so this unit never writes them (owned by `customers-loyalty` / `customers-debt-actions`). 🟢 (`app/Models/Customer.php:15`)

Model surface used:

| Symbol | Signature | Note |
|--------|-----------|------|
| `Customer::$types` | `['khach_le'=>…, 'si_1'=>…, 'si_2'=>…]` | tier list for the edit form + `type` validation 🟢 (`Customer.php:27-31`) |
| `Customer::getTypeAttribute` | `($value)` | `null → 'khach_le'` 🟢 (`Customer.php:49-51`) |
| `Customer::getTypeLabelAttribute` | `()` | appended `type_label` via `$types` map 🟢 (`Customer.php:53-55`) |
| `Customer::scopeCode` | `($query, $code)` | `where('phone', $code)` — phone is the lookup key 🟢 (`Customer.php:58-61`) |
| `Customer` (SoftDeletes) | — | `destroy` sets `deleted_at`; no `deleted` event/rename 🟢 (`Customer.php:9-12`) |

## Main Flow

### `index` — list / search / month filter 🟢 (`:27-49`)

1. Set page header/breadcrumb ("Khách hàng"). 🟢 (`:29-32`)
2. Build the base query: `Customer::select(id, fullname, phone, email, address, gender, birthday, dependant, points, created_at, updated_at, birthday2, type)->orderBy('id','desc')`. 🟢 (`:34-35`)
3. If `q` is present, apply a grouped `where(fn: phone = $q OR fullname LIKE '%q%')` — ✅ fixed 2026-09-21 (was an ungrouped OR, see Risks). 🟢 (`:38-42`)
4. If `month` is present, add a `date_format(birthday2,'%d') AS day` select, **reset the query ordering** (`getQuery()->orders = null`), filter `whereMonth('birthday2','=',$month)`, and order by `day` ascending. 🟢 (`:41-45`)
5. `paginate(30)` and render `pages.customers` with `list`. 🟢 (`:46-48`)

### `create` — form 🟢 (`:56-65`)

1. Set header ("Thêm khách hàng"); the breadcrumb parent crumb is mislabeled `__("Sản phẩm")` ("Product"). 🟡 (`:58-62`)
2. Render `pages.customer-add` with an empty data array (no `types` passed — create has no tier selector). 🟢 (`:64`)

### `store` — create (web + POS quick-add) 🟢 (`:73-105`)

1. Validate the create ruleset (`fullname`, `phone` numeric+live-unique, `email`, `address`, `gender in:…`, `birthday` regex, `dependant`). 🟢 (`:76-84`)
2. `try` to parse `birthday` (`Carbon::createFromFormat('d/m/Y', birthday)->format('Y-m-d')`) into `$valided['birthday2']`; on `Exception` set `$valided['birthday'] = null` (and leave `birthday2` unset). 🟡 (`:86-90`)
3. `Customer::create($valided)`. 🟢 (`:92`)
4. **Content negotiation:** if `request()->expectsJson()` → return `response()->json($customer)` on success, or `response()->json(null, 400)` on falsy. 🟢 (`:93-98`)
5. Otherwise (web form): on success `admin_toastr('Thêm thành công')` + redirect to `customers.index`; on falsy, redirect to `customers.index` with input + error `'Thêm thất bại'`. 🟢 (`:100-104`)

### `edit` — form 🟢 (`:124-137`)

1. Set header ("Chi tiết khách hàng") / breadcrumb. 🟢 (`:127-131`)
2. `Customer::findOrFail($id)` (`404` if missing/soft-deleted), and `$types = Customer::$types`. 🟢 (`:133-134`)
3. Render `pages.customer-edit` with `item`, `types`. 🟢 (`:136`)

### `update` — persist changes 🟢 (`:146-167`)

1. Validate the update ruleset (`phone` string+live-unique-except-id, `type nullable|in:…`, `gender string|in:…` without `nullable`; no `birthday2` re-parse). 🟡 (`:148-157`)
2. `Customer::findOrFail($id)` (assigned to a variable named `$product` — a copy-paste artifact). 🟡 (`:159`)
3. `fill($valided)` then `save()`. 🟢 (`:160-162`)
4. On success `admin_toastr('Cập nhật thành công')` + `redirect()->back()`; on falsy, `back()->withInput()->withErrors('Cập nhật thất bại')`. 🟢 (`:162-166`)

### `destroy` — soft-delete 🟢 (`:175-188`)

1. `Customer::destroy($id)` (SoftDeletes sets `deleted_at`; no rename event). 🟢 (`:177`)
2. Return JSON `{status:true, message: trans('admin.delete_succeeded')}` on truthy, else `{status:false, message: trans('admin.delete_failed')}`. 🟢 (`:177-187`)

## Alternative Flows

- **POS quick-add (`store`, JSON expected):** returns the created `Customer` as JSON (`200`) instead of redirecting; a falsy create returns `400` with a `null` body. The POS terminal consumes this to add a walk-in customer inline. 🟢 (`:93-98`; cross-ref `pos-terminal`)
- **Invalid `birthday` on create:** if `Carbon` cannot parse `birthday`, `birthday` is set to `null`, the customer is still created, and `birthday2` is not persisted. 🟡 (`:86-90`)
- **`month` filter present:** the default `id desc` ordering is discarded and results are ordered by day-of-month ascending. 🟢 (`:42-44`)
- **Missing customer on `edit`/`update`:** `findOrFail` → `404`. 🟢 (`:133,159`)

## Dependencies

- **`Customer` model** — persistence, SoftDeletes, `$types`, `type`/`type_label` accessors, `scopeCode`. 🟢 (`app/Models/Customer.php`)
- **Encore\Admin admin guard / `['web','admin']` group** — authentication for every action (`auth` unit). 🟢 (`routes/web.php:24-28`)
- **`admin_toastr` / `trans('admin.*')`** — Encore\Admin flash + i18n helpers. 🟢 (`:101,163,180,184`)
- **`Carbon`** — `birthday` → `birthday2` parsing on store. 🟢 (`:87`)
- **Blade views** `pages.customers`, `pages.customer-add`, `pages.customer-edit` — presentation (not re-specified here). 🟢

## Identified Design Decisions

| Decision | Evidence in code | Confidence |
|----------|------------------|-----------|
| Same `store` action serves both the web form and the POS quick-add via `expectsJson()` content negotiation | `CustomerController.php:93-104` | 🟢 |
| `phone` (not `id`) is the customer identity/lookup key across POS/pricing/scan | `Customer::scopeCode` `Customer.php:58-61`; `pos-terminal` id→phone remap | 🟢 |
| Customer tier (`type`) is edit-only; new customers default to retail (`khach_le`) | `store` ruleset `:76-84`, `getTypeAttribute` `Customer.php:49-51` | 🟢 |
| Soft-delete without a rename side effect (unlike `products`) | `Customer.php:9-12` (no `boot`/`deleted` event) | 🟢 |
| Dual date representation: display `birthday` string + queryable `birthday2` date | `:86-90`, `data-dictionary.md` (`birthday`/`birthday2`) | 🟢 |

## Internal State

This unit holds no in-process state; all state is the `customers` table row (soft-deletable). `points` / `debt_total` are columns on that row but are mutated by other units, not by CRUD. 🟢 (`app/Models/Customer.php:15-19`)

## Observability

No logging, metrics or tracing is emitted by any `CustomerController` CRUD action. Writes surface to the user only through `admin_toastr` flash messages and validation error bags. 🔴 (no logger/metric calls in `:27-188`)

## Risks and Gaps

- ✅ **Create-time tier is intentionally retail-only.** `store` cannot set `type`; every new customer (web and POS) is `khach_le` until edited. Confirmed with the team (2026-09-21) as intended, not a gap. (`:76-84`)
- ✅ **Fixed (2026-09-21): ungrouped `orWhere` search.** Now grouped in a closure so a phone match can no longer escape the `month` filter. (`:38-42`)
- 🟡 **`update` never recomputes `birthday2`.** Editing `birthday` leaves the queryable `birthday2` stale. (`:146-160`)
- 🟡 **`phone` rule mismatch (`numeric` on create, `string` on update)** — a value editable may be un-creatable, and vice versa. (`:78,150`)
- 🟡 **`update` `gender` lacks `nullable`** — an empty `gender` string fails the `in` rule on edit. (`:153`)
- 🟡 **Silent `birthday` drop on parse failure** — invalid dates are nulled with no user feedback. (`:86-90`)
- 🟡 **No per-record authorization** — any authenticated admin can edit/delete any customer (`permissions.md`). (`routes/web.php:24-28`)
