Appearance
Dev Spec: Quản lý vật tư theo Subtask
Feature slug: subtask-materialVersion: 1.0 Ngày: 2026-03-26
C1) Scope
| Loại | Nội dung |
|---|---|
| In scope | Bảng project_task_material, MaterialForm component, auto-fill, aggregate, fix button, backward compat |
| Out of scope | Xuất kho (requestMaterial), notification, audit log, mobile |
C2) Impact
| Layer | File | Thay đổi |
|---|---|---|
| DB | Migration mới | CREATE TABLE project_task_material + indexes |
| Hasura | public_project_task_material.yaml | Metadata mới: permissions, relationships |
| Hasura | public_project_task.yaml | Thêm array relationship task_materials |
| GraphQL | project.graphql | Fragment + mutations + aggregate query |
| FE | TaskForm/MaterialForm.tsx | Component mới |
| FE | TaskForm/index.tsx | Thêm MaterialForm khi isChild=true |
| FE | TaskDetail/SubtaskTable.tsx | Fix label + delete confirm |
| FE | TaskDetail/General.tsx | Aggregate view + backward compat |
| FE | TaskDetail/MaterialTable.tsx | Giữ cũ, thêm mode |
C3) Rules & Formulas
FORMULA-001: Aggregate SL dự kiến
- Mô tả: Tổng số lượng vật tư dự kiến cho tất cả subtask con của task cha, group theo product.
- Công thức:
planned_qty = SUM(project_task_material.quantity) WHERE task.parent_id = parent_task_id GROUP BY product_id - Biến số:
quantity: số lượng vật tư trên mỗi subtask — nguồn:project_task_material.quantityparent_task_id: ID task cha — nguồn: route params hoặc props
- Đơn vị: Theo
product_unitcủa từng product (ml, tube, miếng, etc.) - Ví dụ:
- Subtask 1: Serum X = 2ml, Gel = 1 tube
- Subtask 2: Serum X = 2ml, Mask = 1 miếng
- Aggregate: Serum X = 4ml, Gel = 1 tube, Mask = 1 miếng
- Edge cases:
- Không có subtask nào có materials → aggregate = empty array → ẩn section
- Subtask bị xóa → CASCADE delete materials → aggregate tự giảm
quantity = 0→ không nên xảy ra (validate FE: quantity > 0)NULL quantity→ default = 1 (DB level)
C4) Data Model
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 (Ref: DEC-T02)
product_name TEXT,
product_sku TEXT,
product_unit TEXT,
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)
);
-- Primary index: load materials by subtask
CREATE INDEX idx_task_material_task_id ON project_task_material(task_id);
-- Future partition support
CREATE INDEX idx_task_material_created_at ON project_task_material(created_at);
-- Trigger: auto-update updated_at
CREATE OR REPLACE FUNCTION update_task_material_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_task_material_updated_at
BEFORE UPDATE ON project_task_material
FOR EACH ROW
EXECUTE FUNCTION update_task_material_updated_at();Relationship diagram
project_task (subtask, parent_id IS NOT NULL)
├── project_task_assignee (tour_money) ← KHÔNG THAY ĐỔI
└── project_task_material (NEW)
├── task_id → project_task.id (FK, ON DELETE CASCADE)
├── product_id → ecommerce.product.id (remote relationship)
└── product_name, product_sku, product_unit (denormalized)C5) API & Hasura
Hasura Metadata: 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: {}Relationship trên project_task
Thêm vào public_project_task.yaml:
yaml
array_relationships:
- name: task_materials
using:
foreign_key_constraint_on:
column: task_id
table:
name: project_task_material
schema: publicGraphQL Fragment — thêm vào ProjectTask
graphql
task_materials {
id
product_id
product_name
product_sku
product_unit
quantity
note
}Query: Aggregate cho task cha (Ref: DEC-T03)
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
}
}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
}
}Query: Auto-fill từ service config
graphql
query GetSubtaskMaterialConfig($subtaskId: uuid!) {
product_relation(
where: {
subtask_id: { _eq: $subtaskId }
related_product: { disabled: { _neq: true } }
}
) {
id
quantity
related_product {
id
name
sku
featured_image_url
product_unit { id, description }
product_contents(where: { language: { _eq: "vi" } }) {
name
}
disabled
}
}
}Error Contract
| Scenario | Error | HTTP/GraphQL | FE handling |
|---|---|---|---|
| task_id không tồn tại | FK violation | 200 (GraphQL error) | Toast "Công việc không tồn tại" |
| Duplicate (task_id, product_id) | UNIQUE violation → upsert | 200 (success via on_conflict) | Transparent — upsert handles |
| product_id không tồn tại (remote) | Insert succeeds (no FK check cross-DB) | 200 | OK — denormalized data vẫn hiển thị |
| quantity <= 0 | FE validation block | N/A | Inline error "SL phải > 0" |
| Unauthorized | Hasura permission deny | 200 (GraphQL error) | Toast "Không có quyền" |
C6) Scheduler
Không có scheduler trong phase 1.
C7) Migration
Migration file
Timestamp: Lấy timestamp mới hơn migration cuối cùng trong project DB.
Path: diva-backend/services/controller/migrations/project/{timestamp}_create_project_task_material/up.sql
Content: SQL từ section C4.
down.sql:
sql
DROP TABLE IF EXISTS project_task_material;
DROP FUNCTION IF EXISTS update_task_material_updated_at();Hasura metadata files
- New:
metadata/databases/project/tables/public_project_task_material.yaml(content từ C5) - Edit:
metadata/databases/project/tables/public_project_task.yaml— thêmtask_materialsrelationship - Edit:
metadata/databases/project/tables/tables.yaml— thêm entrypublic.project_task_material
Validation checklist
- [ ] Migration timestamp > latest existing migration trong project DB
- [ ]
project_task_materialtable naming follows convention (snake_case, prefix-less trong project DB) - [ ] FK
task_idreferencesproject_task(id)— table exists - [ ] Audit fields:
created_at,updated_at,created_by,updated_by— follows pattern - [ ] Không có
deleted_at— CASCADE delete thay vì soft delete (by design, Ref: DEC-T05) - [ ] Hasura metadata YAML follows same structure as existing
public_project_task_*.yamlfiles
C8) Security
| Concern | Mitigation |
|---|---|
| SQL injection | Hasura parameterized queries — không có raw SQL |
| Unauthorized access | Hasura role-based permissions trên project_task_material |
| Cross-branch data leak | Materials scoped qua task → project → department → branch (existing chain) |
| Mass delete | ON DELETE CASCADE chỉ trigger khi subtask bị xóa — confirm dialog bảo vệ |
C9) NFR
| ID | Requirement | Metric | Target |
|---|---|---|---|
| NFR-001 | Insert materials không làm chậm save subtask | Response time | < 500ms cho 10 materials |
| NFR-002 | Aggregate query task cha chạy nhanh | Response time | < 200ms cho 20 subtask × 5 materials |
| NFR-003 | Auto-fill query chạy nhanh | Response time | < 300ms |
C10) Observability
| Metric | Cách đo | Alert threshold |
|---|---|---|
project_task_material table size | pg_total_relation_size | > 10GB → investigate partition |
| Insert latency | Hasura request duration | p99 > 1s → alert |
| Aggregate query latency | Hasura request duration GetParentTaskMaterials | p99 > 500ms → alert |
C11) Tasks (Dev breakdown)
| # | Task | Estimate | Dependency |
|---|---|---|---|
| T1 | Tạo migration create_project_task_material | 0.5d | — |
| T2 | Tạo Hasura metadata (table + permissions + relationships) | 0.5d | T1 |
| T3 | Sửa project_task.yaml thêm task_materials relationship | 0.25d | T2 |
| T4 | Thêm GraphQL fragment + mutations + queries vào project.graphql | 0.5d | T2 |
| T5 | Tạo MaterialForm.tsx component (bảng editable + search) | 2d | T4 |
| T6 | Auto-fill logic: query product_relation → fill MaterialForm | 1d | T5 |
| T7 | Tích hợp MaterialForm vào TaskForm/index.tsx (isChild=true) | 1d | T5 |
| T8 | Save flow: insert/upsert/delete materials khi save subtask | 1d | T4, T7 |
| T9 | Fix button label SubtaskTable.tsx | 0.25d | — |
| T10 | Sửa delete confirm dialog (mention materials count) | 0.25d | T4 |
| T11 | Aggregate view: General.tsx + flat query + aggregate logic | 1d | T4 |
| T12 | Backward compat: hiển thị cả order_materials cũ + data mới | 0.5d | T11 |
| T13 | Readonly mode: MaterialForm khi done/canceled | 0.5d | T5 |
| T14 | Product disabled handling: filter + badge | 0.5d | T5, T6 |
| T15 | Testing + bug fix | 1d | All |
| Tổng | ~10.75d |
C12) Traceability
| FR/NFR | FE Artifact | BE Artifact | TC-ID | Status |
|---|---|---|---|---|
| FR-001 | SCR-01 MaterialForm | InsertTaskMaterials mutation | TC-FR-001 | ✅ |
| FR-002 | SCR-01 auto-fill logic | GetSubtaskMaterialConfig query | TC-FR-002 | ✅ |
| FR-003 | SCR-01 edit mode | DeleteTaskMaterialsByTaskId + InsertTaskMaterials | TC-FR-003 | ✅ |
| FR-004 | SCR-01 readonly prop | viewOnlyTaskStatus() | TC-FR-004 | ✅ |
| FR-005 | SCR-04 AggregateView | GetParentTaskMaterials query | TC-FR-005 | ✅ |
| FR-006 | SCR-04 + SCR-05 | order_materials relationship (existing) | TC-FR-006 | ✅ |
| FR-007 | SCR-03 button + dialog | — | TC-FR-007 | ✅ |
| FR-008 | SCR-01 filter + badge | disabled filter trong queries | TC-FR-008 | ✅ |
| NFR-001 | — | Insert performance | TC-NFR-001 | ✅ |
| NFR-002 | — | Aggregate query performance | TC-NFR-002 | ✅ |