Skip to content

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 — khi project.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.tsx hiển thị read-only, lấy data từ order_materials trê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ận task_id + products[], tạo Order internal_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ầnStatusGhi 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.

MetricGiá trịRisk
Relationships25+ (13 array, 5 object, 11 remote cross-DB)Medium
Event triggers4 (insert, delete, 2x update)Medium
Computed fields3 (code, missed, sort_status_value)Low
Remote relationships11 (3 default, 7 ecommerce, 1 hrm)Medium
Indexes11 (bao gồm 2 cặp duplicate trên project_id, parent_id)Cần cleanup
PartitionKhông cóCritical cho scale
CachingKhông có materialized viewsMedium

2. Quyết định thiết kế

#Quyết địnhLý do
D1Vật tư gắn ở cấp subtask, không phải task chaMỗi subtask (công việc con) có vật tư riêng, phản ánh đúng thực tế spa
D2Tạo bảng mới project_task_material thay vì dùng bảng orderTách "dự kiến" vs "đã xuất kho", không mix data khi kho chưa đúng
D3Auto-fill vật tư từ service config + cho phép tìm kiếm thêm mớiTiết kiệm thời gian KTV, vẫn linh hoạt
D4Task cha hiển thị aggregate vật tư từ tất cả subtask conManager cần nhìn tổng mà không phải click từng subtask
D5Vật tư nằm trong form subtask, không có button riêng ở bảng ngoàiTránh confusion "thêm vật tư cho subtask nào?"
D6Chưa làm xuất kho (phase 2)Kho chưa đúng, ưu tiên subtask đúng trước
D7Denormalize product info vào project_task_materialGiảm remote join cross-DB khi aggregate ở task cha (review R3)
D8Flat query cho aggregate thay vì nested qua subtaskTránh N+1 khi task cha có nhiều subtask (review R1)
D9Không thêm event trigger lên project_taskBả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 kho

3.2. Chi tiết flow tiền tour hiện tại (không thay đổi)

  1. Chọn subtask definition → subtask_tags[].tag.tag_tour_moneys[]
  2. Map theo user.profile.level (seniority) → tour_money per assignee
  3. Supervisor → tour_money = 0 (luôn luôn)
  4. Ghi vào project_task_assignee.tour_money
  5. Khi task complete → backend copy sang ecommerce_task_log.tour_fee
  6. Salary: user_salary.total_salary += tour_money

3.3. Bằng chứng không coupling

Kiểm traKết quả
project_task_assignee_insert.goChỉ đọc tag_tour_moneys, không đọc materials
project_task_assignee_update.goUpdate ecommerce_task_log.tour_fee từ assignee.tour_money, không đọc materials
task_log.go commission flowChỉ dùng assignee.tour_money, không reference materials
report_tour_income viewJOIN task + assignee, không JOIN materials
Database FKKhông có FK giữa project_task_materialproject_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_at range 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: public

Lưu ý (D9): KHÔNG thêm event trigger trên project_task cho materials. Bảng project_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ùng product remote 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_material với filter task.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ại
  • subtaskDefinitionId?: string — ID subtask definition để auto-fill
  • readonly?: 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:

  1. Khi subtaskDefinitionId thay đổi (user chọn subtask definition)
  2. Query product_relation WHERE subtask_id = subtaskDefinitionId AND related_product.disabled IS NOT TRUE (include related_product { name, sku, product_unit })
  3. Map kết quả → TaskMaterialItem[] (ghi cả denormalized fields), fill vào bảng
  4. 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 denormalized product_name + check product.disabled qua remote relationship khi load form sửa).

Readonly mode (E3):

  • Khi subtask status = done_* hoặc canceled_*readonly = true
  • Ẩn icon 🗑, disable input SL, ẩn search bar
  • Logic: viewOnlyTaskStatus(subtask.status_id) (hàm có sẵn trong TaskForm/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/unit từ 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):

  1. Lưu project_task (subtask) trước → nhận task_id
  2. Gọi InsertTaskMaterials với task_id + danh sách materials (bao gồm denormalized fields)

Khi lưu subtask (sửa):

  1. Load task_materials từ fragment ProjectTask → hiển thị trong MaterialForm
  2. Khi lưu:
    • Items mới/sửa → InsertTaskMaterials (upsert on_conflict)
    • Items bị xóa → DeleteTaskMaterial per item, hoặc DeleteTaskMaterialsByTaskId rồi re-insert all (simpler)

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_material aggregate (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_material aggregate → hiển thị bảng "VẬT TƯ DỰ KIẾN" (cột: SL dự kiến)
  • Nếu có order_materials trê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

LayerFileThay đổiMức độ
DBMigration mới create_project_task_materialTạo bảng + indexes + denormalized columnsNew
Hasurapublic_project_task_material.yamlMetadata bảng mới (permissions, relationships)New
Hasurapublic_project_task.yamlThêm relationship task_materialsSmall
GraphQLproject.graphqlThêm task_materials fragment + mutations + aggregate querySmall
FESubtaskTable.tsxFix label button + sửa delete confirm dialog (E1)Small
FETaskForm/MaterialForm.tsxComponent mới: bảng vật tư editable + auto-fill + search + readonly mode (E3)New
FETaskForm/index.tsxThêm section MaterialForm khi isChild=true với readonly propMedium
FETaskDetail/General.tsxHiển thị cả data mới (aggregate) + data cũ (order_materials) (E4)Medium
FETaskDetail/MaterialTable.tsxGiữ nguyên cho backward compat + thêm mode mới cho aggregate viewSmall

7.2. Không ảnh hưởng (giữ nguyên)

  • Tiền tour flow: tag_tour_moneysassignee.tour_moneytask_log.tour_fee → salary (ZERO coupling)
  • requestMaterial backend action (Go) → dùng ở phase 2
  • order_materials relationship → giữ, dùng cho SL thực tế phase 2
  • product_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_material

9. Phase 2 — Xuất kho (ngoài scope hiện tại)

Khi hệ thống kho ổn định:

  1. Subtask detail → thêm nút "Xác nhận xuất kho"
  2. Đọc project_task_material → gọi requestMaterial(task_id=subtask_id, products=[...])
  3. Backend tạo Order internal_material, check tồn kho
  4. MaterialTable hiển thị "SL thực tế" từ order_materials
  5. 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

MetricMinMax1 năm
Subtask/ngày5,00030,0001.8M-10.9M
Materials/ngày (×3 avg)15,00090,0005.4M-32.8M
Bảng project_task_material sau 1 năm5.4M32.8M records

10.2. Query patterns & Indexes

Query patternSử dụng khiIndex coverage
WHERE task_id = XXem/sửa subtask → load materialsidx_task_material_task_id
WHERE task.parent_id = XAggregate ở task chatask_id index + project_task.parent_id index
Upsert ON CONFLICT (task_id, product_id)Lưu form subtaskUNIQUE constraint index

10.3. Tối ưu hóa đã áp dụng

Kỹ thuậtMục đíchImpact
Denormalize product_name/sku/unitLoại bỏ remote join khi aggregateGiảm từ N remote queries → 0
Flat query thay vì nestedTránh N+1 qua 2 tầng (subtask → materials → product)1 query thay vì N+1
Không thêm event triggerGiữ project_task latency ổn địnhKhông tăng trigger chain
ON DELETE CASCADETự cleanup khi xóa subtaskKhông cần application-level cleanup
UNIQUE constraintHỗ trợ upsert, tránh duplicateBuilt-in dedup

10.4. Khuyến nghị tương lai (ngoài scope)

  • Partition bảng project_task_material theo created_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 GetParentTaskMaterials sau deploy

11. Ghi chú kỹ thuật

  • project_task_material đặt ở project DB (cùng DB với project_task) → tránh cross-database write
  • UNIQUE constraint (task_id, product_id) cho phép upsert khi lưu form
  • ON DELETE CASCADE trên task_id → xóa subtask tự xóa materials
  • Auto-fill query product_relation chỉ 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_id dùng denormalized fields, không cần remote join
  • product remote relationship trên project_task_material vẫ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 CaseXử lýĐã cover trong section
E1Xóa subtask → user không biết materials bị xóa theoDelete confirm dialog hiển thị số vật tư6.5
E2Product bị disable sau khi đã thêm vào materialsAuto-fill filter disabled IS NOT TRUE + badge "Ngưng KD" cho materials cũ6.2
E3Sửa materials trên subtask done/canceledMaterialForm readonly mode theo viewOnlyTaskStatus()6.2, 6.3
E4Data 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 đổi6.6
E5Product bị hard-delete sau khi lưu materialsDenormalized fields (product_name/sku/unit) vẫn hiển thị, product_id orphaned nhưng không crash4.1
E6Subtask definition không có vật tư mẫuAuto-fill trả về empty → user tự search thêm6.2
E7Thêm cùng product 2 lần vào 1 subtaskUNIQUE(task_id, product_id) → upsert cập nhật quantity4.1
E8Service thay đổi product_relation sau khi subtask đã tạoSubtask giữ materials đã lưu, không auto-sync. By design11
E92 users sửa materials cùng subtask cùng lúcLast-write-wins (không có optimistic locking). Accept risk — tương đồng behavior hiện tại toàn hệ thốngAccepted
E10Xóa subtask cuối cùng → parent auto-done → aggregate trốngCASCADE 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

RiskSeverityReason accepted
Concurrent editing (last-write-wins)MediumTương đồng behavior hiện tại, cost > benefit để thêm optimistic lock
Denormalized product info staleLowProduct hiếm khi đổi tên/SKU, chỉ ảnh hưởng display
product_relation.subtask_id nullable (no FK)LowManual config relationship trong Hasura, đã hoạt động ổn định
No audit log cho material changesLowPhase 1 focus correctness, audit log thêm sau nếu cần