# 07 — Webshop Bundle (Cửa hàng trực tuyến `/ecom/{channelEcomId}`)

> Đây là "mặt tiền" công khai của hệ thống — nơi khách vãng lai duyệt và mua sản phẩm mà **không cần đăng nhập**. Hoàn toàn tách biệt với API admin ở file 06.

## 1. Đặc điểm khác biệt so với API Bundle

| | API Bundle (`06`) | Webshop Bundle (file này) |
|---|---|---|
| Người dùng | Nhân viên/khách B2B qua ứng dụng React | Khách vãng lai qua trình duyệt |
| Định dạng trả về | JSON | HTML render bằng Twig (chỉ 2 endpoint AJAX nhỏ trả JSON: `check-coupon`, `svea/generate-order`) |
| Xác thực | Bắt buộc Bearer token | **Không cần đăng nhập** — trạng thái lưu ở **session trình duyệt** |
| Trạng thái | Không lưu trạng thái (stateless), thao tác trực tiếp lên dữ liệu thật | Có trạng thái nhiều bước: giỏ hàng (session) → đơn tạm `EcomOrderTemp` → đơn thật `Order` (chỉ sau khi thanh toán xác nhận) |
| Phạm vi URL | Toàn cục `/api/v1/...` | Luôn có tiền tố `/ecom/{channelEcomId}` — 1 mã kênh = 1 "cửa hàng" riêng, nhiều cửa hàng dùng chung 1 codebase |
| Thanh toán | Không áp dụng (nội bộ quản lý hoá đơn) | Tích hợp Svea (đang hoạt động) và Swish (code có sẵn nhưng **đang bị tắt/comment**) |

## 2. Cấu trúc code

- Gần như toàn bộ nằm trong **một controller duy nhất**: `src/Application/WebshopBundle/Controller/AppController.php` (~1320 dòng).
- Route khai báo tại `src/Application/WebshopBundle/Resources/config/routing.yaml`, gắn tiền tố `/ecom/{channelEcomId}` từ `config/routes.yaml`.
- Template Twig nằm ở **cấp project** `templates/webshop/` (không nằm trong bundle), có nhiều theme: `templates/webshop/template/{standard,niclas,nakata}/` — chọn theme theo `Channel.ecomTemplate`.
- Form: `Form/CheckoutType.php`.
- Service riêng: `Service/CartService.php` (giỏ hàng theo session).
- Nếu `{channelEcomId}` không khớp kênh nào đang active/hiển thị ecom → render `webshop/error.html.twig`.

## 3. Bản đồ route

| Method | Path | Mô tả |
|---|---|---|
| GET | `/` | Trang chủ — khác giao diện theo `ecomTemplate`, hiển thị sản phẩm nổi bật/mới, banner, danh mục, 3 cây menu (main/mobile-left/right) |
| GET | `/kategori-{categorySlug}` | Trang danh mục (phân trang) |
| GET | `/sida-{slug}` | Trang nội dung tĩnh (`WikiPage`) |
| GET | `/produkter` | Toàn bộ catalogue (phân trang) |
| GET/POST | `/{productSlug}` | Trang chi tiết sản phẩm; **POST** = thêm sản phẩm (+ add-on) vào giỏ |
| GET/POST | `/vagn` | Trang giỏ hàng; **POST** = cập nhật số lượng |
| GET | `/vagn/ta-bort/{key}` | Xoá 1 dòng khỏi giỏ |
| GET/POST | `/kassa` | Trang checkout; **POST** hợp lệ → tạo đơn tạm, kiểm tra coupon, tính phí ship, rồi hoặc chốt đơn ngay hoặc chuyển sang cổng thanh toán |
| POST | `/kassa/check-coupon` | AJAX kiểm tra mã giảm giá, trả JSON |
| GET | `/kassa/{orderNr}/skapas` `/framgang` `/fel` | Trang chờ/thành công/lỗi thanh toán Swish (hiện **không hoạt động** — xem mục 5) |
| GET | `/svea/confirm/{orderNr}` | URL khách quay lại sau khi thanh toán Svea |
| POST | `/svea/push/{orderNr}` | Callback server-to-server từ Svea |
| ANY | `/svea/generate-order` | AJAX — widget Svea gọi để tạo đơn tạm + đơn Svea, trả về HTML snippet thanh toán |
| ANY | `/svea/webhook/{orderNr}` | Webhook bất đồng bộ từ Svea |
| ANY | `/swish/ateruppringning/{orderNr}` | Callback thanh toán Swish |
| GET | `/cancel` → `/cancel/search` → `/cancel/select` → `/cancel/submit` → `/cancel/success` | Luồng khách **tự huỷ đơn** (self-service cancellation) |

Route ngoài nhóm `/ecom/{channelEcomId}`, khai báo thẳng ở `config/routes.yaml`: `GET /.well-known/apple-developer-merchantid-domain-association` (xác minh domain cho Apple Pay).

## 4. Giỏ hàng & CartService

`Service/CartService.php` quản lý giỏ hàng **theo session**, khoá session riêng cho từng kênh: `{channelEcomId}_ecom_cart` — nghĩa là 1 trình duyệt có thể giữ **giỏ hàng độc lập cho nhiều cửa hàng khác nhau** cùng lúc.

| Hàm | Vai trò |
|---|---|
| `getCart()` / `setCart()` / `resetCart()` | Đọc/ghi/xoá mảng giỏ hàng trong session (`customer` + `items[]` + `quantity` tổng) |
| `addItem(Product, $subProductId, $quantity)` | Thêm sản phẩm (hoặc biến thể size) vào giỏ, kiểm tra `isOutStock()`, tính sẵn breakdown thuế từ `priceEcomIncTax`/`taxCode` |
| `addItemAddon(...)` | Thêm sản phẩm add-on (VD in tên) gắn với 1 dòng giỏ hàng, hỗ trợ nhập text tự do |
| `updateQuantity()` / `removeItem($key)` | Sửa/xoá dòng trong giỏ |
| `addCartTemp(Channel, $customer, $items, $couponData)` | **Bước "đặt hàng"**: trong 1 transaction DB — sinh số đơn (`AutoCountService`), tạo `EcomOrderTemp` + `EcomOrderTempItem` từ giỏ hàng, áp dụng hiệu lực coupon (miễn ship / giảm % hoặc số tiền cố định / loại "invoice"), trả về tổng tiền + đơn tạm vừa tạo |
| `getEcomMainCategories($channelId)` | Dựng cây danh mục hiển thị ở header |

## 5. Luồng thanh toán

### 5.1. Svea (đang hoạt động — phương thức chính)

1. Khách điền `CheckoutType` form (chỉ có lựa chọn `svea` khả dụng, `swish` đã bị comment trong code) → submit `/kassa`.
2. `CartService::addCartTemp()` tạo `EcomOrderTemp` trạng thái `pending`.
3. Widget Svea gọi AJAX `/svea/generate-order` → hệ thống tạo đơn Svea tương ứng, trả HTML snippet nhúng vào trang.
4. Khách hoàn tất thanh toán trên widget Svea → Svea gọi **push** (`/svea/push/{orderNr}`) và/hoặc **webhook** (`/svea/webhook/{orderNr}`) về server.
5. Server nhận push/webhook → tra trạng thái đơn Svea; nếu `Final` (đã thanh toán) → gọi `OrderService::addEcomOrder()` **chuyển `EcomOrderTemp` thành `Order` thật**; nếu bị huỷ → đánh dấu `EcomOrderTemp.status = cancel`.
6. Khách được điều hướng về `/svea/confirm/{orderNr}` — nếu đơn đã thanh toán, hiển thị xác nhận đơn hàng; nếu chưa, hiển thị trang chờ.

### 5.2. Swish (code tồn tại nhưng đang tắt)

Toàn bộ route `/swish/*` và lựa chọn `swish` trong `CheckoutType` **đã bị comment trong code hiện tại** — không khả dụng trên giao diện, dù logic backend (`SwishService`, callback xử lý) vẫn còn trong codebase. Nếu cần bật lại, phải: (1) bỏ comment lựa chọn `swish` trong `CheckoutType`, (2) kiểm tra lại `SwishService` (một số hàm như `refund()` hiện đang **cố tình vô hiệu hoá**, trả lỗi ngay từ đầu hàm — xem file 09).

### Ví dụ tình huống — checkout thành công

> Khách chọn 2 áo (tổng 598 SEK), nhập mã `SUMMER10` → `/kassa/check-coupon` trả về giảm 10% (59.8 SEK). Khách điền thông tin, chọn thanh toán Svea, tick đồng ý điều khoản, bấm đặt hàng. Server tạo `EcomOrderTemp` (tổng tiền đã trừ coupon), widget Svea hiện ra ngay trên trang `/kassa`. Khách quét thẻ/chọn phương thức trong widget Svea → Svea gọi webhook về server → `addEcomOrder()` tạo `Order` thật, gửi email xác nhận đơn (`MailService::createNewEcomOrder`) → khách được chuyển tới `/svea/confirm/{orderNr}` thấy trang "Đặt hàng thành công".

## 6. Luồng khách tự huỷ đơn (Self-service cancellation)

| Bước | Route | Mô tả |
|---|---|---|
| 1 | `GET /cancel` | Form nhập số đơn + email |
| 2 | `POST /cancel/search` | Tìm đơn theo số đơn + email, kiểm tra đơn **có được phép huỷ hay không** (`CancellationService::isStatusAllowed()` — VD đơn đã giao thì không cho huỷ), lưu `orderId` vào session |
| 3 | `GET /cancel/select` | Khách chọn dòng sản phẩm + số lượng muốn huỷ |
| 4 | `POST /cancel/submit` | **Có bảo vệ CSRF** — tạo `Credit`, **hoàn tồn kho** (`restoreStock`), gửi email xác nhận cho khách + cảnh báo nội bộ cho admin |
| 5 | `GET /cancel/success` | Trang xác nhận, hiển thị số credit note vừa tạo |

Toàn bộ luật nghiệp vụ (đơn nào được phép huỷ, cách hoàn kho, tạo credit) nằm trong `CancellationService` — không lặp lại thủ công trong controller.

### Ví dụ tình huống

> Khách đặt nhầm số lượng (mua 5 áo thay vì 2), đơn hàng vẫn đang ở trạng thái "Mới" (chưa vào sản xuất) nên được phép huỷ một phần. Khách vào `/ecom/{shop}/cancel`, nhập số đơn + email đã dùng khi đặt hàng, hệ thống xác nhận đơn hợp lệ và cho phép huỷ. Khách chọn huỷ 3/5 áo → submit → hệ thống tạo `Credit` tương ứng 3 áo, hoàn 3 vào `Product.stock`, gửi email xác nhận cho khách và cảnh báo nội bộ để CSKH theo dõi.

## 7. Form `CheckoutType`

Các field bắt buộc: `firstname`, `lastname`, `customerEmail` (Email + NotNull), `customerAddress`, `customerMobile`, `customerPostCode`, `customerCity`, `paymentMethod` (hiện chỉ có `svea`), `customerCountry` (cố định `SE` — Sweden), checkbox `acceptTerms` (bắt buộc tick, không map vào entity). Có 1 listener `POST_SUBMIT` kiểm tra `swishPhoneNr` khi chọn thanh toán Swish — hiện là code chết vì lựa chọn Swish đang bị tắt.

## 8. Các service dùng chung với phần admin

Controller webshop tái sử dụng trực tiếp nhiều Service ở `src/Service/` (không viết lại logic riêng):

- `WebshopMenuService::getTreeMenu()` — render 3 menu mọi trang.
- `BannerSetService::getActiveList()` — banner trang chủ.
- `CouponCodeService` — validate/tính coupon, **dùng chung logic với phần admin** (đảm bảo quy tắc coupon nhất quán ở cả 2 nơi).
- `OrderService::addEcomOrder()` — chuyển đơn tạm thành đơn thật.
- `CancellationService` — luật huỷ đơn tự phục vụ.
- `MailService` — email xác nhận đơn/huỷ đơn.
- `Payment\SveaService` / `Payment\SwishService` — tích hợp thanh toán (chi tiết ở file 09).

## 9. Lưu ý khi vận hành/mở rộng

- Vì không có xác thực, **mọi input từ webshop đều là input không tin cậy** — cần đặc biệt chú ý validate ở tầng Service khi tiếp tục phát triển thêm tính năng công khai.
- `EcomOrderTemp` ở trạng thái `pending` bị bỏ dở (khách rời trang không thanh toán) **không có cơ chế dọn dẹp tự động** — xem thêm ghi chú ở file 04 mục 7.
- Route `/swish/*` tồn tại nhưng không truy cập được qua UI hiện tại (lựa chọn bị comment) — nếu thấy traffic gọi các route này, khả năng cao là request cũ/thử nghiệm thủ công, không phải luồng thật của khách.
- 1 mã `channelEcomId` = 1 session giỏ hàng riêng biệt — khi test nhiều cửa hàng trên cùng trình duyệt, giỏ hàng sẽ không lẫn vào nhau (đây là hành vi đúng theo thiết kế, không phải lỗi).
