---
date: 2026-05-21
type: prd
epic_id: EPIC-004
title: Structure Material — Vật tư cố định theo kết cấu
status: draft
revision: 3
---

# PRD — EPIC-004: Structure Material (Vật tư cố định theo kết cấu)

## Problem & Goal

### Problem

Mỗi **kết cấu sản phẩm (Structure)** yêu cầu một bộ vật tư cố định để sản xuất, phân thành hai nhóm mục đích khác nhau:

- **Vật tư lắp ráp (Assembly)**: dùng trong quá trình sản xuất tại xưởng — keo PU, đinh vít M6×20, bản lề 3 inch. Người thực hiện là công nhân xưởng.
- **Vật tư đóng gói (Packaging)**: kèm theo sản phẩm cho khách hàng tự lắp — hướng dẫn lắp ráp, bộ đinh vít dự phòng, chìa khóa allen. Người dùng là khách hàng cuối.

Hiện tại hệ thống **không có entity nào lưu danh sách vật tư này gắn với từng kết cấu**, dẫn đến:

- Kỹ thuật viên và quản lý sản xuất ghi chép vật tư ngoài hệ thống (Excel, giấy tờ) → dễ lạc hậu, nhầm lẫn khi kết cấu thay đổi
- Vật tư lắp ráp và đóng gói bị ghi lẫn lộn, gây nhầm khi cấp phát tại xưởng vs khi đóng hộp
- Khi tạo Work Order, quản lý sản xuất không có cơ sở để biết cần chuẩn bị bao nhiêu vật tư lắp ráp
- Thủ kho không có định mức chuẩn → cấp phát theo kinh nghiệm, dễ thiếu hoặc thừa

**Người dùng bị ảnh hưởng:**
- **Kỹ thuật viên** — không có nơi trong hệ thống để ghi nhận vật tư theo từng kết cấu, phân loại đúng assembly/packaging
- **Quản lý sản xuất** — không có dữ liệu để estimate vật tư lắp ráp khi lập kế hoạch Work Order
- **Thủ kho** — không có định mức chuẩn, phải hỏi kỹ thuật viên từng lần

### Data Model (sau EPIC-004)

```
Product (1) ──→ Structure / Kết cấu (nhiều)
                     │
                     ├──→ Component Tree (cây cụm/chi tiết)  ← EPIC-001
                     │
                     ├──→ BOM / Định mức NVL (nhiều)          ← BOM epic
                     │
                     └──→ StructureMaterial (nhiều)            ← EPIC-004 ★
                               │
                               ├── materialId    → Material (có sẵn unit)
                               ├── assemblyQty   → decimal | null  (SL dùng khi lắp ráp)
                               └── packagingQty  → decimal | null  (SL kèm sản phẩm)
```

> **Lưu ý:**
> - `StructureMaterial` **không có `unitId` riêng** — đơn vị tính lấy trực tiếp từ `Material.unitId`.
> - **Unique constraint**: `(structureId, materialId)` — mỗi vật tư chỉ xuất hiện 1 lần/kết cấu, có 2 cột qty riêng biệt.
> - **Check constraint**: `NOT (assemblyQty IS NULL AND packagingQty IS NULL)` — ít nhất một trong hai phải có giá trị.
> - Nếu vật tư chỉ dùng cho lắp ráp → `packagingQty = NULL`; chỉ dùng đóng gói → `assemblyQty = NULL`.

### Goal

| Mục tiêu | Đo lường | Target |
|---------|---------|--------|
| Kỹ thuật viên ghi nhận vật tư cố định vào hệ thống | Tỷ lệ thao tác add/update thành công | > 99% |
| Loại bỏ danh sách vật tư ngoài hệ thống | Số kết cấu ACTIVE có danh sách vật tư trong ERP | 100% sau 1 tháng dùng |
| Phân biệt rõ SL lắp ráp vs SL đóng gói | 2 cột riêng biệt — không thể nhầm loại | Enforce ở DB schema |
| Load danh sách vật tư nhanh | Thời gian trả về `GET /structures/:id/materials` | < 300ms (p95) |
| Không trùng lặp vật tư trong cùng kết cấu | Unique constraint `(structureId, materialId)` | Enforce ở DB + API |

### Why Now

EPIC-001 đã hoàn thành, Structure entity đang ACTIVE trong production. Đây là bước tự nhiên tiếp theo để làm giàu dữ liệu kết cấu trước khi xây epic tính định mức vật tư trong Work Order. Nếu không làm bây giờ, mỗi lần tạo Work Order, quản lý sản xuất tiếp tục tra Excel → rủi ro sai số tích lũy.

---

## User Flow

### Happy Path 1 — Kỹ thuật viên thêm vật tư vào kết cấu

```
1. KTV mở màn hình chi tiết sản phẩm → tab "Kết cấu"
2. Chọn một kết cấu (Structure) từ danh sách
3. Trong màn hình chi tiết kết cấu, chuyển sang tab "Vật tư"
4. Nhấn nút "Thêm vật tư"
5. Dropdown/search chọn Material (search theo tên hoặc mã)
   → Đơn vị tự động hiển thị từ Material đã chọn (read-only)
6. Nhập SL Lắp ráp và/hoặc SL Đóng gói (ít nhất 1 trong 2)
7. Nhấn "Lưu"
8. Vật tư mới xuất hiện trong bảng; form reset để thêm tiếp
```

**Ví dụ đinh vít M6×20 — dùng cho cả hai:**
- SL Lắp ráp = 8 (công nhân dùng khi lắp)
- SL Đóng gói = 4 (kèm theo hộp cho khách)

**Ví dụ keo PU D4 — chỉ lắp ráp:**
- SL Lắp ráp = 0.05
- SL Đóng gói = (để trống)

**Ví dụ hướng dẫn lắp ráp — chỉ đóng gói:**
- SL Lắp ráp = (để trống)
- SL Đóng gói = 1

### Happy Path 2 — Kỹ thuật viên cập nhật số lượng vật tư

```
1. KTV xem danh sách vật tư của kết cấu
2. Nhấn icon Edit trên dòng vật tư cần sửa
3. Cập nhật SL Lắp ráp và/hoặc SL Đóng gói
   (không thể đổi material khi sửa — phải xóa rồi thêm lại)
4. Nhấn "Lưu"
5. Danh sách refresh, hiển thị giá trị mới
```

### Happy Path 3 — Kỹ thuật viên xóa vật tư khỏi kết cấu

```
1. KTV xem danh sách vật tư của kết cấu
2. Nhấn icon Delete trên dòng vật tư cần xóa
3. Dialog xác nhận: "Xóa [tên vật tư] khỏi kết cấu này?"
4. Nhấn "Xác nhận"
5. Dòng bị xóa; bản ghi Material gốc không bị ảnh hưởng
```

### Happy Path 4 — Quản lý sản xuất xem danh sách vật tư

```
1. QLSX mở màn hình chi tiết kết cấu → tab "Vật tư"
2. Xem bảng: Mã | Tên | SL Lắp ráp | SL Đóng gói | Đơn vị
3. QLSX biết ngay từng vật tư cần bao nhiêu cho lắp ráp, bao nhiêu kèm đóng gói
```

### Error Paths

| Tình huống | Hành vi hệ thống |
|-----------|-----------------|
| Không chọn vật tư khi submit | Inline error: "Vui lòng chọn vật tư" |
| Cả 2 qty đều trống khi submit | Inline error: "Vui lòng nhập ít nhất một số lượng" |
| Qty nhập ≤ 0 | Inline error: "Số lượng phải lớn hơn 0" |
| Qty không phải số hợp lệ | Inline error: "Số lượng không hợp lệ" |
| Trùng lặp materialId trong cùng kết cấu | API 400: "Vật tư [tên] đã có trong danh sách kết cấu này" |
| API `/materials` lỗi khi load dropdown | Dropdown hiển thị "Không thể tải danh sách vật tư"; nút Lưu disabled |
| Network timeout khi submit | Toast error: "Không thể lưu, vui lòng thử lại"; form giữ nguyên giá trị |

### Recovery Paths

- Sau lỗi submit → user chỉnh sửa inline, không cần mở lại form
- Sau lỗi trùng lặp → chọn vật tư khác, hoặc edit dòng đã có để cập nhật qty
- Xóa nhầm → không có undo, nhưng có thể thêm lại ngay

---

## Acceptance Criteria

### EPIC-004-AC01 — Thêm vật tư với cả 2 số lượng (Must)
**Given** kỹ thuật viên đang xem tab "Vật tư" của một kết cấu ACTIVE  
**When** chọn Material, nhập assemblyQty > 0 VÀ packagingQty > 0, nhấn Lưu  
**Then**
- API `POST /structures/:id/materials` trả về HTTP 200 với data StructureMaterial mới
- Bảng refresh, hiển thị dòng mới với cả 2 cột SL lắp ráp và SL đóng gói
- Form reset (không đóng) để thêm tiếp

### EPIC-004-AC02 — Thêm vật tư với chỉ 1 số lượng (Must)
**Given** kỹ thuật viên đang xem tab "Vật tư" của một kết cấu ACTIVE  
**When** chọn Material, nhập chỉ assemblyQty > 0 (packagingQty để trống), nhấn Lưu  
**Then**
- API trả về HTTP 200; `packagingQty = null`
- Bảng hiển thị: cột SL Đóng gói trống (dash `—`) cho dòng này
- Tương tự nếu chỉ nhập packagingQty

### EPIC-004-AC03 — Cả 2 qty đều trống → bị block (Must)
**Given** form thêm vật tư đang mở  
**When** chọn Material nhưng để trống cả 2 qty, nhấn Lưu  
**Then**
- Client-side: inline error "Vui lòng nhập ít nhất một số lượng", không submit
- Server-side: API trả về HTTP 400 nếu bypass client validation

### EPIC-004-AC04 — Trùng lặp materialId bị block (Must)
**Given** kết cấu đã có vật tư X  
**When** user cố thêm vật tư X lần nữa  
**Then**
- API trả về HTTP 400: "Vật tư [tên] đã có trong danh sách kết cấu này"
- Form không đóng, hiển thị error; user có thể edit dòng đã có thay vì thêm mới

### EPIC-004-AC05 — Đơn vị tính tự động từ Material (Must)
**Given** form thêm vật tư đang mở  
**When** user chọn một Material từ dropdown  
**Then**
- Field "Đơn vị" tự động hiển thị đơn vị của Material đó (từ `Material.unit.name`)
- Field "Đơn vị" là read-only, user không thể thay đổi

### EPIC-004-AC06 — Validation quantity (Must)
**Given** form thêm/sửa vật tư đang mở  
**When** user nhập qty ≤ 0 vào bất kỳ field qty nào  
**Then**
- Client-side: inline error ngay dưới field đó, không submit
- Server-side: API trả về HTTP 400 nếu bypass client validation

### EPIC-004-AC07 — Cập nhật số lượng (Must)
**Given** kết cấu đang có ít nhất 1 vật tư  
**When** user nhấn Edit, thay đổi assemblyQty và/hoặc packagingQty, nhấn Lưu  
**Then**
- API `PUT /structures/:structureId/materials/:id` trả về HTTP 200
- Bảng cập nhật giá trị mới ngay lập tức
- Material không thể thay đổi khi edit (read-only trong form edit)
- Validation AC03/AC06 vẫn áp dụng khi edit

### EPIC-004-AC08 — Xóa vật tư (Must)
**Given** kết cấu đang có ít nhất 1 vật tư  
**When** user nhấn Delete, xác nhận trong dialog  
**Then**
- API `DELETE /structures/:structureId/materials/:id` trả về HTTP 200
- Dòng bị xóa khỏi bảng
- Bản ghi `Material` gốc không bị thay đổi

### EPIC-004-AC09 — Xem danh sách vật tư (Must)
**Given** người dùng có quyền `product:read` mở tab "Vật tư" của kết cấu  
**When** tab load xong  
**Then**
- API `GET /structures/:id/materials` trả về HTTP 200 trong < 300ms (p95)
- Bảng hiển thị columns: Mã vật tư | Tên vật tư | SL Lắp ráp | SL Đóng gói | Đơn vị | (Action)
- Qty null hiển thị là `—`
- Nếu chưa có vật tư nào: empty state "Chưa có vật tư nào. Nhấn '+ Thêm vật tư' để bắt đầu."

### EPIC-004-AC10 — Không có quyền write thì ẩn nút edit/delete (Should)
**Given** người dùng chỉ có quyền `product:read`, không có `product:update`  
**When** xem tab "Vật tư"  
**Then**
- Danh sách vật tư vẫn hiển thị đầy đủ
- Nút "Thêm vật tư", icon Edit, icon Delete bị ẩn

### EPIC-004-AC11 — Kết cấu INACTIVE: xem được, không edit (Should)
**Given** kết cấu có status = INACTIVE  
**When** người dùng mở tab "Vật tư"  
**Then**
- Danh sách vật tư hiển thị (read-only)
- Nút "Thêm vật tư" và Edit/Delete bị ẩn
- Hiển thị banner "Kết cấu này đã ngừng hoạt động"

---

## UI / Design

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

### Layout — Tab "Vật tư" trong màn hình chi tiết kết cấu

```
┌────────────────────────────────────────────────────────────────────┐
│  [Thông tin chung]  [Component Tree]  [Vật tư ★]                    │
├────────────────────────────────────────────────────────────────────┤
│  Vật tư cố định                              [+ Thêm vật tư]        │
├──────────┬──────────────────┬─────────────┬─────────────┬──────────┤
│ Mã vật tư│ Tên vật tư       │ SL Lắp ráp  │ SL Đóng gói │ Đơn vị  │
├──────────┼──────────────────┼─────────────┼─────────────┼──────────┤
│ VT-001   │ Keo PU D4        │ 0.05        │ —           │ lít  [✏][🗑]│
│ VT-002   │ Đinh vít M6×20   │ 8           │ 4           │ cái  [✏][🗑]│
│ VT-003   │ Bản lề 3 inch    │ 2           │ —           │ cái  [✏][🗑]│
│ VT-010   │ Hướng dẫn lắp    │ —           │ 1           │ tờ   [✏][🗑]│
└──────────┴──────────────────┴─────────────┴─────────────┴──────────┘
```

> Không cần nhóm riêng "Lắp ráp" / "Đóng gói" — 2 cột trên 1 dòng đã đủ rõ.

### Form "Thêm vật tư" (modal)

```
┌──────────────────────────────────────────────┐
│  Thêm vật tư                            [×]  │
├──────────────────────────────────────────────┤
│  Vật tư *                                    │
│  [Search/dropdown — tìm theo tên, mã]        │
│                                              │
│  SL Lắp ráp           SL Đóng gói           │
│  [____________]        [____________]        │
│  (để trống nếu không dùng)                   │
│                                              │
│  Đơn vị                                      │
│  [cái] ← read-only (từ Material đã chọn)     │
│                                              │
│  * Nhập ít nhất một số lượng                 │
│                      [Hủy]  [Lưu]           │
└──────────────────────────────────────────────┘
```

### Form "Sửa vật tư" (modal)

```
┌──────────────────────────────────────────────┐
│  Sửa vật tư                             [×]  │
├──────────────────────────────────────────────┤
│  Vật tư                                      │
│  [Đinh vít M6×20] ← read-only khi edit       │
│                                              │
│  SL Lắp ráp *         SL Đóng gói *         │
│  [____8____]           [____4____]           │
│                                              │
│  Đơn vị                                      │
│  [cái] ← read-only                           │
│                                              │
│                      [Hủy]  [Lưu]           │
└──────────────────────────────────────────────┘
```

### Behavior notes

- Dropdown chọn vật tư: paginated search → `GET /materials?search=...` — không load toàn bộ
- Sau khi chọn Material, field "Đơn vị" auto-populate từ `material.unit.name` (read-only)
- SL Lắp ráp / SL Đóng gói: input số dương, tối đa 4 chữ số nguyên + 2 chữ số thập phân; để trống = null
- Sau khi thêm thành công → form reset (không đóng) để thêm tiếp nhiều vật tư liên tiếp
- Khi edit: chỉ có thể sửa 2 cột qty; material bị lock (muốn đổi material phải xóa rồi thêm lại)
- Delete: confirm dialog hiển thị tên vật tư, không có undo

---

## Non-Functional Requirements

### Performance
- `GET /structures/:id/materials` — response < 300ms p95 (max ~50 dòng/kết cấu trong thực tế)
- `POST`, `PUT`, `DELETE` — response < 500ms p95

### Security & Authorization

| Action | Permission required |
|--------|-------------------|
| Xem danh sách vật tư | `product:read` |
| Thêm vật tư | `product:update` |
| Sửa số lượng | `product:update` |
| Xóa vật tư | `product:update` |

- Auth guard: `ADMIN_JWT` (cùng guard với `/structures` controller hiện tại)
- Không có PII trong StructureMaterial
- Validate `structureId` trong URL: nếu không tồn tại → 404

### Data Integrity
- Unique constraint `(structureId, materialId)` ở cả DB và application layer
- Check constraint: `NOT (assemblyQty IS NULL AND packagingQty IS NULL)` — enforce ở DB + API
- Xóa `Material` gốc không cascade xóa StructureMaterial (FK restrict — quyết định ở tech design)
- Xóa / INACTIVE `Structure` không xóa StructureMaterial (chỉ block edit trên UI)

---

## Dependencies

| Phụ thuộc | Trạng thái | Owner | Ghi chú |
|-----------|-----------|-------|---------|
| `ProductStructure` entity — EPIC-001 | ✅ Done, deployed | Nghĩa | API `/structures` đang hoạt động |
| `Material` entity + `GET /materials` | ✅ Done, deployed | Nghĩa | Dùng để lookup dropdown; `Material.unit` dùng làm đơn vị hiển thị |
| Permission `product:read` / `product:update` | ✅ Done | Nghĩa | Tái dụng permission hiện có |
| Web UI — màn hình chi tiết kết cấu | ✅ Done (EPIC-001 web) | Nghĩa | Thêm tab "Vật tư" vào UI đã có |

---

## Rollout

### Strategy
- **Direct rollout** — không cần feature flag
- Entity hoàn toàn mới, không ảnh hưởng dữ liệu hiện tại
- Migration: tạo bảng `structure_material` mới, không alter bảng cũ nào

### Deployment steps
1. Deploy API: chạy migration tạo bảng + deploy code
2. Deploy Web: thêm tab "Vật tư" vào màn hình chi tiết kết cấu
3. Smoke test: thêm vật tư có cả 2 qty → xem đủ 2 cột; thêm vật tư chỉ có assemblyQty → packagingQty hiển thị `—`; edit qty; xóa

### Kill-switch
- Ẩn tab "Vật tư" trên frontend (1-line config) trong khi rollback API nếu cần
- Rollback migration: `DROP TABLE structure_material` — không ảnh hưởng bảng nào khác
