---
date: 2026-05-26
type: tech-design
epic_id: EPIC-006
title: Product Variant — Cấu hình thuộc tính sản phẩm theo đơn hàng
status: draft
revision: 1
---

# Tech Design — EPIC-006: Product Variant

## Summary

EPIC-006 thêm cơ chế **Variant** — object gắn với một `ProductStructure` cụ thể, chứa các attribute values do khách hàng lựa chọn (hiệu ứng bề mặt, tĩnh điện, v.v.). Attribute schema là EAV: entity mới `ComponentType` định nghĩa bộ `AttributeType` áp dụng cho từng loại component; khi tạo Variant, hệ thống đọc `ComponentType` của các level-1 components trong `ProductStructure` và render đúng form fields. Thiết kế ưu tiên **additive-only migrations** (nullable FKs) để không break bất kỳ flow hiện tại nào.

---

## Deviation from PRD

**1. Tên field trong Component: `componentTypeId` vs Prisma relation conflict**

PRD nói thêm `componentTypeId` FK vào `Component`. Tuy nhiên trong Prisma schema hiện tại, bảng `components` đã có relation `componentType` trỏ đến `MasterData` (field `typeId`). Prisma không cho phép hai relations cùng tên trong một model.

- **PRD nói:** `component.componentTypeId (FK → ComponentType)`
- **Tech design quyết định:** Prisma field vẫn là `componentTypeId`, map sang DB column `component_type_id`; nhưng Prisma relation name đổi thành `variantType` với name `"componentVariantTypeRelation"` để tránh conflict với relation `componentType` hiện có (trỏ MasterData). Domain model sử dụng field `componentTypeId` đúng như PRD.

**2. `OrderLine` thực chất là `OrderItem` trong codebase**

PRD dùng thuật ngữ "OrderLine" (theo epic EPIC-006). Tuy nhiên trong codebase, entity này tên là `OrderItem`, bảng `order_items`. Tech design dùng `OrderItem` để consistent với code, nhưng feature behaves giống như PRD mô tả.

---

## Architecture

### Layer Diagram

```
Presentation (Controller)
    │ dto
    ▼
Application (UseCase)
    │ domain interface
    ▼
Domain (Model + Repository Interface)
    ▲
Infrastructure (PrismaRepository + Mapper)
```

### Layer Mapping — New / Modified Modules

| Layer | Module | File | Action |
|---|---|---|---|
| **Domain** | ComponentType | `domain/model/component-type/` | NEW — interface + model |
| **Domain** | AttributeType | `domain/model/attribute-type/` | NEW — interface + model |
| **Domain** | Variant | `domain/model/variant/` | NEW — interface + model |
| **Domain** | VariantComponentAttribute | `domain/model/variant-component-attribute/` | NEW — interface + model |
| **Domain** | Component | `domain/model/component/component.interface.ts` | MODIFY — thêm `componentTypeId` |
| **Domain** | OrderItem | `domain/model/order-item/order-item.interface.ts` | MODIFY — thêm `variantId` |
| **Domain** | Repository interfaces | `domain/repository/` | NEW 4 repo interfaces |
| **Application** | ComponentType usecases | `usecases/component-type/` | NEW — CRUD |
| **Application** | AttributeType usecases | `usecases/attribute-type/` | NEW — GetList |
| **Application** | Variant usecases | `usecases/variant/` | NEW — CRUD |
| **Infrastructure** | ComponentType repo | `infrastructure/database/mysql/repository/` | NEW |
| **Infrastructure** | AttributeType repo | `infrastructure/database/mysql/repository/` | NEW |
| **Infrastructure** | Variant repo | `infrastructure/database/mysql/repository/` | NEW |
| **Infrastructure** | VariantComponentAttribute repo | `infrastructure/database/mysql/repository/` | NEW |
| **Presentation** | AdminComponentTypeController | `presentation/ports/http/controllers/` | NEW |
| **Presentation** | AdminAttributeTypeController | `presentation/ports/http/controllers/` | NEW |
| **Presentation** | AdminVariantController | `presentation/ports/http/controllers/` | NEW |

### Key Design Choices

**EAV value storage — store text, not optionId:**
VariantComponentAttribute lưu `value: VARCHAR(500)`. Với SELECT type, lưu option text (VD: "Cào xước"), không lưu optionId. Rationale: nếu lưu optionId và option bị xóa/rename, Variant cũ mất ý nghĩa. Text value preserve history.

**Level-1 components là flat trong schema hiện tại:**
Bảng `components` có `structureId` FK trực tiếp về `product_structures`. Không có cấu trúc tree parent/child — tất cả Component với cùng `structureId` đều là "level-1". Không cần query phức tạp; `WHERE structureId = X AND isDeleted = false` là đủ.

**Variant không có domain events:**
Tạo/sửa Variant không trigger downstream event (không tạo Work Order, không thay đổi inventory). Thiết kế đơn giản: `useEventBus: false` trong BaseModelClass.

**ComponentType là entity riêng, không dùng MasterData:**
MasterData là flat lookup table. ComponentType cần relationship M:N với AttributeType (với `isRequired` flag). Sử dụng MasterData cho ComponentType sẽ cần EAV-on-EAV — antipattern. Entity riêng là đúng.

---

## API / Interface Contract

**Auth:** tất cả endpoints dùng `AuthGuard(EGuardStrategy.ADMIN_JWT)`. Không có panel endpoint.
**Versioning:** API v1, không version prefix. Breaking changes → version mới.

### Component Type

```
GET    /component-types              → ComponentTypeDTO[]
POST   /component-types              → ComponentTypeDTO (201)
GET    /component-types/:id          → ComponentTypeDTO
PUT    /component-types/:id          → ComponentTypeDTO
DELETE /component-types/:id          → 200 (guarded: 422 nếu đang có Component dùng type này)
```

**ComponentTypeDTO:**
```json
{
  "id": 1,
  "name": "Khung sắt X",
  "attributeTypes": [
    { "attributeTypeId": 1, "name": "Hiệu ứng bề mặt", "inputType": "SELECT", "isRequired": true,
      "options": ["Cào xước", "Ép cước", "Giả cổ"] },
    { "attributeTypeId": 2, "name": "Tĩnh điện", "inputType": "TEXT", "isRequired": false, "options": [] }
  ],
  "createdAt": "...", "updatedAt": "..."
}
```

**CreateComponentTypeReqDTO:**
```json
{
  "name": "Khung sắt X",
  "attributeTypes": [
    { "attributeTypeId": 1, "isRequired": true }
  ]
}
```

**Error codes:**
- `409` + `COMPONENT_TYPE_NAME_DUPLICATE` — tên đã tồn tại
- `400` + `COMPONENT_TYPE_REQUIRES_ATTRIBUTE` — không có AttributeType nào
- `422` + `COMPONENT_TYPE_IN_USE` — xóa khi đang có Component dùng

### Attribute Type (read-only, seeded)

```
GET    /attribute-types              → AttributeTypeDTO[]
```

**AttributeTypeDTO:**
```json
{
  "id": 1,
  "name": "Hiệu ứng bề mặt",
  "inputType": "SELECT",
  "options": ["Cào xước", "Ép cước", "Giả cổ"]
}
```

### Variant

```
GET    /variants                     → { result: VariantDTO[], total: number }
POST   /variants                     → VariantDTO (201)
GET    /variants/:id                 → VariantDTO
PUT    /variants/:id                 → VariantDTO
DELETE /variants/:id                 → 200
```

**Query params `GET /variants`:** `name` (string, search), `page`, `limit`

**VariantDTO:**
```json
{
  "id": 1,
  "name": "Khung đen cào xước RAL9016",
  "productId": 5,
  "product": { "id": 5, "nameVn": "Ghế bistro" },
  "productStructureId": 3,
  "productStructure": { "id": 3, "name": "V1 - Khung sắt" },
  "components": [
    {
      "componentId": 12,
      "componentName": "Khung sắt chính",
      "attributes": [
        { "attributeTypeId": 1, "name": "Hiệu ứng bề mặt", "inputType": "SELECT", "value": "Cào xước" },
        { "attributeTypeId": 2, "name": "Tĩnh điện", "inputType": "TEXT", "value": "RAL 9016" }
      ]
    }
  ],
  "createdAt": "...", "createdBy": 3
}
```

**CreateVariantReqDTO:**
```json
{
  "name": "Khung đen cào xước RAL9016",
  "productId": 5,
  "productStructureId": 3,
  "attributes": [
    { "componentId": 12, "attributeTypeId": 1, "value": "Cào xước" },
    { "componentId": 12, "attributeTypeId": 2, "value": "RAL 9016" }
  ]
}
```

**Error codes:**
- `404` + `PRODUCT_NOT_FOUND`
- `404` + `PRODUCT_STRUCTURE_NOT_FOUND`
- `404` + `COMPONENT_NOT_FOUND` — componentId không thuộc productStructureId
- `422` + `VARIANT_REQUIRED_ATTRIBUTE_MISSING` — required attribute chưa có giá trị

### OrderItem extension (PATCH)

Extend request DTO hiện có để nhận `variantId` optional:

```
PATCH  /orders/:uuid/items/:id       → thêm field variantId? (nullable)
```

**UpdateOrderItemReqDTO** — thêm field:
```json
{ "variantId": 7 }   // hoặc null để unset
```

Response `OrderItemDTO` — thêm field:
```json
{
  "variantId": 7,
  "variant": {
    "id": 7,
    "name": "Khung đen cào xước RAL9016"
  }
}
```

**Idempotency:** PUT/PATCH Variant là idempotent — gửi lại cùng payload cho kết quả như nhau.

---

## Data Model

### Prisma Schema (new file: `prisma/providers/mysql/prisma-models/variant.prisma`)

```prisma
model ComponentType {
  id        Int       @id @unique @default(autoincrement()) @map("id")
  isDeleted Boolean   @default(false) @map("is_deleted")
  name      String    @map("name")
  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")

  componentTypeAttributes ComponentTypeAttribute[] @relation("componentTypeAttributeRelation")
  components              Component[]              @relation("componentVariantTypeRelation")

  @@unique([name, isDeleted])
  @@map("component_types")
}

model AttributeType {
  id        Int    @id @unique @default(autoincrement()) @map("id")
  name      String @map("name")
  inputType String @map("input_type")  // "SELECT" | "TEXT"

  options                 AttributeTypeOption[]    @relation("attributeTypeOptionRelation")
  componentTypeAttributes ComponentTypeAttribute[] @relation("attributeTypeComponentTypeRelation")
  variantAttributes       VariantComponentAttribute[] @relation("variantAttributeTypeRelation")

  @@map("attribute_types")
}

model AttributeTypeOption {
  id              Int    @id @unique @default(autoincrement()) @map("id")
  attributeTypeId Int    @map("attribute_type_id")
  value           String @map("value")
  sortOrder       Int    @default(0) @map("sort_order")

  attributeType AttributeType @relation("attributeTypeOptionRelation", fields: [attributeTypeId], references: [id])

  @@map("attribute_type_options")
}

model ComponentTypeAttribute {
  id               Int     @id @unique @default(autoincrement()) @map("id")
  componentTypeId  Int     @map("component_type_id")
  attributeTypeId  Int     @map("attribute_type_id")
  isRequired       Boolean @default(false) @map("is_required")

  componentType ComponentType @relation("componentTypeAttributeRelation", fields: [componentTypeId], references: [id])
  attributeType AttributeType @relation("attributeTypeComponentTypeRelation", fields: [attributeTypeId], references: [id])

  @@unique([componentTypeId, attributeTypeId])
  @@map("component_type_attributes")
}

model Variant {
  id                 Int       @id @unique @default(autoincrement()) @map("id")
  isDeleted          Boolean   @default(false) @map("is_deleted")
  name               String    @map("name")
  productId          Int       @map("product_id")
  productStructureId Int       @map("product_structure_id")
  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")

  product          Product          @relation("variantProductRelation", fields: [productId], references: [id])
  productStructure ProductStructure @relation("variantStructureRelation", fields: [productStructureId], references: [id])
  attributes       VariantComponentAttribute[] @relation("variantAttributeRelation")
  orderItems       OrderItem[]      @relation("orderItemVariantRelation")

  @@map("variants")
}

model VariantComponentAttribute {
  id              Int    @id @unique @default(autoincrement()) @map("id")
  variantId       Int    @map("variant_id")
  componentId     Int    @map("component_id")
  attributeTypeId Int    @map("attribute_type_id")
  value           String @map("value") @db.VarChar(500)

  variant       Variant       @relation("variantAttributeRelation", fields: [variantId], references: [id])
  component     Component     @relation("componentVariantAttributeRelation", fields: [componentId], references: [id])
  attributeType AttributeType @relation("variantAttributeTypeRelation", fields: [attributeTypeId], references: [id])

  @@unique([variantId, componentId, attributeTypeId])
  @@index([variantId, componentId])
  @@map("variant_component_attributes")
}
```

### Alter existing models (modify existing `.prisma` files)

**`product.prisma` — Component model, thêm:**
```prisma
// Trong model Component, thêm field:
componentTypeId  Int?      @map("component_type_id")

// Thêm relation:
variantType      ComponentType? @relation("componentVariantTypeRelation", fields: [componentTypeId], references: [id])
variantAttributes VariantComponentAttribute[] @relation("componentVariantAttributeRelation")
```

**`order.prisma` — OrderItem model, thêm:**
```prisma
// Trong model OrderItem, thêm field:
variantId  Int?  @map("variant_id")

// Thêm relation:
variant    Variant? @relation("orderItemVariantRelation", fields: [variantId], references: [id])
```

**`product.prisma` — ProductStructure model, thêm relation:**
```prisma
variants Variant[] @relation("variantStructureRelation")
```

**`product.prisma` — Product model, thêm relation:**
```prisma
variants Variant[] @relation("variantProductRelation")
```

### Migration

File name pattern: `20260526000001_add_variant_entities`

Steps:
1. CREATE TABLE `component_types`
2. CREATE TABLE `attribute_types`
3. CREATE TABLE `attribute_type_options`
4. CREATE TABLE `component_type_attributes`
5. CREATE TABLE `variants`
6. CREATE TABLE `variant_component_attributes`
7. ALTER TABLE `components` ADD COLUMN `component_type_id` INT NULL, ADD FOREIGN KEY
8. ALTER TABLE `order_items` ADD COLUMN `variant_id` INT NULL, ADD FOREIGN KEY

File name pattern: `20260526000002_seed_attribute_types_khung_sat`

Seed data (trong migration):
```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');

-- Component type cho khung sắt (admin tự tạo sau qua UI, đây là seed ban đầu)
INSERT INTO component_types (name) VALUES ('Khung sắt');

-- Gắn 4 attribute types vào ComponentType "Khung sắt"
INSERT INTO component_type_attributes (component_type_id, attribute_type_id, is_required) VALUES
  (1, 1, 1),  -- Hiệu ứng bề mặt: required
  (1, 2, 0),  -- Tĩnh điện: optional
  (1, 3, 0),  -- Dầu màu: optional
  (1, 4, 0);  -- Vật liệu đan: optional

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);
```

### Indexes

- `variant_component_attributes`: `UNIQUE (variant_id, component_id, attribute_type_id)` + `INDEX (variant_id, component_id)` — đủ cho join khi load Variant detail
- `variants`: `INDEX (product_structure_id)` — cho filter trong OrderItem Variant selector
- `component_types`: `UNIQUE (name, is_deleted)` — enforce unique name

### Domain Events

Không có domain event mới. Variant CRUD không trigger downstream effects.

---

## Dependency Wiring / Registration

### api-kingston — ERepositoryToken

Thêm vào `src/core/domain/repository/repository.token.ts`:

```typescript
COMPONENT_TYPE_REPOSITORY = 'ComponentTypeRepository',
ATTRIBUTE_TYPE_REPOSITORY = 'AttributeTypeRepository',
VARIANT_REPOSITORY = 'VariantRepository',
VARIANT_COMPONENT_ATTRIBUTE_REPOSITORY = 'VariantComponentAttributeRepository',
```

### api-kingston — ApplicationModule

Thêm provider arrays vào `src/application/application.module.ts`:

```typescript
const componentTypeProviders = [
  GetComponentTypesUsecase,
  GetComponentTypeUsecase,
  CreateComponentTypeUsecase,
  UpdateComponentTypeUsecase,
  DeleteComponentTypeUsecase,
]

const attributeTypeProviders = [
  GetAttributeTypesUsecase,
]

const variantProviders = [
  GetVariantsUsecase,
  GetVariantUsecase,
  CreateVariantUsecase,
  UpdateVariantUsecase,
  DeleteVariantUsecase,
]

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

### api-kingston — InfrastructureModule

Thêm repository providers vào `src/infrastructure/infrastructure.module.ts`:

```typescript
{
  provide: ERepositoryToken.COMPONENT_TYPE_REPOSITORY,
  useClass: ComponentTypePrismaRepository,
},
{
  provide: ERepositoryToken.ATTRIBUTE_TYPE_REPOSITORY,
  useClass: AttributeTypePrismaRepository,
},
{
  provide: ERepositoryToken.VARIANT_REPOSITORY,
  useClass: VariantPrismaRepository,
},
{
  provide: ERepositoryToken.VARIANT_COMPONENT_ATTRIBUTE_REPOSITORY,
  useClass: VariantComponentAttributePrismaRepository,
},
```

### api-kingston — AdminModule

Thêm controllers vào `src/presentation/ports/http/modules/admin.module.ts`:

```typescript
AdminComponentTypeController,
AdminAttributeTypeController,
AdminVariantController,
```

### web-kingston — DI types.ts

Thêm vào `packages/main-app/src/clean-architecture/di/types.ts`:

```typescript
// ComponentType
ComponentTypeRepository: Symbol.for("ComponentTypeRepository"),
GetComponentTypes: Symbol.for("GetComponentTypes"),
CreateComponentType: Symbol.for("CreateComponentType"),
UpdateComponentType: Symbol.for("UpdateComponentType"),
DeleteComponentType: Symbol.for("DeleteComponentType"),

// AttributeType
AttributeTypeRepository: Symbol.for("AttributeTypeRepository"),
GetAttributeTypes: Symbol.for("GetAttributeTypes"),

// Variant
VariantRepository: Symbol.for("VariantRepository"),
GetVariants: Symbol.for("GetVariants"),
GetVariant: Symbol.for("GetVariant"),
CreateVariant: Symbol.for("CreateVariant"),
UpdateVariant: Symbol.for("UpdateVariant"),
DeleteVariant: Symbol.for("DeleteVariant"),
```

### web-kingston — container.ts

Thêm bindings vào `packages/main-app/src/clean-architecture/di/container.ts`:

```typescript
// Repositories
container.bind<ComponentTypeRepository>(TYPES.ComponentTypeRepository)
  .to(ComponentTypeApiRepository).inSingletonScope();
container.bind<AttributeTypeRepository>(TYPES.AttributeTypeRepository)
  .to(AttributeTypeApiRepository).inSingletonScope();
container.bind<VariantRepository>(TYPES.VariantRepository)
  .to(VariantApiRepository).inSingletonScope();

// Use Cases
container.bind<GetComponentTypesUseCase>(TYPES.GetComponentTypes)
  .to(GetComponentTypesUseCase).inSingletonScope();
// ... (tương tự cho tất cả use cases)
```

---

## Non-Functional Design

### Performance

| Query | Target | Strategy |
|---|---|---|
| `GET /variants` (list) | < 500ms p95 | Index on `product_structure_id`; pagination; eager load product + structure name only |
| `GET /variants/:id` (detail) | < 300ms p95 | Single query với includes: attributes → component → attributeType |
| `GET /product-structures/:id` with components+variantType | < 300ms p95 | Existing query + include variantType.componentTypeAttributes.attributeType |
| EAV join `variant_component_attributes` | < 100ms | Covered by `(variant_id, component_id)` index |

### Reliability

- No retries needed (all synchronous, transactional operations)
- Validation tại Controller (DTO) và Use Case layer
- Transaction: `CreateVariantUsecase` dùng `runInTransaction` khi write `Variant` + multiple `VariantComponentAttribute`

### Security

- JWT guard trên tất cả endpoints — consistent với các module hiện có
- Input validation: tên max 255 ký tự, value max 500 ký tự (enforce ở DTO + DB)
- `componentId` trong CreateVariant request phải thuộc `productStructureId` — validate trong use case (không cho inject component của structure khác)

---

## Rollout & Reversibility

**Strategy:** Direct rollout — không cần feature flag.

- Migration chỉ ADD columns/tables (nullable FKs, new tables) → zero downtime risk
- `OrderItem.variantId` nullable → existing order flow không bị break
- `Component.componentTypeId` nullable → existing BOM/component flow không bị break

**Rollback:**
- Nếu phát hiện bug sau deploy: ẩn route `/variants`, `/component-types` trên web (route guard) → zero impact trên data
- DB rollback: `DROP` new tables + `ALTER` remove nullable FKs — safe vì không có existing data dependency

---

## File / Module Impact

### api-kingston — New files

| File | Lý do |
|---|---|
| `src/core/domain/model/component-type/component-type.interface.ts` | Domain interface mới |
| `src/core/domain/model/component-type/component-type.model.ts` | Domain model + builder |
| `src/core/domain/model/component-type/index.ts` | Barrel export |
| `src/core/domain/model/attribute-type/attribute-type.interface.ts` | Domain interface mới |
| `src/core/domain/model/attribute-type/attribute-type.model.ts` | Domain model (read-only) |
| `src/core/domain/model/attribute-type/index.ts` | Barrel export |
| `src/core/domain/model/variant/variant.interface.ts` | Domain interface mới |
| `src/core/domain/model/variant/variant.model.ts` | Domain model + builder |
| `src/core/domain/model/variant/index.ts` | Barrel export |
| `src/core/domain/model/variant-component-attribute/variant-component-attribute.interface.ts` | Domain interface mới |
| `src/core/domain/model/variant-component-attribute/variant-component-attribute.model.ts` | Domain model + builder |
| `src/core/domain/model/variant-component-attribute/index.ts` | Barrel export |
| `src/core/domain/repository/component-type.repository.ts` | Repo interface |
| `src/core/domain/repository/attribute-type.repository.ts` | Repo interface |
| `src/core/domain/repository/variant.repository.ts` | Repo interface |
| `src/core/domain/repository/variant-component-attribute.repository.ts` | Repo interface |
| `src/core/exception/component-type.exception.ts` | Exception classes |
| `src/core/exception/variant.exception.ts` | Exception classes |
| `src/application/usecases/component-type/*.usecase.ts` (5 files) | Use cases CRUD |
| `src/application/usecases/attribute-type/get-attribute-types.usecase.ts` | Use case read-only |
| `src/application/usecases/variant/*.usecase.ts` (5 files) | Use cases CRUD |
| `src/infrastructure/database/mysql/repository/component-type.prisma-repository.ts` | Repo impl |
| `src/infrastructure/database/mysql/repository/attribute-type.prisma-repository.ts` | Repo impl |
| `src/infrastructure/database/mysql/repository/variant.prisma-repository.ts` | Repo impl |
| `src/infrastructure/database/mysql/mapper/component-type.mapper.ts` | Mapper |
| `src/infrastructure/database/mysql/mapper/attribute-type.mapper.ts` | Mapper |
| `src/infrastructure/database/mysql/mapper/variant.mapper.ts` | Mapper |
| `src/presentation/ports/http/controllers/component-type/*.controller.ts` | Controllers |
| `src/presentation/ports/http/controllers/variant/admin-variant.controller.ts` | Controller |
| `src/presentation/ports/http/controllers/attribute-type/admin-attribute-type.controller.ts` | Controller |
| `src/presentation/dtos/component-type.dto.ts` | DTOs |
| `src/presentation/dtos/attribute-type.dto.ts` | DTOs |
| `src/presentation/dtos/variant.dto.ts` | DTOs |
| `prisma/providers/mysql/prisma-models/variant.prisma` | Prisma models mới |

### api-kingston — Modified files

| File | Thay đổi |
|---|---|
| `prisma/providers/mysql/prisma-models/product.prisma` | Thêm `componentTypeId` vào Component; thêm `variants` relation vào Product + ProductStructure |
| `prisma/providers/mysql/prisma-models/order.prisma` | Thêm `variantId` vào OrderItem |
| `src/core/domain/model/component/component.interface.ts` | Thêm `componentTypeId?: ID` |
| `src/core/domain/model/order-item/order-item.interface.ts` | Thêm `variantId?: ID` |
| `src/core/domain/repository/repository.token.ts` | Thêm 4 token mới |
| `src/application/application.module.ts` | Thêm provider arrays |
| `src/infrastructure/infrastructure.module.ts` | Thêm repository providers |
| `src/presentation/ports/http/modules/admin.module.ts` | Thêm 3 controllers |
| `src/infrastructure/database/mysql/mapper/order-item.mapper.ts` | Include variantId trong fromDomain/toDomain |
| `src/infrastructure/database/mysql/mapper/component.mapper.ts` | Include componentTypeId trong toDomain |

### web-kingston — New files

| Path | Lý do |
|---|---|
| `clean-architecture/domain/entities/ComponentType.ts` | Entity + interface |
| `clean-architecture/domain/entities/AttributeType.ts` | Entity + interface |
| `clean-architecture/domain/entities/Variant.ts` | Entity + interface |
| `clean-architecture/domain/repositories/ComponentTypeRepository.ts` | Repo interface |
| `clean-architecture/domain/repositories/AttributeTypeRepository.ts` | Repo interface |
| `clean-architecture/domain/repositories/VariantRepository.ts` | Repo interface |
| `clean-architecture/application/useCases/componentType/` (5 files) | Use cases |
| `clean-architecture/application/useCases/attributeType/GetAttributeTypesUseCase.ts` | Use case |
| `clean-architecture/application/useCases/variant/` (5 files) | Use cases |
| `clean-architecture/infrastructure/api/ComponentTypeApiRepository.ts` | API repo |
| `clean-architecture/infrastructure/api/AttributeTypeApiRepository.ts` | API repo |
| `clean-architecture/infrastructure/api/VariantApiRepository.ts` | API repo |
| `clean-architecture/infrastructure/api/mapper/ComponentTypeMapper.ts` | Mapper |
| `clean-architecture/infrastructure/api/mapper/AttributeTypeMapper.ts` | Mapper |
| `clean-architecture/infrastructure/api/mapper/VariantMapper.ts` | Mapper |
| `clean-architecture/infrastructure/api/message/ComponentTypeMessage.ts` | Error messages |
| `clean-architecture/infrastructure/api/message/VariantMessage.ts` | Error messages |
| `clean-architecture/presentation/modules/componentType/` | Module (list + form) |
| `clean-architecture/presentation/modules/variant/` | Module (list + create + edit + detail) |
| `clean-architecture/presentation/components/pages/componentType/` | Pages |
| `clean-architecture/presentation/components/pages/variant/` | Pages |

### web-kingston — Modified files

| File | Thay đổi |
|---|---|
| `clean-architecture/di/types.ts` | Thêm tokens mới |
| `clean-architecture/di/container.ts` | Thêm bindings |
| `clean-architecture/infrastructure/api/mapper/OrderItemMapper.ts` | Thêm variantId mapping |
| OrderItem form component | Thêm Variant selector field |
| OrderItem detail component | Thêm "Thông số sản phẩm" section |
| Router config | Thêm routes `/component-types`, `/variants` |

---

## Risks & Technical Debt

| Rủi ro | Mức | Mitigation |
|---|---|---|
| EAV join slow nếu nhiều Variant | Thấp | Index trên `(variant_id, component_id)` đủ cho scope hiện tại; báo cáo/thống kê để scope sau |
| Component hiện tại chưa có `componentTypeId` | Cao | Admin assign qua UI sau rollout; `componentTypeId` nullable → không block create Variant; component chưa có type → hiển thị "Chưa cấu hình loại" và bỏ qua validate |
| SELECT value drift (option bị đổi tên sau khi Variant đã lưu) | Thấp | Đã quyết định lưu text value — old Variants giữ nguyên giá trị lịch sử. Trade-off: không thể filter by option |
| Deadline 3 ngày — scope lớn | Cao | Split Phase 1 (ComponentType config + CRUD Variant) / Phase 2 (OrderItem integration). Phase 1 đã deliver giá trị; Phase 2 là additive |
| `product.prisma` + `order.prisma` bị modify đồng thời với các feature khác | Trung bình | Prisma schema files riêng biệt; conflict ở git level dễ resolve hơn |

**Intentional shortcuts:**
- `AttributeTypeOption` không có CRUD UI — seeded qua migration. Admin muốn thêm option → migration hoặc direct DB (acceptable ở MVP stage)
- `VariantComponentAttribute` không có riêng use case — được create/update trong bulk cùng Variant (nested write trong CreateVariantUsecase)

**GitNexus Impact Analysis — modified symbols:**
- `IOrderItem` (add `variantId`): downstream callers include `CreateOrderItemUsecase`, `UpdateOrderItemUsecase`, `OrderItemMapper`, `OrderItemDTO`. Risk: **MEDIUM** — nullable field, backward compatible
- `IComponent` (add `componentTypeId`): downstream callers include `ComponentMapper`, `ComponentDTO`, `GetComponentsUsecase`. Risk: **LOW** — nullable field, read-only addition

---

## Test Strategy

| AC | Loại test | Scenario |
|---|---|---|
| AC01 | Integration | `POST /component-types` với name + attributes → 201, entity trong DB |
| AC01 | Integration | `POST /component-types` với tên trùng → 409 |
| AC01 | Integration | `POST /component-types` không có attributeTypes → 400 |
| AC02 | Integration | `PATCH /components/:id` với `componentTypeId` hợp lệ → 200, field updated |
| AC03 | Unit | `CreateVariantUsecase` — validate componentId không thuộc productStructureId → throw exception |
| AC03 | Integration | `POST /variants` với đủ required fields → 201 |
| AC03 | Integration | `POST /variants` thiếu required attribute → 422 |
| AC03 | Integration | `POST /variants` với componentId không thuộc productStructure → 404 |
| AC04 | Integration | `PUT /variants/:id` update attribute value → 200, giá trị mới |
| AC05 | Integration | `PUT /variants/:id` bỏ trống required attribute → 422 |
| AC06 | Integration | `GET /variants?name=khung` → filter kết quả đúng |
| AC06 | Integration | `GET /variants` empty state → `{ result: [], total: 0 }` |
| AC07 | Integration | `PATCH /orders/:uuid/items/:id` với `variantId` hợp lệ → 200 |
| AC07 | Integration | `PATCH /orders/:uuid/items/:id` với `variantId: null` → 200, field = null |
| AC08 | Integration | `GET /orders/:uuid` response includes `variant` nested trong orderItem |
| — | Unit | `VariantModel.validate()` — missing required → throw `VariantRequiredAttributeMissingException` |
| — | E2E | Happy path: tạo ComponentType → assign to Component → tạo Variant → gắn vào OrderItem → xem OrderItem detail có Variant |
