---
date: 2026-05-21
type: prd
epic_id: EPIC-005
title: Add Packaging Material — Vật tư đóng gói theo Đơn hàng
status: draft
revision: 1
---

# PRD — EPIC-005: Add Packaging Material (Vật tư đóng gói theo Đơn hàng)

## Problem & Goal

### Problem

Mỗi đơn hàng tại Kingston có **cách đóng gói vận chuyển khác nhau** tùy vào điểm giao, số lượng kiện và yêu cầu khách hàng. Ví dụ: đơn giao xa cần foam góc + màng co PE + dây đai; đơn giao tại showroom chỉ cần thùng carton. Hiện tại hệ thống **không có nơi ghi nhận danh sách vật tư đóng gói theo từng đơn hàng**, khiến:

- Kỹ thuật viên ghi chú vật tư đóng gói ra ngoài hệ thống (Zalo/chat nhóm, giấy tay, file Excel) → thông tin phân tán, dễ lạc hậu khi đơn thay đổi
- Kho không có căn cứ trong hệ thống để chuẩn bị vật tư đóng gói trước khi thành phẩm hoàn thành
- Khi giao hàng thiếu vật tư đóng gói → phải chờ bổ sung, trễ tiến độ giao

> **Phân biệt với EPIC-004:** `StructureMaterial.packagingQty` là vật tư kèm theo sản phẩm cho khách tự lắp (đinh vít dự phòng, hướng dẫn lắp ráp). EPIC-005 là **vật tư dùng để đóng gói vận chuyển** (thùng carton, xốp PE, màng co, dây đai) — dùng tại kho, không giao cho khách.

**Người dùng bị ảnh hưởng:**
- **Kỹ thuật viên** — không có nơi chuẩn hóa ghi nhận vật tư đóng gói theo từng đơn
- **Thủ kho** — không có căn cứ trong hệ thống để chuẩn bị đúng loại, đúng số lượng
- **Quản lý sản xuất** — không lên được kế hoạch logistics đóng gói song song với sản xuất

### Data Model (sau EPIC-005)

```
Order (1) ──→ OrderItem (nhiều) → Product
  │
  └──→ OrderPackagingMaterial (nhiều)  ← EPIC-005 ★
            │
            ├── orderUUID   → Order.uuid
            ├── materialId  → Material (toàn bộ danh mục, không lọc typeId)
            ├── quantity    → decimal > 0
            └── note        → text | null
            [đơn vị = Material.unit — không lưu riêng]
```

> **Quyết định thiết kế:**
> - Không có cột `unitId` riêng — đơn vị tính đọc từ `Material.unitId`.
> - Unique constraint: `(orderUUID, materialId)`.
> - **Write chỉ được phép khi Order.status = `WAIT_FOR_APPROVE`**. Mọi trạng thái khác (TODO, IN_PROGRESS, COMPLETED, REJECTED) → read-only.
> - FK: `orderUUID` tham chiếu `Order.uuid` (lưu ý Order dùng `uuid`, không phải `id`).

### Goal

| Mục tiêu | Đo lường | Target |
|---------|---------|--------|
| Ghi nhận vật tư đóng gói vào hệ thống | % đơn WAIT_FOR_APPROVE có ít nhất 1 vật tư đóng gói được ghi | ≥ 90% sau 1 tháng |
| Thao tác thành công | Tỷ lệ add/update/delete thành công | > 99% |
| Tải danh sách nhanh | Response time `GET /orders/:uuid/packaging-materials` | < 300ms p95 |
| Không trùng lặp | Unique `(orderUUID, materialId)` | Enforce DB + API |

### Why Now

Đây là feature nhỏ, effort thấp, nhưng giải quyết ngay một điểm đau cụ thể trong quy trình giao hàng. Nếu không làm, đội tiếp tục dùng chat nhóm → thông tin thất lạc mỗi khi đơn điều chỉnh.

---

## User Flow

### Happy Path 1 — Kỹ thuật viên thêm vật tư đóng gói vào đơn

```
1. KTV mở màn hình chi tiết đơn hàng (Order.status = WAIT_FOR_APPROVE)
2. Cuộn đến section "Vật tư đóng gói"
3. Nhấn "+ Thêm vật tư đóng gói"
4. Modal mở — tìm/chọn vật tư từ toàn bộ danh mục Material
   → Đơn vị tự động hiển thị từ Material đã chọn (read-only)
5. Nhập số lượng (> 0)
6. Nhập ghi chú (tùy chọn)
7. Nhấn "Lưu"
8. Vật tư xuất hiện trong bảng; modal giữ nguyên mở (form reset) để thêm tiếp
```

**Ví dụ đơn giao HCM → Hà Nội:**
- Thùng carton 5 lớp — 2 thùng
- Xốp PE tấm — 4 tấm
- Màng co PE — 1 cuộn
- Dây đai nhựa — 2 cây

**Ví dụ đơn giao local (showroom):**
- Thùng carton 3 lớp — 2 thùng

### Happy Path 2 — Cập nhật số lượng / ghi chú

```
1. KTV xem section "Vật tư đóng gói" (Order = WAIT_FOR_APPROVE)
2. Nhấn icon Edit trên dòng cần sửa
3. Cập nhật quantity và/hoặc note (không thể đổi material khi edit)
4. Nhấn "Lưu"
5. Bảng refresh hiển thị giá trị mới
```

### Happy Path 3 — Xóa một dòng vật tư

```
1. KTV xem section "Vật tư đóng gói" (Order = WAIT_FOR_APPROVE)
2. Nhấn icon Delete trên dòng cần xóa
3. Dialog xác nhận: "Xóa [tên vật tư] khỏi danh sách đóng gói của đơn hàng 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 — Thủ kho / quản lý sản xuất xem danh sách

```
1. Mở màn hình chi tiết đơn hàng (bất kỳ trạng thái)
2. Xem section "Vật tư đóng gói"
3. Đọc bảng: Mã | Tên | Số lượng | Đơn vị | Ghi chú
4. Biết ngay cần chuẩn bị vật tư gì, bao nhiêu
```

### Error / Edge 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ư đóng gói" |
| Quantity ≤ 0 hoặc bỏ trống | Inline error: "Số lượng phải lớn hơn 0" |
| Quantity không phải số hợp lệ | Inline error: "Số lượng không hợp lệ" |
| Trùng materialId trong cùng đơn | API 400: "Vật tư [tên] đã có trong danh sách đóng gói của đơn hàng này"; modal không đóng |
| API `/materials` lỗi khi load dropdown | Dropdown hiển thị "Không thể tải danh sách vật tư. Thử lại."; 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ị |
| Cố write khi Order không phải WAIT_FOR_APPROVE | Nút thêm/sửa/xóa bị ẩn trên UI; API trả HTTP 403 nếu bypass |
| Order không tồn tại | API 404 |

### Recovery Paths

- Sau lỗi submit → chỉnh sửa inline trong modal, không cần mở lại
- Sau lỗi trùng lặp → chọn vật tư khác, hoặc đóng modal và edit dòng đã có
- Xóa nhầm → thêm lại (thao tác đơn giản, không có undo)
- Order đã qua WAIT_FOR_APPROVE → không thể sửa; nếu cần điều chỉnh đóng gói phải liên hệ người duyệt

---

## Acceptance Criteria

### EPIC-005-AC01 — Thêm vật tư đóng gói (Must)
**Given** kỹ thuật viên đang xem Order detail, `Order.status = WAIT_FOR_APPROVE`  
**When** nhấn "+ Thêm vật tư đóng gói", chọn Material, nhập quantity > 0, nhấn Lưu  
**Then**
- API `POST /orders/:uuid/packaging-materials` trả HTTP 200 với data entry mới
- Bảng refresh, hiển thị dòng mới: Mã, Tên, Số lượng, Đơn vị (từ Material.unit), Ghi chú
- Modal giữ nguyên mở, form reset để thêm tiếp

### EPIC-005-AC02 — Dropdown hiện toàn bộ Material (Must)
**Given** modal thêm vật tư đóng gói đang mở  
**When** user tìm kiếm hoặc cuộn dropdown  
**Then**
- Hiển thị toàn bộ Material trong danh mục (không lọc typeId)
- Hỗ trợ tìm kiếm theo tên hoặc mã; paginated, không load toàn bộ một lúc

### EPIC-005-AC03 — Đơn vị tự động từ Material (Must)
**Given** user đã chọn một Material trong modal  
**When** Material được chọn  
**Then**
- Field "Đơn vị" tự động hiển thị `Material.unit.name`
- Field "Đơn vị" là read-only

### EPIC-005-AC04 — Validation quantity (Must)
**Given** modal thêm hoặc sửa đang mở  
**When** user nhập quantity ≤ 0 hoặc bỏ trống rồi nhấn Lưu  
**Then**
- Client-side: inline error ngay dưới field, không submit
- Server-side: API trả HTTP 400 nếu bypass client

### EPIC-005-AC05 — Trùng lặp materialId bị block (Must)
**Given** đơn hàng đã có vật tư X trong danh sách đóng gói  
**When** user cố thêm vật tư X lần nữa  
**Then**
- API trả HTTP 400: "Vật tư [tên] đã có trong danh sách đóng gói của đơn hàng này"
- Modal không đóng; user có thể edit dòng đã có thay vì thêm mới

### EPIC-005-AC06 — Cập nhật quantity / ghi chú (Must)
**Given** `Order.status = WAIT_FOR_APPROVE`, danh sách có ít nhất 1 vật tư  
**When** user nhấn Edit, thay đổi quantity và/hoặc note, nhấn Lưu  
**Then**
- API `PUT /orders/:uuid/packaging-materials/:id` trả HTTP 200
- Bảng cập nhật giá trị mới ngay lập tức
- Field Material bị lock khi edit (muốn đổi vật tư → xóa rồi thêm lại)
- Validation AC04 vẫn áp dụng

### EPIC-005-AC07 — Xóa vật tư đóng gói (Must)
**Given** `Order.status = WAIT_FOR_APPROVE`, danh sách có ít nhất 1 vật tư  
**When** user nhấn Delete, xác nhận trong dialog  
**Then**
- API `DELETE /orders/:uuid/packaging-materials/:id` trả HTTP 200
- Dòng bị xóa khỏi bảng; bản ghi Material gốc không bị ảnh hưởng

### EPIC-005-AC08 — Xem danh sách vật tư đóng gói (Must)
**Given** người dùng có quyền xem đơn hàng, bất kỳ status  
**When** mở Order detail  
**Then**
- API `GET /orders/:uuid/packaging-materials` trả HTTP 200 trong < 300ms p95
- Section hiển thị columns: Mã vật tư | Tên vật tư | Số lượng | Đơn vị | Ghi chú | (Action)
- Empty state: "Chưa có vật tư đóng gói. Nhấn '+ Thêm vật tư đóng gói' để bắt đầu." — nút action chỉ hiển thị khi Order = WAIT_FOR_APPROVE và user có quyền write

### EPIC-005-AC09 — Block write khi Order không phải WAIT_FOR_APPROVE (Must)
**Given** `Order.status ∈ {TODO, IN_PROGRESS, COMPLETED, REJECTED}`  
**When** user xem section "Vật tư đóng gói"  
**Then**
- Danh sách vật tư hiển thị đầy đủ (read-only)
- Nút "+ Thêm vật tư đóng gói", icon Edit, icon Delete không hiển thị
- API từ chối mọi request write với HTTP 403

### EPIC-005-AC10 — Phân quyền: read-only khi không có quyền write (Should)
**Given** user có `order:read` nhưng không có `order:update`  
**When** xem section "Vật tư đóng gói" dù Order đang WAIT_FOR_APPROVE  
**Then**
- Danh sách hiển thị đầy đủ
- Nút thêm/sửa/xóa không hiển thị

---

## UI / Design

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

### Layout — Section "Vật tư đóng gói" trong Order detail

```
┌─────────────────────────────────────────────────────────────────────┐
│  Thông tin đơn hàng  ...                                            │
├─────────────────────────────────────────────────────────────────────┤
│  Sản phẩm trong đơn  (Order Items table)  ...                       │
├─────────────────────────────────────────────────────────────────────┤
│  Vật tư đóng gói                [+ Thêm vật tư đóng gói]            │
│  (nút ẩn khi Order ≠ WAIT_FOR_APPROVE hoặc không có quyền write)    │
├──────────┬──────────────────────┬──────────┬─────────┬─────────────┤
│ Mã VT    │ Tên vật tư           │ Số lượng │ Đơn vị  │ Ghi chú    │
├──────────┼──────────────────────┼──────────┼─────────┼─────────────┤
│ VT-001   │ Thùng carton 5 lớp   │ 2        │ thùng   │        [✏][🗑]│
│ VT-002   │ Xốp PE tấm 2cm       │ 4        │ tấm     │        [✏][🗑]│
│ VT-003   │ Màng co PE           │ 1        │ cuộn    │ 2 vòng [✏][🗑]│
└──────────┴──────────────────────┴──────────┴─────────┴─────────────┘
```

> Section đặt sau Order Items, trước phần Notes/Attachments.  
> Icon Edit/Delete ẩn khi Order ≠ WAIT_FOR_APPROVE hoặc user không có quyền write.

### Modal "Thêm vật tư đóng gói"

```
┌──────────────────────────────────────────────────┐
│  Thêm vật tư đóng gói                       [×]  │
├──────────────────────────────────────────────────┤
│  Vật tư *                                        │
│  [Search theo tên hoặc mã vật tư...]             │
│                                                  │
│  Số lượng *              Đơn vị                  │
│  [_______________]       [thùng] ← read-only      │
│                                                  │
│  Ghi chú                                         │
│  [_________________________________________]     │
│  (tùy chọn — ghi chú riêng cho đơn này)          │
│                                                  │
│                           [Hủy]  [Lưu]          │
└──────────────────────────────────────────────────┘
```

### Modal "Sửa vật tư đóng gói"

```
┌──────────────────────────────────────────────────┐
│  Sửa vật tư đóng gói                        [×]  │
├──────────────────────────────────────────────────┤
│  Vật tư                                          │
│  [Thùng carton 5 lớp] ← read-only khi edit       │
│                                                  │
│  Số lượng *              Đơn vị                  │
│  [______2______]         [thùng] ← read-only      │
│                                                  │
│  Ghi chú                                         │
│  [_________________________________________]     │
│                                                  │
│                           [Hủy]  [Lưu]          │
└──────────────────────────────────────────────────┘
```

### Behavior notes

- Dropdown: paginated search `GET /materials?search=...` — không filter typeId, không load toàn bộ
- Sau khi chọn Material → "Đơn vị" auto-populate từ `material.unit.name` (read-only)
- Số lượng: số dương, tối đa 4 chữ số nguyên + 2 chữ số thập phân
- Ghi chú: text tự do, tối đa 255 ký tự
- Sau khi thêm thành công → form reset, modal giữ nguyên mở để thêm tiếp
- Edit: chỉ sửa được quantity và note; muốn đổi material → 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 /orders/:uuid/packaging-materials` — < 300ms p95 (thực tế ≤ 20 dòng/đơn)
- `POST`, `PUT`, `DELETE` — < 500ms p95

### Security & Authorization

| Action | Permission required | Điều kiện bổ sung |
|--------|-------------------|--------------------|
| Xem danh sách | `order:read` | — |
| Thêm vật tư | `order:update` | Order.status = WAIT_FOR_APPROVE |
| Sửa số lượng / ghi chú | `order:update` | Order.status = WAIT_FOR_APPROVE |
| Xóa vật tư | `order:update` | Order.status = WAIT_FOR_APPROVE |

- Auth guard: dùng cùng guard với `/orders` controller hiện tại
- Validate `orderUUID` trong URL path → 404 nếu Order không tồn tại
- Status check: trả 403 nếu Order không ở WAIT_FOR_APPROVE khi write
- Không có PII trong `OrderPackagingMaterial`

### Data Integrity
- Unique constraint `(orderUUID, materialId)` ở DB và application layer
- `quantity`: NOT NULL, > 0, DECIMAL(6,2)
- FK `materialId`: RESTRICT — không cascade xóa khi Material bị xóa (quyết định final ở tech design)
- FK `orderUUID`: CASCADE DELETE — xóa Order thì xóa toàn bộ `OrderPackagingMaterial` liên quan

---

## Dependencies

| Phụ thuộc | Trạng thái | Owner | Ghi chú |
|-----------|-----------|-------|---------|
| `Order` entity — `uuid` làm PK | ✅ Done | — | API `/orders` đang hoạt động; `uuid` ≠ `id` — cần lưu ý khi design FK và API path |
| `Material` entity + `GET /materials` | ✅ Done | — | Dùng toàn bộ danh mục; paginated search đã có |
| Web UI — Order detail page | ✅ Done | — | Thêm section mới vào cuối trang |
| Permission `order:read` / `order:update` | ✅ Done | — | Tái dụng permission hiện có |

---

## Rollout

### Strategy
- **Direct rollout** — không cần feature flag
- Entity hoàn toàn mới, không alter bảng nào hiện có → zero migration risk

### Deployment steps
1. Deploy API: migration tạo bảng `order_packaging_material` + deploy code
2. Deploy Web: hiển thị section "Vật tư đóng gói" trong Order detail
3. Smoke test: mở đơn WAIT_FOR_APPROVE → thêm 2 vật tư → verify hiển thị đúng → edit quantity → xóa 1 dòng → chuyển đơn sang TODO → verify nút thêm/sửa/xóa bị ẩn

### Kill-switch
- Ẩn section "Vật tư đóng gói" trên frontend (1-line config) nếu cần rollback API
- Rollback DB: `DROP TABLE order_packaging_material` — không ảnh hưởng bảng khác
