# Customers CRUD Design

## Data Model

### `customers` table

| Column | Type | Constraints | Notes |
|--------|------|-------------|-------|
| `id` | bigint PK | auto-increment | |
| `fullname` | string | not null | |
| `phone` | string | live-unique (partial index over `deleted_at IS NULL`) | business identity key |
| `email` | string | nullable, live-unique | not enforced at validator level |
| `address` | string | nullable | |
| `gender` | string | nullable, `in:male,female,other` | |
| `birthday` | string | nullable | display representation `d/m/Y` |
| `birthday2` | date | nullable | parsed `Y-m-d`; queryable; derived from `birthday` only on `store` |
| `dependant` | string | nullable | |
| `points` | — | fillable | owned by `customers-loyalty`; never written here |
| `debt_total` | float | fillable, cast | owned by `customers-debt-actions`; never written here |
| `type` | string | nullable, `in:khach_le,si_1,si_2` | null coerced to `khach_le` via accessor |
| `deleted_at` | timestamp | nullable | SoftDeletes |
| `created_at` / `updated_at` | timestamp | | |

### `Customer` model surface

| Symbol | Signature | Role |
|--------|-----------|------|
| `Customer::$types` | `['khach_le'=>…, 'si_1'=>…, 'si_2'=>…]` | tier label map; fed to edit form and `type` validation |
| `getTypeAttribute` | `($value)` | `null → 'khach_le'` — tier default on read |
| `getTypeLabelAttribute` | `()` | appended `type_label`; never stored |
| `scopeCode` | `($query, $code)` | `where('phone', $code)` — canonical phone-keyed lookup |
| SoftDeletes | — | `destroy` sets `deleted_at`; no `deleted` model event |

**Uniqueness semantics:** the partial unique index on `phone` (and `email`) covers `deleted_at IS NULL` only, so soft-deleted customers' identifiers are reusable.

**Columns `points` and `debt_total` are `fillable` but neither `store` nor `update` validates them.** They are mutated exclusively by `customers-loyalty` and `customers-debt-actions`.

---

## Internal Flows

### `index` — list / search / month filter (`CustomerController.php:27-49`)

```
1. Build base query:
   Customer::select(id, fullname, phone, email, address, gender,
                    birthday, dependant, points, created_at, updated_at,
                    birthday2, type)
           ->orderBy('id', 'desc')

2. If `q` present:
   ->where(fn: phone = $q  OR  fullname LIKE '%q%')
   ← wrapped in a closure to remain AND-scoped with any other filter

3. If `month` present:
   ->addSelect(date_format(birthday2,'%d') AS day)
   ->getQuery()->orders = null          ← reset default id-desc order
   ->whereMonth('birthday2', '=', $month)
   ->orderBy('day', 'asc')

4. ->paginate(30)
5. Render pages.customers with $list
```

### `create` — form (`CustomerController.php:56-65`)

```
1. Set page header / breadcrumb ("Customer" — legacy crumb was mislabeled "Product")
2. Render pages.customer-add with empty data  (no $types; create has no tier selector)
```

### `store` — create, web + POS quick-add (`CustomerController.php:73-105`)

```
1. Validate:
   fullname    required|string
   phone       required|numeric|unique:customers,phone,NULL,id,deleted_at,NULL
   email       nullable|string
   address     nullable|string
   gender      nullable|string|in:male,female,other
   birthday    nullable|string|regex:/^([1-9]|[1-2][0-9]|3[0-1])\/([1-9]|1[0-2])\/[0-9]{4}$/
   dependant   nullable|string
   (type not accepted)

2. try {
     $valided['birthday2'] = Carbon::createFromFormat('d/m/Y', birthday)->format('Y-m-d')
   } catch (Exception) {
     $valided['birthday'] = null   // birthday2 left unset; silent drop
   }

3. $customer = Customer::create($valided)

4. Content negotiation:
   if request()->expectsJson():
     success → response()->json($customer, 200)
     falsy   → response()->json(null, 400)
   else (web form):
     success → admin_toastr('Thêm thành công') + redirect(customers.index)
     falsy   → redirect(customers.index)->withInput()->withErrors('Thêm thất bại')
```

### `edit` — form (`CustomerController.php:124-137`)

```
1. Set page header / breadcrumb
2. $item  = Customer::findOrFail($id)   // 404 if missing or soft-deleted
3. $types = Customer::$types
4. Render pages.customer-edit with $item, $types
```

### `update` — persist changes (`CustomerController.php:146-167`)

```
1. Validate:
   fullname    required|string
   phone       required|string|unique:customers,phone,{id},id,deleted_at,NULL
   email       nullable|string
   address     nullable|string
   gender      string|in:male,female,other         ← no nullable (gap)
   birthday    nullable|string                      ← no regex; birthday2 NOT recomputed
   dependant   nullable|string
   type        nullable|in:khach_le,si_1,si_2

2. $product = Customer::findOrFail($id)  ← variable named $product (copy-paste artifact)
3. $product->fill($valided)->save()
4. success → admin_toastr('Cập nhật thành công') + redirect()->back()
   falsy   → back()->withInput()->withErrors('Cập nhật thất bại')
```

### `destroy` — soft-delete (`CustomerController.php:175-188`)

```
1. Customer::destroy($id)   // sets deleted_at; no model event; no rename
2. truthy → {status: true,  message: trans('admin.delete_succeeded')}  JSON
   falsy  → {status: false, message: trans('admin.delete_failed')}     JSON
```

### `show` — empty stub (`CustomerController.php:113-116`)

The method has no body. The resource route exists but returns an empty response.

---

## Technical Decisions

| Decision | Evidence |
|----------|----------|
| **`store` serves both the web form and the POS quick-add** via `request()->expectsJson()` content negotiation — avoids a separate endpoint | `CustomerController.php:93-104` |
| **`phone` is the customer's identity key**, not `id` — used as the search token, the POS `select2` value, and the `scopeCode` lookup column | `Customer::scopeCode` (`Customer.php:58-61`); `CustomerController.php:39` |
| **`type` is not settable at create; it is edit-only.** New customers always start as `khach_le` (via the `getTypeAttribute` null-coercion). Confirmed intentional (2026-09-21). | `CustomerController.php:76-84`, `Customer.php:49-51` |
| **Soft-delete without a rename side effect.** No `boot`/`deleted` model event, unlike the `products` unit. Deleted customers' `phone` and `email` become reusable through the live-only unique index. | `Customer.php:9-12`; `CustomerController.php:175-188` |
| **Dual date representation:** `birthday` stores the human display string (`d/m/Y`); `birthday2` stores the parsed `Y-m-d` date for SQL `whereMonth`/`date_format` queries. Parsing happens only on `store`, not on `update`. | `CustomerController.php:82,86-90,154` |
| **`q` search is wrapped in a closure** so the `phone = q OR fullname LIKE %q%` OR cannot escape a concurrent `month` filter via SQL precedence. Fixed 2026-09-21 (previously ungrouped). | `CustomerController.php:38-42` |
| **`month` filter resets the default `id desc` ordering** and replaces it with `day asc` (day-of-month extracted via `date_format`). | `CustomerController.php:41-45` |

---

## Notes

**Known gaps (inherited from legacy, not introduced here):**

- `update` never recomputes `birthday2` from `birthday`. Editing the display date leaves the stored queryable date stale — the `month` filter reads `birthday2`. (`CustomerController.php:146-160` vs `:86-90`)
- `phone` validation is inconsistent: `numeric` on `store`, `string` on `update`. A value accepted on update could be rejected on create. (`:78` vs `:150`)
- `update` `gender` rule lacks `nullable`: an empty string fails the `in` rule; an absent field passes. (`:153`)
- `store` silently nulls `birthday` on a `Carbon` parse failure — no user-facing error is emitted. (`:86-90`)
- The variable holding the fetched customer in `update` is named `$product` — a copy-paste artifact from the products controller, functional but misleading. (`:159`)
- `create` breadcrumb parent label reads `__("Sản phẩm")` ("Product") — cosmetic copy-paste artifact. (`:60`)

**Observability:** no logging, metrics, or tracing in any `CustomerController` action. Write outcomes surface only through `admin_toastr` flash messages and Laravel validation error bags.

**Authorization:** role-level only (the `['web','admin']` route group). There is no per-record authorization — any authenticated admin can edit or delete any customer.
