# TSHIRTORDER-1602 — API tạo customer mới không cần login (chỉ áp dụng channel PODDY)

## Yêu cầu gốc

> New create customer link - not required login
> add new page to create customer for channel PODDY only
> dont need to login and the custoemr is inactive after created
> see image for the files needed

Kèm 2 ảnh mockup:
- `files/mockup-edit-customer-poddy-only-hide-fields.png` — chụp màn hình trang **Redigera kund** (edit customer) hiện tại, tab **Grundinfo**, khoanh vùng "Only add to Chanel PODDY" trỏ vào dropdown **Kanal**, và đánh dấu "hide" lên 4 field: **Kundnummer**, **Typ**, **Status**, **Standard betalningstyp** (field **Information text** cũng bị gạch chéo).
- `files/mockup-edit-customer-add-delivery-address.png` — chụp màn hình cùng trang, tab **Leveransadresser**, mũi tên "Make so they can add a delivery address" trỏ vào nút **+ Lägg till leveransadress**.

> Đã trao đổi lại và **xác nhận phạm vi task này chỉ là làm 1 API endpoint (backend) để frontend gọi submit tạo customer** — không cần dựng trang/UI mới trong repo này (repo này chỉ có React admin FE tiêu thụ `/api/v1/`, phần UI form do FE team tự làm ở repo khác). 2 ảnh mockup ở trên chỉ dùng để tham khảo **field nào cần nhận vào API, field nào không** (không phải thiết kế UI thật cho trang mới).

---

## Tổng quan

Thêm 1 API endpoint mới trong `ApplicationApiBundle` để **tạo Customer mà không cần Bearer token** (khác với `api_customer_add` hiện tại luôn bắt buộc token qua `ApiService::getToken()`), chỉ cho phép tạo khi `channelId` gửi lên thuộc về channel **PODDY**, và customer tạo ra luôn có **status = "Inactive"** (bảng `status_list`, `type = customer_status`), bất kể client gửi gì lên. Field `active` (boolean) **luôn luôn set `true`** — theo xác nhận, "inactive" ở đây chỉ thể hiện qua `status`/`statusId`, không dùng field `active`.

> ⚠️ **Phát hiện quan trọng: phần persist Customer/Contact/DeliveryAddress đã có sẵn gần như đầy đủ, không cần viết lại từ đầu**
>
> - `CustomerService::add($data)` (`src/Service/CustomerService.php:417`) đã tạo `Customer`, generate `customerNr`/`customerCount` tự động qua `AutoCountService`, validate bằng `Symfony Validator`, và gọi sẵn:
>   - `__addContact()` (`CustomerService.php:305`) — nhận `data['contacts']` (mảng name/email/mobile) → map sang `CustomerContact`, đúng với block **Kontaktpersoner** trong mockup.
>   - `__addDeliveryAddress()` (`CustomerService.php:358`) — nhận `data['deliveryAddresses']` (mảng field khớp 1-1 với `CustomerDeliveryAddress` entity: `name`, `deliveryAddress`, `deliveryAddressOther`, `contactName`, `contactEmail`, `mobileNr`, `deliveryPostNr`, `deliveryCity`, `deliveryCountry`) → đúng với nút "+ Lägg till leveransadress" ở mockup thứ 2.
>   - `generateUpdate()` (`CustomerService.php:83`) dùng reflection `set{Field}` generic — mọi field trên `Customer` entity (`name`, `email`, `address`, `addressOther`, `postCode`, `city`, `country`, `mobile`, `orgNrPersonNr`, `invoiceEmail`, `kickbackInvoiceEmail`, ...) đã tự động nhận được từ `$data` mà không cần thêm code.
> - Nghĩa là việc **duy nhất còn thiếu** là: (1) 1 route/controller method public không check token, (2) chặn theo channel PODDY, (3) ép field nhạy cảm (status/active/type) về giá trị cố định phía server thay vì tin client.

> ⚠️ **Đã kiểm tra: field `status`/`statusId`/`active` trên `Customer` hiện KHÔNG được dùng để gate bất kỳ logic nghiệp vụ nào trong toàn bộ codebase.**
>
> - `getStatus()`/`getStatusId()`/`isActive()` của `Customer` chỉ xuất hiện ở 2 chỗ: `CustomerService::generateItem()` (`CustomerService.php:55-59`, trả field này cho FE hiển thị) và `ExportCustomerCsvCommand.php:55` (xuất CSV). Không có `if`/`where` nào trong `OrderService`, `InvoiceService`, hay bất kỳ service nào khác lọc/chặn theo `Customer.isActive` hay `Customer.status`.
> - Hệ thống **không có khái niệm "customer tự đăng nhập"** — `Customer` chỉ là bản ghi nghiệp vụ (khách hàng B2B), không phải tài khoản có thể login. Chỉ `User` (nhân viên/admin nội bộ) mới đăng nhập được, và `UserService::checkLogin()` (`UserService.php:506`) có check `isActive = 1` (+ `isVerify = 1`) — nhưng đó là **field `isActive` của entity `User`, hoàn toàn tách biệt** với `Customer.isActive`.
> - **Kết luận**: set status "Inactive" khi tạo customer qua endpoint này **hiện tại chỉ có tác dụng hiển thị/lọc** (ví dụ để admin lọc ra danh sách "customer mới tạo, chưa duyệt" trong trang quản lý), **không tự động chặn** việc customer này được dùng để tạo order/invoice hay bất kỳ thao tác nào khác trong hệ thống. Nếu nghiệp vụ thật sự cần chặn (VD: chặn tạo order cho customer status `Inactive`), đây là **phạm vi ngoài ticket này**, cần một task riêng để thêm check đó vào `OrderService`/nơi liên quan — task hiện tại chỉ đảm bảo đúng yêu cầu "customer tạo ra ở trạng thái inactive" (qua field `status`), còn việc gate nghiệp vụ theo trạng thái này (nếu có) chưa tồn tại sẵn.
> - **Đã xác nhận với người ra yêu cầu**: field `active` (boolean) **luôn set `true`** khi tạo qua endpoint này — không set `false`. "Inactive" của ticket chỉ áp dụng cho field `status`/`statusId` (bảng `status_list`).

---

## Luồng hoạt động

1. FE gọi `POST /api/v1/customers/public-add` (route mới, **không gửi header `Authorization`**) — **không cần truyền `channelId`**, chỉ gửi các field customer.
2. Backend tự resolve channel PODDY phía server bằng cách search **`channelName` = `"poddy"`, không phân biệt hoa thường** (không hard-code `channelId`, không phụ thuộc input từ client). Nếu vì lý do gì đó không tìm thấy channel PODDY trong DB (lỗi cấu hình hệ thống, không phải lỗi do client), trả lỗi 500 kèm log để dev biết đi fix, không phải 400 validate thông thường.
3. Backend check **trùng email**: nếu đã tồn tại 1 `Customer` khác (chưa xoá) có cùng `email` trong channel PODDY, trả lỗi 400, không tạo customer mới.
4. Backend lấy field cho phép từ mockup 1 (tab Grundinfo, trừ 4 field bị đánh dấu "hide" + "Information text"):

   | Field FE gửi | Map sang | Ghi chú |
   |---|---|---|
   | `name` | `Customer.name` | bắt buộc (`NotNull` ở entity) |
   | `email` | `Customer.email` | bắt buộc, phải là email hợp lệ (`NotNull` + `Email` ở entity) |
   | `mobile` | `Customer.mobile` | optional |
   | `orgNrPersonNr` | `Customer.orgNrPersonNr` | optional |
   | `address` | `Customer.address` | optional |
   | `addressOther` | `Customer.addressOther` | optional |
   | `postCode` | `Customer.postCode` | optional |
   | `city` | `Customer.city` | optional |
   | `country` | `Customer.country` | optional |
   | `invoiceEmail` | `Customer.invoiceEmail` | "Faktura email" |
   | `kickbackInvoiceEmail` | `Customer.kickbackInvoiceEmail` | "Självfaktura email" |
   | `contacts[]` | `CustomerContact` | "Kontaktpersoner", tái dùng `__addContact()` có sẵn |
   | `deliveryAddresses[]` | `CustomerDeliveryAddress` | tái dùng `__addDeliveryAddress()` có sẵn, khớp mockup 2 |

   **Không nhận / bỏ qua nếu client gửi** (đúng các field bị "hide" trong mockup, đồng thời là field nhạy cảm): `channelId`, `customerNr`, `typeId`/`type`, `statusId`/`status`, `active`, `informationText`, `defaultPaymentTypeId`, `bankKonto`, `bankGiro`.
5. Backend tự set: `channelId`/`channelName`/`channelShortName` = channel PODDY vừa resolve ở bước 2, `active = true` (luôn `true`, không set `false`), `statusId`/`status` = entry **"Inactive"** trong `status_list` (`type = customer_status`), rồi gọi lại `CustomerService::add()` hiện có để persist.

---

## Questions / Đã xác nhận

| # | Câu hỏi | Trả lời | Ghi chú kỹ thuật |
|---|---------|---------|-------------------|
| 1 | Nhận diện channel PODDY bằng cách nào? | Search theo `channelName = "poddy"`, không phân biệt hoa thường | Không hard-code `channelId`. Query `LOWER(TRIM(channelName)) = 'poddy'`. Lưu ý field `Channel.poddyList` (boolean) sẵn trong entity là tính năng khác (lọc order, `OrderRepository.php:123-128`), **không liên quan** — tránh nhầm. |
| 2 | Có cần chống trùng email không? | Có, chặn trùng | Nếu đã tồn tại `Customer` (chưa xoá) cùng `email` trong channel PODDY → trả lỗi 400, không tạo mới. Hiện `UniqueEntity` trên `Customer` chỉ check trùng `customerNr + channelId`; phần check `email` đang bị comment out ở `Customer.php:146-148` (constraint global, ảnh hưởng mọi flow) — nên **check thủ công trong `addPublicForPoddy()`** thay vì bật lại constraint global, tránh side-effect ngoài ý muốn cho các chỗ khác đang tạo/sửa customer. |
| 3 | "Inactive" thể hiện qua field nào — `status`/`statusId` hay `active`? | Chỉ dùng `status`/`statusId` (bảng `status_list`, `type = customer_status`, `name = "Inactive"`) | Field `active` (boolean) **luôn set `true`**, không set `false`. Đã kiểm tra: cả `status` lẫn `active` của `Customer` hiện không được dùng để gate logic nghiệp vụ nào khác trong code (xem mục "Phát hiện quan trọng" ở trên) — chỉ ảnh hưởng hiển thị/lọc trong trang quản lý. |
| 4 | Cần biết trước id thật của status "Inactive" trong DB không? | Không cần | Code không hard-code id — tra động bằng `findOneBy(['type' => TYPE_CUSTOMER_STATUS, 'name' => 'Inactive'])` mỗi lần gọi, giống hệt cách `findPoddyChannel()` tra channel PODDY bằng tên thay vì hard-code id. Chỉ cần đảm bảo entry `name = "Inactive"` tồn tại sẵn trong `status_list` (`type = customer_status`) — không tồn tại thì query trả `null`, code hiện tại (`if ($inactiveStatus) {...}`) sẽ **âm thầm bỏ qua** không set `statusId`, nên cần thêm log cảnh báo khi rơi vào case này (xem TODO). |

---

## API contract / Thiết kế kỹ thuật

### Route mới

File: `src/Application/ApiBundle/Resources/config/route/customer.yaml`

```yaml
api_customer_public_add:
    path: /public-add
    controller: App\Application\ApiBundle\Controller\CustomerController::publicAdd
    methods: ['POST']
```

`POST /api/v1/customers/public-add` — **không cần header `Authorization`**.

### Controller

File: `src/Application/ApiBundle/Controller/CustomerController.php`

```php
public function publicAdd(Request $request, ApiService $apiService, CustomerService $customerService)
{
    $data = $apiService->getRequestData($request);
    $result = $customerService->addPublicForPoddy($data);
    return $this->json($result['data'], $result['status_code']);
}
```

Không gọi `ApiService::getToken()` — đây là endpoint public duy nhất trong `CustomerController` không cần token, cần ghi rõ comment trong code lý do (tránh sau này có ai "tiện tay" thêm check token vào rồi phá flow, hoặc ngược lại copy pattern này sang chỗ khác không nên public).

### Service

File: `src/Service/CustomerService.php` — thêm method mới `addPublicForPoddy($data)`, gọi trước `add()`:

```php
private function findPoddyChannel(): ?Channel
{
    $qb = $this->em->createQueryBuilder();
    $qb->select('c')
        ->from(Channel::class, 'c')
        ->where('LOWER(TRIM(c.channelName)) = :name')
        ->andWhere('c.dateDeleted IS NULL')
        ->setParameter('name', 'poddy')
        ->setMaxResults(1);
    return $qb->getQuery()->getOneOrNullResult();
}

public function addPublicForPoddy($data)
{
    $channel = $this->findPoddyChannel();
    if (!$channel) {
        // Lỗi cấu hình hệ thống (không tìm thấy channel tên "poddy"), không phải lỗi do client
        $this->logger->critical('myApp-customerAddPublicForPoddy - PODDY channel not found by name');
        return [
            'status_code' => 500,
            'data' => ['message' => $this->trans->trans('Something went wrong, please try again later')]
        ];
    }

    $email = strtolower(trim($data['email'] ?? ''));
    if (empty($email)) {
        return [
            'status_code' => 400,
            'data' => ['message' => $this->trans->trans('Missing field {field}', ['{field}' => 'email'])]
        ];
    }
    $existingCustomer = $this->em->getRepository(Customer::class)->findOneBy([
        'email' => $email,
        'channelId' => $channel->getId(),
        'dateDeleted' => null,
    ]);
    if ($existingCustomer) {
        return [
            'status_code' => 400,
            'data' => ['message' => $this->trans->trans('Email already exists')]
        ];
    }

    // Field nhạy cảm / không cho client tự set: luôn ép theo server, không tin dữ liệu client gửi lên
    unset(
        $data['channelId'], $data['customerNr'], $data['typeId'], $data['type'],
        $data['statusId'], $data['status'], $data['active'],
        $data['informationText'], $data['defaultPaymentTypeId'],
        $data['bankKonto'], $data['bankGiro']
    );
    $data['channelId'] = $channel->getId();
    $data['active'] = true; // luôn true, "inactive" của ticket này chỉ thể hiện qua status/statusId
    $inactiveStatus = $this->em->getRepository(StatusList::class)->findOneBy([
        'type' => StatusList::TYPE_CUSTOMER_STATUS,
        'name' => 'Inactive', // tra động, không hard-code id — giống cách findPoddyChannel() tra theo tên
    ]);
    if ($inactiveStatus) {
        $data['statusId'] = $inactiveStatus->getId();
    } else {
        // Không có entry "Inactive" trong status_list — lỗi cấu hình DB, log để dev biết đi bổ sung,
        // vẫn cho tạo customer (không chặn cả nghiệp vụ chỉ vì thiếu 1 dòng status)
        $this->logger->critical('myApp-customerAddPublicForPoddy - "Inactive" status not found in status_list (type=customer_status)');
    }

    return $this->add($data);
}
```

#### ⚠️ Lưu ý bẫy quan trọng

Đây là endpoint **public, không xác thực** — bắt buộc phải `unset()` các field nhạy cảm (`channelId`, `statusId`, `active`, `typeId`, `defaultPaymentTypeId`, ...) **trước khi** gọi `generateUpdate()`, vì `generateUpdate()` (`CustomerService.php:83`) dùng reflection generic, sẽ set **bất kỳ field nào** có setter tương ứng nếu client gửi lên trong JSON. Nếu không chặn:
- Client có thể tự gửi `"statusId": <id status active>` để tạo thẳng customer với status active, bỏ qua yêu cầu "inactive after created". (`active` boolean thì không có gì phải chặn vì luôn ép `true` bất kể client gửi gì.)
- Client có thể tự gửi `"channelId": <id channel khác>` để tạo customer cho channel bất kỳ, bỏ qua yêu cầu "chỉ áp dụng channel PODDY".

Vì channel PODDY giờ do backend tự resolve (không nhận từ FE), field `channelId` **luôn luôn** phải bị `unset()` khỏi `$data` client gửi lên trước khi gán lại giá trị server tính ra — không chỉ validate mà bỏ qua như cách làm thông thường.

Check trùng email phải chạy **trước** khi gọi `add()` — nếu để `add()` tự validate thì sẽ không bắt được, vì `UniqueEntity` check trùng `email` trên `Customer` entity hiện đang **bị comment out** (`Customer.php:146-148`), và ticket này chỉ cần chặn theo `email + channelId` (PODDY) chứ không nên bật lại constraint global (ảnh hưởng các channel/flow khác).

---

## TODO List

```
### Backend — Data (verify trước khi deploy, không cần tra id vì code tra động)
- [ ] Verify entry `name = "Inactive"` đã tồn tại sẵn trong `status_list` (`type = customer_status`) trên DB (dev + production) — nếu chưa có thì tạo trước, không thì `addPublicForPoddy()` vẫn tạo được customer nhưng thiếu `statusId` (có log critical cảnh báo)

### Backend — Service
- [ ] `src/Service/CustomerService.php` — thêm `findPoddyChannel()`: resolve channel PODDY bằng `LOWER(TRIM(channelName)) = 'poddy'` (FE không truyền `channelId`)
- [ ] `src/Service/CustomerService.php` — thêm method `addPublicForPoddy($data)`: check trùng `email` trong channel PODDY (trả 400 nếu trùng), unset toàn bộ field nhạy cảm (kể cả `channelId` nếu client lỡ gửi), force `channelId` = PODDY + `active=true` (luôn true) + `statusId` = tra động theo `name = "Inactive"`, gọi lại `add()` có sẵn

### Backend — Controller & Routing
- [ ] `src/Application/ApiBundle/Controller/CustomerController.php` — thêm method `publicAdd()`, KHÔNG check `ApiService::getToken()`
- [ ] `src/Application/ApiBundle/Resources/config/route/customer.yaml` — thêm route `api_customer_public_add`, `POST /public-add`

### Test / kiểm tra
- [ ] Gọi API không kèm Authorization header, không truyền `channelId` → tạo customer thành công (status 200), customer được gán đúng channel PODDY
- [ ] Gọi API cố tình gửi kèm `channelId` của channel khác → verify customer tạo ra vẫn thuộc channel PODDY (server tự override, không bị client can thiệp)
- [ ] Gọi API cố tình gửi kèm `active: false` / `statusId` của status active → verify customer tạo ra vẫn `active = true` / status "Inactive" (không bị client override)
- [ ] Gọi API thiếu `name`/`email` → trả lỗi 400 theo validate hiện có
- [ ] Gọi API 2 lần với cùng `email` trong channel PODDY → lần 2 trả lỗi 400 trùng email, không tạo customer mới
- [ ] Gọi API với `email` đã tồn tại nhưng ở channel khác (không phải PODDY) → vẫn tạo được bình thường (chỉ check trùng trong phạm vi channel PODDY)
- [ ] Gọi API kèm `contacts[]` và `deliveryAddresses[]` → verify tạo đúng `CustomerContact`/`CustomerDeliveryAddress` liên kết đúng `customerId`
- [ ] Báo route + field contract cho FE team để tích hợp
```

---

## Các file/files liên quan

| File | Mục đích |
|------|----------|
| `src/Service/CustomerService.php` | `add()` (dòng 417, tái sử dụng), `__addContact()` (dòng 305), `__addDeliveryAddress()` (dòng 358), thêm mới `addPublicForPoddy()` |
| `src/Application/ApiBundle/Controller/CustomerController.php` | Thêm method `publicAdd()` — endpoint public duy nhất, không check token |
| `src/Application/ApiBundle/Resources/config/route/customer.yaml` | Thêm route `api_customer_public_add` |
| `src/Entity/Customer.php` | Entity + validate (`NotNull` name/email, `Email` format) |
| `src/Entity/CustomerDeliveryAddress.php` | Entity đích của `deliveryAddresses[]` |
| `src/Entity/CustomerContact.php` | Entity đích của `contacts[]` |
| `src/Entity/Channel.php` | `channelShortName` — dùng để nhận diện channel PODDY |
| `src/Entity/StatusList.php` | `TYPE_CUSTOMER_STATUS` — tra status "Inactive" |
