Skip to content

Dev Spec: Quản lý vật tư theo Subtask

Feature slug: subtask-materialVersion: 1.0 Ngày: 2026-03-26


C1) Scope

LoạiNội dung
In scopeBảng project_task_material, MaterialForm component, auto-fill, aggregate, fix button, backward compat
Out of scopeXuất kho (requestMaterial), notification, audit log, mobile

C2) Impact

LayerFileThay đổi
DBMigration mớiCREATE TABLE project_task_material + indexes
Hasurapublic_project_task_material.yamlMetadata mới: permissions, relationships
Hasurapublic_project_task.yamlThêm array relationship task_materials
GraphQLproject.graphqlFragment + mutations + aggregate query
FETaskForm/MaterialForm.tsxComponent mới
FETaskForm/index.tsxThêm MaterialForm khi isChild=true
FETaskDetail/SubtaskTable.tsxFix label + delete confirm
FETaskDetail/General.tsxAggregate view + backward compat
FETaskDetail/MaterialTable.tsxGiữ 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.quantity
    • parent_task_id: ID task cha — nguồn: route params hoặc props
  • Đơn vị: Theo product_unit củ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: public

GraphQL 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

ScenarioErrorHTTP/GraphQLFE handling
task_id không tồn tạiFK violation200 (GraphQL error)Toast "Công việc không tồn tại"
Duplicate (task_id, product_id)UNIQUE violation → upsert200 (success via on_conflict)Transparent — upsert handles
product_id không tồn tại (remote)Insert succeeds (no FK check cross-DB)200OK — denormalized data vẫn hiển thị
quantity <= 0FE validation blockN/AInline error "SL phải > 0"
UnauthorizedHasura permission deny200 (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

  1. New: metadata/databases/project/tables/public_project_task_material.yaml (content từ C5)
  2. Edit: metadata/databases/project/tables/public_project_task.yaml — thêm task_materials relationship
  3. Edit: metadata/databases/project/tables/tables.yaml — thêm entry public.project_task_material

Validation checklist

  • [ ] Migration timestamp > latest existing migration trong project DB
  • [ ] project_task_material table naming follows convention (snake_case, prefix-less trong project DB)
  • [ ] FK task_id references project_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_*.yaml files

C8) Security

ConcernMitigation
SQL injectionHasura parameterized queries — không có raw SQL
Unauthorized accessHasura role-based permissions trên project_task_material
Cross-branch data leakMaterials scoped qua task → project → department → branch (existing chain)
Mass deleteON DELETE CASCADE chỉ trigger khi subtask bị xóa — confirm dialog bảo vệ

C9) NFR

IDRequirementMetricTarget
NFR-001Insert materials không làm chậm save subtaskResponse time< 500ms cho 10 materials
NFR-002Aggregate query task cha chạy nhanhResponse time< 200ms cho 20 subtask × 5 materials
NFR-003Auto-fill query chạy nhanhResponse time< 300ms

C10) Observability

MetricCách đoAlert threshold
project_task_material table sizepg_total_relation_size> 10GB → investigate partition
Insert latencyHasura request durationp99 > 1s → alert
Aggregate query latencyHasura request duration GetParentTaskMaterialsp99 > 500ms → alert

C11) Tasks (Dev breakdown)

#TaskEstimateDependency
T1Tạo migration create_project_task_material0.5d
T2Tạo Hasura metadata (table + permissions + relationships)0.5dT1
T3Sửa project_task.yaml thêm task_materials relationship0.25dT2
T4Thêm GraphQL fragment + mutations + queries vào project.graphql0.5dT2
T5Tạo MaterialForm.tsx component (bảng editable + search)2dT4
T6Auto-fill logic: query product_relation → fill MaterialForm1dT5
T7Tích hợp MaterialForm vào TaskForm/index.tsx (isChild=true)1dT5
T8Save flow: insert/upsert/delete materials khi save subtask1dT4, T7
T9Fix button label SubtaskTable.tsx0.25d
T10Sửa delete confirm dialog (mention materials count)0.25dT4
T11Aggregate view: General.tsx + flat query + aggregate logic1dT4
T12Backward compat: hiển thị cả order_materials cũ + data mới0.5dT11
T13Readonly mode: MaterialForm khi done/canceled0.5dT5
T14Product disabled handling: filter + badge0.5dT5, T6
T15Testing + bug fix1dAll
Tổng~10.75d

C12) Traceability

FR/NFRFE ArtifactBE ArtifactTC-IDStatus
FR-001SCR-01 MaterialFormInsertTaskMaterials mutationTC-FR-001
FR-002SCR-01 auto-fill logicGetSubtaskMaterialConfig queryTC-FR-002
FR-003SCR-01 edit modeDeleteTaskMaterialsByTaskId + InsertTaskMaterialsTC-FR-003
FR-004SCR-01 readonly propviewOnlyTaskStatus()TC-FR-004
FR-005SCR-04 AggregateViewGetParentTaskMaterials queryTC-FR-005
FR-006SCR-04 + SCR-05order_materials relationship (existing)TC-FR-006
FR-007SCR-03 button + dialogTC-FR-007
FR-008SCR-01 filter + badgedisabled filter trong queriesTC-FR-008
NFR-001Insert performanceTC-NFR-001
NFR-002Aggregate query performanceTC-NFR-002