Appearance
Đặ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.md→C1) Phạm vi→C2) Tóm tắt ảnh hưởng→C3) Quy tắc và công thức→C5) Quy ước tích hợp. Văn phong: theotemplates/_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
| File | Vai trò | Nếu xung đột |
|---|---|---|
source-of-truth.md | Truth chuẩn + Phương án đã chốt | Ưu tiên cao nhất |
evidence-pack.md | Fact code/DB/config + thành phần reuse | Ưu tiên fact discovery |
prd.md | FR, lifecycle, công thức | A8 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
| Module | Loại | Thay đổi |
|---|---|---|
| ecommerce (FE) | FE | OrderReceiverInfo.tsx, order.graphql, component mới ServiceOrderFinancialSummary |
| ecommerce-api (BE) | BE | Action handler get_order_financial_summary.go, hook invoice_complete.go, migration order schema |
| settings (FE) | FE | SystemTable.tsx thêm group fixed_cost, permissions.ts thêm 2 action |
| controller (Hasura) | BE | actions.graphql, actions.yaml, metadata permission |
| pkg/store (BE) | BE | app_setting.go, order.go typed access |
| migration | BE | Schema + 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ửa | Dữ liệu đọc/ghi | Điều kiện | Ngoại lệ | Rủi ro |
|---|---|---|---|---|---|---|
| Section TÀI CHÍNH trong sidebar | FE: ServiceOrderFinancialSummary.tsx (NEW) mount trong OrderReceiverInfo.tsx | Sidebar pattern đã có; component MỚI | Read action GetOrderFinancialSummary | Có ≥1 financial action | API timeout / 403 / 404 | Trung bình |
| Action backend aggregate | BE: get_order_financial_summary.go (NEW) + register trong action.go | Pattern GetListTourLimit đã có; handler MỚI | Read order + invoices + commissions + tour + fixed_cost | Auth + permission | Order không tồn tại; permission denied | Cao (cross-source) |
| Field setting "Tỷ lệ chi phí cố định" | FE: SystemTable.tsx extend; BE: app_setting.go extend nested struct | Settings pattern đã có; field MỚI | Read/write app_setting.order.fixed_cost.rate | Admin role | Validation 0-100 | Thấp |
| Snapshot fixed_cost vào order mới | BE: create_order.go extend object map | Logic create_order.go đã có; mở rộng object | Write order.fixed_cost_rate, order.fixed_cost_amount=0 | Tạo đơn dịch vụ | Setting NULL → cũng NULL | Trung bình (race condition setting đổi giữa create) |
| Hook invoice_complete update high-water | BE: invoice_complete.go extend | Hook đã có (parent invoice only); thêm logic update | Read paid aggregate, write order.fixed_cost_amount | parent_invoice complete + rate IS NOT NULL | Sub-invoice skip; rate NULL skip | Cao (sign refund chưa rõ) |
| Permission seed | BE: migration mới add_financial_permissions.up.sql | Pattern permission_v2 đã có; thêm 2 action | Insert module_permission_action, role_module.actions | Migration deploy lần đầu | Idempotent (skip nếu đã có) | Thấp |
| Cảnh báo đơn lỗ | FE: LossWarningBanner.tsx (NEW) trong section | Banner pattern chưa có dạng này; component MỚI | Read profit_estimated < 0 từ action response | Có view_financial_pnl | Hide nếu chỉ summary | Thấ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:
revenue←ecommerce.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← aggregateinvoice.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_amountcó 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:
debt←order.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← aggregateorder_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_atcó 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 filtertour_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_cost←order.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 markfixed_cost_rate IS NOT NULLskip 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.order— Persistence:order.fixed_cost_amount(BIGINT, nullable)
| Từ trạng thái | Event | Guard | Sang | Side effects |
|---|---|---|---|---|
NULL (đơn cũ) | invoice_completed | fixed_cost_rate IS NOT NULL (FALSE cho đơn cũ) | NULL (không đổi) | — |
0 (đơn mới, chưa thu) | invoice_completed lần đầu | fixed_cost_rate IS NOT NULL AND paid > 0 | paid × rate / 100 | Cập nhật DB |
X > 0 | invoice_completed lần n (paid tăng) | new_amount > X | new_amount | Cập nhật DB |
X > 0 | invoice_completed lần n (refund làm paid giảm) | new_amount < X | X (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ảng | Dùng cho | Cột khóa |
|---|---|---|
order | Đơn hàng | id, amount, debt_amount (computed) |
invoice | Hóa đơn | id, order_id, parent_id, customer_paid_amount, status |
order_commission | Hoa hồng | id, order_id, amount, voided_at (TBD) |
order_item | Item của đơn | id, order_id |
Database: project
| Bảng | Dùng cho | Cột khóa |
|---|---|---|
project_task_assignee | Phân công KTV | order_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
ordertrong nested JSON struct theoAppSettingspattern 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: userFile: 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ường | Giá trị |
|---|---|
| Loại | Mutation (Hasura action style) |
| Input | { order_id: uuid } |
| Output | OrderFinancialSummaryOutput (theo schema GraphQL trên) |
| Auth / RBAC | Resolve view_financial_summary ∨ view_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ại | Trigger | Nguồn | Handler | Idempotency |
|---|---|---|---|---|
| Event | invoice_complete (parent only) | ecommerce.invoice status='invoice_completed' | invoice_complete.go extend với UPDATE high-water mark | GREATEST đảm bảo idempotent |
Bảng mã lỗi (M+)
| Tình huống nghiệp vụ | Error code | HTTP | FE xử lý | Copy hiển thị |
|---|---|---|---|---|
| Order không tồn tại | ORDER_NOT_FOUND | 404 | Section không mount | "Đơn không tồn tại" (parent guard) |
| Không có quyền | UNAUTHORIZED | 403 | ẨN section, không error toast | (không hiện gì) |
| Internal error / DB timeout | INTERNAL_ERROR | 500 | Inline 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_ERROR | 200 (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
| DB | Migration mới nhất | Migration mới |
|---|---|---|
ecommerce | 1761808767073_perm_v2 | 20260430_120000_add_order_financial_columns_and_permissions |
Thứ tự deploy:
- Migration up:
ALTER TABLE ecommerce.order ADD fixed_cost_rate, fixed_cost_amount+INSERT module_permission_action+INSERT/UPDATE role_module - Hasura metadata apply: action permission + alter column metadata
- BE deploy: handler
GetOrderFinancialSummary+ hook updateinvoice_complete+ create_order extend - 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
| Module | Portal | Action | Ý nghĩa | Default seed | View mode / Field access |
|---|---|---|---|---|---|
service_order | admin | view_financial_summary | Xem doanh thu/đã thu/còn nợ | Admin, BOD, Manager (self_branch), Kế toán (all), Lễ tân (self_branch) | summary fields only |
service_order | admin | view_financial_pnl | Xem chi phí + lợi nhuận + tỷ suất | Admin, BOD | full P&L fields |
service_order | pos/crm/staff | (chưa cấp day-1) | — | — | — (có thể cấp sau qua Dynamic Permission) |
app_setting | admin | update (đã có) | Update settings | Admin | — |
Permission migration / metadata delta
| Thành phần | Delta | Ghi chú |
|---|---|---|
module_permission_action | INSERT 2 action với visible=TRUE | Phải hiện trong Dynamic Permission UI |
FE permissions.ts | Thêm constants VIEW_FINANCIAL_SUMMARY, VIEW_FINANCIAL_PNL | Tránh mismatch TS/runtime |
role_module.actions seed | Cấp default theo bảng default seed | Default seed, đổi được qua UI |
| Hasura metadata | Action permission role:user (Hasura layer chỉ là transport) | Permission thật ở BE handler |
Backend enforcement
- BE handler
get_order_financial_summary.goresolve 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ảng | Vai trò | Select | Insert | Update | Delete |
|---|---|---|---|---|---|
ecommerce.order | user | (đã có filter branch theo branch_id) | — | — | — |
ecommerce.invoice | user | (đã 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
- 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. - DEC-016: KHÔNG tạo màn permission riêng — reuse Dynamic Permission UI để Admin cấp/thu hồi.
- 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ằng | Index hỗ trợ |
|---|---|---|---|
GetOrderFinancialSummary action P95 latency | < 200ms | Grafana / EXPLAIN ANALYZE | idx_invoice_order_status_parent, idx_order_commission_order, idx_pta_order_item |
Hook invoice_complete UPDATE | < 50ms | EXPLAIN ANALYZE | PK order.id |
| FE component mount → first render | < 1.5s P95 | Sentry performance | API + render |
Log / Metric / Alert
| Loại | Tên / Event | Khi nào | Mục đích / Ngưỡng |
|---|---|---|---|
| Log | financial_summary.fetch (INFO) | Mỗi action call | {order_id, user_id, has_pnl, latency_ms} |
| Log | financial_summary.permission_denied (WARN) | 403 response | {order_id, user_id, reason} — audit |
| Log | fixed_cost.high_water_updated (INFO) | Hook update fixed_cost_amount | {order_id, old, new, paid} |
| Metric | order_financial_summary_latency_seconds (Histogram) | Mỗi request | labels: permission_level (summary/pnl) |
| Metric | order_financial_summary_total (Counter) | Mỗi request | labels: result (success/unauthorized/error) |
| Alert | FinancialSummaryHighLatency | P95 > 500ms trong 10 phút | Warning → #ops-spa Slack |
| Alert | FinancialSummaryErrorSpike | 5xx rate > 5% trong 5 phút | Critical → #ops-spa Slack + on-call |
Capacity (chỉ khi > 100K records)
| Chỉ số | Hiện tại | 1 năm | Ghi chú |
|---|---|---|---|
| Tổng order/ngày | ~5.000 | ~10.000 | Action call ~50.000/ngày |
| Hook invoice_complete/ngày | ~3.000 | ~6.000 | UPDATE order high-water |
| DB load | Thấp | Trung bình | Index composite đủ |
C10) Danh sách việc triển khai
Giai đoạn 1 — Foundation (3 ngày)
| # | Task | Ước lượng | Phụ thuộc | Phụ trách |
|---|---|---|---|---|
| P1-01 | Migration up: ALTER order schema + 2 columns + index | 0.5d | — | BE |
| P1-02 | Migration up: INSERT 2 module_permission_action + seed role_module | 0.5d | P1-01 | BE |
| P1-03 | Mở rộng pkg/store/app_setting.go nested struct FixedCostSettings | 0.5d | — | BE |
| P1-04 | Test rollback down.sql trên staging | 0.5d | P1-01, P1-02 | BE |
Giai đoạn 2 — Backend action (4 ngày)
| # | Task | Ước lượng | Phụ thuộc | Phụ trách |
|---|---|---|---|---|
| P2-01 | Build get_order_financial_summary.go handler | 1.5d | P1-01 | BE |
| P2-02 | Register action trong action.go + Hasura actions.yaml/.graphql | 0.5d | P2-01 | BE |
| P2-03 | Extend create_order.go snapshot fixed_cost_rate | 0.5d | P1-01, P1-03 | BE |
| P2-04 | Extend invoice_complete.go hook high-water mark | 1d | P1-01 | BE |
| P2-05 | Unit test handler + hook (Go test) | 0.5d | P2-01, P2-04 | BE |
Giai đoạn 3 — Frontend (4 ngày)
| # | Task | Ước lượng | Phụ thuộc | Phụ trách |
|---|---|---|---|---|
| P3-01 | pnpm codegen sau khi metadata apply | 0.25d | P2-02 | FE |
| P3-02 | Component ServiceOrderFinancialSummary.tsx + FinancialRow + LossWarningBanner | 1.5d | P3-01 | FE |
| P3-03 | Mount trong OrderReceiverInfo.tsx đúng vị trí | 0.5d | P3-02 | FE |
| P3-04 | Mở rộng SystemTable.tsx field "Tỷ lệ chi phí cố định (%)" | 1d | P1-03 | FE |
| P3-05 | Permission constants + useGlobalStore integration | 0.25d | P1-02 | FE |
| P3-06 | i18n + tooltip dictionary | 0.5d | P3-02 | FE |
Giai đoạn 4 — QA + Go-Live (3 ngày)
| # | Task | Ước lượng | Phụ thuộc | Phụ trách |
|---|---|---|---|---|
| P4-01 | QA test theo D2 + 4 negative invoice case | 2d | P3-* | QA |
| P4-02 | Smoke staging + verify performance < 200ms P95 | 0.5d | P4-01 | TL + BE |
| P4-03 | Deploy production + monitoring 48h | 0.5d | P4-02 | TL + 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
| FR | Component FE | Artifact BE | TC |
|---|---|---|---|
| FR-001 | ServiceOrderFinancialSummary.tsx, LossWarningBanner.tsx | get_order_financial_summary.go | TC-001-* |
| FR-002 | (consumed by FR-001) | get_order_financial_summary.go | TC-002-* |
| FR-003 | SystemTable.tsx extend | app_setting.go extend | TC-003-* |
| FR-004 | — | create_order.go extend | TC-004-* |
| FR-005 | — | invoice_complete.go extend | TC-005-* |
| FR-006 | permissions.ts constants | Migration permission seed | TC-PERM-* |
| FR-007 | LossWarningBanner.tsx | (logic in handler) | TC-007-* |
Mapping quyết định → triển khai
| DEC | Cách triển khai | Cách kiểm chứng |
|---|---|---|
| DEC-001 (action backend) | Handler get_order_financial_summary.go, không FE aggregate | Code review + load test 100 đơn concurrent |
| DEC-003 (Dynamic Permission) | Migration seed + BE resolve effective permission | TC-PERM-01 đến TC-PERM-05 |
| DEC-006 (vật tư pending) | Field note_pending_material=true trong response, FE hiện note xám | TC-006-01 |
| DEC-012 (high-water mark) | SQL GREATEST(COALESCE, ...) trong hook | TC-005-03 (refund không giảm) |
| DEC-015 (cảnh báo lỗ chỉ pnl) | FE showLossBanner = hasPnL && profit_estimated < 0 | TC-007-01, TC-007-02 |
| DEC-016 (reuse Permission UI) | Không tạo screen mới | Verify không có route /s/financial-permission |