---
date: 2026-05-26
type: impl-plan
epic_id: EPIC-006
repo: api-kingston
title: Product Variant — API Implementation Plan
---

# IMPL-PLAN — EPIC-006 (api-kingston)

> **Mục đích:** Specification cho `/implement-feature` skill. KHÔNG viết full code — chỉ mô tả input/output/rules đủ để generate code đúng pattern.
>
> **Pattern reference repo:** `/Users/lcnghia95/workspace/kingston/api-kingston`
> **Follow EPIC-005 (OrderPackagingMaterial) pattern** cho phần lớn các steps.

---

## Step 1 — Database: Prisma Schema

**File mới:** `prisma/providers/mysql/prisma-models/variant.prisma`

Tạo models:
- `ComponentType` — fields: id, isDeleted, name, createdAt/updatedAt/deletedAt, createdBy/updatedBy/deletedBy. Relations: componentTypeAttributes[], components[]
- `AttributeType` — fields: id, name, inputType. Relations: options[], componentTypeAttributes[], variantAttributes[]
- `AttributeTypeOption` — fields: id, attributeTypeId, value, sortOrder. Relation: attributeType
- `ComponentTypeAttribute` — join table: id, componentTypeId, attributeTypeId, isRequired. Relations: componentType, attributeType. Unique: (componentTypeId, attributeTypeId)
- `Variant` — fields: id, isDeleted, name, productId, productStructureId, audit fields. Relations: product, productStructure, attributes[], orderItems[]
- `VariantComponentAttribute` — fields: id, variantId, componentId, attributeTypeId, value (VarChar 500). Relations: variant, component, attributeType. Unique: (variantId, componentId, attributeTypeId). Index: (variantId, componentId)

**File sửa:** `prisma/providers/mysql/prisma-models/product.prisma`
- Trong model `Component`: thêm `componentTypeId Int? @map("component_type_id")`, relation `variantType ComponentType? @relation("componentVariantTypeRelation", ...)`, relation `variantAttributes VariantComponentAttribute[] @relation(...)`
- Trong model `ProductStructure`: thêm `variants Variant[] @relation("variantStructureRelation")`
- Trong model `Product`: thêm `variants Variant[] @relation("variantProductRelation")`

**File sửa:** `prisma/providers/mysql/prisma-models/order.prisma`
- Trong model `OrderItem`: thêm `variantId Int? @map("variant_id")`, relation `variant Variant? @relation("orderItemVariantRelation", ...)`

Sau khi sửa schema: `bun run prisma:merge && bun run prisma:generate`

---

## Step 2 — Database: Migration Files

**File 1:** `prisma/providers/mysql/migrations/20260526000001_add_variant_entities/migration.sql`

Nội dung:
```sql
CREATE TABLE `component_types` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `is_deleted` BOOLEAN NOT NULL DEFAULT false,
  `name` VARCHAR(255) NOT NULL,
  `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  `deleted_at` DATETIME(3) NULL,
  `created_by` INT NULL,
  `updated_by` INT NULL,
  `deleted_by` INT NULL,
  PRIMARY KEY (`id`),
  UNIQUE (`id`),
  UNIQUE INDEX `component_types_name_is_deleted_key` (`name`, `is_deleted`)
) DEFAULT CHARACTER SET utf8mb4;

CREATE TABLE `attribute_types` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(255) NOT NULL,
  `input_type` VARCHAR(50) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE (`id`)
) DEFAULT CHARACTER SET utf8mb4;

CREATE TABLE `attribute_type_options` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `attribute_type_id` INT NOT NULL,
  `value` VARCHAR(255) NOT NULL,
  `sort_order` INT NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  UNIQUE (`id`),
  CONSTRAINT `fk_attr_type_option` FOREIGN KEY (`attribute_type_id`) REFERENCES `attribute_types` (`id`)
) DEFAULT CHARACTER SET utf8mb4;

CREATE TABLE `component_type_attributes` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `component_type_id` INT NOT NULL,
  `attribute_type_id` INT NOT NULL,
  `is_required` BOOLEAN NOT NULL DEFAULT false,
  PRIMARY KEY (`id`),
  UNIQUE (`id`),
  UNIQUE INDEX `cta_unique` (`component_type_id`, `attribute_type_id`),
  CONSTRAINT `fk_cta_component_type` FOREIGN KEY (`component_type_id`) REFERENCES `component_types` (`id`),
  CONSTRAINT `fk_cta_attribute_type` FOREIGN KEY (`attribute_type_id`) REFERENCES `attribute_types` (`id`)
) DEFAULT CHARACTER SET utf8mb4;

CREATE TABLE `variants` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `is_deleted` BOOLEAN NOT NULL DEFAULT false,
  `name` VARCHAR(255) NOT NULL,
  `product_id` INT NOT NULL,
  `product_structure_id` INT NOT NULL,
  `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  `deleted_at` DATETIME(3) NULL,
  `created_by` INT NULL,
  `updated_by` INT NULL,
  `deleted_by` INT NULL,
  PRIMARY KEY (`id`),
  UNIQUE (`id`),
  INDEX `idx_variant_structure` (`product_structure_id`),
  CONSTRAINT `fk_variant_product` FOREIGN KEY (`product_id`) REFERENCES `products` (`id`),
  CONSTRAINT `fk_variant_structure` FOREIGN KEY (`product_structure_id`) REFERENCES `product_structures` (`id`)
) DEFAULT CHARACTER SET utf8mb4;

CREATE TABLE `variant_component_attributes` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `variant_id` INT NOT NULL,
  `component_id` INT NOT NULL,
  `attribute_type_id` INT NOT NULL,
  `value` VARCHAR(500) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE (`id`),
  UNIQUE INDEX `vca_unique` (`variant_id`, `component_id`, `attribute_type_id`),
  INDEX `idx_vca_variant_component` (`variant_id`, `component_id`),
  CONSTRAINT `fk_vca_variant` FOREIGN KEY (`variant_id`) REFERENCES `variants` (`id`),
  CONSTRAINT `fk_vca_component` FOREIGN KEY (`component_id`) REFERENCES `components` (`id`),
  CONSTRAINT `fk_vca_attribute_type` FOREIGN KEY (`attribute_type_id`) REFERENCES `attribute_types` (`id`)
) DEFAULT CHARACTER SET utf8mb4;

ALTER TABLE `components` ADD COLUMN `component_type_id` INT NULL;
ALTER TABLE `components` ADD CONSTRAINT `fk_component_variant_type` FOREIGN KEY (`component_type_id`) REFERENCES `component_types` (`id`);

ALTER TABLE `order_items` ADD COLUMN `variant_id` INT NULL;
ALTER TABLE `order_items` ADD CONSTRAINT `fk_order_item_variant` FOREIGN KEY (`variant_id`) REFERENCES `variants` (`id`);
```

**File 2:** `prisma/providers/mysql/migrations/20260526000002_seed_attribute_types/migration.sql`

```sql
INSERT INTO `attribute_types` (`name`, `input_type`) VALUES
  ('Hiệu ứng bề mặt', 'SELECT'),
  ('Tĩnh điện', 'TEXT'),
  ('Dầu màu', 'TEXT'),
  ('Vật liệu đan', 'TEXT');

INSERT INTO `attribute_type_options` (`attribute_type_id`, `value`, `sort_order`) VALUES
  (1, 'Cào xước', 1),
  (1, 'Ép cước', 2),
  (1, 'Giả cổ', 3);

INSERT INTO `component_types` (`name`) VALUES ('Khung sắt');

INSERT INTO `component_type_attributes` (`component_type_id`, `attribute_type_id`, `is_required`) VALUES
  (1, 1, 1),
  (1, 2, 0),
  (1, 3, 0),
  (1, 4, 0);
```

---

## Step 3 — Domain: Repository Token

**File sửa:** `src/core/domain/repository/repository.token.ts`

Thêm vào enum `ERepositoryToken`:
```typescript
COMPONENT_TYPE_REPOSITORY = 'ComponentTypeRepository',
ATTRIBUTE_TYPE_REPOSITORY = 'AttributeTypeRepository',
VARIANT_REPOSITORY = 'VariantRepository',
VARIANT_COMPONENT_ATTRIBUTE_REPOSITORY = 'VariantComponentAttributeRepository',
```

---

## Step 4 — Domain: ComponentType Interface + Model

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

- Base config: `createBaseModelClass({ useId: true, useCreate: true, useUpdate: true, useDelete: true, useEventBus: false })`
- Interface `IComponentType`: `name: string`
- `IComponentTypeData = IComponentType & IComponentTypeBase & { attributeTypes?: IComponentTypeAttributeData[] }`
- `IComponentTypeAttributeData`: `attributeTypeId: ID, attributeTypeName: string, inputType: string, isRequired: boolean, options: string[]`
- `IComponentTypeCreate = { reqUserId: ID, name: string, attributeTypes: Array<{ attributeTypeId: ID, isRequired: boolean }> }`
- `IComponentTypeUpdate = { reqUserId: ID } & Partial<Pick<IComponentType, 'name'>> & { attributeTypes?: Array<{ attributeTypeId: ID, isRequired: boolean }> }`

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

- Model class: `ComponentTypeModel extends ComponentTypeBase`
- Method `update(input: IComponentTypeUpdate)`: gọi `auditModification(reqUserId)`, update `name` nếu có
- Builder: `ComponentTypeBuilder` với `fromRequest(input)` set fields từ `IComponentTypeCreate`

---

## Step 5 — Domain: AttributeType Interface + Model (read-only)

**File mới:** `src/core/domain/model/attribute-type/attribute-type.interface.ts`

- Đây là **read-only entity** (seeded, không có CRUD từ API)
- Interface `IAttributeType`: `name: string, inputType: string`
- `IAttributeTypeData = IAttributeType & { id: ID, options: string[] }`
- Không có Create/Update types
- Base config: `createBaseModelClass({ useId: true, useCreate: false, useUpdate: false, useDelete: false, useEventBus: false })`

**File mới:** `src/core/domain/model/attribute-type/attribute-type.model.ts`

- Model class: `AttributeTypeModel` — chỉ cần `get data()`, không có mutation methods
- Builder: `AttributeTypeBuilder` với `fromRecord(record)` để map từ DB

---

## Step 6 — Domain: Variant Interface + Model

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

- Base config: `createBaseModelClass({ useId: true, useCreate: true, useUpdate: true, useDelete: true, useEventBus: false })`
- Interface `IVariant`: `name: string, productId: ID, productStructureId: ID`
- `IVariantAttributeInput`: `componentId: ID, attributeTypeId: ID, value: string`
- `IVariantData = IVariant & IVariantBase & { product?: IProductData, productStructure?: IProductStructureData, components?: IVariantComponentGroupData[] }`
- `IVariantComponentGroupData`: nhóm attributes theo component: `{ componentId: ID, componentName: string, attributes: IVariantAttributeViewData[] }`
- `IVariantAttributeViewData`: `{ attributeTypeId: ID, name: string, inputType: string, value: string }`
- `IVariantCreate = { reqUserId: ID, name: string, productId: ID, productStructureId: ID, attributes: IVariantAttributeInput[] }`
- `IVariantUpdate = { reqUserId: ID } & Partial<Pick<IVariant, 'name'>> & { attributes?: IVariantAttributeInput[] }`

**File mới:** `src/core/domain/model/variant/variant.model.ts`

- Model class: `VariantModel extends VariantBase`
- Method `update(input: IVariantUpdate)`: auditModification, update name nếu có
- Builder: `VariantBuilder` với `fromRequest(input)`, `build()`

---

## Step 7 — Domain: Repository Interfaces

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

```typescript
interface IComponentTypeRepository {
  getAll(filter: IComponentTypeFilter): Promise<[ComponentTypeModel[], number]>
  getById(id: ID): Promise<ComponentTypeModel | null>
  getByName(name: string): Promise<ComponentTypeModel | null>
  create(model: ComponentTypeModel, attributeTypes: Array<{attributeTypeId: ID, isRequired: boolean}>): Promise<ComponentTypeModel>
  update(model: ComponentTypeModel, attributeTypes?: Array<{attributeTypeId: ID, isRequired: boolean}>): Promise<ComponentTypeModel>
  delete(model: ComponentTypeModel): Promise<void>
  countComponentsUsing(componentTypeId: ID): Promise<number>
}
```

**File mới:** `src/core/domain/repository/attribute-type.repository.ts`

```typescript
interface IAttributeTypeRepository {
  getAll(): Promise<AttributeTypeModel[]>
}
```

**File mới:** `src/core/domain/repository/variant.repository.ts`

```typescript
interface IVariantRepository {
  getAll(filter: IVariantFilter): Promise<[VariantModel[], number]>
  getById(id: ID): Promise<VariantModel | null>
  getByStructureId(productStructureId: ID): Promise<VariantModel[]>
  create(model: VariantModel, attributes: IVariantAttributeInput[]): Promise<VariantModel>
  update(model: VariantModel, attributes?: IVariantAttributeInput[]): Promise<VariantModel>
  delete(model: VariantModel): Promise<void>
}
// IVariantFilter: { name?: string, page: number, limit: number }
```

---

## Step 8 — Domain: Exceptions

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

```typescript
export class ComponentTypeNotFoundException extends HttpException { ... }   // 404
export class ComponentTypeNameDuplicateException extends HttpException { ... } // 409
export class ComponentTypeRequiresAttributeException extends HttpException { ... } // 400
export class ComponentTypeInUseException extends HttpException { ... } // 422
```

**File mới:** `src/core/exception/variant.exception.ts`

```typescript
export class VariantNotFoundException extends HttpException { ... }  // 404
export class VariantRequiredAttributeMissingException extends HttpException { ... }  // 422
export class VariantComponentNotInStructureException extends HttpException { ... }  // 404
```

---

## Step 9 — Application: ComponentType Use Cases

**Follow pattern:** use cases trong `src/application/usecases/order-packaging-material/`

### 9a. GetComponentTypesUsecase

**File:** `src/application/usecases/component-type/get-component-types.usecase.ts`
**Skill:** `/generate-usecase` — GetList pattern

- **Input:** `IGetComponentTypesInput { page, limit, name? }`
- **Output:** `{ result: IComponentTypeData[], total: number }`
- **Injected:** `COMPONENT_TYPE_REPOSITORY`
- **Logic:** call `repo.getAll(filter)` → map to data

### 9b. GetComponentTypeUsecase

**File:** `src/application/usecases/component-type/get-component-type.usecase.ts`

- **Input:** `{ id: ID }`
- **Output:** `IComponentTypeData`
- **Logic:** getById → throw `ComponentTypeNotFoundException` nếu null

### 9c. CreateComponentTypeUsecase

**File:** `src/application/usecases/component-type/create-component-type.usecase.ts`
**Skill:** `/generate-usecase` — Create pattern
**Follow pattern:** `src/application/usecases/order-packaging-material/create-order-packaging-material.usecase.ts`

- **Input:** `IComponentTypeCreate`
- **Output:** `IComponentTypeData`
- **Injected:** `COMPONENT_TYPE_REPOSITORY`, `ATTRIBUTE_TYPE_REPOSITORY`
- **Business rules:**
  1. Validate `attributeTypes.length === 0` → throw `ComponentTypeRequiresAttributeException`
  2. Check trùng tên: `repo.getByName(name)` → throw `ComponentTypeNameDuplicateException` nếu tồn tại
  3. Validate mỗi `attributeTypeId` tồn tại trong `AttributeTypeRepository`
  4. `runInTransaction`: create ComponentType + create ComponentTypeAttribute records (bulk)
  5. Re-fetch với includes (attributeTypes + options)
- **Khác pattern chuẩn:** inject 2 repos; write nhiều records trong 1 transaction

### 9d. UpdateComponentTypeUsecase

**File:** `src/application/usecases/component-type/update-component-type.usecase.ts`

- **Input:** `{ id: ID } & IComponentTypeUpdate`
- **Business rules:**
  1. getById → throw not found nếu null
  2. Nếu `name` thay đổi: check duplicate tên mới
  3. Nếu `attributeTypes` thay đổi: delete existing ComponentTypeAttribute records + insert new ones (trong transaction)
  4. Re-fetch sau update

### 9e. DeleteComponentTypeUsecase

**File:** `src/application/usecases/component-type/delete-component-type.usecase.ts`

- **Input:** `{ id: ID, reqUserId: ID }`
- **Business rules:**
  1. getById → throw not found
  2. `repo.countComponentsUsing(id)` > 0 → throw `ComponentTypeInUseException`
  3. Soft delete

---

## Step 10 — Application: AttributeType Use Cases

### 10a. GetAttributeTypesUsecase

**File:** `src/application/usecases/attribute-type/get-attribute-types.usecase.ts`

- **Input:** none
- **Output:** `IAttributeTypeData[]`
- **Injected:** `ATTRIBUTE_TYPE_REPOSITORY`
- **Logic:** `repo.getAll()` → map to data (bao gồm options)

---

## Step 11 — Application: Variant Use Cases

### 11a. GetVariantsUsecase

**File:** `src/application/usecases/variant/get-variants.usecase.ts`

- **Input:** `{ name?, page, limit }`
- **Output:** `{ result: IVariantData[], total: number }`
- **Injected:** `VARIANT_REPOSITORY`
- **Logic:** call `repo.getAll(filter)` với include: product.nameVn, productStructure.name

### 11b. GetVariantUsecase

**File:** `src/application/usecases/variant/get-variant.usecase.ts`

- **Input:** `{ id: ID }`
- **Output:** `IVariantData` (full detail với components + attributes)
- **Logic:** getById với includes đầy đủ → throw NotFoundException nếu null

### 11c. CreateVariantUsecase

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

- **Input:** `IVariantCreate`
- **Output:** `IVariantData`
- **Injected:** `VARIANT_REPOSITORY`, `PRODUCT_REPOSITORY`, `PRODUCT_STRUCTURE_REPOSITORY`, `COMPONENT_TYPE_REPOSITORY`
- **Business rules:**
  1. Validate product exists
  2. Validate productStructure exists và thuộc product đó
  3. Load level-1 components của productStructure: `WHERE structureId = X AND isDeleted = false`
  4. Load ComponentType + required AttributeTypes cho từng component có `componentTypeId`
  5. Validate: mỗi required attribute trong `IVariantCreate.attributes` phải có value → throw `VariantRequiredAttributeMissingException` nếu thiếu
  6. Validate: mỗi `componentId` trong attributes phải thuộc productStructure → throw `VariantComponentNotInStructureException`
  7. `runInTransaction`: create Variant + create VariantComponentAttribute records (bulk upsert)
  8. Re-fetch với full includes
- **Khác pattern chuẩn:** inject 4 repos; validation phức tạp trên attribute; bulk write nhiều records

### 11d. UpdateVariantUsecase

**File:** `src/application/usecases/variant/update-variant.usecase.ts`

- **Input:** `{ id: ID } & IVariantUpdate`
- **Business rules:**
  1. getById → throw not found
  2. Nếu `attributes` thay đổi: delete existing VariantComponentAttribute + insert new (transaction)
  3. Validation bước 4-6 giống CreateVariantUsecase
  4. Re-fetch sau update

### 11e. DeleteVariantUsecase

**File:** `src/application/usecases/variant/delete-variant.usecase.ts`

- **Input:** `{ id: ID, reqUserId: ID }`
- **Logic:** getById → soft delete

---

## Step 12 — Infrastructure: Mappers

### 12a. ComponentTypeMapper

**File:** `src/infrastructure/database/mysql/mapper/component-type.mapper.ts`
**Follow pattern:** `src/infrastructure/database/mysql/mapper/order-packaging-material.mapper.ts`

- `toDomain(record)`: map Prisma ComponentType record → ComponentTypeModel
- `fromDomain(model)`: map model → Prisma create/update input
- `this.includes`: `{ componentTypeAttributes: { include: { attributeType: { include: { options: true } } } } }`
- Khi map `componentTypeAttributes` → `attributeTypes` trong data: group options thành `string[]`

### 12b. AttributeTypeMapper

**File:** `src/infrastructure/database/mysql/mapper/attribute-type.mapper.ts`

- `toDomain(record)`: map với options → `options: record.options.map(o => o.value)`

### 12c. VariantMapper

**File:** `src/infrastructure/database/mysql/mapper/variant.mapper.ts`

- `toDomain(record)`: map Variant với nested attributes → group theo componentId thành `IVariantComponentGroupData[]`
- `this.includes`:
  ```
  {
    product: true,
    productStructure: true,
    attributes: {
      include: {
        component: { select: { id: true, name: true } },
        attributeType: { select: { id: true, name: true, inputType: true } }
      }
    }
  }
  ```
- Grouping logic: attributes grouped by componentId; mỗi group = `IVariantComponentGroupData`

---

## Step 13 — Infrastructure: Repository Implementations

### 13a. ComponentTypePrismaRepository

**File:** `src/infrastructure/database/mysql/repository/component-type.prisma-repository.ts`
**Follow pattern:** `order-packaging-material.prisma-repository.ts`

- Extends `ComponentTypeMapper`, implements `IComponentTypeRepository`
- `create(model, attributeTypes)`: `tx.getClient().componentType.create()` + bulk `componentTypeAttribute.createMany()`
- `update(model, attributeTypes)`:
  - Update ComponentType record
  - Nếu attributeTypes thay đổi: delete all `WHERE componentTypeId = id` + createMany mới
  - Tất cả trong 1 transaction
- `delete(model)`: soft delete (update `isDeleted = true, deletedAt, deletedBy`)
- `countComponentsUsing(id)`: `tx.getClient().component.count({ where: { componentTypeId: id, isDeleted: false } })`
- `getAll(filter)`: `findMany` với `where { isDeleted: false, name contains filter.name }` + `include this.includes`

### 13b. AttributeTypePrismaRepository

**File:** `src/infrastructure/database/mysql/repository/attribute-type.prisma-repository.ts`

- Extends `AttributeTypeMapper`, implements `IAttributeTypeRepository`
- `getAll()`: `findMany()` với `include: { options: { orderBy: { sortOrder: 'asc' } } }`

### 13c. VariantPrismaRepository

**File:** `src/infrastructure/database/mysql/repository/variant.prisma-repository.ts`

- Extends `VariantMapper`, implements `IVariantRepository`
- `create(model, attributes)`:
  - `tx.getClient().variant.create(data)` 
  - `tx.getClient().variantComponentAttribute.createMany({ data: attributes.map(...) })`
  - Re-fetch với `this.includes`
- `update(model, attributes)`:
  - Update variant record
  - Nếu attributes: `deleteMany({ where: { variantId } })` + `createMany()`
  - Re-fetch
- `getAll(filter)`: paginated; include product + productStructure (name only), không include full attributes (list view)
- `getById(id)`: include full attributes với grouping
- `getByStructureId(id)`: cho OrderItem selector — return list nhẹ (id, name only)

---

## Step 14 — Infrastructure: Modify Existing Mappers

**File sửa:** `src/infrastructure/database/mysql/mapper/component.mapper.ts`
- `toDomain(record)`: thêm `componentTypeId: record.componentTypeId ?? undefined`

**File sửa:** `src/infrastructure/database/mysql/mapper/order-item.mapper.ts`
- `toDomain(record)`: thêm `variantId: record.variantId ?? undefined`
- `fromDomain(model)`: thêm `variantId` nếu có

---

## Step 15 — Presentation: DTOs

**File mới:** `src/presentation/dtos/component-type.dto.ts`
**Follow pattern:** `src/presentation/dtos/order-packaging-material.dto.ts`

```
ComponentTypeAttributeDTO: { attributeTypeId, name, inputType, isRequired, options: string[] }
ComponentTypeDTO: { id, name, attributeTypes: ComponentTypeAttributeDTO[], createdAt, updatedAt }
CreateComponentTypeReqDTO: { name: string, attributeTypes: [{ attributeTypeId: number, isRequired: boolean }] }
UpdateComponentTypeReqDTO: Partial của Create
GetComponentTypesReqDTO: { name?, page, limit }
```

**File mới:** `src/presentation/dtos/attribute-type.dto.ts`

```
AttributeTypeDTO: { id, name, inputType, options: string[] }
```

**File mới:** `src/presentation/dtos/variant.dto.ts`

```
VariantAttributeDTO: { attributeTypeId, name, inputType, value }
VariantComponentGroupDTO: { componentId, componentName, attributes: VariantAttributeDTO[] }
VariantDTO: { id, name, productId, product: { id, nameVn }, productStructureId, productStructure: { id, name }, components: VariantComponentGroupDTO[], createdAt, createdBy }
CreateVariantReqDTO: { name: string, productId: number, productStructureId: number, attributes: [{ componentId, attributeTypeId, value }] }
UpdateVariantReqDTO: Partial của Create
GetVariantsReqDTO: { name?, page, limit }
```

**Validation decorators cần thêm (không standard):**
- `CreateVariantReqDTO.attributes`: `@IsArray()`, `@ValidateNested({ each: true })`, `@ArrayMinSize(0)`
- `CreateVariantReqDTO.name`: `@MaxLength(255)`
- attribute `value`: `@MaxLength(500)`

---

## Step 16 — Presentation: Controllers

### 16a. AdminComponentTypeController

**File:** `src/presentation/ports/http/controllers/component-type/admin-component-type.controller.ts`
**Follow pattern:** controller trong `controllers/order/admin-order-packaging-material.controller.ts`

```
GET  /component-types         → getComponentTypes(query)   → GetComponentTypesUsecase
POST /component-types         → createComponentType(body)  → CreateComponentTypeUsecase
GET  /component-types/:id     → getComponentType(id)       → GetComponentTypeUsecase
PUT  /component-types/:id     → updateComponentType(id, body) → UpdateComponentTypeUsecase
DELETE /component-types/:id   → deleteComponentType(id)    → DeleteComponentTypeUsecase
```

### 16b. AdminAttributeTypeController

**File:** `src/presentation/ports/http/controllers/attribute-type/admin-attribute-type.controller.ts`

```
GET  /attribute-types         → getAttributeTypes()        → GetAttributeTypesUsecase
```

### 16c. AdminVariantController

**File:** `src/presentation/ports/http/controllers/variant/admin-variant.controller.ts`

```
GET  /variants                → getVariants(query)         → GetVariantsUsecase
POST /variants                → createVariant(body)        → CreateVariantUsecase
GET  /variants/:id            → getVariant(id)             → GetVariantUsecase
PUT  /variants/:id            → updateVariant(id, body)    → UpdateVariantUsecase
DELETE /variants/:id          → deleteVariant(id)          → DeleteVariantUsecase
GET  /variants?structureId=X  → getVariants (filter)       → GetVariantsUsecase (bổ sung param)
```

---

## Step 17 — Wiring: ApplicationModule + InfrastructureModule + AdminModule

**File sửa:** `src/application/application.module.ts`

Thêm:
```typescript
const componentTypeProviders = [GetComponentTypesUsecase, GetComponentTypeUsecase, CreateComponentTypeUsecase, UpdateComponentTypeUsecase, DeleteComponentTypeUsecase]
const attributeTypeProviders = [GetAttributeTypesUsecase]
const variantProviders = [GetVariantsUsecase, GetVariantUsecase, CreateVariantUsecase, UpdateVariantUsecase, DeleteVariantUsecase]

// Trong providers[]: ...componentTypeProviders, ...attributeTypeProviders, ...variantProviders
```

**File sửa:** `src/infrastructure/infrastructure.module.ts`

Thêm 4 repository providers với token và useClass tương ứng.

**File sửa:** `src/presentation/ports/http/modules/admin.module.ts`

Thêm 3 controllers: `AdminComponentTypeController`, `AdminAttributeTypeController`, `AdminVariantController`

---

## Step 18 — Export Barrels (index.ts)

Thêm export trong các `index.ts`:
- `src/core/domain/model/component-type/index.ts`
- `src/core/domain/model/attribute-type/index.ts`
- `src/core/domain/model/variant/index.ts`
- `src/presentation/ports/http/controllers/index.ts` — export 3 controllers mới

---

## Checklist

- [ ] `bun run prisma:merge && bun run prisma:generate` sau khi sửa schema
- [ ] `bun run build:app` không có TypeScript error
- [ ] `bun run lint` pass
- [ ] Migration files có thể chạy idempotent trên staging DB
- [ ] Seed data được apply qua migration file (không phải seed script)
- [ ] `reqUserId` có trong tất cả state-changing use case inputs
- [ ] Event publish (nếu có) nằm ngoài `runInTransaction`
- [ ] Nullable FKs không break existing tests
