---
date: 2026-05-21
type: tech-design
epic_id: EPIC-004
title: Structure Material — Tech Design
status: draft
---

# Tech Design — EPIC-004: Structure Material

## Summary

Xây dựng entity mới `StructureMaterial` — bảng liên kết giữa `ProductStructure` và `Material` với 2 trường số lượng độc lập: `assemblyQty` (dùng khi lắp ráp tại xưởng) và `packagingQty` (kèm theo sản phẩm cho khách). Thiết kế theo đúng Clean Architecture của Kingston: domain model → repository interface → use case → Prisma repository impl → NestJS controller. Frontend React thêm tab "Vật tư" vào màn hình chi tiết kết cấu, dùng Inversify DI + React Query theo pattern đã có.

---

## Architecture

### Layer Diagram

```
Presentation (NestJS Controller)
    ↓  DTOs
Application (Use Cases)
    ↓  Domain interfaces / models
Domain (IStructureMaterial, StructureMaterialModel, IStructureMaterialRepository)
    ↑  implements
Infrastructure (StructureMaterialMapper, StructureMaterialRepoImpl + Prisma)
```

### Layer Mapping — api-kingston

| Layer | File(s) mới / sửa | Trách nhiệm |
|-------|-------------------|-------------|
| **Domain** | `model/structure-material/structure-material.interface.ts` | Enums, IStructureMaterial, Create/Update types |
| **Domain** | `model/structure-material/structure-material.model.ts` | Model class + Builder |
| **Domain** | `repository/structure-material.repository.ts` | Repository interface |
| **Domain** | `repository/repository.token.ts` *(sửa)* | Thêm `STRUCTURE_MATERIAL_REPOSITORY` |
| **Domain** | `exception/structure-material.exception.ts` | Domain exceptions |
| **Application** | `usecases/structure-material/get-structure-materials.usecase.ts` | List vật tư của 1 structure |
| **Application** | `usecases/structure-material/create-structure-material.usecase.ts` | Thêm vật tư, validate unique |
| **Application** | `usecases/structure-material/update-structure-material.usecase.ts` | Cập nhật qty |
| **Application** | `usecases/structure-material/delete-structure-material.usecase.ts` | Soft delete |
| **Application** | `application.module.ts` *(sửa)* | Thêm 4 use cases vào providers/exports |
| **Infrastructure** | `mapper/structure-material.mapper.ts` | fromDomain / toDomain / toDomainWith |
| **Infrastructure** | `repository/structure-material-repository.implement.ts` | Prisma CRUD |
| **Infrastructure** | `prisma-models/product.prisma` *(sửa)* | Model StructureMaterial + FK relations |
| **Presentation** | `controllers/product-structure/structure-material.dto.ts` | Request/Response DTOs |
| **Presentation** | `controllers/product-structure/admin-product-structure.controller.ts` *(sửa)* | 4 endpoints mới |

### Layer Mapping — web-kingston

| Layer | File(s) mới / sửa | Trách nhiệm |
|-------|-------------------|-------------|
| **Domain** | `domain/entities/StructureMaterial.ts` | Entity + repository interface |
| **Application** | `application/useCases/structureMaterial/GetStructureMaterials.ts` | Fetch list |
| **Application** | `application/useCases/structureMaterial/CreateStructureMaterial.ts` | Create |
| **Application** | `application/useCases/structureMaterial/UpdateStructureMaterial.ts` | Update qty |
| **Application** | `application/useCases/structureMaterial/DeleteStructureMaterial.ts` | Delete |
| **Infrastructure** | `infrastructure/api/types/StructureMaterialResponse.ts` | API response types |
| **Infrastructure** | `infrastructure/api/mapper/StructureMaterialMapper.ts` | toDomain / toDomainList |
| **Infrastructure** | `infrastructure/api/StructureMaterialApiRepository.ts` | HTTP calls |
| **DI** | `di/types.ts` *(sửa)* | 5 tokens mới |
| **DI** | `di/container.ts` *(sửa)* | 5 bindings mới |
| **Presentation** | `presentation/forms/structureMaterial/StructureMaterialFormValues.ts` | Form schema (yup) |
| **Presentation** | `presentation/modules/structureMaterial/hooks/` | useGetStructureMaterials, useStructureMaterialActions |
| **Presentation** | `presentation/modules/structureMaterial/components/` | StructureMaterialTable, StructureMaterialFormModal |
| **Presentation** | `presentation/components/pages/structure/DetailProductStructureMaterials/` | Tab page component |
| **Presentation** | `presentation/components/pages/structure/DetailProductStructureLayout/DetailProductStructureLayout.tsx` *(sửa)* | Thêm tab "Vật tư" |
| **Presentation** | `routes/allRoutes.tsx` *(sửa)* | Thêm route `/structures/:id/materials` |

---

## API / Interface Contract

### Endpoints mới (tất cả dưới `/structures`, guard `ADMIN_JWT + PermissionGuard`)

#### GET `/structures/:id/materials`
- Permission: `product:read`
- Response: `StructureMaterialDTO[]`
- Error: `404` nếu structure không tồn tại

#### POST `/structures/:id/materials`
- Permission: `product:update`
- Body: `CreateStructureMaterialReqDTO`
- Response: `StructureMaterialDTO`
- Error: `400` nếu trùng materialId, cả 2 qty null, hoặc qty ≤ 0; `404` nếu structure/material không tồn tại

#### PUT `/structures/:structureId/materials/:id`
- Permission: `product:update`
- Body: `UpdateStructureMaterialReqDTO`
- Response: `StructureMaterialDTO`
- Error: `400` nếu cả 2 qty null/0; `404` nếu record không tồn tại

#### DELETE `/structures/:structureId/materials/:id`
- Permission: `product:update`
- Response: `{ id: number }`
- Error: `404` nếu record không tồn tại

### Request/Response Shapes

```typescript
// Request
interface CreateStructureMaterialReqDTO {
  materialId: number;
  assemblyQty?: number;   // optional, null = không dùng khi lắp ráp
  packagingQty?: number;  // optional, null = không kèm sản phẩm
  // Validate: NOT (assemblyQty IS NULL AND packagingQty IS NULL)
  // Validate: nếu có giá trị → > 0
}

interface UpdateStructureMaterialReqDTO {
  assemblyQty?: number | null;
  packagingQty?: number | null;
  // Validate: NOT (assemblyQty IS NULL AND packagingQty IS NULL)
}

// Response
interface StructureMaterialDTO {
  id: number;
  structureId: number;
  materialId: number;
  assemblyQty: number | null;
  packagingQty: number | null;
  material: {
    id: number;
    code: string;
    name: string;
    unit: { id: number; name: string } | null;
  };
  createdAt: string;
  updatedAt: string;
}
```

### Error Codes

| Situation | HTTP | Message |
|-----------|------|---------|
| Structure không tồn tại | 404 | `STRUCTURE_NOT_FOUND` |
| Material không tồn tại | 404 | `MATERIAL_NOT_FOUND` |
| Trùng materialId trong structure | 400 | `STRUCTURE_MATERIAL_DUPLICATE` |
| Cả 2 qty đều null | 400 | `STRUCTURE_MATERIAL_QTY_REQUIRED` |
| qty ≤ 0 | 400 | `STRUCTURE_MATERIAL_QTY_INVALID` |
| StructureMaterial record không tồn tại | 404 | `STRUCTURE_MATERIAL_NOT_FOUND` |

### Idempotency
- `DELETE` là idempotent (soft delete, không throw nếu đã deleted)
- `POST` không idempotent — duplicate trả 400

---

## Data Model

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

```prisma
model StructureMaterial {
  id            Int       @id @unique @default(autoincrement()) @map("id")
  isDeleted     Boolean   @default(false) @map("is_deleted")
  structureId   Int       @map("structure_id")
  materialId    Int       @map("material_id")
  assemblyQty   Decimal?  @map("assembly_qty") @db.Decimal(10, 2)
  packagingQty  Decimal?  @map("packaging_qty") @db.Decimal(10, 2)
  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")

  structure  ProductStructure @relation("structureMaterialRelation", fields: [structureId], references: [id])
  material   Material         @relation("materialStructureMaterialRelation", fields: [materialId], references: [id])

  @@unique([structureId, materialId, isDeleted])
  @@map("structure_materials")
}
```

> **Unique pattern:** `@@unique([structureId, materialId, isDeleted])` — theo đúng convention của codebase (xem ProductStructure `@@unique([productId, name, isDeleted])`). Cho phép tạo lại bản ghi sau khi soft-delete.

### Thêm relations vào model hiện có

```prisma
// Trong model ProductStructure — thêm:
structureMaterials StructureMaterial[] @relation("structureMaterialRelation")

// Trong model Material — thêm:
structureMaterials StructureMaterial[] @relation("materialStructureMaterialRelation")
```

### Migration

File: `prisma/providers/mysql/prisma-models/product.prisma` (sửa)  
Migration name: `YYYYMMDDHHMMSS_add_structure_material`

Nội dung SQL tương đương:
```sql
CREATE TABLE `structure_materials` (
  `id`            INT NOT NULL AUTO_INCREMENT,
  `is_deleted`    BOOLEAN NOT NULL DEFAULT false,
  `structure_id`  INT NOT NULL,
  `material_id`   INT NOT NULL,
  `assembly_qty`  DECIMAL(10,2) NULL,
  `packaging_qty` DECIMAL(10,2) NULL,
  `created_at`    DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `updated_at`    DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `deleted_at`    DATETIME(3) NULL,
  `created_by`    INT NULL,
  `updated_by`    INT NULL,
  `deleted_by`    INT NULL,
  PRIMARY KEY (`id`),
  UNIQUE INDEX `structure_materials_id_key` (`id`),
  UNIQUE INDEX `structure_materials_structure_id_material_id_is_deleted_key` (`structure_id`, `material_id`, `is_deleted`),
  FOREIGN KEY (`structure_id`) REFERENCES `product_structures`(`id`),
  FOREIGN KEY (`material_id`) REFERENCES `materials`(`id`)
);
```

### Check constraint (application layer)
MySQL 5.x không enforce CHECK constraints → validate trong use case:
- `CreateStructureMaterialUsecase`: throw nếu `assemblyQty == null && packagingQty == null`
- `UpdateStructureMaterialUsecase`: throw nếu cả 2 qty bị set về null

### Indexes
- Unique index trên `(structure_id, material_id, is_deleted)` — covers `GET /structures/:id/materials` query
- Không cần thêm index riêng cho `structure_id` vì unique index đã bao gồm

### Domain Events
Không raise domain events — StructureMaterial là master data configuration, không trigger workflow.

---

## Dependency Wiring / Registration

### api-kingston — NestJS

**`repository.token.ts`** — thêm:
```typescript
STRUCTURE_MATERIAL_REPOSITORY = 'StructureMaterialRepository',
```

**`application.module.ts`** — thêm vào `providers[]` và `exports[]`:
```typescript
GetStructureMaterialsUsecase,
CreateStructureMaterialUsecase,
UpdateStructureMaterialUsecase,
DeleteStructureMaterialUsecase,
```

**`admin.module.ts`** (infrastructure module) — thêm vào providers:
```typescript
{
  provide: ERepositoryToken.STRUCTURE_MATERIAL_REPOSITORY,
  useClass: StructureMaterialRepoImpl,
}
```

### web-kingston — Inversify DI

**`di/types.ts`** — thêm vào section structure-material:
```typescript
// Structure Material
StructureMaterialRepository: Symbol.for("StructureMaterialRepository"),
GetStructureMaterials: Symbol.for("GetStructureMaterials"),
CreateStructureMaterial: Symbol.for("CreateStructureMaterial"),
UpdateStructureMaterial: Symbol.for("UpdateStructureMaterial"),
DeleteStructureMaterial: Symbol.for("DeleteStructureMaterial"),
```

**`di/container.ts`** — thêm bindings:
```typescript
container.bind<IStructureMaterialRepository>(TYPES.StructureMaterialRepository)
  .to(StructureMaterialApiRepository).inSingletonScope();
container.bind<GetStructureMaterials>(TYPES.GetStructureMaterials)
  .to(GetStructureMaterials).inSingletonScope();
container.bind<CreateStructureMaterial>(TYPES.CreateStructureMaterial)
  .to(CreateStructureMaterial).inSingletonScope();
container.bind<UpdateStructureMaterial>(TYPES.UpdateStructureMaterial)
  .to(UpdateStructureMaterial).inSingletonScope();
container.bind<DeleteStructureMaterial>(TYPES.DeleteStructureMaterial)
  .to(DeleteStructureMaterial).inSingletonScope();
```

---

## Kingston Wiring Checklist

### api-kingston

- [x] `ERepositoryToken.STRUCTURE_MATERIAL_REPOSITORY` thêm vào `repository.token.ts`
- [x] `StructureMaterialRepoImpl` thêm vào infrastructure providers trong `admin.module.ts`
- [x] 4 use cases thêm vào `application.module.ts` providers và exports
- [x] Controller method mới trong `admin-product-structure.controller.ts`

### web-kingston

- [x] `di/types.ts` — 5 tokens mới (repository + 4 use cases)
- [x] `di/container.ts` — 5 bindings (singleton scope, theo pattern `MaterialApiRepository`)
- [x] Route `/structures/:id/materials` thêm vào `allRoutes.tsx` (child của layout hiện tại)
- [x] Tab "Vật tư" thêm vào `DetailProductStructureLayout.tsx`

---

## Non-Functional Design

### Performance
- `GET /structures/:id/materials`: unique index trên `(structureId, materialId, isDeleted)` → O(1) lookup cho structure. Include material+unit: 1 JOIN query duy nhất. Expected < 50ms cho ≤ 50 records.
- `POST/PUT/DELETE`: write path không cần transaction (không có multi-entity write) → < 100ms DB.
- Frontend: React Query cache với staleTime = 5 phút cho list query; invalidate on mutation.

### Security
- Auth guard: `ADMIN_JWT` — cùng guard toàn bộ `/structures` controller.
- Permission: `product:read` cho GET, `product:update` cho POST/PUT/DELETE — tái dùng permission hiện có, không cần thêm permission mới.
- Input validation: class-validator trên DTO (qty > 0, materialId integer > 0).
- `structureId` trong URL validated bởi `ParamUuidPipe` → không cần thêm xử lý.

### Reliability
- Không cần retry logic — write là idempotent ở mức use case (check-before-write).
- Không dùng transaction vì mỗi operation chỉ write 1 table.

---

## Rollout & Reversibility

### Strategy
- Direct rollout — không feature flag.
- Migration chỉ CREATE TABLE, không ALTER table nào cũ → zero downtime.
- Deploy order: API trước, Web sau.

### Rollback
- API: `DROP TABLE structure_materials` + revert code — không ảnh hưởng bảng nào khác.
- Web: revert tab component + route — không ảnh hưởng route hiện tại.

---

## File / Module Impact

### api-kingston

| File | New/Modified | Lý do |
|------|-------------|-------|
| `src/core/domain/model/structure-material/structure-material.interface.ts` | New | Domain types cho StructureMaterial |
| `src/core/domain/model/structure-material/structure-material.model.ts` | New | Model class + Builder |
| `src/core/domain/model/structure-material/index.ts` | New | Re-export |
| `src/core/domain/repository/structure-material.repository.ts` | New | Repository interface |
| `src/core/domain/repository/repository.token.ts` | Modified | Thêm token mới |
| `src/core/domain/repository/index.ts` | Modified | Export interface mới |
| `src/core/exception/structure-material.exception.ts` | New | Domain exceptions |
| `src/application/usecases/structure-material/get-structure-materials.usecase.ts` | New | |
| `src/application/usecases/structure-material/create-structure-material.usecase.ts` | New | |
| `src/application/usecases/structure-material/update-structure-material.usecase.ts` | New | |
| `src/application/usecases/structure-material/delete-structure-material.usecase.ts` | New | |
| `src/application/usecases/structure-material/index.ts` | New | Re-export |
| `src/application/application.module.ts` | Modified | Thêm 4 use cases |
| `src/infrastructure/database/mysql/mapper/structure-material.mapper.ts` | New | Prisma ↔ Domain |
| `src/infrastructure/database/mysql/repository/structure-material-repository.implement.ts` | New | Prisma CRUD |
| `prisma/providers/mysql/prisma-models/product.prisma` | Modified | Thêm StructureMaterial model + relations |
| `src/presentation/ports/http/controllers/product-structure/structure-material.dto.ts` | New | Request/Response DTOs |
| `src/presentation/ports/http/controllers/product-structure/admin-product-structure.controller.ts` | Modified | 4 endpoints mới |

### web-kingston

| File | New/Modified | Lý do |
|------|-------------|-------|
| `src/clean-architecture/domain/entities/StructureMaterial.ts` | New | Entity + repository interface |
| `src/clean-architecture/application/useCases/structureMaterial/GetStructureMaterials.ts` | New | |
| `src/clean-architecture/application/useCases/structureMaterial/CreateStructureMaterial.ts` | New | |
| `src/clean-architecture/application/useCases/structureMaterial/UpdateStructureMaterial.ts` | New | |
| `src/clean-architecture/application/useCases/structureMaterial/DeleteStructureMaterial.ts` | New | |
| `src/clean-architecture/infrastructure/api/types/StructureMaterialResponse.ts` | New | API response types |
| `src/clean-architecture/infrastructure/api/mapper/StructureMaterialMapper.ts` | New | toDomain |
| `src/clean-architecture/infrastructure/api/StructureMaterialApiRepository.ts` | New | HTTP client |
| `src/clean-architecture/di/types.ts` | Modified | 5 tokens mới |
| `src/clean-architecture/di/container.ts` | Modified | 5 bindings mới |
| `src/clean-architecture/presentation/forms/structureMaterial/StructureMaterialFormValues.ts` | New | Yup schema + form types |
| `src/clean-architecture/presentation/modules/structureMaterial/hooks/useGetStructureMaterials.ts` | New | React Query hook |
| `src/clean-architecture/presentation/modules/structureMaterial/hooks/useStructureMaterialActions.ts` | New | Mutation hooks |
| `src/clean-architecture/presentation/modules/structureMaterial/components/StructureMaterialTable/StructureMaterialTable.tsx` | New | Bảng danh sách |
| `src/clean-architecture/presentation/modules/structureMaterial/components/StructureMaterialFormModal/StructureMaterialFormModal.tsx` | New | Modal thêm/sửa |
| `src/clean-architecture/presentation/components/pages/structure/DetailProductStructureMaterials/DetailProductStructureMaterials.tsx` | New | Tab page |
| `src/clean-architecture/presentation/components/pages/structure/DetailProductStructureLayout/DetailProductStructureLayout.tsx` | Modified | Thêm tab "Vật tư" |
| `src/clean-architecture/routes/allRoutes.tsx` | Modified | Route `/structures/:id/materials` |

---

## Risks & Technical Debt

| Rủi ro | Mức | Mitigation |
|--------|-----|-----------|
| Soft-delete unique index `(structureId, materialId, isDeleted)`: nếu delete rồi add lại, `isDeleted=false` bị trùng unique với record cũ `isDeleted=true` — OK vì unique bao gồm `isDeleted` | Thấp | Đây là pattern chuẩn của codebase, không phải bug |
| Check constraint (both qty null) không được enforce ở DB | Thấp | Enforce tại use case + client validation — đủ cho internal tool |
| Khi `Material` bị soft-delete, `StructureMaterial` vẫn còn FK tham chiếu | Trung bình | Prisma FK restrict ở DB level sẽ báo lỗi khi delete Material đang được tham chiếu — acceptable, document trong delete-material usecase tương lai |
| `Decimal` trong Prisma trả về dưới dạng `string` trong JS → cần `parseFloat` trong mapper | Thấp | Handle trong `toDomain` của mapper |
