---
date: 2026-05-12
type: tech-design
epic_id: EPIC-001
title: Quản lý Kết cấu Sản phẩm (Product Structure)
status: complete
revision: 2
---

# Tech Design — EPIC-001: Quản lý Kết cấu Sản phẩm

## Summary

Thêm entity `ProductStructure` (Kết cấu) làm lớp trung gian giữa `Product` và `Component`. Mỗi Product có thể có nhiều Structure; mỗi Structure sở hữu cây Component độc lập của riêng nó; và BOM được chuẩn bị để link về Structure qua FK nullable `structureId`. Approach: expand-only — không xóa FK `productId` trên `Component` ngay (nullable migration), tạo aggregate mới `StructureTreeModel` song song với `ProductTreeModel` đang có, và tách hoàn toàn use case Structure ra context riêng trong Application layer. Web UI thêm tab "Kết cấu" vào màn hình chi tiết sản phẩm hiện tại, reuse `ComponentTreeViewer` đang có với Structure switcher mới.

---

## Architecture

### Layer Diagram

```
api-kingston                          web-kingston
─────────────────────────────────     ─────────────────────────────────────
Domain                                Domain
  ProductStructure model/interface      IProductStructure, IStructureTree
  StructureTreeModel (aggregate)        ProductStructureRepository interface
  IProductStructureRepository
                 │                                    │
Application      │                     Application   │
  CRUD Structure usecases              useCases/structure/
  GetStructureTree usecase               GetStructuresUseCase
                 │                       CreateStructureUseCase
Infrastructure   │                       DeactivateStructureUseCase
  ProductStructurePrismaRepo             GetStructureTreeUseCase
  ProductStructureMapper                                │
  Prisma migration                      Infrastructure  │
                 │                        StructureApiRepository
Presentation     │                        (React Query hooks)
  panel-structure.controller                            │
  structure.dto.ts                       Presentation   │
                                          modules/product/
                                            tab: "Kết cấu"
                                            StructureList
                                            StructureTreePanel
```

### Layer Mapping (api-kingston)

| Layer | Path | New/Modified |
|-------|------|-------------|
| Domain — Model | `src/core/domain/model/product-structure/` | **NEW**: `product-structure.interface.ts`, `product-structure.model.ts`, `structure-tree.model.ts` |
| Domain — Repository | `src/core/domain/repository/product-structure.repository.ts` | **NEW** |
| Domain — Token | `src/core/domain/repository/repository.token.ts` | **MODIFY**: thêm `PRODUCT_STRUCTURE_REPOSITORY` |
| Application | `src/application/usecases/product-structure/` | **NEW**: 5 use cases |
| Application | `src/application/usecases/component/` | **MODIFY**: `get-components.usecase.ts` thêm filter `structureId` |
| Infrastructure — Repo | `src/infrastructure/database/mysql/repository/product-structure-repository.implement.ts` | **NEW** |
| Infrastructure — Mapper | `src/infrastructure/database/mysql/mapper/product-structure.mapper.ts` | **NEW** |
| Infrastructure — Provider | `src/infrastructure/providers/repository.provider.ts` | **MODIFY**: đăng ký `ProductStructureRepoImpl` |
| Presentation — Controller | `src/presentation/ports/http/controllers/product-structure/` | **NEW**: `panel-structure.controller.ts`, `structure.dto.ts` |
| Presentation — HTTP module | `src/presentation/ports/http/http.module.ts` | **MODIFY**: thêm controller vào providers |
| DB Schema | `prisma/providers/mysql/prisma-models/product.prisma` | **MODIFY**: thêm model `ProductStructure`; alter `Component`, `Bom` |
| Migration | `prisma/migrations/<timestamp>_add_product_structure/` | **NEW** |

### Layer Mapping (web-kingston)

| Layer | Path | New/Modified |
|-------|------|-------------|
| Domain | `domain/entities/ProductStructure.ts` | **NEW** |
| Domain — Repository | `domain/repositories/ProductStructureRepository.ts` | **NEW** interface |
| Application | `application/useCases/structure/` | **NEW**: 4 use cases |
| Infrastructure | `infrastructure/api/ProductStructureApiRepository.ts` | **NEW** |
| Infrastructure — Hooks | `infrastructure/react-query/useStructureQueries.ts` | **NEW** |
| Presentation — Module | `presentation/modules/product/` | **MODIFY**: thêm tab Kết cấu, StructureList, StructureTreePanel |
| Presentation — Forms | `presentation/forms/product/CreateStructureForm.tsx` | **NEW** |
| DI | `di/types.ts` | **MODIFY**: thêm `ProductStructureRepository` token |
| DI | `di/container.ts` | **MODIFY**: bind `ProductStructureApiRepository` |

### Key Design Choices

| Quyết định | Rationale |
|-----------|-----------|
| **Không xóa `productId` trên Component ngay** | Expand-contract pattern — `productId` giữ giá trị cũ (idempotent migration), `structureId` được thêm nullable. Phá vỡ FK sẽ là breaking change cho BOM epic. |
| **`StructureTreeModel` riêng, không kế thừa `ProductTreeModel`** | `ProductTreeModel` hiện tại bind chặt với `product.components`. Structure cần aggregate riêng để không phá code BOM/WorkOrder đang dùng `ProductTreeModel`. |
| **Structure context tách hoàn toàn trong Application** | Tránh god-usecase; dễ test; future BOM epic chỉ cần inject thêm `IProductStructureRepository`. |
| **Tab UI — không route mới** | Structure là sub-resource của Product. Tab trong trang chi tiết đúng hơn route mới vì ít navigation complexity. |
| **BOM: chỉ thêm FK nullable, không thêm logic** | Scope EPIC-001. Logic enforce required `structureId` khi tạo BOM sẽ là BOM epic. |

---

## API / Interface Contract

### Endpoints mới

#### `GET /products/:id/structures`
Lấy danh sách Structure của Product.

**Request:**
```
GET /products/42/structures?status=ACTIVE
Authorization: Bearer <token>
```

**Query params:**
- `status` (optional): `ACTIVE` | `INACTIVE` — mặc định không filter (trả cả hai)

**Response 200:**
```json
{
  "result": [
    {
      "id": 1,
      "productId": 42,
      "name": "Kết cấu gỗ tự nhiên",
      "description": "...",
      "status": "ACTIVE",
      "componentCount": 12,
      "createdAt": "...",
      "updatedAt": "..."
    }
  ],
  "total": 2
}
```

**Errors:**
- `403` — user không có quyền xem product
- `404` — product không tồn tại

---

#### `POST /products/:id/structures`
Tạo Structure mới.

**Request body:**
```json
{
  "name": "Kết cấu MDF",
  "description": "..."
}
```

**Validation:**
- `name`: required, 1–100 chars
- `name` unique trong cùng `productId` (check ở use case)

**Response 201:**
```json
{
  "id": 3,
  "productId": 42,
  "name": "Kết cấu MDF",
  "status": "ACTIVE",
  ...
}
```

**Errors:**
- `400` — validation fail
- `403` — không có role KTV/Admin
- `409` — tên đã tồn tại trong Product (duplicate name)

**Idempotency:** Không — mỗi POST tạo 1 Structure mới (tên unique đủ bảo vệ duplicate)

---

#### `PATCH /products/:productId/structures/:id`
Cập nhật tên/mô tả Structure.

**Request body:**
```json
{
  "name": "Kết cấu gỗ sồi",
  "description": "Updated"
}
```

**Response 200:** `ProductStructureDTO`

**Errors:** `400`, `403`, `404`, `409`

---

#### `PATCH /products/:productId/structures/:id/deactivate`
Deactivate Structure.

**Request body:** none

**Response 200:** `ProductStructureDTO` với `status: "INACTIVE"`

**Business rule:** Nếu là Structure ACTIVE duy nhất → `422` với message "Phải có ít nhất 1 kết cấu đang hoạt động..."

---

#### `GET /products/:productId/structures/:id/tree`
Lấy cây component của Structure.

**Response 200:**
```json
{
  "id": 1,
  "name": "Kết cấu gỗ tự nhiên",
  "status": "ACTIVE",
  "componentTrees": [
    {
      "id": 10, "code": "1", "name": "Mặt bàn",
      "children": [
        { "id": 11, "code": "1.1", "name": "Tấm gỗ 18mm", "children": [] }
      ]
    }
  ]
}
```

---

#### `POST /products/:productId/structures/:id/components`
Thêm component node vào cây của Structure.

**Request body:**
```json
{
  "parentCode": "1",
  "name": "Tấm gỗ tự nhiên",
  "typeId": 5,
  "quantity": 1
}
```

**Response 201:** Component mới được tạo, bao gồm `code` tự sinh.

**Errors:**
- `422` — cây đang ở depth 10 và add thêm con

---

#### `DELETE /products/:productId/structures/:id/components/:componentCode`
Xóa component node (và toàn bộ con nếu có).

**Response 200:**
```json
{ "removedCodes": ["1.2", "1.2.1"] }
```

---

### Error envelope

Tất cả errors theo format hiện có của api-kingston:

```json
{
  "statusCode": 422,
  "message": "Phải có ít nhất 1 kết cấu đang hoạt động...",
  "error": "Unprocessable Entity"
}
```

---

## Data Model

### Prisma Schema — ProductStructure (mới)

```prisma
// prisma/providers/mysql/prisma-models/product.prisma

model ProductStructure {
  id          Int       @id @unique @default(autoincrement()) @map("id")
  isDeleted   Boolean   @default(false) @map("is_deleted")
  productId   Int       @map("product_id")
  name        String    @map("name")
  description String?   @map("description")
  status      String    @default("ACTIVE") @map("status")
  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("productStructureRelation", fields: [productId], references: [id])
  components Component[] @relation("structureRelationComponent")
  boms       Bom[]       @relation("bomStructureRelation")

  @@unique([productId, name, isDeleted])
  @@map("product_structures")
}
```

### Alter Component — thêm `structureId` nullable

```prisma
// prisma/providers/mysql/prisma-models/product.prisma — model Component

model Component {
  // ... fields hiện có giữ nguyên ...
  structureId Int?  @map("structure_id")   // nullable — thêm mới

  // relations
  product            Product            @relation("productRelationComponent", ...)
  structure          ProductStructure?  @relation("structureRelationComponent", fields: [structureId], references: [id])
  // ... relations hiện có giữ nguyên ...
}
```

### Alter Bom — thêm `structureId` nullable

```prisma
// prisma/providers/mysql/prisma-models/bom.prisma — model Bom

model Bom {
  // ... fields hiện có giữ nguyên ...
  structureId Int?  @map("structure_id")   // nullable — thêm mới

  // relations
  product    Product           @relation("bomProductRelation", ...)
  structure  ProductStructure? @relation("bomStructureRelation", fields: [structureId], references: [id])
  // ... relations hiện có giữ nguyên ...
}
```

> **Lưu ý:** `Product` model cần thêm relation ngược:
> ```prisma
> structures ProductStructure[] @relation("productStructureRelation")
> ```

### Migration Strategy

**Migration file name:** `YYYYMMDDHHMMSS_add_product_structure`

Sequence:
1. `CREATE TABLE product_structures` với unique constraint `(product_id, name, is_deleted)`
2. `ALTER TABLE components ADD COLUMN structure_id INT NULL`
3. `ALTER TABLE boms ADD COLUMN structure_id INT NULL`
4. Data migration (idempotent script trong use case / seed):
   - Với mỗi Product có Component: tạo `product_structures` row tên "Kết cấu mặc định", ACTIVE
   - `UPDATE components SET structure_id = <new_structure_id> WHERE product_id = X AND structure_id IS NULL`
   - `UPDATE boms SET structure_id = <new_structure_id> WHERE product_id = X AND structure_id IS NULL`
5. Rollback: `DROP TABLE product_structures; ALTER TABLE components DROP COLUMN structure_id; ALTER TABLE boms DROP COLUMN structure_id;`

**Invariant sau migration:**
- `component.structureId` NOT NULL (sau migration hoàn thành)
- `bom.structureId` NOT NULL (sau migration hoàn thành)
- Nhưng DB schema giữ nullable để support rollback

### Indexes

```sql
-- product_structures
INDEX idx_product_structures_product_id ON product_structures(product_id);
INDEX idx_product_structures_status ON product_structures(status);

-- components
INDEX idx_components_structure_id ON components(structure_id);

-- boms
INDEX idx_boms_structure_id ON boms(structure_id);
```

### Constraints & Invariants

- `product_structures.name` unique per `(productId, isDeleted=false)` — enforced tại DB unique constraint + application check
- Deactivate: không thể INACTIVE nếu là Structure ACTIVE duy nhất của Product — enforced tại application layer
- Depth: `ComponentTreeModel` tính depth khi add node — enforced tại application layer

### Domain Events (EPIC-001)

Không raise domain event trong EPIC-001 (Structure CRUD không cần trigger downstream). Events sẽ được thêm khi BOM epic cần `StructureActivatedEvent`/`StructureDeactivatedEvent`.

---

## Dependency Wiring / Registration

### api-kingston

**`src/core/domain/repository/repository.token.ts`:**
```typescript
export enum ERepositoryToken {
  // ... existing ...
  PRODUCT_STRUCTURE_REPOSITORY = 'ProductStructureRepository',
}
```

**`src/infrastructure/providers/repository.provider.ts`:**
```typescript
{
  provide: ERepositoryToken.PRODUCT_STRUCTURE_REPOSITORY,
  useClass: ProductStructureRepoImpl,
},
```

**`src/presentation/ports/http/http.module.ts`** — thêm `PanelStructureController` vào `controllers[]`.

**Application Module** (`src/application/application.module.ts`) — thêm use cases của Structure vào `providers[]` và `exports[]`.

---

### Kingston Wiring Checklist

**api-kingston:**
- [x] `ERepositoryToken.PRODUCT_STRUCTURE_REPOSITORY = 'ProductStructureRepository'` — thêm vào `repository.token.ts`
- [x] `repository.provider.ts` — bind `ProductStructureRepoImpl`
- [x] `application.module.ts` — export 5 use cases mới
- [x] `http.module.ts` — đăng ký `PanelStructureController`
- [x] `product.prisma` — model `ProductStructure`, alter `Component`, alter `Bom`, update `Product` relations

**web-kingston:**
- [x] `di/types.ts` — thêm `ProductStructureRepository: Symbol.for('ProductStructureRepository')`
- [x] `di/container.ts` — `.bind<ProductStructureRepository>(TYPES.ProductStructureRepository).to(ProductStructureApiRepository).inSingletonScope()`
- [x] `infrastructure/api/ProductStructureApiRepository.ts` — implement CRUD + tree calls
- [x] `infrastructure/react-query/` — hooks: `useGetStructures`, `useCreateStructure`, `useDeactivateStructure`, `useGetStructureTree`, `useAddComponent`, `useRemoveComponent`

---

## Non-Functional Design

### Performance

| Endpoint | Budget | Approach |
|----------|--------|---------|
| `GET /structures` | < 300ms | Index trên `productId, status`; không load component tree |
| `GET /structures/:id/tree` | < 500ms | Single query `Component WHERE structureId = X`; build tree in-memory |
| `POST /structures` | < 500ms | Single insert |
| `PATCH /deactivate` | < 300ms | Count query + update |
| `POST /components` | < 300ms | Insert + update codes |

Không cache structure tree — data thay đổi thường xuyên khi KTV đang làm việc. React Query `staleTime: 30s` là đủ.

### Reliability

- Mọi write operation wrap trong `transactionContext.runInTransaction`
- Deactivate: đếm ACTIVE structures trước khi update — race condition thấp (single user thao tác)
- Migration script: idempotent — kiểm tra `structureId IS NULL` trước khi update

### Security

- Auth guard: `@UseGuards(AuthGuard(EGuardStrategy.PANEL))` — reuse guard hiện có
- Authorization: Dùng permission framework hiện có (`api-kingston-role-permission-v2`)
  - `GET` endpoints: role bất kỳ có quyền xem product
  - `POST`, `PATCH`, `DELETE` endpoints: check permission `structure:write` hoặc role `KTV/Admin`
- Input validation: `class-validator` trên DTOs — `@IsString()`, `@MaxLength(100)`, `@IsNotEmpty()`
- `structureId` trong response: integer ID, không expose thông tin nhạy cảm

---

## Rollout & Reversibility

### Strategy: Direct rollout

Không cần feature flag cho API. Web tab có thể ẩn/hiện qua config nếu cần kill-switch.

### Deployment Order

```
1. prisma migrate deploy  →  tạo bảng + alter columns (nullable, không break)
2. Run data migration     →  populate structureId cho component + bom hiện có
3. Deploy API             →  new endpoints live
4. Deploy Web             →  tab "Kết cấu" visible
```

### Rollback Path

- `structureId` nullable trên cả `Component` và `Bom` → code cũ hoạt động bình thường khi rollback API
- Prisma rollback: `prisma migrate resolve --rolled-back <migration_name>`; sau đó `ALTER TABLE DROP COLUMN` thủ công
- Web: ẩn tab bằng config — zero API calls

---

## File / Module Impact

### api-kingston — Files mới

| File | Lý do |
|------|-------|
| `src/core/domain/model/product-structure/product-structure.interface.ts` | Domain types cho Structure |
| `src/core/domain/model/product-structure/product-structure.model.ts` | Business logic model |
| `src/core/domain/model/product-structure/structure-tree.model.ts` | Aggregate: Structure + ComponentTree |
| `src/core/domain/model/product-structure/index.ts` | Barrel export |
| `src/core/domain/repository/product-structure.repository.ts` | Repository interface |
| `src/core/exception/product-structure.exception.ts` | Domain exceptions (DuplicateName, LastActive) |
| `src/application/usecases/product-structure/create-product-structure.usecase.ts` | Tạo Structure mới |
| `src/application/usecases/product-structure/get-product-structures.usecase.ts` | Lấy danh sách |
| `src/application/usecases/product-structure/deactivate-product-structure.usecase.ts` | Deactivate |
| `src/application/usecases/product-structure/get-structure-tree.usecase.ts` | Lấy cây component |
| `src/application/usecases/product-structure/update-product-structure.usecase.ts` | Cập nhật tên/mô tả |
| `src/application/usecases/product-structure/add-structure-component.usecase.ts` | Thêm node vào cây |
| `src/application/usecases/product-structure/remove-structure-component.usecase.ts` | Xóa node khỏi cây |
| `src/application/usecases/product-structure/index.ts` | Barrel export |
| `src/infrastructure/database/mysql/mapper/product-structure.mapper.ts` | Prisma↔Domain mapping |
| `src/infrastructure/database/mysql/repository/product-structure-repository.implement.ts` | DB access |
| `src/presentation/ports/http/controllers/product-structure/structure.dto.ts` | Request/Response DTOs |
| `src/presentation/ports/http/controllers/product-structure/panel-structure.controller.ts` | HTTP endpoints |
| `src/presentation/ports/http/controllers/product-structure/index.ts` | Barrel export |
| `prisma/providers/mysql/prisma-models/product.prisma` | Thêm `ProductStructure` model |
| `prisma/providers/mysql/prisma-models/bom.prisma` | Alter `Bom` thêm `structureId` |

### api-kingston — Files sửa

| File | Thay đổi |
|------|---------|
| `src/core/domain/repository/repository.token.ts` | Thêm `PRODUCT_STRUCTURE_REPOSITORY` |
| `src/infrastructure/providers/repository.provider.ts` | Đăng ký `ProductStructureRepoImpl` |
| `src/application/application.module.ts` | Export 7 use cases mới |
| `src/presentation/ports/http/http.module.ts` | Thêm `PanelStructureController` |

### web-kingston — Files mới

| File | Lý do |
|------|-------|
| `domain/entities/ProductStructure.ts` | Domain entity |
| `domain/repositories/ProductStructureRepository.ts` | Repository interface |
| `application/useCases/structure/GetStructuresUseCase.ts` | |
| `application/useCases/structure/CreateStructureUseCase.ts` | |
| `application/useCases/structure/DeactivateStructureUseCase.ts` | |
| `application/useCases/structure/GetStructureTreeUseCase.ts` | |
| `infrastructure/api/ProductStructureApiRepository.ts` | HTTP calls đến api-kingston |
| `infrastructure/react-query/useGetStructures.ts` | Query hook |
| `infrastructure/react-query/useCreateStructure.ts` | Mutation hook |
| `infrastructure/react-query/useDeactivateStructure.ts` | Mutation hook |
| `infrastructure/react-query/useGetStructureTree.ts` | Query hook |
| `infrastructure/react-query/useAddStructureComponent.ts` | Mutation hook |
| `infrastructure/react-query/useRemoveStructureComponent.ts` | Mutation hook |
| `presentation/modules/product/components/StructureTab.tsx` | Tab container |
| `presentation/modules/product/components/StructureList.tsx` | Danh sách Structure |
| `presentation/modules/product/components/StructureTreePanel.tsx` | Cây component của Structure |
| `presentation/forms/product/CreateStructureForm.tsx` | Form tạo Structure |

### web-kingston — Files sửa

| File | Thay đổi |
|------|---------|
| `di/types.ts` | Thêm `ProductStructureRepository` token |
| `di/container.ts` | Bind `ProductStructureApiRepository` |
| `presentation/modules/product/` | Thêm tab "Kết cấu" vào Product detail page |

---

## Risks & Technical Debt

| Risk | Mức | Mitigation |
|------|-----|-----------|
| `ProductTreeModel.addChildrenFromNode` hardcode `productId` — cần refactor để hỗ trợ `structureId` | Medium | `StructureTreeModel` sẽ có method riêng `addChildrenFromNode` không kế thừa — không ảnh hưởng `ProductTreeModel` |
| Migration data fail giữa chừng (nhiều Product) | Low | Idempotent: check `IS NULL` trước khi update; script có thể retry |
| Race condition tạo 2 Structure cùng tên đồng thời | Low | DB unique constraint `(product_id, name, is_deleted)` là backstop |
| `ComponentTreeBuilder` hiện dùng `component.code` làm key — code vẫn unique per Structure (không per Product nữa) | Medium | `StructureTreeModel` build tree từ components của đúng structureId — tránh collision |
| **Debt**: `Component.productId` vẫn được giữ sau EPIC-001 | Intentional | BOM epic sẽ quyết định có drop FK này không sau khi `structureId` required |
