---
date: 2026-05-21
type: impl-plan
epic_id: EPIC-005
repo: api-kingston
title: Implementation Plan — api-kingston
---

# IMPL-PLAN-API — EPIC-005: Add Packaging Material

## Thứ tự thực hiện

1. Domain: Interface + Model
2. Domain: Repository Interface
3. Domain: Exceptions
4. DB: Prisma Schema
5. Application: Use Cases (4 files)
6. Infrastructure: Mapper
7. Infrastructure: Repository Implementation
8. Infrastructure: Provider Registration
9. Presentation: Controller + DTOs
10. Wiring: Application Module

---

## Step 1 — Domain: Interface + Model

### 1a. Interface

**File mới:** `src/core/domain/model/order-packaging-material/order-packaging-material.interface.ts`
**Follow pattern:** `src/core/domain/model/product-structure/product-structure.interface.ts`

- Base config: `createBaseModelClass({ useId: true, useCreate: true, useUpdate: true, useDelete: true, useEventBus: false })`
- Builder base config: `createBaseBuilderClass({ useId: true, useUuid: false, useCreate: true, useUpdate: true, useDelete: true })`
  - **Lưu ý:** `useUuid: false` — entity này không có uuid riêng, dùng `id` Int thông thường
- Interface `IOrderPackagingMaterial`: `orderUUID: UUID`, `materialId: ID`, `quantity: number`, `note?: string`
- Data type: `IOrderPackagingMaterialData = IOrderPackagingMaterial & IOrderPackagingMaterialBase & { material?: IMaterialData }`
  - `IMaterialData` import từ `../material`
- Create type: `IOrderPackagingMaterialCreate = { reqUserId: ID } & IOrderPackagingMaterial`
- Update type: `IOrderPackagingMaterialUpdate = { reqUserId: ID } & Partial<Pick<IOrderPackagingMaterial, 'quantity' | 'note'>>`
- Export type aliases: `IOrderPackagingMaterialBase`, `OrderPackagingMaterialBase`, `OrderPackagingMaterialBuilderBase`

### 1b. Model

**File mới:** `src/core/domain/model/order-packaging-material/order-packaging-material.model.ts`
**Follow pattern:** `src/core/domain/model/product-structure/product-structure.model.ts`

- Class: `OrderPackagingMaterialModel extends OrderPackagingMaterialBase`
- Fields: `orderUUID: UUID`, `materialId: ID`, `quantity: number`, `note?: string`, `material?: MaterialModel`
- Constructor: `super()` + `this.assignValues(this, builder.model)`
- `get data(): IOrderPackagingMaterialData` — spread `super.baseData`, spread `this`, include `material: this.material?.data`
- Method `update(input: IOrderPackagingMaterialUpdate)`: gọi `super.auditModification(reqUserId)` + `this.assignValues(this, { quantity, note })`
- Builder `OrderPackagingMaterialBuilder extends OrderPackagingMaterialBuilderBase`:
  - `model: Partial<OrderPackagingMaterialModel> = {}`
  - `setField<K>(key, value)` — return `this`
  - `fromRequest(input: IOrderPackagingMaterialCreate)` — set `createdBy`/`updatedBy` = `reqUserId`, assign `orderUUID`, `materialId`, `quantity`, `note`
  - `build()` — return `new OrderPackagingMaterialModel(this)`

### 1c. Barrel

**File mới:** `src/core/domain/model/order-packaging-material/index.ts`

- Export all from `./order-packaging-material.interface`
- Export all from `./order-packaging-material.model`

---

## Step 2 — Domain: Repository Interface

**File mới:** `src/core/domain/repository/order-packaging-material.repository.ts`
**Follow pattern:** `src/core/domain/repository/product-structure.repository.ts`

- Interface gọi là `IOrderPackagingMaterialRepository`
- Không cần paging — chỉ cần `get` và `getList` (không có `getListPaging`)
- Filter option `IOrderPackagingMaterialGeneralOption`:
  - `id?: ID`, `orderUUID?: UUID`, `materialIds?: ID[]`, `isDeleted?: boolean`
  - `includes?: { material?: boolean }`

```ts
export type IOrderPackagingMaterialRepository = BuildRepository<
  { useGet: true; useGetList: true; useCreate: true; useUpdate: true; useDelete: true },
  {
    get: { input: IOrderPackagingMaterialGeneralOption; output: OrderPackagingMaterialModel };
    getList: { input: IOrderPackagingMaterialGeneralOption; output: OrderPackagingMaterialModel[] };
    create: { input: OrderPackagingMaterialModel; output: OrderPackagingMaterialModel };
    update: { input: OrderPackagingMaterialModel; output: ID };
    delete: { input: OrderPackagingMaterialModel; output: ID };
  }
>;
```

**Modified:** `src/core/domain/repository/repository.token.ts`

```ts
ORDER_PACKAGING_MATERIAL_REPOSITORY = 'OrderPackagingMaterialRepository',
```

Thêm vào cuối enum `ERepositoryToken`.

---

## Step 3 — Domain: Exceptions

**File mới:** `src/core/exception/order-packaging-material.exception.ts`
**Follow pattern:** `src/core/exception/order.exception.ts`

Dùng NestJS built-in exceptions (nhất quán với order domain):

- `OrderPackagingMaterialNotFoundException extends NotFoundException` — message: `'Order Packaging Material Not Found'`
- `OrderPackagingMaterialDuplicateException extends ConflictException` — message: `'Order Packaging Material Already Exists'`
- `OrderPackagingMaterialInvalidQuantityException extends UnprocessableEntityException` — message: `'Order Packaging Material Invalid Quantity'`
- `OrderNotEditableException` — **dùng lại từ `order.exception.ts`** nếu đã tồn tại, hoặc tạo mới nếu chưa có: `extends ForbiddenException`, message: `'Order Is Not Editable'`

**Export**: Thêm exports mới vào `src/core/index.ts` hoặc barrel exception file hiện tại.

---

## Step 4 — DB: Prisma Schema + Migration

**Modified:** `prisma/providers/mysql/schema.prisma`

Thêm model mới và cập nhật relations trên `Order` và `Material`:

```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")
}
```

Thêm vào model `Order`:
```prisma
  packagingMaterials OrderPackagingMaterial[] @relation("orderPackagingMaterialRelation")
```

Thêm vào model `Material`:
```prisma
  packagingMaterials OrderPackagingMaterial[] @relation("materialPackagingRelation")
```

**Migration file:** `YYYYMMDDHHMMSS_add_order_packaging_material.ts`  
Nội dung: tạo bảng + index. Không có data migration.

---

## Step 5 — Application: Use Cases

### 5a. GetListOrderPackagingMaterial

**File mới:** `src/application/usecases/order-packaging-material/get-list-order-packaging-material.usecase.ts`
**Follow pattern:** `src/application/usecases/material/get-materials.usecase.ts`

- **Input:** `{ orderUUID: UUID }`
- **Output:** `IOrderPackagingMaterialData[]`
- **Injected repos:** `ORDER_REPOSITORY`, `ORDER_PACKAGING_MATERIAL_REPOSITORY`
- **Business rules:**
  1. Fetch order by `uuid: orderUUID` → not found → throw `OrderNotFoundException`
  2. `packagingMaterialRepo.getList({ orderUUID, isDeleted: false, includes: { material: true } })`
  3. Return `models.map(m => m.data)`
- **Khác pattern chuẩn:** inject 2 repos; validate order exists trước khi list

### 5b. CreateOrderPackagingMaterial

**File mới:** `src/application/usecases/order-packaging-material/create-order-packaging-material.usecase.ts`
**Follow pattern:** `src/application/usecases/material/create-material.usecase.ts`

- **Input:** `IOrderPackagingMaterialCreate` (`orderUUID`, `materialId`, `quantity`, `note?`, `reqUserId`)
- **Output:** `IOrderPackagingMaterialData` (kèm `material` relation)
- **Injected repos:** `ORDER_REPOSITORY`, `MATERIAL_REPOSITORY`, `ORDER_PACKAGING_MATERIAL_REPOSITORY`
- **Business rules:**
  1. Validate `quantity <= 0` → throw `OrderPackagingMaterialInvalidQuantityException`
  2. Parallel fetch: `orderRepo.get({ uuid: orderUUID })` + `materialRepo.get({ id: materialId })`
  3. Order not found → throw `OrderNotFoundException`
  4. `order.status !== EOrderStatus.WAIT_FOR_APPROVE` → throw `OrderNotEditableException`
  5. Material not found → throw `MaterialNotFoundException`
  6. Check duplicate: `packagingMaterialRepo.get({ orderUUID, materialId: materialId, isDeleted: false })` → exists → throw `OrderPackagingMaterialDuplicateException`
  7. Create model: `new OrderPackagingMaterialBuilder().fromRequest(input).build()`
  8. `packagingMaterialRepo.create(model)` → re-fetch với `includes: { material: true }` để trả về data đầy đủ
  9. Return `refetchedModel.data`
- **Khác pattern chuẩn:** inject 3 repos; validate status; re-fetch after create; không dùng transaction (single insert)

### 5c. UpdateOrderPackagingMaterial

**File mới:** `src/application/usecases/order-packaging-material/update-order-packaging-material.usecase.ts`
**Follow pattern:** `src/application/usecases/material/update-material.usecase.ts`

- **Input:** `{ id: ID, orderUUID: UUID, quantity?: number, note?: string, reqUserId: ID }`
- **Output:** `IOrderPackagingMaterialData`
- **Injected repos:** `ORDER_REPOSITORY`, `ORDER_PACKAGING_MATERIAL_REPOSITORY`
- **Business rules:**
  1. Validate `quantity !== undefined && quantity <= 0` → throw `OrderPackagingMaterialInvalidQuantityException`
  2. Parallel fetch: `orderRepo.get({ uuid: orderUUID })` + `packagingMaterialRepo.get({ id, orderUUID, isDeleted: false })`
  3. Order not found → throw `OrderNotFoundException`
  4. `order.status !== EOrderStatus.WAIT_FOR_APPROVE` → throw `OrderNotEditableException`
  5. PackagingMaterial not found → throw `OrderPackagingMaterialNotFoundException`
  6. `model.update({ reqUserId, quantity, note })`
  7. `packagingMaterialRepo.update(model)` → re-fetch với `includes: { material: true }`
  8. Return `refetchedModel.data`
- **Khác pattern chuẩn:** inject 2 repos; validate status; re-fetch after update

### 5d. DeleteOrderPackagingMaterial

**File mới:** `src/application/usecases/order-packaging-material/delete-order-packaging-material.usecase.ts`
**Follow pattern:** `src/application/usecases/material/delete-material.usecase.ts`

- **Input:** `{ id: ID, orderUUID: UUID, reqUserId: ID }`
- **Output:** `ID`
- **Injected repos:** `ORDER_REPOSITORY`, `ORDER_PACKAGING_MATERIAL_REPOSITORY`
- **Business rules:**
  1. Parallel fetch: `orderRepo.get({ uuid: orderUUID })` + `packagingMaterialRepo.get({ id, orderUUID, isDeleted: false })`
  2. Order not found → throw `OrderNotFoundException`
  3. `order.status !== EOrderStatus.WAIT_FOR_APPROVE` → throw `OrderNotEditableException`
  4. PackagingMaterial not found → throw `OrderPackagingMaterialNotFoundException`
  5. `model.auditDeletion(reqUserId)` (gọi trực tiếp base method)
  6. `packagingMaterialRepo.delete(model)`
  7. Return `model.id`
- **Khác pattern chuẩn:** inject 2 repos; validate status

### 5e. Barrel

**File mới:** `src/application/usecases/order-packaging-material/index.ts`

- Export tất cả 4 use case classes và output types

**Modified:** `src/application/usecases/index.ts` — thêm `export * from './order-packaging-material'`

---

## Step 6 — Infrastructure: Mapper

**File mới:** `src/infrastructure/database/mysql/mapper/order-packaging-material.mapper.ts`
**Follow pattern:** `src/infrastructure/database/mysql/mapper/product-structure.mapper.ts`

- Class: `OrderPackagingMaterialMapper extends GenericMapper`
- `fromDomain(model: OrderPackagingMaterialModel): Prisma.OrderPackagingMaterialUncheckedCreateInput`
  - Dùng `this.fromDomainDefault(model)` cho các audit fields
  - Map: `orderUUID: model.orderUUID` (string trực tiếp, KHÔNG dùng `this.fromID` vì đây là UUID string, không phải Int ID)
  - Map: `materialId: this.fromID(model.materialId)`
  - Map: `quantity: model.quantity` (Decimal sẽ được Prisma tự xử lý)
  - Map: `note: model.note`
- `toDomain(record: OrderPackagingMaterial): OrderPackagingMaterialModel`
  - Build bằng `new OrderPackagingMaterialBuilder().setField(...).build()`
  - `orderUUID: record.orderUUID` — string trực tiếp (KHÔNG qua `this.toID`)
  - `materialId: this.toID(record.materialId)`
  - `quantity: parseFloat(record.quantity.toString())` — Decimal từ Prisma → number
- `toDomains(records)` — map list
- `toDomainWith(record: any)` — gọi `toDomain(record)`, nếu `record.material` tồn tại thì set `model.material = new MaterialMapper().toDomain(record.material)`

---

## Step 7 — Infrastructure: Repository Implementation

**File mới:** `src/infrastructure/database/mysql/repository/order-packaging-material-repository.implement.ts`
**Follow pattern:** `src/infrastructure/database/mysql/repository/product-structure-repository.implement.ts`

- Class: `OrderPackagingMaterialRepoImpl extends OrderPackagingMaterialMapper implements IOrderPackagingMaterialRepository`
- Constructor: `@Inject(ERepositoryToken.TRANSACTION_CONTEXT) protected context: ITransactionContext<Prisma.TransactionClient>`
- `get(option)`: `prisma.orderPackagingMaterial.findFirst({ where: fromDomainFilter(option), include: { material: !!includes?.material } })` → `toDomainWith(record)`
- `getList(option)`: `prisma.orderPackagingMaterial.findMany(...)` → `records.map(r => toDomainWith(r))`
- `create(model)`: `prisma.orderPackagingMaterial.create({ data: fromDomain(model) })` → re-fetch với `include: { material: true }` (vì create không support include trực tiếp với generated Prisma) → return `toDomainWith(refetch)`
  - **Alternative**: sau khi create lấy `result.id` rồi gọi `get({ id: result.id, includes: { material: true } })`
- `update(model)`: `prisma.orderPackagingMaterial.update({ where: { id }, data: fromDomain(model) })` → return `model.id`
- `delete(model)`: `prisma.orderPackagingMaterial.update({ where: { id }, data: { ...fromDomain(model), isDeleted: true } })` → return `model.id`
- `fromDomainFilter(option)` method: build Prisma `where` clause từ `IOrderPackagingMaterialGeneralOption`
  - `id`, `orderUUID`, `isDeleted` mapping trực tiếp
  - `materialIds`: `materialId: { in: option.materialIds.map(id => this.fromID(id)) }`

---

## Step 8 — Infrastructure: Provider Registration

**File mới:** `src/infrastructure/providers/order-packaging-material-repository.provider.ts`

```ts
import { ERepositoryToken } from '@app/core';
import { OrderPackagingMaterialRepoImpl } from '../database/mysql/repository/order-packaging-material-repository.implement';

export const orderPackagingMaterialRepositoryProvider = {
  provide: ERepositoryToken.ORDER_PACKAGING_MATERIAL_REPOSITORY,
  useClass: OrderPackagingMaterialRepoImpl,
};
```

**Modified:** `src/infrastructure/providers/repository.provider.ts`

```ts
import { orderPackagingMaterialRepositoryProvider } from './order-packaging-material-repository.provider';

// trong array repositoryProviders, thêm:
orderPackagingMaterialRepositoryProvider,
```

---

## Step 9 — Presentation: Controller + DTOs

**File mới:** `src/presentation/ports/http/controllers/order/admin-order-packaging-material.controller.ts`
**Follow pattern:** `src/presentation/ports/http/controllers/order/order.controller.ts`

- `@Controller('orders/:orderUUID/packaging-materials')`
- `@ApiTags('Order Packaging Material')`
- `@UseGuards(AuthGuard(EGuardStrategy.ADMIN_JWT), PermissionGuard)`
- `@ApiBearerAuth()`
- Route param `orderUUID`: `@Param('orderUUID', ParamUuidPipe) orderUUID: UUID`
- Route param `id`: `@Param('id', ParamIdPipe) id: ID`

**DTOs** (định nghĩa inline trong controller file hoặc file `order-packaging-material.dto.ts` cùng folder):

```ts
// CreateOrderPackagingMaterialReqDTO
class CreateOrderPackagingMaterialReqDTO {
  @IsInt()
  @IsNotEmpty()
  materialId: number;

  @IsNumber()
  @IsPositive()
  @IsNotEmpty()
  quantity: number;

  @IsString()
  @IsOptional()
  note?: string;
}

// UpdateOrderPackagingMaterialReqDTO
class UpdateOrderPackagingMaterialReqDTO {
  @IsNumber()
  @IsPositive()
  @IsOptional()
  quantity?: number;

  @IsString()
  @IsOptional()
  note?: string;
}
```

**Endpoints:**

| Method | Decorator | Permission | Use case |
|--------|-----------|-----------|----------|
| `GET /` | `@Get()` | `order:read` | `GetOrderPackagingMaterialsUsecase` |
| `POST /` | `@Post()` | `order:update` | `CreateOrderPackagingMaterialUsecase` |
| `PUT /:id` | `@Put(':id')` | `order:update` | `UpdateOrderPackagingMaterialUsecase` |
| `DELETE /:id` | `@Delete(':id')` | `order:update` | `DeleteOrderPackagingMaterialUsecase` |

**Modified:** Thêm `AdminOrderPackagingMaterialController` vào `controllers[]` của NestJS module presentation (tìm trong presentation module file).

---

## Step 10 — Wiring: Application Module

**Modified:** `src/application/application.module.ts`

Import 4 use cases mới từ `./usecases/order-packaging-material` và thêm vào `providers[]`:

```ts
import {
  CreateOrderPackagingMaterialUsecase,
  UpdateOrderPackagingMaterialUsecase,
  DeleteOrderPackagingMaterialUsecase,
  GetOrderPackagingMaterialsUsecase,
} from './usecases/order-packaging-material';

// Trong @Module({ providers: [...] }):
CreateOrderPackagingMaterialUsecase,
UpdateOrderPackagingMaterialUsecase,
DeleteOrderPackagingMaterialUsecase,
GetOrderPackagingMaterialsUsecase,
```

Thêm vào `exports[]` nếu module hiện tại export use cases (kiểm tra pattern hiện có).

---

## Checklist hoàn thành

- [ ] Step 1: Interface + Model + Barrel
- [ ] Step 2: Repository interface + `repository.token.ts`
- [ ] Step 3: Exception classes + export
- [ ] Step 4: Prisma schema + run `npx prisma migrate dev --name add_order_packaging_material`
- [ ] Step 5: 4 use cases + barrel + export từ usecases index
- [ ] Step 6: Mapper (chú ý `orderUUID` KHÔNG qua `toID`/`fromID`, `quantity` parse từ Decimal)
- [ ] Step 7: Repository implement
- [ ] Step 8: Provider file + thêm vào `repositoryProviders`
- [ ] Step 9: Controller + DTOs + đăng ký controller
- [ ] Step 10: Application module providers
- [ ] Type-check: `npx tsc --noEmit`
- [ ] Smoke test: `GET /api/admin/orders/:uuid/packaging-materials` → `200 []`
