Appearance
Design: Chuyển vật tư (Materials) xuống Subtask
Ngày: 2026-03-26 Tác giả: PO/BA + AI Tech Lead Status: Approved (reviewed by Senior Tech Lead + Senior PO) Scope: Module Công Việc (Tasks) — Tab Công Việc trong chi tiết khách hàng Scale: 5,000-30,000 task+subtask/ngày (~150K-900K records/tháng)
1. Bối cảnh & Vấn đề
1.1. Kiến trúc hiện tại
project_task (task cha, parent_id = null)
├── subtask 1 (parent_id = task_cha.id)
│ └── project_task_assignee (tour_money, surgery_money)
├── subtask 2 (parent_id = task_cha.id)
│ └── project_task_assignee (tour_money, surgery_money)
└── order_materials → order (order.task_id = project_task.id)
└── product_supplyings (inventory: vật tư xuất kho)1.2. Các vấn đề phát hiện
Vấn đề 1 — Button "Thêm vật tư" mở form tạo subtask
- File:
SubtaskTable.tsx:402— khiproject.automate = true, button label đổi thành "Thêm vật tư" - Logic onClick vẫn mở
<TaskCreate>popup (tạo subtask), KHÔNG phải thêm vật tư - Cùng 1 action (tạo subtask) nhưng gọi tên khác nhau → gây nhầm lẫn
Vấn đề 2 — order_materials gắn ở cấp task, không phải subtask
- Hasura remote relationship:
project_task.id → order.task_id MaterialTable.tsxhiển thị read-only, lấy data từorder_materialstrên task cha- Chưa có cơ chế gắn vật tư riêng cho từng subtask
Vấn đề 3 — Backend requestMaterial action tồn tại nhưng chưa có UI
- Backend Go:
action/request_material.go— nhậntask_id+products[], tạo Orderinternal_material - GraphQL mutation đã define nhưng không có button/UI nào gọi trong module projects
Vấn đề 4 — Hệ thống kho chưa đúng
- Phần kho (inventory) hiện tại chưa hoạt động chính xác
- Cần tách "ghi nhận vật tư dự kiến" ra khỏi "xuất kho thực tế"
1.3. Hạ tầng sẵn có
| Thành phần | Status | Ghi chú |
|---|---|---|
product_relation.subtask_id | Đã có (migration 1710127061851) | Link vật tư mẫu → subtask definition |
requestMaterial action (Go) | Đã có | Nhận task_id + products[], tạo Order |
order_materials relationship | Đã có | project_task.id → order.task_id |
ServiceFormMaterial component | Đã có | Search product + thêm vật tư (module ecommerce) |
1.4. Bảng project_task hiện tại — đánh giá tải
Phát hiện từ Senior Tech Lead review — cần lưu ý khi thêm relationship.
| Metric | Giá trị | Risk |
|---|---|---|
| Relationships | 25+ (13 array, 5 object, 11 remote cross-DB) | Medium |
| Event triggers | 4 (insert, delete, 2x update) | Medium |
| Computed fields | 3 (code, missed, sort_status_value) | Low |
| Remote relationships | 11 (3 default, 7 ecommerce, 1 hrm) | Medium |
| Indexes | 11 (bao gồm 2 cặp duplicate trên project_id, parent_id) | Cần cleanup |
| Partition | Không có | Critical cho scale |
| Caching | Không có materialized views | Medium |
2. Quyết định thiết kế
| # | Quyết định | Lý do |
|---|---|---|
| D1 | Vật tư gắn ở cấp subtask, không phải task cha | Mỗi subtask (công việc con) có vật tư riêng, phản ánh đúng thực tế spa |
| D2 | Tạo bảng mới project_task_material thay vì dùng bảng order | Tách "dự kiến" vs "đã xuất kho", không mix data khi kho chưa đúng |
| D3 | Auto-fill vật tư từ service config + cho phép tìm kiếm thêm mới | Tiết kiệm thời gian KTV, vẫn linh hoạt |
| D4 | Task cha hiển thị aggregate vật tư từ tất cả subtask con | Manager cần nhìn tổng mà không phải click từng subtask |
| D5 | Vật tư nằm trong form subtask, không có button riêng ở bảng ngoài | Tránh confusion "thêm vật tư cho subtask nào?" |
| D6 | Chưa làm xuất kho (phase 2) | Kho chưa đúng, ưu tiên subtask đúng trước |
| D7 | Denormalize product info vào project_task_material | Giảm remote join cross-DB khi aggregate ở task cha (review R3) |
| D8 | Flat query cho aggregate thay vì nested qua subtask | Tránh N+1 khi task cha có nhiều subtask (review R1) |
| D9 | Không thêm event trigger lên project_task | Bảng đã quá nặng (4 triggers), materials xử lý ở application layer (review R4) |
3. Tiền tour — Impact Assessment
3.1. Kết luận: KHÔNG BỊ ẢNH HƯỞNG
Hai hệ thống hoàn toàn độc lập, không giao nhau:
TIỀN TOUR (giữ nguyên, không đổi):
tag_tour_moneys (by seniority level)
→ project_task_assignee.tour_money (per person per subtask)
→ ecommerce_task_log.tour_fee (copy khi task complete)
→ user_salary.total_salary (increment)
VẬT TƯ (mới, song song):
product_relation (service config, auto-fill)
→ project_task_material (ghi nhận dự kiến)
→ [Phase 2] requestMaterial → Order internal_material → xuất kho3.2. Chi tiết flow tiền tour hiện tại (không thay đổi)
- Chọn subtask definition →
subtask_tags[].tag.tag_tour_moneys[] - Map theo
user.profile.level(seniority) → tour_money per assignee - Supervisor → tour_money = 0 (luôn luôn)
- Ghi vào
project_task_assignee.tour_money - Khi task complete → backend copy sang
ecommerce_task_log.tour_fee - Salary:
user_salary.total_salary += tour_money
3.3. Bằng chứng không coupling
| Kiểm tra | Kết quả |
|---|---|
project_task_assignee_insert.go | Chỉ đọc tag_tour_moneys, không đọc materials |
project_task_assignee_update.go | Update ecommerce_task_log.tour_fee từ assignee.tour_money, không đọc materials |
task_log.go commission flow | Chỉ dùng assignee.tour_money, không reference materials |
report_tour_income view | JOIN task + assignee, không JOIN materials |
| Database FK | Không có FK giữa project_task_material và project_task_assignee |
4. Data Model
4.1. Bảng mới: project_task_material
Database: project
sql
CREATE TABLE project_task_material (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
task_id UUID NOT NULL REFERENCES project_task(id) ON DELETE CASCADE,
product_id UUID NOT NULL,
-- Denormalized fields (giảm remote join cho aggregate view)
product_name TEXT, -- Cache tên sản phẩm tại thời điểm thêm
product_sku TEXT, -- Cache SKU
product_unit TEXT, -- Cache đơn vị (vi description)
quantity NUMERIC NOT NULL DEFAULT 1,
note TEXT,
created_by TEXT,
updated_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(task_id, product_id)
);
-- Index chính: query theo subtask
CREATE INDEX idx_task_material_task_id ON project_task_material(task_id);
-- Index cho aggregate query: tìm materials của tất cả subtask con
-- (dùng khi flat query WHERE task.parent_id = X)
CREATE INDEX idx_task_material_created_at ON project_task_material(created_at);Denormalize rationale (D7):
- Aggregate ở task cha cần hiển thị
product_name,product_sku,product_unit - Nếu join remote đến ecommerce.product → mỗi material row = 1 cross-DB query
- 5 subtask × 3 vật tư = 15 remote queries chỉ để hiển thị bảng tổng hợp
- Cache product info khi insert → aggregate chỉ query 1 bảng, zero remote join
- Trade-off: product đổi tên/SKU thì cache stale → chấp nhận được (hiếm khi đổi, chỉ ảnh hưởng display)
Scale estimate:
- 5K-30K subtask/ngày × trung bình 3 vật tư = 15K-90K records/ngày
- 1 năm ≈ 5.4M-32.8M records
- UNIQUE constraint index + task_id index đủ cho query pattern hiện tại
- Nếu cần partition sau: dùng
created_atrange partition theo tháng
4.2. Hasura metadata
File mới: metadata/databases/project/tables/public_project_task_material.yaml
yaml
table:
name: project_task_material
schema: public
object_relationships:
- name: task
using:
foreign_key_constraint_on: task_id
remote_relationships:
- name: product
definition:
to_source:
field_mapping:
product_id: id
relationship_type: object
source: ecommerce
table:
name: product
schema: public
insert_permissions:
- role: user
permission:
columns: [task_id, product_id, product_name, product_sku, product_unit, quantity, note]
set:
created_by: x-hasura-user-id
select_permissions:
- role: user
permission:
columns: '*'
filter: {}
update_permissions:
- role: user
permission:
columns: [quantity, note, updated_by]
delete_permissions:
- role: user
permission:
filter: {}Sửa file: public_project_task.yaml — thêm array relationship:
yaml
array_relationships:
- name: task_materials
using:
foreign_key_constraint_on:
column: task_id
table:
name: project_task_material
schema: publicLưu ý (D9): KHÔNG thêm event trigger trên
project_taskcho materials. Bảngproject_taskđã có 4 triggers, thêm nữa tăng latency. Materials insert/update/delete xử lý ở frontend application layer (GraphQL mutation trực tiếp).
5. GraphQL
5.1. Cập nhật fragment ProjectTask
File: diva-admin/src/modules/projects/graphql/project.graphql
Thêm vào fragment ProjectTask on project_task:
graphql
task_materials {
id
product_id
product_name
product_sku
product_unit
quantity
note
}Lưu ý: Fragment KHÔNG include
product { ... }remote relationship. Dùng denormalized fields (product_name,product_sku,product_unit) cho hiển thị. Chỉ dùngproductremote relationship trong form sửa subtask khi cần full product info (search/autocomplete).
5.2. Query aggregate cho task cha (D8 — Flat query)
graphql
query GetParentTaskMaterials($parentTaskId: uuid!) {
project_task_material(
where: { task: { parent_id: { _eq: $parentTaskId } } }
) {
product_id
product_name
product_sku
product_unit
quantity
task_id
}
}Tại sao flat query thay vì nested?
- Nested:
project_tasks { task_materials { product { ... } } }= N+1 qua 2 tầng- Flat: 1 query trực tiếp vào
project_task_materialvới filtertask.parent_id- Dùng denormalized fields → zero remote join, 1 single query
5.3. Mutations
graphql
mutation InsertTaskMaterials($objects: [project_task_material_insert_input!]!) {
insert_project_task_material(
objects: $objects
on_conflict: {
constraint: project_task_material_task_id_product_id_key
update_columns: [quantity, note, updated_by]
}
) {
returning { id task_id product_id quantity }
}
}
mutation DeleteTaskMaterial($id: uuid!) {
delete_project_task_material_by_pk(id: $id) { id }
}
mutation DeleteTaskMaterialsByTaskId($taskId: uuid!) {
delete_project_task_material(where: { task_id: { _eq: $taskId } }) {
affected_rows
}
}6. Frontend Changes
6.1. Fix button label — SubtaskTable.tsx
File: diva-admin/src/modules/projects/components/TaskDetail/SubtaskTable.tsx:402
// Trước:
label={isAutomaticBoard.value ? "Thêm vật tư" : "Thêm công việc"}
// Sau:
label={"Thêm công việc"}6.2. Component mới: MaterialForm.tsx
File: diva-admin/src/modules/projects/components/TaskForm/MaterialForm.tsx
Props:
modelValue: TaskMaterialItem[]— danh sách vật tư hiện tạisubtaskDefinitionId?: string— ID subtask definition để auto-fillreadonly?: boolean— khóa sửa khi subtask done/canceled
Emits:
update:modelValue— khi thêm/sửa/xóa vật tư
Type:
typescript
export type TaskMaterialItem = {
_id: string; // client-side unique ID
id?: string; // server ID (nếu đã lưu)
product_id: string;
product_name: string; // denormalized
product_sku: string; // denormalized
product_unit: string; // denormalized
quantity: number;
note?: string;
product?: Product; // full product object (cho search/display image)
};Wireframe:
┌─── VẬT TƯ ──────────────────────────────────────────┐
│ # │ Vật tư │ Mã │ ĐVT │ SL │ │
│ 1 │ Serum Laser X │ SP001 │ ml │ [2]│ 🗑 │ ← auto-fill
│ 2 │ Gel làm mát │ SP042 │ tube │ [1]│ 🗑 │ ← auto-fill
│ 3 │ Mask dưỡng da │ SP018 │ miếng │ [1]│ 🗑 │ ← user thêm
│ │
│ [🔍 Tìm kiếm thêm vật tư...] │
└───────────────────────────────────────────────────────┘Logic auto-fill:
- Khi
subtaskDefinitionIdthay đổi (user chọn subtask definition) - Query
product_relation WHERE subtask_id = subtaskDefinitionId AND related_product.disabled IS NOT TRUE(includerelated_product { name, sku, product_unit }) - Map kết quả →
TaskMaterialItem[](ghi cả denormalized fields), fill vào bảng - User có thể: sửa SL (inline input), xóa (icon 🗑), thêm mới qua search
Edge case (E2): Auto-fill filter
related_product.disabled IS NOT TRUEđể loại product đã ngưng kinh doanh. Nếu material đã lưu trước đó mà product sau đó bị disable → vẫn hiển thị nhưng kèm badge "Ngưng KD" (dùng denormalizedproduct_name+ checkproduct.disabledqua remote relationship khi load form sửa).
Readonly mode (E3):
- Khi subtask status =
done_*hoặccanceled_*→readonly = true - Ẩn icon 🗑, disable input SL, ẩn search bar
- Logic:
viewOnlyTaskStatus(subtask.status_id)(hàm có sẵn trongTaskForm/index.tsx:98)
Search thêm vật tư:
- Tham khảo component
ServiceFormMaterial.tsx(module ecommerce) cho pattern search product - Tìm theo tên/SKU, chọn → thêm dòng mới, set
product_name/sku/unittừ product object, nhập SL - Search filter:
product.disabled IS NOT TRUE(chỉ hiện product đang hoạt động)
6.3. Tích hợp MaterialForm vào TaskForm
File: diva-admin/src/modules/projects/components/TaskForm/index.tsx
Khi isChild = true (đang tạo/sửa subtask), thêm section vật tư sau phần assignees/TourFeeTable:
tsx
{props.isChild && (
<MaterialForm
v-model={formData.materials}
subtaskDefinitionId={selectedSubtaskDefinition.value?.id}
readonly={viewOnlyTaskStatus(formData.status_id)}
/>
)}Khi lưu subtask (tạo mới):
- Lưu
project_task(subtask) trước → nhậntask_id - Gọi
InsertTaskMaterialsvớitask_id+ danh sách materials (bao gồm denormalized fields)
Khi lưu subtask (sửa):
- Load
task_materialstừ fragmentProjectTask→ hiển thị trongMaterialForm - Khi lưu:
- Items mới/sửa →
InsertTaskMaterials(upsert on_conflict) - Items bị xóa →
DeleteTaskMaterialper item, hoặcDeleteTaskMaterialsByTaskIdrồi re-insert all (simpler)
- Items mới/sửa →
6.4. Aggregate MaterialTable ở task cha
File: diva-admin/src/modules/projects/components/TaskDetail/General.tsx (line 1718-1724)
Data source mới: Dùng flat query GetParentTaskMaterials (section 5.2)
Frontend aggregate logic:
typescript
const aggregateMaterials = (materials: TaskMaterial[]) => {
const map = new Map<string, { product_name, product_sku, product_unit, quantity }>();
for (const m of materials) {
const existing = map.get(m.product_id);
if (existing) {
existing.quantity += m.quantity;
} else {
map.set(m.product_id, {
product_name: m.product_name,
product_sku: m.product_sku,
product_unit: m.product_unit,
quantity: m.quantity,
});
}
}
return Array.from(map.values());
};Wireframe task cha:
┌─────────────────────────────────────────────────────┐
│ VẬT TƯ (tổng hợp từ 2 công việc con) │
├───┬──────────────────┬────────┬───────┬─────────────┤
│ # │ Vật tư │ Mã │ ĐVT │ SL dự kiến │
├───┼──────────────────┼────────┼───────┼─────────────┤
│ 1 │ Serum Laser X │ SP001 │ ml │ 4 │
│ 2 │ Gel làm mát │ SP042 │ tube │ 1 │
│ 3 │ Mask dưỡng da │ SP018 │ miếng │ 2 │
└───┴──────────────────┴────────┴───────┴─────────────┘6.5. Sửa delete confirm dialog — SubtaskTable.tsx
File: diva-admin/src/modules/projects/components/TaskDetail/SubtaskTable.tsx:411-423
// Trước:
content={`Bạn có chắc chắn muốn xóa công việc ${projectTaskEdit.value?.name}?`}
// Sau:
content={`Bạn có chắc chắn muốn xóa công việc ${projectTaskEdit.value?.name}?`
+ (projectTaskEdit.value?.task_materials?.length
? `\n(Sẽ xóa ${projectTaskEdit.value.task_materials.length} vật tư đi kèm)`
: '')}Edge case (E1): User cần biết xóa subtask sẽ CASCADE xóa tất cả vật tư. Hiển thị số lượng vật tư trong confirm dialog để tránh xóa nhầm.
6.6. Backward compatibility — General.tsx hiển thị cả data cũ và mới
File: diva-admin/src/modules/projects/components/TaskDetail/General.tsx (line 1708-1725)
Edge case (E4): Sau deploy, task cha có thể có cả:
order_materials(data cũ, từ Order.task_id trỏ đến parent task — trước khi có project_task_material)project_task_materialaggregate (data mới, từ subtask con)Cần hiển thị cả 2 nguồn trong giai đoạn chuyển đổi.
tsx
{isAutomaticBoard(props.value as Project_Task) && (
<>
{/* Data mới: aggregate vật tư dự kiến từ subtask con */}
{aggregatedNewMaterials.value.length > 0 && (
<>
<QText class="text-bold q-my-md text-uppercase">VẬT TƯ DỰ KIẾN</QText>
<MaterialTableNew rows={aggregatedNewMaterials.value} />
</>
)}
{/* Data cũ: order_materials trên parent task (backward compat) */}
{props.value?.order_materials?.length > 0 && (
<>
<QText class="text-bold q-my-md text-uppercase">VẬT TƯ (ĐÃ XUẤT KHO)</QText>
<MaterialTable modelValue={props.value} />
</>
)}
</>
)}Logic:
- Nếu có
project_task_materialaggregate → hiển thị bảng "VẬT TƯ DỰ KIẾN" (cột: SL dự kiến) - Nếu có
order_materialstrên parent task → hiển thị bảng "VẬT TƯ (ĐÃ XUẤT KHO)" giữ nguyên MaterialTable cũ (cột: SL mẫu, SL thực tế) - Cả 2 có thể cùng hiển thị nếu task cha có cả data cũ và mới
- Khi data cũ không còn (tất cả task mới dùng project_task_material) → tự nhiên chỉ hiện bảng mới
6.7. Sửa MaterialTable columns
File: diva-admin/src/modules/projects/components/TaskDetail/MaterialTable.tsx
Giữ nguyên MaterialTable cũ cho backward compat (hiển thị order_materials):
{ name: "quantity", label: "SL mẫu" }
{ name: "_quantity", label: "SL thực tế" }Tạo MaterialTableNew (hoặc reuse MaterialTable với prop mode):
// Aggregate view (task cha — data mới từ project_task_material):
{ name: "quantity", label: "SL dự kiến" }
// Không có cột "SL thực tế" (phase 2)
// Detail view (trong subtask — data mới):
{ name: "quantity", label: "SL dự kiến" }
{ name: "_quantity", label: "SL thực tế" } // Hiển thị "--" (phase 2)7. Impact Map
7.1. Files thay đổi
| Layer | File | Thay đổi | Mức độ |
|---|---|---|---|
| DB | Migration mới create_project_task_material | Tạo bảng + indexes + denormalized columns | New |
| Hasura | public_project_task_material.yaml | Metadata bảng mới (permissions, relationships) | New |
| Hasura | public_project_task.yaml | Thêm relationship task_materials | Small |
| GraphQL | project.graphql | Thêm task_materials fragment + mutations + aggregate query | Small |
| FE | SubtaskTable.tsx | Fix label button + sửa delete confirm dialog (E1) | Small |
| FE | TaskForm/MaterialForm.tsx | Component mới: bảng vật tư editable + auto-fill + search + readonly mode (E3) | New |
| FE | TaskForm/index.tsx | Thêm section MaterialForm khi isChild=true với readonly prop | Medium |
| FE | TaskDetail/General.tsx | Hiển thị cả data mới (aggregate) + data cũ (order_materials) (E4) | Medium |
| FE | TaskDetail/MaterialTable.tsx | Giữ nguyên cho backward compat + thêm mode mới cho aggregate view | Small |
7.2. Không ảnh hưởng (giữ nguyên)
- Tiền tour flow:
tag_tour_moneys→assignee.tour_money→task_log.tour_fee→ salary (ZERO coupling) requestMaterialbackend action (Go) → dùng ở phase 2order_materialsrelationship → giữ, dùng cho SL thực tế phase 2product_relation.subtask_id→ chỉ đọc để auto-fill- Bảng
order,product_supplying→ không đụng - Flow giao việc hàng loạt (
AssignMultipleTaskForm) TourFeeTable(tiền tour trên subtask assignee)- Backend event triggers (task insert/update/delete) → không thêm trigger mới
- Commission calculation (
task_log.go) → không thay đổi - Salary calculation → không thay đổi
8. Flow tổng hợp
1. User mở tab "Công Việc" trong chi tiết khách hàng
2. Click vào task cha → TaskDetail
├── Bảng subtask: nút "Thêm công việc" (đã fix label)
└── Bảng VẬT TƯ: flat query aggregate "SL dự kiến" từ project_task_material
(dùng denormalized fields, zero remote join)
3. Click "Thêm công việc" → TaskCreate popup (isChild=true)
├── Chọn subtask definition → auto-fill vật tư từ product_relation
│ (ghi product_name/sku/unit vào form)
├── Bảng vật tư editable (sửa SL, xóa, tìm kiếm thêm mới)
└── Lưu → insert project_task + insert project_task_material[]
(bao gồm denormalized product info)
4. Click sửa subtask → TaskCreate popup (isChild=true, có taskId)
├── Load task_materials từ ProjectTask fragment
├── Chỉnh sửa vật tư
└── Lưu → delete all by task_id + re-insert (hoặc upsert + delete removed)
5. Xóa subtask → ON DELETE CASCADE tự xóa project_task_material9. Phase 2 — Xuất kho (ngoài scope hiện tại)
Khi hệ thống kho ổn định:
- Subtask detail → thêm nút "Xác nhận xuất kho"
- Đọc
project_task_material→ gọirequestMaterial(task_id=subtask_id, products=[...]) - Backend tạo Order
internal_material, check tồn kho MaterialTablehiển thị "SL thực tế" từorder_materials- So sánh "SL dự kiến" (từ
project_task_material) vs "SL thực tế" (từorder_materials)
10. Performance & Scale Notes
10.1. Ước tính data volume
| Metric | Min | Max | 1 năm |
|---|---|---|---|
| Subtask/ngày | 5,000 | 30,000 | 1.8M-10.9M |
| Materials/ngày (×3 avg) | 15,000 | 90,000 | 5.4M-32.8M |
Bảng project_task_material sau 1 năm | 5.4M | 32.8M records |
10.2. Query patterns & Indexes
| Query pattern | Sử dụng khi | Index coverage |
|---|---|---|
WHERE task_id = X | Xem/sửa subtask → load materials | idx_task_material_task_id |
WHERE task.parent_id = X | Aggregate ở task cha | task_id index + project_task.parent_id index |
Upsert ON CONFLICT (task_id, product_id) | Lưu form subtask | UNIQUE constraint index |
10.3. Tối ưu hóa đã áp dụng
| Kỹ thuật | Mục đích | Impact |
|---|---|---|
| Denormalize product_name/sku/unit | Loại bỏ remote join khi aggregate | Giảm từ N remote queries → 0 |
| Flat query thay vì nested | Tránh N+1 qua 2 tầng (subtask → materials → product) | 1 query thay vì N+1 |
| Không thêm event trigger | Giữ project_task latency ổn định | Không tăng trigger chain |
| ON DELETE CASCADE | Tự cleanup khi xóa subtask | Không cần application-level cleanup |
| UNIQUE constraint | Hỗ trợ upsert, tránh duplicate | Built-in dedup |
10.4. Khuyến nghị tương lai (ngoài scope)
- Partition bảng
project_task_materialtheocreated_at(monthly) nếu > 20M records - Materialized view cho aggregate stats nếu query chậm
- Cleanup duplicate indexes trên
project_task(2 cặp trùng: project_id, parent_id) - Monitor query latency trên
GetParentTaskMaterialssau deploy
11. Ghi chú kỹ thuật
project_task_materialđặt ở project DB (cùng DB vớiproject_task) → tránh cross-database write- UNIQUE constraint
(task_id, product_id)cho phép upsert khi lưu form ON DELETE CASCADEtrêntask_id→ xóa subtask tự xóa materials- Auto-fill query
product_relationchỉ chạy 1 lần khi chọn subtask definition, không re-run khi sửa - Denormalized fields (
product_name,product_sku,product_unit) set 1 lần khi insert, không auto-sync nếu product đổi tên (chấp nhận được) - Aggregate ở task cha cộng dồn theo
product_iddùng denormalized fields, không cần remote join productremote relationship trênproject_task_materialvẫn giữ để dùng khi cần full product info (search/autocomplete trong form)
12. Edge Cases & Backward Compatibility
12.1. Edge Cases Matrix
| # | Edge Case | Xử lý | Đã cover trong section |
|---|---|---|---|
| E1 | Xóa subtask → user không biết materials bị xóa theo | Delete confirm dialog hiển thị số vật tư | 6.5 |
| E2 | Product bị disable sau khi đã thêm vào materials | Auto-fill filter disabled IS NOT TRUE + badge "Ngưng KD" cho materials cũ | 6.2 |
| E3 | Sửa materials trên subtask done/canceled | MaterialForm readonly mode theo viewOnlyTaskStatus() | 6.2, 6.3 |
| E4 | Data cũ (order_materials trên parent) vs data mới (project_task_material) | Hiển thị cả 2 sections riêng biệt trong giai đoạn chuyển đổi | 6.6 |
| E5 | Product bị hard-delete sau khi lưu materials | Denormalized fields (product_name/sku/unit) vẫn hiển thị, product_id orphaned nhưng không crash | 4.1 |
| E6 | Subtask definition không có vật tư mẫu | Auto-fill trả về empty → user tự search thêm | 6.2 |
| E7 | Thêm cùng product 2 lần vào 1 subtask | UNIQUE(task_id, product_id) → upsert cập nhật quantity | 4.1 |
| E8 | Service thay đổi product_relation sau khi subtask đã tạo | Subtask giữ materials đã lưu, không auto-sync. By design | 11 |
| E9 | 2 users sửa materials cùng subtask cùng lúc | Last-write-wins (không có optimistic locking). Accept risk — tương đồng behavior hiện tại toàn hệ thống | Accepted |
| E10 | Xóa subtask cuối cùng → parent auto-done → aggregate trống | CASCADE xóa materials → aggregate empty → bảng trống (OK) | Automatic |
12.2. Backward Compatibility Checklist
✅ order_materials relationship trên project_task: GIỮA NGUYÊN, không đổi
✅ MaterialTable cũ: GIỮA NGUYÊN cho hiển thị order_materials (data cũ)
✅ Report modules: ZERO IMPACT (chỉ filter order_kind, không aggregate materials)
✅ Tiền tour flow: ZERO COUPLING (đã chứng minh section 3)
✅ Fragment ProjectTask: Thêm task_materials là additive, không breaking
✅ DeleteSubTask mutation: ON DELETE CASCADE tự xóa project_task_material
✅ Backend event triggers: Không thêm trigger mới, không đổi logic cũ
✅ Commission/Salary: Không thay đổi
⚠️ General.tsx: Cần hiển thị cả 2 data source (section 6.6)
⚠️ Delete confirm: Cần mention số materials (section 6.5)12.3. Risks accepted
| Risk | Severity | Reason accepted |
|---|---|---|
| Concurrent editing (last-write-wins) | Medium | Tương đồng behavior hiện tại, cost > benefit để thêm optimistic lock |
| Denormalized product info stale | Low | Product hiếm khi đổi tên/SKU, chỉ ảnh hưởng display |
| product_relation.subtask_id nullable (no FK) | Low | Manual config relationship trong Hasura, đã hoạt động ổn định |
| No audit log cho material changes | Low | Phase 1 focus correctness, audit log thêm sau nếu cần |