---
date: 2026-05-21
type: tech-design
epic_id: EPIC-005
title: Add Packaging Material — Technical Design
status: draft
---

# TECH-DESIGN — EPIC-005: Add Packaging Material

## Summary

Thêm entity `OrderPackagingMaterial` — bảng junction giữa `Order` và `Material` — cho phép kỹ thuật viên ghi nhận danh sách vật tư đóng gói cho một đơn hàng khi đơn đang ở trạng thái `WAIT_FOR_APPROVE`. API theo pattern Clean/Hexagonal đã có trong api-kingston (domain model + repository interface + use cases + controller), web theo Clean Architecture + Inversify DI + React Query đã có trong web-kingston. Đây là feature CRUD thuần túy, không có state machine, không có event bus, không migrate dữ liệu cũ.

---

## Deviation from PRD

Không có deviation đáng kể so với PRD.

Một điểm làm rõ thêm: FK `orderUUID` trong bảng DB sẽ tham chiếu cột `uuid` của bảng `Order` (kiểu `VARCHAR`/`String`), không phải `id` (kiểu `Int`). Pattern này unique trong codebase — cần xử lý cẩn thận trong Mapper (không dùng `this.fromID`/`this.toID` cho uuid field).

---

## Architecture

### Layer Diagram

```
┌─────────────────────────────────────────────────────────────┐
│ api-kingston (NestJS, Clean/Hexagonal)                       │
│                                                             │
│  domain/model/order-packaging-material/                     │
│    interface.ts  ─── pure contracts, types, enums           │
│    model.ts      ─── OrderPackagingMaterialModel + Builder  │
│                                                             │
│  domain/repository/order-packaging-material.repository.ts  │
│    IOrderPackagingMaterialRepository (port interface)       │
│                                                             │
│  application/usecases/order-packaging-material/             │
│    create-*.usecase.ts  update-*.usecase.ts                 │
│    delete-*.usecase.ts  get-list-*.usecase.ts               │
│                                                             │
│  infrastructure/database/mysql/                             │
│    mapper/order-packaging-material.mapper.ts                │
│    repository/order-packaging-material-repository.impl.ts  │
│                                                             │
│  presentation/ports/http/controllers/order/                 │
│    admin-order-packaging-material.controller.ts             │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ web-kingston (React + Inversify + React Query)               │
│                                                             │
│  domain/entities/OrderPackagingMaterial.ts                  │
│  domain/repositories/OrderPackagingMaterialRepository.ts    │
│                                                             │
│  application/useCases/order-packaging-material/             │
│    GetOrderPackagingMaterials.ts                            │
│    CreateOrderPackagingMaterial.ts                          │
│    UpdateOrderPackagingMaterial.ts                          │
│    DeleteOrderPackagingMaterial.ts                          │
│                                                             │
│  infrastructure/api/                                        │
│    OrderPackagingMaterialApiRepository.ts                   │
│    mapper/OrderPackagingMaterialMapper.ts                   │
│    types/OrderPackagingMaterialResponse.ts                  │
│                                                             │
│  presentation/modules/order/hooks/                          │
│    useGetOrderPackagingMaterials.tsx                        │
│    useCreateOrderPackagingMaterial.tsx                      │
│    useUpdateOrderPackagingMaterial.tsx                      │
│    useDeleteOrderPackagingMaterial.tsx                      │
│                                                             │
│  presentation/modules/order/components/                     │
│    OrderPackagingMaterialSection/                           │
└─────────────────────────────────────────────────────────────┘
```

### Layer Mapping

| Layer | New / Modified | Responsibility |
|-------|---------------|---------------|
| **Domain** (api) | `order-packaging-material/` (new) | Interface types, Model class, Builder |
| **Domain** (api) | `repository/order-packaging-material.repository.ts` (new) | Port interface — CRUD + list by orderUUID |
| **Domain** (api) | `repository/repository.token.ts` (modify) | Add `ORDER_PACKAGING_MATERIAL_REPOSITORY` token |
| **Application** (api) | `usecases/order-packaging-material/` (new) | 4 use cases: create, update, delete, get-list |
| **Infrastructure** (api) | mapper + repository impl (new) | Prisma ↔ domain mapping, DB queries |
| **Infrastructure** (api) | `providers/` (modify) | Register new repository provider |
| **Presentation** (api) | `admin-order-packaging-material.controller.ts` (new) | HTTP endpoints, DTO, guards |
| **DB** | `schema.prisma` (modify) | New model `OrderPackagingMaterial` |
| **Domain** (web) | Entity + Repository interface (new) | Domain contracts |
| **Application** (web) | 4 use cases (new) | Orchestrate API calls |
| **Infrastructure** (web) | ApiRepository + Mapper + Response types (new) | HTTP → Domain mapping |
| **DI** (web) | `types.ts`, `container.ts` (modify) | Register 5 new tokens + bindings |
| **Presentation** (web) | hooks + section component (new) | UI wiring trong Order detail |

### Key Design Choices

1. **FK trên `orderUUID` (String) thay vì `orderId` (Int)**: Order domain dùng `uuid` làm public key trong API. Dùng `uuid` làm FK để tránh expose internal `id` trong URL, nhất quán với các pattern khác trong Order module.

2. **No state machine / no event bus**: `OrderPackagingMaterial` là dữ liệu tĩnh (CRUD), không có workflow. `useEventBus: false` trong `createBaseModelClass`. Không raise domain events.

3. **Status gate ở use case, không phải domain model**: Check `Order.status === WAIT_FOR_APPROVE` trong use case (application layer), không phải trong domain model, vì đây là business workflow rule, không phải invariant của `OrderPackagingMaterial` entity.

4. **Re-fetch after create để trả về relation**: Sau `create`, repository re-fetch với `includes: { material: true }` để trả `IOrderPackagingMaterialData` đầy đủ (kèm `material.unit.name`).

---

## API / Interface Contract

### Endpoints

Base path: `/api/admin/orders/:orderUUID/packaging-materials`

| Method | Path | Description | Auth |
|--------|------|-------------|------|
| `GET` | `/api/admin/orders/:orderUUID/packaging-materials` | List vật tư đóng gói của đơn | `ADMIN_JWT` |
| `POST` | `/api/admin/orders/:orderUUID/packaging-materials` | Thêm vật tư | `ADMIN_JWT` |
| `PUT` | `/api/admin/orders/:orderUUID/packaging-materials/:id` | Cập nhật quantity/note | `ADMIN_JWT` |
| `DELETE` | `/api/admin/orders/:orderUUID/packaging-materials/:id` | Xóa một dòng | `ADMIN_JWT` |

### Request / Response Shapes

**GET** — Response body:
```json
[
  {
    "id": 1,
    "orderUUID": "uuid-string",
    "materialId": 42,
    "quantity": 2.0,
    "note": null,
    "createdAt": "...",
    "material": {
      "id": 42,
      "code": "BB-001",
      "name": "Thùng carton 5 lớp",
      "unit": { "id": 5, "name": "thùng" }
    }
  }
]
```

**POST** — Request body (DTO: `CreateOrderPackagingMaterialReqDTO`):
```json
{ "materialId": 42, "quantity": 2.0, "note": "dùng thùng 2 lớp" }
```
Response: `201` + created entity (same shape as GET item).

**PUT** — Request body (DTO: `UpdateOrderPackagingMaterialReqDTO`):
```json
{ "quantity": 3.0, "note": "updated note" }
```
Response: `200` + updated entity.

**DELETE** — No body. Response: `200` + `{ "id": 1 }`.

### Error Cases

| HTTP | Error code string | Trigger |
|------|------------------|---------|
| `404` | `ORDER_NOT_FOUND` | orderUUID không tồn tại |
| `403` | `ORDER_NOT_EDITABLE` | Order status ≠ WAIT_FOR_APPROVE khi write |
| `404` | `MATERIAL_NOT_FOUND` | materialId không tồn tại (POST) |
| `404` | `ORDER_PACKAGING_MATERIAL_NOT_FOUND` | id không tồn tại hoặc không thuộc orderUUID (PUT/DELETE) |
| `409` | `ORDER_PACKAGING_MATERIAL_DUPLICATE` | (orderUUID, materialId) đã tồn tại (POST) |
| `422` | `ORDER_PACKAGING_MATERIAL_INVALID_QUANTITY` | quantity ≤ 0 |

Pattern exception: kế thừa `HttpException` như convention hiện có (ví dụ: `OrderPackagingMaterialDuplicateException extends HttpException`).

### Versioning
API v1, không có version prefix. Breaking changes sẽ cần tạo version mới.

---

## Data Model

### Prisma Schema (thêm vào `schema.prisma`)

```prisma
model OrderPackagingMaterial {
  id        Int       @id @unique @default(autoincrement()) @map("id")
  isDeleted Boolean   @default(false) @map("is_deleted")
  orderUUID String    @map("order_uuid")
  materialId Int      @map("material_id")
  quantity  Decimal   @map("quantity") @db.Decimal(6, 2)
  note      String?   @map("note")
  createdAt DateTime  @default(now()) @map("created_at")
  updatedAt DateTime  @default(now()) @updatedAt @map("updated_at")
  deletedAt DateTime? @map("deleted_at")
  createdBy Int?      @map("created_by")
  updatedBy Int?      @map("updated_by")
  deletedBy Int?      @map("deleted_by")

  order    Order    @relation("orderPackagingMaterialRelation", fields: [orderUUID], references: [uuid])
  material Material @relation("materialPackagingRelation", fields: [materialId], references: [id])

  @@unique([orderUUID, materialId, isDeleted], name: "unique_order_packaging_material")
  @@map("order_packaging_materials")
}
```

**Cập nhật model `Order`** — thêm relation:
```prisma
  packagingMaterials OrderPackagingMaterial[] @relation("orderPackagingMaterialRelation")
```

**Cập nhật model `Material`** — thêm relation:
```prisma
  packagingMaterials OrderPackagingMaterial[] @relation("materialPackagingRelation")
```

### Indexes & Constraints

| Constraint | Mô tả |
|-----------|-------|
| `@@unique([orderUUID, materialId, isDeleted])` | Unique constraint — tránh duplicate; include `isDeleted` vì soft-delete pattern |
| FK `orderUUID → Order.uuid` | RESTRICT (không cascade xóa khi xóa Order packaging khi Order bị xóa) — thực tế Order dùng soft-delete, không xóa vật lý |
| FK `materialId → Material.id` | RESTRICT — không cascade xóa `OrderPackagingMaterial` khi Material bị soft-delete |
| `quantity DECIMAL(6,2)` | Max 9999.99; check ở application layer (> 0) |

### Migration

File naming: `YYYYMMDDHHMMSS_add_order_packaging_material.ts`  
Nội dung: tạo bảng `order_packaging_materials`, thêm index `@@unique([order_uuid, material_id, is_deleted])`. Không có data migration (entity hoàn toàn mới).

### Domain Events

Không có domain events cho `OrderPackagingMaterial` (`useEventBus: false`).

---

## Dependency Wiring / Registration

### api-kingston

**`repository.token.ts`** — thêm:
```ts
ORDER_PACKAGING_MATERIAL_REPOSITORY = 'OrderPackagingMaterialRepository',
```

**`src/infrastructure/providers/`** — tạo file `order-packaging-material-repository.provider.ts`:
```ts
{
  provide: ERepositoryToken.ORDER_PACKAGING_MATERIAL_REPOSITORY,
  useClass: OrderPackagingMaterialRepoImpl,
}
```

Provider này sẽ được đưa vào array `infrastructureProviders` trong `src/infrastructure/providers/index.ts`.

**NestJS Module** — `OrderPackagingMaterial` use cases sẽ nằm trong module chứa Order use cases hiện tại (tìm module hiện có `providers` Order usecases và thêm vào đó).

### web-kingston

**`di/types.ts`** — thêm group mới:
```ts
// OrderPackagingMaterial
OrderPackagingMaterialRepository: Symbol.for("OrderPackagingMaterialRepository"),
GetOrderPackagingMaterials: Symbol.for("GetOrderPackagingMaterials"),
CreateOrderPackagingMaterial: Symbol.for("CreateOrderPackagingMaterial"),
UpdateOrderPackagingMaterial: Symbol.for("UpdateOrderPackagingMaterial"),
DeleteOrderPackagingMaterial: Symbol.for("DeleteOrderPackagingMaterial"),
```

**`di/container.ts`** — thêm bindings:
```ts
container.bind<OrderPackagingMaterialRepository>(TYPES.OrderPackagingMaterialRepository)
  .to(OrderPackagingMaterialApiRepository).inSingletonScope();
container.bind<GetOrderPackagingMaterials>(TYPES.GetOrderPackagingMaterials)
  .to(GetOrderPackagingMaterials).inSingletonScope();
container.bind<CreateOrderPackagingMaterial>(TYPES.CreateOrderPackagingMaterial)
  .to(CreateOrderPackagingMaterial).inSingletonScope();
container.bind<UpdateOrderPackagingMaterial>(TYPES.UpdateOrderPackagingMaterial)
  .to(UpdateOrderPackagingMaterial).inSingletonScope();
container.bind<DeleteOrderPackagingMaterial>(TYPES.DeleteOrderPackagingMaterial)
  .to(DeleteOrderPackagingMaterial).inSingletonScope();
```

**Lifetimes**: tất cả `inSingletonScope` — nhất quán với các repository và use case khác trong codebase.

---

## Kingston Wiring Checklist

### api-kingston

- [ ] `repository.token.ts` — thêm `ORDER_PACKAGING_MATERIAL_REPOSITORY = 'OrderPackagingMaterialRepository'`
- [ ] `src/infrastructure/providers/order-packaging-material-repository.provider.ts` — tạo mới, thêm vào `infrastructureProviders`
- [ ] Tìm NestJS module chứa Order use cases → thêm 4 use case mới vào `providers[]`
- [ ] `src/core/index.ts` hoặc barrel export của core — export types mới từ `order-packaging-material/`

### web-kingston

- [ ] `di/types.ts` — 5 token mới (1 repo + 4 use cases)
- [ ] `di/container.ts` — 5 binding mới, tất cả `inSingletonScope`

---

## Non-Functional Design

### Performance
- `GET /orders/:uuid/packaging-materials`: single `findMany` với `where: { orderUUID, isDeleted: false }`, `include: { material: { include: { unit: true } } }`. Thực tế ≤ 20 rows/đơn → < 50ms DB query → đạt target < 300ms p95.
- `POST`/`PUT`/`DELETE`: 2-3 DB queries (validate order status + create/update/delete) → < 100ms DB → đạt < 500ms p95.

### Reliability
- Không cần retry hay idempotency key đặc biệt — đây là CRUD synchronous.
- Status check (WAIT_FOR_APPROVE) thực hiện trong use case bằng cách fetch Order trước mỗi write operation — không dùng cache để tránh stale data.

### Security
- `@UseGuards(AuthGuard(EGuardStrategy.ADMIN_JWT))` trên controller — nhất quán với tất cả admin endpoints.
- `@ReqUserId()` để lấy userId cho audit trail (createdBy, updatedBy).
- Validate `orderUUID` là UUID hợp lệ bằng `ParamUuidPipe` trên route param.
- Input validation: `quantity > 0` được enforce cả DTO level (class-validator) và application layer.
- Không có PII trong `OrderPackagingMaterial`.

---

## Rollout & Reversibility

### Strategy
Direct rollout — không cần feature flag. Entity hoàn toàn mới, không alter bảng cũ.

### Deployment Steps
1. API: merge schema migration + deploy code (Prisma tự chạy migration khi start)
2. Web: deploy section "Vật tư đóng gói" trong Order detail
3. Smoke test theo checklist trong PRD

### Rollback
- Web: ẩn `<OrderPackagingMaterialSection />` component (1 line change) nếu cần
- API: `DROP TABLE order_packaging_materials` + revert migration (không ảnh hưởng bảng khác)
- DB rollback an toàn vì không có data cũ

---

## File / Module Impact

### api-kingston — New Files

| File | Mô tả |
|------|-------|
| `src/core/domain/model/order-packaging-material/order-packaging-material.interface.ts` | Interface, types, enums |
| `src/core/domain/model/order-packaging-material/order-packaging-material.model.ts` | Model class + Builder |
| `src/core/domain/model/order-packaging-material/index.ts` | Barrel export |
| `src/core/domain/repository/order-packaging-material.repository.ts` | Port interface |
| `src/core/exception/order-packaging-material.exception.ts` | Exception classes |
| `src/application/usecases/order-packaging-material/create-order-packaging-material.usecase.ts` | Create use case |
| `src/application/usecases/order-packaging-material/update-order-packaging-material.usecase.ts` | Update use case |
| `src/application/usecases/order-packaging-material/delete-order-packaging-material.usecase.ts` | Delete use case |
| `src/application/usecases/order-packaging-material/get-list-order-packaging-material.usecase.ts` | Get list use case |
| `src/application/usecases/order-packaging-material/index.ts` | Barrel export |
| `src/infrastructure/database/mysql/mapper/order-packaging-material.mapper.ts` | Prisma ↔ Domain mapper |
| `src/infrastructure/database/mysql/repository/order-packaging-material-repository.implement.ts` | Prisma repository |
| `src/infrastructure/providers/order-packaging-material-repository.provider.ts` | NestJS provider |
| `src/presentation/ports/http/controllers/order/admin-order-packaging-material.controller.ts` | HTTP controller + DTOs |

### api-kingston — Modified Files

| File | Lý do |
|------|-------|
| `src/core/domain/repository/repository.token.ts` | Thêm `ORDER_PACKAGING_MATERIAL_REPOSITORY` token |
| `src/infrastructure/providers/index.ts` | Thêm provider mới vào array |
| `prisma/providers/mysql/schema.prisma` | Thêm model `OrderPackagingMaterial`, cập nhật `Order` và `Material` relations |
| NestJS module chứa Order use cases | Thêm 4 use cases mới vào `providers[]` |
| `src/core/index.ts` (hoặc barrel) | Export types mới |

### web-kingston — New Files

| File | Mô tả |
|------|-------|
| `src/clean-architecture/domain/entities/OrderPackagingMaterial.ts` | Domain entity |
| `src/clean-architecture/domain/repositories/OrderPackagingMaterialRepository.ts` | Repository interface + Args types |
| `src/clean-architecture/application/useCases/order-packaging-material/GetOrderPackagingMaterials.ts` | Use case |
| `src/clean-architecture/application/useCases/order-packaging-material/CreateOrderPackagingMaterial.ts` | Use case |
| `src/clean-architecture/application/useCases/order-packaging-material/UpdateOrderPackagingMaterial.ts` | Use case |
| `src/clean-architecture/application/useCases/order-packaging-material/DeleteOrderPackagingMaterial.ts` | Use case |
| `src/clean-architecture/infrastructure/api/OrderPackagingMaterialApiRepository.ts` | HTTP implementation |
| `src/clean-architecture/infrastructure/api/mapper/OrderPackagingMaterialMapper.ts` | Response → Domain mapper |
| `src/clean-architecture/infrastructure/api/types/OrderPackagingMaterialResponse.ts` | API response type |
| `src/clean-architecture/infrastructure/api/message/OrderPackagingMaterialMessage.ts` | Error messages |
| `src/clean-architecture/presentation/modules/order/hooks/useGetOrderPackagingMaterials.tsx` | Query hook |
| `src/clean-architecture/presentation/modules/order/hooks/useCreateOrderPackagingMaterial.tsx` | Mutation hook |
| `src/clean-architecture/presentation/modules/order/hooks/useUpdateOrderPackagingMaterial.tsx` | Mutation hook |
| `src/clean-architecture/presentation/modules/order/hooks/useDeleteOrderPackagingMaterial.tsx` | Mutation hook |
| `src/clean-architecture/presentation/modules/order/components/OrderPackagingMaterialSection/OrderPackagingMaterialSection.tsx` | Section component |
| `src/clean-architecture/presentation/modules/order/components/OrderPackagingMaterialSection/index.ts` | Barrel |

### web-kingston — Modified Files

| File | Lý do |
|------|-------|
| `src/clean-architecture/di/types.ts` | 5 token mới |
| `src/clean-architecture/di/container.ts` | 5 binding mới |
| `src/clean-architecture/presentation/modules/order/hooks/index.ts` | Export hooks mới |
| Order detail page component | Mount `<OrderPackagingMaterialSection orderUUID={...} orderStatus={...} />` |

---

## Risks & Technical Debt

| Rủi ro | Mức độ | Mitigation |
|--------|--------|-----------|
| FK `orderUUID` (String) thay vì `orderId` (Int) — pattern mới trong codebase | Thấp | Mapper xử lý riêng: không dùng `this.fromID`/`this.toID` cho `orderUUID`; ghi chú rõ trong mapper |
| Unique constraint bao gồm `isDeleted` — soft-delete pattern | Thấp | `@@unique([orderUUID, materialId, isDeleted])` cho phép thêm lại sau khi soft-delete; application layer check trước khi insert |
| Order detail page có thể chưa expose `orderStatus` xuống sub-components | Thấp | Truyền `orderStatus` qua props từ Order detail component; không dùng global state |

### GitNexus Impact Analysis
- `OrderModel` — blast radius check: không thêm field, chỉ thêm relation trong Prisma (không thay đổi TypeScript interface `IOrder`). Impact = LOW.
- `MaterialModel` — tương tự, chỉ thêm Prisma relation, không thay đổi `IMaterial`. Impact = LOW.
- `repository.token.ts` — thêm enum value mới, không đổi giá trị cũ. Impact = LOW.

---

## Test Strategy

### Unit Tests (api-kingston)

| Scenario | Type | Expected |
|----------|------|---------|
| `CreateOrderPackagingMaterial` — order không tồn tại | Unit | throw `OrderNotFoundException` |
| `CreateOrderPackagingMaterial` — order status ≠ WAIT_FOR_APPROVE | Unit | throw `OrderNotEditableException` (HTTP 403) |
| `CreateOrderPackagingMaterial` — material không tồn tại | Unit | throw `MaterialNotFoundException` |
| `CreateOrderPackagingMaterial` — duplicate (orderUUID, materialId) | Unit | throw `OrderPackagingMaterialDuplicateException` (HTTP 409) |
| `CreateOrderPackagingMaterial` — quantity ≤ 0 | Unit | throw `OrderPackagingMaterialInvalidQuantityException` (HTTP 422) |
| `CreateOrderPackagingMaterial` — happy path | Unit | return `IOrderPackagingMaterialData` với material relation |
| `UpdateOrderPackagingMaterial` — entry không tồn tại | Unit | throw `OrderPackagingMaterialNotFoundException` |
| `UpdateOrderPackagingMaterial` — order không ở WAIT_FOR_APPROVE | Unit | throw `OrderNotEditableException` |
| `DeleteOrderPackagingMaterial` — entry không tồn tại | Unit | throw `OrderPackagingMaterialNotFoundException` |
| `GetListOrderPackagingMaterial` — order không tồn tại | Unit | throw `OrderNotFoundException` |

### Integration Tests (api-kingston)

| Scenario | Expected |
|----------|---------|
| POST → verify row trong DB với đúng `orderUUID`, `materialId`, `quantity` | 201 + data |
| POST duplicate → verify DB không có row thứ hai | 409 DUPLICATE |
| PUT → verify quantity updated trong DB | 200 + updated data |
| DELETE → verify `isDeleted = true` trong DB | 200 + id |
| GET list → verify trả về kèm `material.unit` | 200 + array |

### E2E / API Tests

| Scenario | Request | Expected |
|----------|---------|---------|
| GET list empty | `GET /orders/:uuid/packaging-materials` (đơn mới) | 200 `[]` |
| POST valid | body `{materialId, quantity: 2}` | 201 với data đầy đủ |
| POST invalid quantity | body `{materialId, quantity: 0}` | 422 |
| POST duplicate | POST cùng materialId lần 2 | 409 |
| PUT valid | `{quantity: 5}` | 200 với quantity mới |
| DELETE valid | `DELETE /:id` | 200 |
| POST khi order = TODO | order đã approve | 403 ORDER_NOT_EDITABLE |
| POST orderUUID không tồn tại | invalid uuid | 404 |
