Skip to content

Đặc tả kỹ thuật (Dev Spec) — Tổng hợp tài chính đơn hàng

Tham chiếu: PRD v3.0 | Ngày: 30/04/2026


Mục đích: chốt ảnh hưởng kỹ thuật, contract dữ liệu, migration, task, traceability. Đọc trước: decision-brief.mdC1) Phạm viC2) Tóm tắt ảnh hưởngC3) Quy tắc và công thứcC5) Quy ước tích hợp. Văn phong: theo templates/_LANGUAGE_RULES.md + _STYLE_GUIDE.md. Mô tả tiếng Việt; tên bảng/API/field/SQL giữ tiếng Anh.

Tài liệu đầu vào chuẩn

FileVai tròNếu xung đột
source-of-truth.mdTruth chuẩn + Phương án đã chốtƯu tiên cao nhất
evidence-pack.mdFact code/DB/config + thành phần reuseƯu tiên fact discovery
prd.mdFR, lifecycle, công thứcA8 thắng mọi formula

Dev Spec không tạo truth nghiệp vụ; C3 chỉ ghi implementation delta, ref PRD A8.


C1) Phạm vi

Modules ảnh hưởng

ModuleLoạiThay đổi
ecommerce (FE)FEOrderReceiverInfo.tsx, order.graphql, component mới ServiceOrderFinancialSummary
ecommerce-api (BE)BEAction handler get_order_financial_summary.go, hook invoice_complete.go, migration order schema
settings (FE)FESystemTable.tsx thêm group fixed_cost, permissions.ts thêm 2 action
controller (Hasura)BEactions.graphql, actions.yaml, metadata permission
pkg/store (BE)BEapp_setting.go, order.go typed access
migrationBESchema + permission seed

Không thuộc phạm vi Dev Spec

  • Định lượng vật tư + cost calculation từ inventory_document.capture (Phase 2)
  • Export Excel financial summary
  • Notification SMS/Email cho cảnh báo lỗ
  • Per-branch fixed cost rate

C2) Tóm tắt ảnh hưởng + mapping nghiệp vụ → kỹ thuật

Nghiệp vụComponent / API / HandlerĐã có / Cần thêm-sửaDữ liệu đọc/ghiĐiều kiệnNgoại lệRủi ro
Section TÀI CHÍNH trong sidebarFE: ServiceOrderFinancialSummary.tsx (NEW) mount trong OrderReceiverInfo.tsxSidebar pattern đã có; component MỚIRead action GetOrderFinancialSummaryCó ≥1 financial actionAPI timeout / 403 / 404Trung bình
Action backend aggregateBE: get_order_financial_summary.go (NEW) + register trong action.goPattern GetListTourLimit đã có; handler MỚIRead order + invoices + commissions + tour + fixed_costAuth + permissionOrder không tồn tại; permission deniedCao (cross-source)
Field setting "Tỷ lệ chi phí cố định"FE: SystemTable.tsx extend; BE: app_setting.go extend nested structSettings pattern đã có; field MỚIRead/write app_setting.order.fixed_cost.rateAdmin roleValidation 0-100Thấp
Snapshot fixed_cost vào order mớiBE: create_order.go extend object mapLogic create_order.go đã có; mở rộng objectWrite order.fixed_cost_rate, order.fixed_cost_amount=0Tạo đơn dịch vụSetting NULL → cũng NULLTrung bình (race condition setting đổi giữa create)
Hook invoice_complete update high-waterBE: invoice_complete.go extendHook đã có (parent invoice only); thêm logic updateRead paid aggregate, write order.fixed_cost_amountparent_invoice complete + rate IS NOT NULLSub-invoice skip; rate NULL skipCao (sign refund chưa rõ)
Permission seedBE: migration mới add_financial_permissions.up.sqlPattern permission_v2 đã có; thêm 2 actionInsert module_permission_action, role_module.actionsMigration deploy lần đầuIdempotent (skip nếu đã có)Thấp
Cảnh báo đơn lỗFE: LossWarningBanner.tsx (NEW) trong sectionBanner pattern chưa có dạng này; component MỚIRead profit_estimated < 0 từ action responseview_financial_pnlHide nếu chỉ summaryThấp

C3) Quy tắc và công thức (chỉ phần triển khai)

Định nghĩa nghiệp vụ → xem PRD A8. Section này chỉ ghi implementation delta.

FORMULA-001: Doanh thu

  • Tham chiếu: PRD A8 FORMULA-001
  • SQL: SELECT amount FROM ecommerce.order WHERE id = $1
  • Mapping: revenueecommerce.order.amount
  • Hiệu năng: PRIMARY KEY index, < 1ms

FORMULA-002: Đã thu

  • Tham chiếu: PRD A8 FORMULA-002
  • SQL:
sql
SELECT COALESCE(SUM(customer_paid_amount), 0) AS paid
FROM ecommerce.invoice
WHERE order_id = $1
  AND parent_id IS NULL
  AND status = 'invoice_completed';
  • Mapping: paid ← aggregate invoice.customer_paid_amount
  • Index cần: idx_invoice_order_status_parent ON ecommerce.invoice(order_id, status, parent_id) (composite)
  • Hiệu năng: < 30ms cho đơn ≤ 50 invoice
  • Lưu ý: customer_paid_amount có thể âm (negative invoice). QA blocker test 4 case (RSK-001).

FORMULA-003: Còn nợ

  • Tham chiếu: PRD A8 FORMULA-003
  • SQL: Reuse Hasura computed field get_order_debt
  • Mapping: debtorder.debt_amount
  • Hiệu năng: Đã được Hasura cache; < 5ms

FORMULA-004: Hoa hồng

  • Tham chiếu: PRD A8 FORMULA-004
  • SQL:
sql
SELECT COALESCE(SUM(amount), 0) AS commission
FROM ecommerce.order_commission
WHERE order_id = $1
  AND voided_at IS NULL;  -- nếu có field voided_at; TBD discovery
  • Mapping: commission ← aggregate order_commission.amount
  • Index cần: idx_order_commission_order_voided ON ecommerce.order_commission(order_id, voided_at) (đã có hoặc thêm)
  • Lưu ý: Cần verify field voided_at có tồn tại không qua discovery

FORMULA-005: Tour cost

  • Tham chiếu: PRD A8 FORMULA-005 + pattern GetListTourLimit
  • SQL:
sql
SELECT COALESCE(SUM(pta.tour_money), 0) AS tour_cost
FROM project.project_task_assignee pta
JOIN ecommerce.order_item oi ON oi.id = pta.order_item_id
WHERE oi.order_id = $1;
  • Cross-DB: ecommerce.order_item ↔ project.project_task_assignee
  • Index cần: idx_project_task_assignee_order_item ON project.project_task_assignee(order_item_id) (verify)
  • Hiệu năng: < 50ms cho đơn ≤ 20 items × ≤ 5 KTV
  • PD-002: Day-1 include đầy đủ tour_money (không filter tour_paid_at)

FORMULA-006: Chi phí cố định (read pre-computed)

  • Tham chiếu: PRD A8 FORMULA-006
  • SQL: SELECT fixed_cost_amount FROM ecommerce.order WHERE id = $1
  • Mapping: fixed_costorder.fixed_cost_amount (đã pre-compute qua hook)
  • Hiệu năng: < 1ms (PRIMARY KEY)
  • Lưu ý: NULL → FE ẩn dòng

FORMULA-007: Lợi nhuận tạm tính (compute in-handler)

  • Tham chiếu: PRD A8 FORMULA-007
  • Pseudocode:
go
profitEstimated := revenue - commission - tourCost - fixedCost
// All NULL handling done in Go: convert NULL → 0 nếu cần tính
  • Hiệu năng: Compute trong-memory, < 1µs

FORMULA-008: Tỷ suất

  • Tham chiếu: PRD A8 FORMULA-008
  • Pseudocode:
go
if revenue == 0 {
  marginEstimated = nil  // → FE hiện "—"
} else {
  marginEstimated := math.Round(profitEstimated/revenue*10000) / 100  // 2 chữ số thập phân
}

FORMULA-009: Update fixed_cost_amount high-water mark (FR-005)

  • Tham chiếu: PRD A8 (implicit từ DEC-012)
  • SQL trong hook invoice_complete:
sql
UPDATE ecommerce.order
SET fixed_cost_amount = GREATEST(
  COALESCE(fixed_cost_amount, 0),
  ROUND($paid_amount * fixed_cost_rate / 100)::BIGINT
)
WHERE id = $order_id
  AND fixed_cost_rate IS NOT NULL;
  • Lưu ý:
    • GREATEST(COALESCE(...), ...) đảm bảo high-water mark
    • fixed_cost_rate IS NOT NULL skip order cũ
    • Run trong cùng transaction với invoice complete để đảm bảo atomicity

STATE-001 — Vòng đời order.fixed_cost_amount

  • Tham chiếu: DEC-012 (high-water mark)
  • Object: ecommerce.orderPersistence: order.fixed_cost_amount (BIGINT, nullable)
Từ trạng tháiEventGuardSangSide effects
NULL (đơn cũ)invoice_completedfixed_cost_rate IS NOT NULL (FALSE cho đơn cũ)NULL (không đổi)
0 (đơn mới, chưa thu)invoice_completed lần đầufixed_cost_rate IS NOT NULL AND paid > 0paid × rate / 100Cập nhật DB
X > 0invoice_completed lần n (paid tăng)new_amount > Xnew_amountCập nhật DB
X > 0invoice_completed lần n (refund làm paid giảm)new_amount < XX (giữ high-water)Skip update

Ghi chú triển khai:

  • Idempotent: hook chạy lại với cùng paid → kết quả không đổi (vì GREATEST)
  • Order đã hủy: hook không chạy (hủy đã trigger void invoice trước đó)

C4) Mô hình dữ liệu

Bảng hiện có

Database: ecommerce

BảngDùng choCột khóa
orderĐơn hàngid, amount, debt_amount (computed)
invoiceHóa đơnid, order_id, parent_id, customer_paid_amount, status
order_commissionHoa hồngid, order_id, amount, voided_at (TBD)
order_itemItem của đơnid, order_id

Database: project

BảngDùng choCột khóa
project_task_assigneePhân công KTVorder_item_id, tour_money

Bảng SỬA (ecommerce.order thêm columns)

sql
ALTER TABLE ecommerce.order
ADD COLUMN fixed_cost_rate NUMERIC(5,2) NULL,
ADD COLUMN fixed_cost_amount BIGINT NULL;

COMMENT ON COLUMN ecommerce.order.fixed_cost_rate IS 'Tỷ lệ % chi phí cố định, snapshot từ app_setting.order.fixed_cost.rate khi tạo đơn. NULL = đơn cũ trước khi config.';
COMMENT ON COLUMN ecommerce.order.fixed_cost_amount IS 'Số tiền chi phí cố định, high-water mark theo paid amount cao nhất × rate. NULL = đơn cũ.';

CREATE INDEX idx_order_fixed_cost_rate ON ecommerce.order(fixed_cost_rate) WHERE fixed_cost_rate IS NOT NULL;

Bảng app_setting (extend nested JSON)

Không alter schema; mở rộng key order trong nested JSON struct theo AppSettings pattern hiện có.

json
{
  "order": {
    "tax": { "rate": 10 },
    "fixed_cost": { "rate": 15.50 }
  }
}

File ảnh hưởng: pkg/store/app_setting.go mở rộng struct:

go
type OrderSettings struct {
  Tax       TaxSettings       `json:"tax"`
  FixedCost FixedCostSettings `json:"fixed_cost"`
}

type FixedCostSettings struct {
  Rate float64 `json:"rate"` // 0-100
}

Permission seed (module_permission_action + role_module.actions)

sql
-- Seed 2 actions cho module service_order, portal admin
INSERT INTO module_permission_action (module_id, action, name, description, visible)
VALUES
  ('service_order', 'view_financial_summary', 'Xem tài chính tóm tắt', 'Xem doanh thu, đã thu, còn nợ của 1 đơn', TRUE),
  ('service_order', 'view_financial_pnl', 'Xem tài chính đầy đủ (P&L)', 'Xem chi phí, lợi nhuận, tỷ suất của 1 đơn', TRUE)
ON CONFLICT (module_id, action) DO NOTHING;

-- Default seed: Admin/BOD có cả 2 action; Manager/Kế toán/Lễ tân chỉ summary
-- (Pseudo-SQL — adjust theo schema thực)
INSERT INTO role_module (role_id, module_id, portal, actions, branch_mode)
VALUES
  ('admin', 'service_order', 'admin', '["view_financial_summary","view_financial_pnl"]', 'all'),
  ('bod', 'service_order', 'admin', '["view_financial_summary","view_financial_pnl"]', 'all'),
  ('manager', 'service_order', 'admin', '["view_financial_summary"]', 'self_branch'),
  ('accountant', 'service_order', 'admin', '["view_financial_summary"]', 'all'),
  ('receptionist', 'service_order', 'admin', '["view_financial_summary"]', 'self_branch')
ON CONFLICT (role_id, module_id, portal) DO UPDATE SET actions = EXCLUDED.actions;

C5) Quy ước tích hợp

Hasura metadata

File: metadata/databases/ecommerce/tables/public_order.yaml — không đổi schema gốc, chỉ alter ALTER cột mới

File: metadata/actions.yaml — thêm action GetOrderFinancialSummary:

yaml
- name: GetOrderFinancialSummary
  definition:
    kind: ""
    handler: '{{ECOMMERCE_API_URL}}/actions/get_order_financial_summary'
    forward_client_headers: true
    headers: []
    timeout: 5
  permissions:
    - role: user

File: metadata/actions.graphql:

graphql
type Mutation {
  GetOrderFinancialSummary(
    order_id: uuid!
  ): OrderFinancialSummaryOutput
}

type OrderFinancialSummaryOutput {
  # Always returned (if has any permission)
  order_id: uuid!
  is_cancelled: Boolean!
  currency: String!
  last_updated_at: timestamptz!
  note_pending_material: Boolean!
  # view_financial_summary fields
  revenue: bigint
  paid: bigint
  debt: bigint
  # view_financial_pnl fields (only if has pnl permission)
  commission: bigint
  tour_cost: bigint
  fixed_cost: bigint
  profit_estimated: bigint
  margin_estimated: numeric
}

GraphQL query FE

File: diva-admin/src/modules/ecommerce/graphql/order.graphql:

graphql
query GetOrderFinancialSummary($order_id: uuid!) {
  GetOrderFinancialSummary(order_id: $order_id) {
    order_id
    is_cancelled
    currency
    last_updated_at
    note_pending_material
    revenue
    paid
    debt
    commission
    tour_cost
    fixed_cost
    profit_estimated
    margin_estimated
  }
}

Hasura Action handler

Action: GetOrderFinancialSummary | Handler: POST /actions/get_order_financial_summary

Ý nghĩa nghiệp vụ: trả tổng hợp tài chính của 1 đơn dịch vụ, áp dụng phân quyền field-level dựa trên view_financial_summary + view_financial_pnl trong session JWT.

TrườngGiá trị
LoạiMutation (Hasura action style)
Input{ order_id: uuid }
OutputOrderFinancialSummaryOutput (theo schema GraphQL trên)
Auth / RBACResolve view_financial_summaryview_financial_pnl từ JWT + portal hiện tại
Điều kiện hợp lệorder tồn tại + user có ≥1 action + branch_mode match
Ngoại lệORDER_NOT_FOUND (404) / UNAUTHORIZED (403) / INTERNAL_ERROR (500)

Pseudo Go handler:

go
func GetOrderFinancialSummary(c *gin.Context) {
  orderID := input.OrderID
  user := getUserFromContext(c)
  effectivePerms := resolveEffectivePermissions(user, "service_order", user.Portal)

  if !effectivePerms.Has("view_financial_summary") && !effectivePerms.Has("view_financial_pnl") {
    return error(403, "UNAUTHORIZED")
  }

  order, err := store.GetOrderByID(ctx, orderID)
  if err != nil { return error(404, "ORDER_NOT_FOUND") }

  // Always compute summary
  paid := store.AggregatePaidParentInvoice(ctx, orderID)
  debt := order.DebtAmount

  output := &OrderFinancialSummaryOutput{
    OrderID: orderID,
    IsCancelled: order.Status == "cancelled",
    Currency: "VND",
    LastUpdatedAt: time.Now(),
    NotePendingMaterial: true,
    Revenue: order.Amount,
    Paid: paid,
    Debt: debt,
  }

  // Only fill PnL fields if has permission
  if effectivePerms.Has("view_financial_pnl") {
    commission := store.AggregateCommission(ctx, orderID)
    tourCost := store.AggregateTourCost(ctx, orderID)
    fixedCost := order.FixedCostAmount  // pre-computed, may be NULL

    output.Commission = &commission
    output.TourCost = &tourCost
    output.FixedCost = fixedCost  // pointer, NULL → omit in response

    if fixedCost != nil {
      profit := order.Amount - commission - tourCost - *fixedCost
      output.ProfitEstimated = &profit
      if order.Amount > 0 {
        margin := math.Round(float64(profit)/float64(order.Amount)*10000) / 100
        output.MarginEstimated = &margin
      }
    }
  }

  return c.JSON(200, output)
}

Event trigger / hook

LoạiTriggerNguồnHandlerIdempotency
Eventinvoice_complete (parent only)ecommerce.invoice status='invoice_completed'invoice_complete.go extend với UPDATE high-water markGREATEST đảm bảo idempotent

Bảng mã lỗi (M+)

Tình huống nghiệp vụError codeHTTPFE xử lýCopy hiển thị
Order không tồn tạiORDER_NOT_FOUND404Section không mount"Đơn không tồn tại" (parent guard)
Không có quyềnUNAUTHORIZED403ẨN section, không error toast(không hiện gì)
Internal error / DB timeoutINTERNAL_ERROR500Inline error + nút Thử lại"Không thể tải tài chính. Vui lòng thử lại."
Validation (rate sai range)VALIDATION_ERROR200 (GraphQL error)Hiện lỗi tại field"Tỷ lệ phải trong khoảng 0-100%"

C6) Component frontend

Cấu trúc file

diva-admin/src/modules/ecommerce/
├── components/order/
│   ├── OrderReceiverInfo.tsx                  ← EDIT (mount section mới)
│   └── financial-summary/                     ← NEW folder
│       ├── ServiceOrderFinancialSummary.tsx   ← NEW main component
│       ├── LossWarningBanner.tsx              ← NEW banner cảnh báo
│       └── style.scss                         ← NEW
├── graphql/order.graphql                       ← EDIT (thêm query)
└── shared/permissions.ts                       ← EDIT (thêm 2 action constants)

Logic lõi (TypeScript pseudocode)

typescript
// ServiceOrderFinancialSummary.tsx
import { useQuery } from '@urql/vue'
import { computed } from 'vue'
import { GetOrderFinancialSummaryDocument } from '@/modules/ecommerce/graphql'
import { useGlobalStore } from '@/stores/useGlobalStore'

const props = defineProps<{ orderId: string }>()
const globalStore = useGlobalStore()

const hasSummary = computed(() => globalStore.hasPermission('service_order', 'view_financial_summary'))
const hasPnL = computed(() => globalStore.hasPermission('service_order', 'view_financial_pnl'))
const hasAnyAccess = computed(() => hasSummary.value || hasPnL.value)

if (!hasAnyAccess.value) {
  // Component không render
  return null
}

const { data, fetching, error, executeQuery } = useQuery({
  query: GetOrderFinancialSummaryDocument,
  variables: { order_id: props.orderId },
  pause: !hasAnyAccess.value,
})

const showLossBanner = computed(() =>
  hasPnL.value &&
  data.value?.GetOrderFinancialSummary?.profit_estimated != null &&
  data.value.GetOrderFinancialSummary.profit_estimated < 0
)
vue
<!-- Template -->
<XCard v-if="hasAnyAccess" class="financial-summary-section">
  <template #header>
    <div class="title">TÀI CHÍNH <q-icon name="info" /></div>
  </template>

  <q-skeleton v-if="fetching" type="rect" :height="120" />
  <div v-else-if="error" class="error-state">
    <p>Không thể tải tài chính. Vui lòng thử lại.</p>
    <q-btn label="Thử lại" @click="executeQuery({ requestPolicy: 'network-only' })" />
  </div>
  <template v-else>
    <LossWarningBanner v-if="showLossBanner" :loss-amount="-data.profit_estimated" />
    <q-badge v-if="data.is_cancelled" color="grey-7">🚫 Đơn đã hủy — chỉ đối soát</q-badge>

    <!-- Summary rows (3) -->
    <FinancialRow label="Doanh thu" :value="data.revenue" />
    <FinancialRow label="Đã thu" :value="data.paid" :allow-negative="true" />
    <FinancialRow label="Còn nợ" :value="data.debt" />

    <!-- PnL rows (5, only if hasPnL) -->
    <template v-if="hasPnL">
      <q-separator />
      <FinancialRow label="Hoa hồng" :value="data.commission" />
      <FinancialRow label="Tour cost" :value="data.tour_cost" />
      <FinancialRow v-if="data.fixed_cost != null" label="Chi phí cố định" :value="data.fixed_cost" />
      <FinancialRow label="Lợi nhuận tạm tính" :value="data.profit_estimated" :loss-style="data.profit_estimated < 0" />
      <FinancialRow label="Tỷ suất" :value="data.margin_estimated" format="percent" />
      <p v-if="data.note_pending_material" class="note-pending">
        ℹ Chi phí vật tư đang phát triển — chưa cộng vào lợi nhuận tạm tính.
      </p>
    </template>
  </template>
</XCard>

C7) Chiến lược migration

DBMigration mới nhấtMigration mới
ecommerce1761808767073_perm_v220260430_120000_add_order_financial_columns_and_permissions

Thứ tự deploy:

  1. Migration up: ALTER TABLE ecommerce.order ADD fixed_cost_rate, fixed_cost_amount + INSERT module_permission_action + INSERT/UPDATE role_module
  2. Hasura metadata apply: action permission + alter column metadata
  3. BE deploy: handler GetOrderFinancialSummary + hook update invoice_complete + create_order extend
  4. FE deploy: component mới + GraphQL codegen + permission constants

Rollback:

  • down.sql: xóa 2 cột, xóa 2 action, revert role_module seed
  • Hasura metadata revert
  • FE rollback bundle previous version

Test rollback trên staging: sau apply up + tạo đơn mới + complete invoice → apply down → kiểm tra không có lỗi (cột bị xóa nhưng đơn vẫn hoạt động bình thường).


C8) Bảo mật

Ma trận Dynamic Permission

ModulePortalActionÝ nghĩaDefault seedView mode / Field access
service_orderadminview_financial_summaryXem doanh thu/đã thu/còn nợAdmin, BOD, Manager (self_branch), Kế toán (all), Lễ tân (self_branch)summary fields only
service_orderadminview_financial_pnlXem chi phí + lợi nhuận + tỷ suấtAdmin, BODfull P&L fields
service_orderpos/crm/staff(chưa cấp day-1)— (có thể cấp sau qua Dynamic Permission)
app_settingadminupdate (đã có)Update settingsAdmin

Permission migration / metadata delta

Thành phầnDeltaGhi chú
module_permission_actionINSERT 2 action với visible=TRUEPhải hiện trong Dynamic Permission UI
FE permissions.tsThêm constants VIEW_FINANCIAL_SUMMARY, VIEW_FINANCIAL_PNLTránh mismatch TS/runtime
role_module.actions seedCấp default theo bảng default seedDefault seed, đổi được qua UI
Hasura metadataAction permission role:user (Hasura layer chỉ là transport)Permission thật ở BE handler

Backend enforcement

  • BE handler get_order_financial_summary.go resolve effective permission từ JWT + portal + module
  • Field PnL bị loại khỏi response struct (không phải null) nếu user không có view_financial_pnl — least-data
  • Permission revoke → request kế tiếp ngay sau cache TTL (60s) → field bị ẩn
  • Branch_mode self_branch (Manager/Lễ tân): chỉ cho xem đơn của branch hiện tại; vi phạm → 403

Hasura Row-Level Security

BảngVai tròSelectInsertUpdateDelete
ecommerce.orderuser(đã có filter branch theo branch_id)
ecommerce.invoiceuser(đã có filter qua order)

Permission tài chính field-level KHÔNG enforce ở Hasura RLS (vì Hasura RLS không hỗ trợ field-level dynamic). Enforce ở BE action handler.

Quy tắc bảo mật quan trọng

  1. DEC-003: Field PnL phải bị loại khỏi response struct, không trả null. Tránh leak bằng cách inspect response.
  2. DEC-016: KHÔNG tạo màn permission riêng — reuse Dynamic Permission UI để Admin cấp/thu hồi.
  3. Portal isolation: action service_order.view_financial_pnl ở admin KHÔNG tự động có ở POS — phải cấp riêng.

C9) Phi chức năng + Quan sát vận hành

Hiệu năng + index

Chỉ sốMục tiêuĐo bằngIndex hỗ trợ
GetOrderFinancialSummary action P95 latency< 200msGrafana / EXPLAIN ANALYZEidx_invoice_order_status_parent, idx_order_commission_order, idx_pta_order_item
Hook invoice_complete UPDATE< 50msEXPLAIN ANALYZEPK order.id
FE component mount → first render< 1.5s P95Sentry performanceAPI + render

Log / Metric / Alert

LoạiTên / EventKhi nàoMục đích / Ngưỡng
Logfinancial_summary.fetch (INFO)Mỗi action call{order_id, user_id, has_pnl, latency_ms}
Logfinancial_summary.permission_denied (WARN)403 response{order_id, user_id, reason} — audit
Logfixed_cost.high_water_updated (INFO)Hook update fixed_cost_amount{order_id, old, new, paid}
Metricorder_financial_summary_latency_seconds (Histogram)Mỗi requestlabels: permission_level (summary/pnl)
Metricorder_financial_summary_total (Counter)Mỗi requestlabels: result (success/unauthorized/error)
AlertFinancialSummaryHighLatencyP95 > 500ms trong 10 phútWarning → #ops-spa Slack
AlertFinancialSummaryErrorSpike5xx rate > 5% trong 5 phútCritical → #ops-spa Slack + on-call

Capacity (chỉ khi > 100K records)

Chỉ sốHiện tại1 nămGhi chú
Tổng order/ngày~5.000~10.000Action call ~50.000/ngày
Hook invoice_complete/ngày~3.000~6.000UPDATE order high-water
DB loadThấpTrung bìnhIndex composite đủ

C10) Danh sách việc triển khai

Giai đoạn 1 — Foundation (3 ngày)

#TaskƯớc lượngPhụ thuộcPhụ trách
P1-01Migration up: ALTER order schema + 2 columns + index0.5dBE
P1-02Migration up: INSERT 2 module_permission_action + seed role_module0.5dP1-01BE
P1-03Mở rộng pkg/store/app_setting.go nested struct FixedCostSettings0.5dBE
P1-04Test rollback down.sql trên staging0.5dP1-01, P1-02BE

Giai đoạn 2 — Backend action (4 ngày)

#TaskƯớc lượngPhụ thuộcPhụ trách
P2-01Build get_order_financial_summary.go handler1.5dP1-01BE
P2-02Register action trong action.go + Hasura actions.yaml/.graphql0.5dP2-01BE
P2-03Extend create_order.go snapshot fixed_cost_rate0.5dP1-01, P1-03BE
P2-04Extend invoice_complete.go hook high-water mark1dP1-01BE
P2-05Unit test handler + hook (Go test)0.5dP2-01, P2-04BE

Giai đoạn 3 — Frontend (4 ngày)

#TaskƯớc lượngPhụ thuộcPhụ trách
P3-01pnpm codegen sau khi metadata apply0.25dP2-02FE
P3-02Component ServiceOrderFinancialSummary.tsx + FinancialRow + LossWarningBanner1.5dP3-01FE
P3-03Mount trong OrderReceiverInfo.tsx đúng vị trí0.5dP3-02FE
P3-04Mở rộng SystemTable.tsx field "Tỷ lệ chi phí cố định (%)"1dP1-03FE
P3-05Permission constants + useGlobalStore integration0.25dP1-02FE
P3-06i18n + tooltip dictionary0.5dP3-02FE

Giai đoạn 4 — QA + Go-Live (3 ngày)

#TaskƯớc lượngPhụ thuộcPhụ trách
P4-01QA test theo D2 + 4 negative invoice case2dP3-*QA
P4-02Smoke staging + verify performance < 200ms P950.5dP4-01TL + BE
P4-03Deploy production + monitoring 48h0.5dP4-02TL + Ops

Sơ đồ phụ thuộc

Migration (P1-01,02)─┐
                     ├─→ BE handler (P2-01,02)─┐
Store extend (P1-03)─┤                          ├─→ FE codegen (P3-01)
                     ├─→ create_order (P2-03)──┤              │
Rollback test (P1-04)─┘                          │              ├─→ FE component (P3-02,03)
                       Hook (P2-04)─────────────┤              ├─→ FE settings (P3-04)
                       Unit test (P2-05)─────────┘              ├─→ Permission FE (P3-05)
                                                                ├─→ i18n (P3-06)

                                                                └─→ QA (P4-01)─→ Smoke (P4-02)─→ Deploy (P4-03)

Tổng: 14 ngày (~3 tuần làm việc)


C11) Truy vết

FRComponent FEArtifact BETC
FR-001ServiceOrderFinancialSummary.tsx, LossWarningBanner.tsxget_order_financial_summary.goTC-001-*
FR-002(consumed by FR-001)get_order_financial_summary.goTC-002-*
FR-003SystemTable.tsx extendapp_setting.go extendTC-003-*
FR-004create_order.go extendTC-004-*
FR-005invoice_complete.go extendTC-005-*
FR-006permissions.ts constantsMigration permission seedTC-PERM-*
FR-007LossWarningBanner.tsx(logic in handler)TC-007-*

Mapping quyết định → triển khai

DECCách triển khaiCách kiểm chứng
DEC-001 (action backend)Handler get_order_financial_summary.go, không FE aggregateCode review + load test 100 đơn concurrent
DEC-003 (Dynamic Permission)Migration seed + BE resolve effective permissionTC-PERM-01 đến TC-PERM-05
DEC-006 (vật tư pending)Field note_pending_material=true trong response, FE hiện note xámTC-006-01
DEC-012 (high-water mark)SQL GREATEST(COALESCE, ...) trong hookTC-005-03 (refund không giảm)
DEC-015 (cảnh báo lỗ chỉ pnl)FE showLossBanner = hasPnL && profit_estimated < 0TC-007-01, TC-007-02
DEC-016 (reuse Permission UI)Không tạo screen mớiVerify không có route /s/financial-permission