# 01 — Tổng quan kiến trúc hệ thống

> Tài liệu này là điểm khởi đầu của bộ spec `/docs/spec`. Đọc file này trước để hiểu bức tranh tổng thể, sau đó đi vào từng domain cụ thể ở các file 02–12.

## 1. Hệ thống này là gì?

**TshirtOrder** là một nền tảng quản lý bán hàng B2B (và một phần B2C qua webshop) cho ngành in ấn/áo thun theo yêu cầu (POD — print on demand). Hệ thống quản lý toàn bộ vòng đời từ catalogue sản phẩm, đơn hàng, sản xuất (in/thêu/ép chuyển nhiệt...), giao hàng (PostNord), thanh toán (Svea/Swish), cho tới hoá đơn, nhắc nợ, hoa hồng đại lý (kickback) và đồng bộ với các nền tảng bán hàng bên ngoài (Shopify, WooCommerce/WordPress).

Đặc điểm cốt lõi: hệ thống là **multi-tenant theo "Channel"** — mỗi `Channel` là một khách hàng doanh nghiệp/một cửa hàng riêng (có thể là một nhãn hàng B2B nội bộ, hoặc một shop Shopify/WooCommerce thật), có cấu hình riêng về hoá đơn, giao diện webshop, tích hợp thanh toán, đồng bộ tồn kho...

## 2. Tech stack

| Thành phần | Công nghệ |
|---|---|
| Backend framework | Symfony 7.1, PHP ≥ 8.2 |
| Database | PostgreSQL (qua Doctrine ORM ^3.2 / DBAL ^3) |
| ORM mapping | PHP Attributes, naming strategy `underscore_number_aware`, custom DQL `ILIKE` (tìm kiếm không phân biệt hoa/thường kiểu Postgres) |
| Migration | doctrine-migrations-bundle — **viết tay** bằng `doctrine:migrations:generate`, **KHÔNG dùng `doctrine:migrations:diff`**: schema DB đã lệch so với entity nên diff luôn kéo theo `DROP TABLE activity_log_yYYYYmMM` (các partition theo tháng của `activity_log` → mất dữ liệu log) và `DROP INDEX` các index đã thêm tay. Xem mục *Migrations* trong `CLAUDE.md` và `Version20260924090615` |
| PDF | `dompdf/dompdf` (render HTML/Twig → PDF) + `iio/libmergepdf` (gộp PDF) |
| Excel | `phpoffice/phpspreadsheet` (import/export .xlsx, .csv) |
| Email giao dịch | `mailchimp/transactional` (Mandrill) — **không dùng Symfony Mailer/SMTP** cho email nghiệp vụ |
| Thanh toán | `sveaekonomi/checkout` (Svea Checkout SDK) + tích hợp Swish tự viết bằng cURL + mTLS |
| SFTP/FTP | `phpseclib/phpseclib` (SFTP là chính; một vài chỗ dùng FTP thuần qua hàm `ftp_*` của PHP) |
| API docs | `nelmio/api-doc-bundle` (OpenAPI, chỉ áp dụng cho `/api/v1/*`) |
| CORS | `nelmio/cors-bundle` |
| Phân trang | `knplabs/knp-paginator-bundle` |
| Hàng đợi | Symfony Messenger, transport mặc định là **Doctrine** (bảng trong Postgres), không dùng RabbitMQ/Redis |
| Test | PHPUnit 9.5, Symfony test bridge |

Không có SDK Shopify chính thức — tích hợp Shopify được viết tay bằng GraphQL Admin API qua `symfony/http-client`/cURL.

## 3. Hai bundle chính

Toàn bộ nghiệp vụ chia làm 2 "cửa vào" (bundle Symfony), dùng chung tầng `src/Entity` + `src/Service` bên dưới:

```
                        ┌───────────────────────────┐
                        │   src/Entity (Doctrine)    │
                        │   src/Repository            │
                        │   src/Service (nghiệp vụ)   │
                        └─────────────┬─────────────┘
                    ┌──────────────────┴──────────────────┐
                    ▼                                       ▼
     ApplicationApiBundle                     ApplicationWebshopBundle
     /api/v1/...                              /ecom/{channelEcomId}/...
     REST JSON, có xác thực Bearer token       Server-side render (Twig), public
     Dùng bởi: React admin/backoffice          Dùng bởi: khách vãng lai mua hàng
```

### 3.1. `ApplicationApiBundle` — REST API quản trị

- Route gốc: `/api/v1/...`, khai báo tại `src/Application/ApiBundle/Resources/config/routing.yaml`, mỗi resource (channel, order, invoice...) có 1 file YAML riêng trong `Resources/config/route/`.
- Controller: `src/Application/ApiBundle/Controller/*.php` — **controller rất mỏng**, chỉ parse request rồi gọi Service tương ứng, không chứa logic nghiệp vụ.
- Đối tượng dùng: ứng dụng React admin/backoffice nội bộ, và một số hệ thống bên ngoài gọi vào (import đơn hàng, webhook Shopify...) bằng "integration token".
- Trả JSON, luôn theo khuôn dạng chuẩn `{"data": ..., "status_code": ...}` do Service trả về, Controller chỉ forward nguyên trạng.
- **Không dùng firewall Symfony Security** — `config/packages/security.yaml` không khai báo `access_control` nào cho `/api`. Xác thực được kiểm tra **thủ công trong từng action controller** (xem file 12 — Bảo mật).

### 3.2. `ApplicationWebshopBundle` — Cửa hàng trực tuyến (ecom)

- Route gốc: `/ecom/{channelEcomId}/...` — `{channelEcomId}` là slug của `Channel`, cho phép nhiều cửa hàng (nhiều `Channel`) dùng chung 1 codebase nhưng khác giao diện/dữ liệu.
- Gần như toàn bộ nằm trong **một controller duy nhất**: `AppController.php` (~1300 dòng) — render Twig HTML, không phải JSON API (trừ 2 endpoint AJAX nhỏ: kiểm tra coupon, tạo đơn Svea).
- **Không cần đăng nhập** — khách vãng lai duyệt/mua hàng, trạng thái giỏ hàng lưu trong **session** (không phải tài khoản user).
- Chi tiết đầy đủ ở file `07-webshop-bundle.md`.

## 4. Luồng request điển hình (API)

```
Frontend React → POST /api/v1/orders/  (Header: Authorization: Bearer <token>)
    → OrderController::add()
    → ApiService::getToken($authorization)   // xác thực, trả về User hoặc null
    → OrderService::add($data)               // toàn bộ logic nghiệp vụ ở đây
    → EntityManager persist/flush
    → trả về ['data' => [...], 'status_code' => 200]
    → Controller trả JSON nguyên trạng
```

Quy ước bắt buộc trong mọi Service (`src/Service/*.php`):

| Method | Ý nghĩa |
|---|---|
| `generateItem(Entity $e): array` | Chuyển 1 entity thành mảng trả về cho frontend |
| `list($criteria)` | Danh sách có phân trang/lọc |
| `find($id)` | Lấy 1 bản ghi, 404 nếu không có/đã xoá mềm |
| `add($data)` | Tạo mới |
| `update($id, $data)` | Cập nhật |
| `delete($id)` | **Xoá mềm** (set `dateDeleted`) — phần lớn entity theo quy ước này, một số ngoại lệ xoá cứng được ghi chú riêng trong từng file domain |

Mọi method Service trả về đúng khuôn `['data' => ..., 'status_code' => int]`.

## 5. Bản đồ các domain dữ liệu (đọc chi tiết ở file 02–05)

| # | Domain | Entity chính | File chi tiết |
|---|---|---|---|
| 1 | Kênh bán & cấu hình hệ thống | `Channel`, `User`, `StatusList`, `ActivityLog`, `AutoCount`, CMS (`WikiPage`, `WebshopMenuItem`, `BannerSet`, `EmailTemplate`) | `02-du-lieu-kenh-cau-hinh.md` |
| 2 | Sản phẩm & sản xuất | `Product`, `ProductCategory`, `ProductModel`, `Production`, `Brand` | `03-du-lieu-san-pham-san-xuat.md` |
| 3 | Khách hàng & đơn hàng | `Customer`, `Order` (entity trung tâm ~150 cột), `OrderProduct`, `BulkOrder`, `ReturnOrder`, `EcomOrderTemp` | `04-du-lieu-khach-hang-don-hang.md` |
| 4 | Tài chính & hoá đơn | `Invoice`, `DownPayment*`, `KickbackInvoice`, `Credit`, `CouponCampaign/Code` | `05-du-lieu-tai-chinh-hoa-don.md` |

Và các mảng vận hành:

| # | Chủ đề | File |
|---|---|---|
| 5 | API Bundle — toàn bộ endpoint | `06-api-bundle.md` |
| 6 | Webshop Bundle — luồng mua hàng | `07-webshop-bundle.md` |
| 7 | Service nghiệp vụ lõi (Auth, Mail, PDF, File, OrderService...) | `08-service-nghiep-vu-loi.md` |
| 8 | Tích hợp bên thứ ba (Shopify, WooCommerce, Svea, Swish, PostNord, FTP) | `09-tich-hop-ben-thu-ba.md` |
| 9 | Cronjob định kỳ | `10-cronjob-dinh-ky.md` |
| 10 | Script bảo trì một lần (⚠️ có script phá huỷ dữ liệu) | `11-script-bao-tri-mot-lan.md` |
| 11 | Bảo mật, xác thực, cấu hình | `12-bao-mat-xac-thuc-cau-hinh.md` |

## 6. Các quy ước/pattern lặp lại xuyên suốt hệ thống

Hiểu các pattern này sẽ giúp đọc nhanh mọi entity/service khác trong hệ thống:

1. **Xoá mềm (soft delete)**: hầu hết entity có cột `dateDeleted` (nullable datetime); mọi query `list()`/`find()` mặc định lọc `dateDeleted IS NULL`. Một số entity xoá cứng (hard delete) không nhất quán — xem ghi chú "⚠️" trong từng file domain.
2. **Snapshot / phi chuẩn hoá (denormalization)**: dữ liệu khách hàng/kênh/sản phẩm được **copy trực tiếp** vào đơn hàng, hoá đơn... tại thời điểm tạo, thay vì join sống. Mục đích: giữ đúng lịch sử ngay cả khi bản ghi gốc (VD `Customer`) sau này bị sửa. Ví dụ: `Order.customerName`, `Order.customerEmail` là bản sao tại thời điểm đặt hàng, không phải join tới `Customer` hiện tại.
3. **`StatusList` — bảng "enum" tổng quát**: thay vì hard-code enum trong code, hệ thống dùng 1 bảng `status_list` dùng chung cho mọi loại trạng thái/phân loại (trạng thái đơn hàng, loại thanh toán, loại giao hàng, loại sản phẩm, trạng thái hoá đơn...), phân biệt bằng cột `type`. Xem chi tiết ở file 02.
4. **`AutoCount` — bộ đếm số thứ tự dùng chung**: mọi entity cần số chạy (`orderCountNr`, `invoiceCountNr`, `customerNr`...) đều lấy từ 1 service atomic `AutoCountService::generateNewCount($name)` (dùng `SELECT ... FOR UPDATE` để tránh trùng số khi có nhiều request cùng lúc).
5. **Danh sách ID lưu dạng chuỗi serialize**: các quan hệ "gom nhóm" (VD: `Invoice.orderIds`, `BulkOrder.orderIds`, `InvoiceJournal.invoiceIds`) lưu bằng PHP `serialize()` một mảng ID trong cột `text`, **không phải** bảng trung gian hay JSON — đây là lựa chọn thiết kế có chủ đích của dự án, cần lưu ý khi viết truy vấn SQL trực tiếp.
6. **Update kiểu "generic setter"**: rất nhiều Service có method `generateUpdate()` lặp qua mảng dữ liệu gửi lên và gọi `set{Field}()` tương ứng bằng reflection/convention, thay vì gán từng field thủ công — kèm một danh sách nhỏ các field cần ép kiểu boolean (`"true"`/`"false"` string → bool thật).
7. **Nhật ký thao tác (`ActivityLog`)**: các thay đổi quan trọng (tạo/sửa/xoá/gửi mail) trên `Order`, `Invoice`, `BulkOrder` được ghi vào bảng `activity_log` (**phân vùng theo tháng** — partition Postgres) qua `ActivityLogService::addLog()`.

## 7. Ví dụ tình huống tổng quan — một đơn hàng đi qua hệ thống như thế nào

Để hình dung cách các phần trên phối hợp, đây là hành trình đầy đủ của **một đơn hàng B2B tạo qua API** (đơn tạo qua webshop công khai xem thêm ở file 07):

1. Nhân viên/khách hàng B2B đăng nhập qua `POST /api/v1/users/app/login` (hoặc `.../channel/login` nếu là tài khoản Channel khách hàng) → nhận `accessToken` (hiệu lực 24h).
2. Tạo đơn: `POST /api/v1/orders/` với `Authorization: Bearer <token>` → `OrderController::add()` → `OrderService::add()`:
   - Sinh `orderCountNr` từ `AutoCountService`.
   - Copy snapshot thông tin `Customer`, `Channel` vào các cột denormalized của `Order`.
   - Tạo các dòng `OrderProduct`, trừ tồn kho `Product.stock` tương ứng.
   - Tính tổng tiền (`updateSum()`) và hoa hồng kickback (`updatePriceKickback()`).
3. Xưởng sản xuất xử lý: cập nhật trạng thái từng bước in ấn qua `OrderProduct.productionStatus` (4 nhóm loại sản xuất song song: Screen/DTG/Transfer/Special).
4. Khi hoàn tất, tạo nhãn vận chuyển PostNord (`POST /orders/{id}/postnord/create-label`) → `PostNordService` gọi API PostNord, lưu mã vận đơn, **tự động chuyển đơn sang trạng thái "complete"** và gọi `OrderService::postOrderUpdateComplete()`.
5. Tuỳ nguồn gốc đơn hàng, bước 4 sẽ kích hoạt đồng bộ ngược: nếu đơn đến từ Shopify → gọi GraphQL `fulfillmentCreateV2` báo đã giao; nếu từ WordPress/WooCommerce → gọi webhook lưu sẵn trên đơn; nếu thất bại, ghi vào bảng `OrderResyncWp` để cronjob `resync-wp-completed-order` thử lại sau.
6. Cuối tháng, kế toán gom các đơn "complete" chưa xuất hoá đơn thành 1 `Invoice` (`POST /invoices/create-from-multi-orders`), PDF hoá đơn được sinh bằng `PdfService` (Dompdf) và gửi email qua `MailService` (Mailchimp Transactional).
7. Khi khách thanh toán, file CSV từ ngân hàng/Svea được nhập qua `DownPaymentRowService::applyMulti()`, đối chiếu số tiền với `Invoice`/`Order`, cập nhật trạng thái thanh toán.
8. Nếu kênh bán có bật hoa hồng đại lý (`Channel.kickbackInvoiceActive`), cronjob `kickbackInvoice:create` chạy hàng tháng, gom các đơn "complete" trong kỳ thành `KickbackInvoice` trả hoa hồng cho seller.

Mỗi bước trên được mô tả sâu hơn trong các file domain tương ứng.
