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

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

## Problem & Goal

### Problem

Hiện tại mỗi `Product` chỉ có **một cây component duy nhất**. Trong thực tế sản xuất nội thất của Kingston, cùng một sản phẩm (ví dụ: bàn làm việc) có thể tồn tại nhiều **phiên bản kết cấu vật lý** khác nhau — kết cấu gỗ tự nhiên, kết cấu MDF, kết cấu theo đơn đặt riêng. Khi kỹ thuật viên cần tạo BOM, không có cách nào phân biệt kết cấu nào được dùng, dẫn đến nhầm lẫn và ghi chú thủ công ngoài hệ thống.

**Người dùng bị ảnh hưởng:**
- **Kỹ thuật viên** — không thể định nghĩa nhiều phiên bản kết cấu cho cùng một sản phẩm
- **Quản lý sản xuất** — không biết chọn "phiên bản kết cấu" nào khi tạo BOM/Work Order
- **Nhân viên kinh doanh** — không biết sản phẩm có những biến thể kết cấu nào để tư vấn khách

### Data Model (sau EPIC-001)

```
Product (1) ──→ Structure / Kết cấu (nhiều)
                     │
                     ├──→ Component Tree (cây cụm/chi tiết)
                     │
                     └──→ BOM / Định mức NVL (nhiều)
```

- **1 Product có nhiều Kết cấu (Structure)** — mỗi kết cấu là một phiên bản vật lý riêng
- **1 Kết cấu có một cây Component** — định nghĩa cụm/chi tiết của kết cấu đó
- **1 Kết cấu có nhiều BOM sản xuất** — mỗi BOM link về đúng kết cấu tương ứng

> **Lưu ý migration:** BOM entity **giữ nguyên cấu trúc hiện có**, chỉ thêm FK `structureId` (nullable) để chuẩn bị cho BOM epic. Dữ liệu BOM hiện tại sẽ được set `structureId` trỏ về Structure mặc định của Product tương ứng.

### Goal

| Mục tiêu | Đo lường | Target |
|---------|---------|--------|
| Loại bỏ BOM tạo sai kết cấu | Số BOM bị tạo nhầm kết cấu | 0 sau release |
| Kỹ thuật viên quản lý được nhiều kết cấu per product | Tỷ lệ tạo Structure thành công | > 99% |
| Dữ liệu cũ không bị mất sau migration | Row count component và BOM trước/sau migration | 100% preserved |
| Nhân viên KD xem được danh sách kết cấu | Thời gian load danh sách kết cấu | < 500ms |

### Why Now

BOM epic (tương lai) cần Structure làm nền tảng để link `BOM → Structure`. Nếu không xây dựng Structure trước, BOM epic sẽ phải refactor lại data model lần nữa. Đây là thời điểm tốt nhất — trước khi BOM được mở rộng.

---

## User Flow

### Happy Path — Kỹ thuật viên tạo kết cấu mới

```
1. KTV mở màn hình chi tiết sản phẩm
2. Chọn tab "Kết cấu"
3. Nhấn "Thêm kết cấu"
4. Nhập tên (bắt buộc, max 100 ký tự) + mô tả (tùy chọn)
5. Nhấn "Lưu"
6. Structure mới xuất hiện trong danh sách với status ACTIVE
7. KTV nhấn vào Structure → xem cây component rỗng
8. KTV thêm cụm/chi tiết vào cây lần lượt
9. Cây được lưu sau mỗi thao tác (không cần nhấn Save riêng)
```

### Happy Path — Quản lý sản xuất xem kết cấu trước khi tạo BOM

```
1. QLSX mở màn hình chi tiết sản phẩm
2. Chọn tab "Kết cấu"
3. Xem danh sách Structure (mặc định chỉ ACTIVE)
4. Nhấn vào một Structure để xem cây component đầy đủ
5. Kiểm tra vật liệu + thông số từng node
6. Ghi nhớ structureId để dùng khi tạo BOM (luồng BOM epic sau)
```

### Happy Path — Kỹ thuật viên deactivate kết cấu cũ

```
1. KTV mở tab "Kết cấu" của sản phẩm
2. Nhấn "Deactivate" trên Structure cần tắt
3. Hộp thoại xác nhận: "Bạn có chắc muốn tắt kết cấu này?"
4. Nhấn "Xác nhận"
5. Structure chuyển sang INACTIVE
6. Danh sách cập nhật (filter ACTIVE mặc định → Structure bị ẩn khỏi list)
```

### Error Path — Tên kết cấu trùng

```
1. KTV tạo Structure với tên đã tồn tại trong cùng Product
2. API trả về lỗi 409
3. Form hiển thị: "Tên kết cấu đã tồn tại trong sản phẩm này"
4. KTV sửa tên, nhấn Lưu lại
```

### Error Path — Deactivate Structure ACTIVE duy nhất

```
1. KTV nhấn Deactivate Structure duy nhất đang ACTIVE
2. API từ chối, trả về 422
3. Modal hiển thị: "Phải có ít nhất 1 kết cấu đang hoạt động. Hãy tạo kết cấu mới trước khi deactivate kết cấu này."
4. Nút "Thêm kết cấu" được highlight/suggest
```

### Error Path — Mất kết nối API

```
1. User thực hiện bất kỳ action nào (tạo, sửa, deactivate, thêm node)
2. Request timeout / network error
3. Toast error: "Không thể kết nối. Vui lòng thử lại."
4. Action không được thực thi (không partial-save)
5. Form/state giữ nguyên để user thử lại
```

### Edge Path — Product chưa có Structure nào (empty state)

```
1. User mở tab "Kết cấu" của Product chưa qua migration hoặc vừa tạo mới
2. Hiển thị empty state theo role:
   - KTV: "Sản phẩm này chưa có kết cấu nào. [Thêm kết cấu]"
   - QLSX / KD: "Sản phẩm này chưa có kết cấu nào. Liên hệ kỹ thuật viên để tạo kết cấu."
```

### Recovery Path — Sau khi thêm node sai

```
1. KTV thêm nhầm node vào cây
2. KTV nhấn "Xóa node" → xác nhận (nếu node có con: cảnh báo xóa cả nhánh)
3. Node và con của nó bị xóa, code tự động re-index
```

---

## Acceptance Criteria

### EPIC-001-AC01 — Tạo Structure thành công
**Priority:** Must  
**Given** kỹ thuật viên đang ở tab "Kết cấu" của màn hình chi tiết sản phẩm  
**When** nhấn "Thêm kết cấu", nhập tên hợp lệ (1–100 ký tự, không trùng trong Product), nhấn "Lưu"  
**Then**
- Structure mới được tạo với status `ACTIVE`
- Hiển thị ngay trong danh sách tab "Kết cấu"
- API `POST /products/:id/structures` trả về HTTP 201 với `structureId`

### EPIC-001-AC02 — Validation tên Structure
**Priority:** Must  
**Given** kỹ thuật viên đang ở form tạo/sửa Structure  
**When** submit với tên rỗng  
**Then** lỗi validation "Tên kết cấu là bắt buộc" hiển thị inline, không gọi API

**When** submit với tên đã tồn tại trong cùng Product  
**Then** API trả về 409, form hiển thị "Tên kết cấu đã tồn tại trong sản phẩm này"

**When** submit với tên dài hơn 100 ký tự  
**Then** input bị giới hạn ở 100 ký tự (hoặc lỗi validation trước khi submit)

### EPIC-001-AC03 — Thêm component node vào cây
**Priority:** Must  
**Given** kỹ thuật viên đang xem cây component của một Structure  
**When** thêm node mới (cụm hoặc chi tiết) vào vị trí hợp lệ trong cây  
**Then**
- Node xuất hiện đúng vị trí trong cây
- Code tự động cập nhật (ví dụ: "1.2.3")
- Cây re-render không flicker

**When** cây đang ở độ sâu 10 levels và KTV thêm node con  
**Then** API từ chối 422: "Cây kết cấu không được sâu quá 10 cấp"

### EPIC-001-AC04 — Xóa component node khỏi cây
**Priority:** Must  
**Given** kỹ thuật viên đang xem cây component của một Structure  
**When** xóa một node lá (không có con)  
**Then** node bị xóa ngay, code các node khác tự re-index

**When** xóa một node có node con  
**Then** hiển thị cảnh báo: "Xóa node này sẽ xóa [N] node con. Bạn có chắc không?"  
Nếu xác nhận → xóa cả nhánh

### EPIC-001-AC05 — Deactivate Structure
**Priority:** Must  
**Given** kỹ thuật viên muốn deactivate một Structure đang ACTIVE  
**When** có ít nhất 2 Structure ACTIVE trong Product và KTV xác nhận deactivate  
**Then**
- Structure chuyển sang `INACTIVE`
- Không thể chọn Structure này khi tạo BOM (hiển thị disabled)
- Toàn bộ component vẫn được lưu đầy đủ

**When** Structure đó là ACTIVE duy nhất của Product  
**Then** API trả về 422, hiển thị: "Phải có ít nhất 1 kết cấu đang hoạt động. Hãy tạo kết cấu mới trước khi deactivate kết cấu này."

### EPIC-001-AC06 — Danh sách Structure
**Priority:** Must  
**Given** bất kỳ user có quyền xem sản phẩm  
**When** mở tab "Kết cấu" của một Product  
**Then**
- Hiển thị danh sách Structure: tên, mô tả, số component, status
- Mặc định filter = ACTIVE
- Có thể toggle "Xem cả INACTIVE"
- Response time < 300ms

**When** Product chưa có Structure nào  
**Then** empty state theo role (xem User Flow — Edge Path)

### EPIC-001-AC07 — Xem cây component của Structure
**Priority:** Must  
**Given** bất kỳ user có quyền xem sản phẩm  
**When** nhấn vào một Structure trong danh sách  
**Then**
- Hiển thị cây component đầy đủ: tên, code, vật liệu, thông số kích thước
- Response time < 500ms với cây ≤ 10 levels, ≤ 200 nodes

### EPIC-001-AC08 — Phân quyền
**Priority:** Must  
**Given** user đang đăng nhập  
**When** user có role **Kỹ thuật viên** hoặc **Admin**  
**Then** thấy nút "Thêm kết cấu", "Deactivate", "Thêm node", "Xóa node" (full CRUD)

**When** user có role **Quản lý sản xuất** hoặc **Nhân viên kinh doanh**  
**Then** chỉ thấy danh sách và cây component (read-only); không thấy nút tạo/sửa/xóa

**When** user không có quyền xem sản phẩm gọi API Structure  
**Then** API trả về 403

### EPIC-001-AC09 — Migration data
**Priority:** Must  
**Given** hệ thống có Product với component hiện tại (chưa có Structure) và BOM hiện có  
**When** chạy migration script  
**Then**
- Mỗi Product được tạo 1 Structure mặc định tên "Kết cấu mặc định", status `ACTIVE`
- Toàn bộ component cũ của Product được gắn vào Structure đó (`component.structureId` không null)
- Toàn bộ BOM hiện có của Product được set `bom.structureId` trỏ về Structure mặc định đó
- Row count của bảng `component` không thay đổi
- Row count của bảng `bom` không thay đổi (chỉ update `structureId`, không insert/delete)
- Script idempotent: chạy nhiều lần không tạo duplicate Structure, không overwrite `structureId` đã có

**Verification sau migration:**
- Mọi `component` đều có `structureId` hợp lệ (non-null)
- Mọi `bom` đều có `structureId` hợp lệ (non-null)
- Số lượng Structure mặc định = số Product có component trước migration

### EPIC-001-AC10 — Keyboard navigation (Web)
**Priority:** Should  
**Given** user điều hướng bằng bàn phím  
**When** dùng Tab để di chuyển qua các nút trong tab "Kết cấu"  
**Then** focus visible rõ ràng; Enter kích hoạt action; Escape đóng modal/form

---

## UI / Design

> Chưa có Figma. Mô tả behavior requirements để developer implement.

### Tab "Kết cấu" trong màn hình chi tiết sản phẩm

```
[Thông tin | BOM | Kết cấu | ...]     ← tab bar hiện tại thêm tab mới

Tab "Kết cấu":
┌─────────────────────────────────────────────┐
│  [+ Thêm kết cấu]           [Xem INACTIVE ☐]│
├─────────────────────────────────────────────┤
│  Tên              Mô tả     Components Status│
│  Kết cấu gỗ tự nhiên  ...   12 nodes  ACTIVE│
│  Kết cấu MDF          ...    8 nodes  ACTIVE│
└─────────────────────────────────────────────┘
```

- Nút "Thêm kết cấu" chỉ hiện với KTV/Admin
- Click vào row → mở panel/modal xem cây component

### Panel xem cây component

```
┌── Kết cấu gỗ tự nhiên ─────────────────────┐
│  [+ Thêm cụm]  [+ Thêm chi tiết]           │
│                                             │
│  ▼ 1. Mặt bàn                               │
│     ▼ 1.1 Tấm gỗ tự nhiên 18mm             │
│        1.1.1 Cạnh viền gỗ sồi              │
│     1.2 Keo dán PVC                        │
│  ▼ 2. Chân bàn (cụm)                       │
│     2.1 Thanh thép hộp 40x40               │
└─────────────────────────────────────────────┘
```

- Nút thêm node chỉ hiện với KTV/Admin
- Mỗi node có nút "⋮" (xóa, sửa tên) visible on hover

### Form tạo/sửa Structure

```
Tên kết cấu *   [_________________________]  max 100 ký tự
Mô tả           [_________________________]
                [Hủy]  [Lưu]
```

---

## Non-Functional Requirements

### Performance

| API / Action | Budget | Condition |
|-------------|--------|-----------|
| `GET /products/:id/structures` | < 300ms | ≤ 20 structures per product |
| `GET /products/:id/structures/:sid/tree` | < 500ms | ≤ 10 levels sâu, ≤ 200 nodes |
| `POST /products/:id/structures` | < 500ms | — |
| `PATCH /products/:id/structures/:sid/deactivate` | < 300ms | — |
| Web UI render cây component | < 1s | sau khi API trả về |

### Security & Authorization

| Role | Create Structure | Read Structure/Tree | Update/Deactivate | Add/Remove node |
|------|-----------------|---------------------|-------------------|-----------------|
| Kỹ thuật viên | ✅ | ✅ | ✅ | ✅ |
| Admin | ✅ | ✅ | ✅ | ✅ |
| Quản lý sản xuất | ❌ | ✅ | ❌ | ❌ |
| Nhân viên kinh doanh | ❌ | ✅ | ❌ | ❌ |

- Dùng permission framework hiện có (`api-kingston-role-permission-v2`)
- Không có PII trong Structure entity — không cần xử lý GDPR
- `structureId` trong API response không expose thông tin nhạy cảm

### Accessibility

- Tab "Kết cấu" và tất cả nút action: keyboard navigable (Tab/Enter/Escape)
- Form tạo/sửa Structure: `label` liên kết với `input` (`htmlFor`)
- Error messages được announce bởi screen reader (`aria-live` hoặc `role="alert"`)
- WCAG 2.1 AA cho toàn bộ UI mới

### Compatibility

- Web: Chrome 120+, Firefox 120+, Safari 17+ (theo tiêu chuẩn hiện tại của web-kingston)
- Mobile app: **Không trong scope EPIC-001**

---

## Dependencies

| Dependency | Status | Owner | Ghi chú |
|-----------|--------|-------|---------|
| Product model (`api-kingston`) | Done | Dev | Cần đọc `ProductTreeModel` trước thiết kế |
| Component model (`api-kingston`) | Done | Dev | `ComponentTreeBuilder` cần nhận `structureId`; FK đổi từ `productId` → `structureId` |
| BOM model (`api-kingston`) | Done | Dev | Chỉ thêm nullable `structureId` FK — không thay đổi logic BOM; migration set giá trị |
| Permission framework (`api-kingston-role-permission-v2`) | Done | Dev | Xem `topics/phan-mem/api-kingston-role-permission-v2.md` |
| Prisma schema + migration | In scope | Dev | Migration phải idempotent; `structureId` nullable trên cả component và bom |
| BOM epic (tương lai) | Not started | — | BOM epic sẽ enforce `structureId` required và thêm logic chọn kết cấu khi tạo BOM |
| Web component tree UI (hiện có) | Done | Dev | Reuse component tree viewer; chỉ thêm Structure switcher |

### Affected Areas

| Module | Surface | Thay đổi |
|--------|---------|---------|
| `api-kingston` | Domain model: Product, Component | Thêm entity `Structure`; `Component` FK đổi từ `productId` → `structureId` |
| `api-kingston` | Domain model: BOM | Thêm nullable `structureId` FK — **không thay đổi logic BOM** |
| `api-kingston` | Use cases: product/ | Thêm CRUD Structure usecases; sửa `get-product-structure` nhận `structureId` |
| `api-kingston` | Controller/DTO | Endpoint mới: `GET/POST /products/:id/structures`, `GET /products/:id/structures/:sid/tree`, `PATCH /products/:id/structures/:sid/deactivate` |
| `web-kingston` | Product detail page | Thêm tab "Kết cấu"; component tree viewer per Structure |
| DB (Prisma) | Schema | Bảng mới `product_structure`; alter `component` thêm `structure_id`; alter `bom` thêm `structure_id` nullable |
| DB (Prisma) | Migration data | Script tạo Structure mặc định + gắn component + gắn BOM |

---

## Analytics Events

| Event | Trigger | Properties |
|-------|---------|-----------|
| `structure.created` | KTV tạo Structure mới thành công | `productId`, `structureId`, `userId` |
| `structure.deactivated` | KTV deactivate Structure | `productId`, `structureId`, `userId` |
| `structure.component_added` | Thêm node vào cây | `structureId`, `nodeType` (`cum`/`chi-tiet`), `depth` |
| `structure.component_removed` | Xóa node khỏi cây | `structureId`, `nodeType`, `hadChildren` |
| `structure.viewed` | User mở tab Kết cấu | `productId`, `userRole` |
| `structure.tree_viewed` | User xem cây component của 1 Structure | `structureId`, `nodeCount`, `userRole` |

---

## Rollout

### Strategy: Direct rollout (không cần feature flag)

Lý do: Đây là tính năng hoàn toàn mới (thêm tab + entity). Migration `structureId nullable` trên cả `component` và `bom` → không breaking change với logic BOM hiện tại.

### Deployment Steps

| Bước | Mô tả | Verify |
|------|-------|--------|
| 1. Deploy API + Prisma migration | `prisma migrate deploy` chạy tự động qua CI/CD; migration script tạo Structure mặc định + gắn component + gắn BOM | Row count `component` và `bom` không đổi; mọi row đều có `structureId` non-null |
| 2. Verify staging | CRUD Structure, add/remove node, deactivate; kiểm tra BOM hiện có vẫn hoạt động bình thường | Smoke test checklist |
| 3. Deploy production | Same as staging | Monitor error rate < 0.1% |
| 4. Enable tab "Kết cấu" Web UI | Tab xuất hiện sau khi migration confirmed | Manual spot-check 2–3 products |

### Success Metrics (sau 2 tuần)

- Số Structure được tạo ≥ 1 (kỹ thuật viên đã dùng thực sự)
- Lỗi 5xx từ Structure API < 0.1%
- Không có báo cáo mất dữ liệu component hoặc BOM sau migration

### Kill-switch

- Nếu Web UI có bug nghiêm trọng: ẩn tab "Kết cấu" bằng config phía web — không ảnh hưởng API
- Nếu migration fail: `prisma migrate resolve --rolled-back`; `structureId` nullable trên cả `component` và `bom` → không ảnh hưởng hoạt động hiện tại; deploy lại API phiên bản trước

---

## Open Questions

| # | Câu hỏi | Quyết định tạm thời | Cần confirm |
|---|---------|---------------------|------------|
| Q1 | Approval flow cho Structure: cần multi-step approval (KTV tạo → QLSX approve) không? | Không cần approval cho EPIC-001 — KTV tạo xong là ACTIVE ngay | Tech Lead + QLSX confirm |
| Q2 | `structureId` trên BOM: nullable hay required sau migration? | Nullable trong EPIC-001; BOM epic sẽ enforce required | Tech Lead quyết định khi design BOM epic |
| Q3 | Mobile scope: Phase 1 hay Phase 2? | Out of scope EPIC-001 (ghi rõ trong Scope) | Đã quyết định |
