Skip to content

v1.11 — 15/05/2026

Thay đổiSectionẢnh hưởng
Đảo DEC-031 sang Option A — SCR-09-MOBILE-01 đổi từ "screen riêng" → tab thứ 3 trong wallet_screen.dart cùng cấp 2 tab hiện hữu (Ví thẻ trả trước + Ví khuyến mãi). Sửa wireframe B2.9.2 minh hoạ 3 tab. Note kỹ thuật: dev tự chốt widget (refactor FolderTabs 3 tab / swap CustomTabbar / swap TabPageWidget)B2.9FE Mobile, QA
Thêm SCR-06-NEW-03 — popup StatisticWalletPromotion2Popup.tsx lịch sử giao dịch KM 2 (web admin, parity popup KM 1 hiện hữu — Option α tạo file riêng)B0.1, B2.6.6AFE Web, QA
Update B2.9.3 reuse table — đổi từ "Screen riêng + BLoC mới" thành "Extend wallet_screen.dart + extend WalletBloc 3 event/state cho KM2"B2.9.3FE Mobile
Update bảng index SCR (B1.1) — SCR-09 đổi 🆕 New🔧 Extend (mobile)B1.1FE, QA

v1.10 — 15/05/2026

Thay đổiSectionẢnh hưởng
Audit mobile gap với codebase Flutter customer + staff — fix 2 wireframe SAI ở B2.9.2 (Home thực tế 4 card không có ví KM 1; wallet_screen.dart thực tế chỉ 2 tab FolderTabs, không phải 5)B2.9.2FE Mobile, QA
DEC-031 (Option C) — SCR-09-MOBILE-01 đổi từ "Tab Ví KM 2 trong wallet_screen.dart" thành screen riêng route mới /wallet/promotion2 (file mới wallet_km2_screen.dart + BLoC mới), KHÔNG refactor wallet_screen.dart [ĐÃ ĐẢO ở v1.11 — chốt Option A](B2.9)FE Mobile
DEC-032 (Option α) — Staff AffiliateFor KHÔNG bổ sung WalletType.promotion2, danh sách ví CTV nhận hoa hồng giữ [commission, promotion](PRD Z)FE Mobile, BE
Thêm SCR-09-MOBILE-03 (WalletType enum + l10n shared) và SCR-10-STAFF-01 (Staff customer detail balance KM 2)B0.1, B2.10FE Mobile, QA
Update bảng index SCR (B1.1) thêm row SCR-10B1.1FE, QA

v1.8 — 15/05/2026

Thay đổiSectionẢnh hưởng
DEC-027: bỏ trạng thái lot pending khỏi lifecycle (17 chỗ); đơn còn nợ hiển thị qua prepaid_order.status='pending' + inline "Đã nạp X/Y"B0.7, B2.6.11FE, QA
DEC-029: SCR-04 + SCR-06 + SCR-FR012 dùng action get_customer_km2_balance thay wallet.amountB2.4, B2.6FE, BE
Rename 4 field config canonical theo tên Dev (max_percent_per_order, allow_combine_km1, refund_fee_percent, refund_deadline_days)B0.4FE, QA
Thêm B0.9 Bảng kiểm kê tương tác (~60 element × 9 SCR, 10 enum canonical)B0.9FE, QA
Lint vietnamese clean: chuẩn hoá calque headings + heading section sang tiếng Việt canonicalToàn fileNone

Đặc tả UI (UI Spec) — Ví KM 2 (Promotion Wallet 2)

Tham chiếu: PRD v1.11 + SOURCE_OF_TRUTH v1.6 + EVIDENCE_PACK v1.4 | Phiên bản UI Spec: 2.3 | Ngày: 15/05/2026


File này dùng để làm gì: mô tả màn hình, thay đổi (delta), trạng thái, biến thể theo role/permission/lifecycle, tương tác, copy hiển thị, tooltip, tình huống cá biệt và verification cho Ví KM 2 để FE/UI/QA triển khai thống nhất.

Đọc trước: decision-brief.mdB-PRE) Kiểm tra discoveryB0) Hiện trạng UI và hợp đồng thay đổiB1) Bản đồ màn hình → các SCR áp dụng → B-POST) VerificationB-QUALITY) Rà soát rủi ro thiếu sót.

Quy tắc ưu tiên: nếu nội dung file này xung đột với SOURCE_OF_TRUTH.md, ưu tiên SOURCE_OF_TRUTH.md. Với formula, PRD A10 thắng spec UI; với RACI/timeline, handoff.md thắng; với readiness/rollback, go-live-checklist.md thắng.

Lịch sử thay đổi

Phiên bảnNgàyTác giảThay đổi
2.030/04/2026PO/BAViết lại theo po-ba-workflow v1.5: bổ sung B-PRE Discovery, B0.4 Field × Surface, B0.5 State × Screen, B0.6-0.8 wireframe/bilingual/schema, B2.x.1A UX nguyên tắc, B2.x.2.0 ma trận variant, B2.x.7B/C/D Form/Concurrency/Network, B2.x.8A Role × Variant, B2.x.10A Error Taxonomy, B5A/B6A/B7A/B8A/B8B/B8C contract chuyên sâu, B-Microcopy/B-Voice/B-i18n/B-Versioning/B-Help, 12 nhóm tình huống biên (G1-G12), B-POST verification, B-QUALITY 30+ rủi ro
1.628/04/2026PO/BACập nhật Dynamic Permission v2 và luồng Hoàn ví KM2 reuse Yêu cầu hoàn tiền
1.528/04/2026PO/BAĐồng bộ tiếng Việt-first, thêm B1.0 mục tiêu màn hình + CTA
1.428/04/2026PO/BAThêm setup readiness, runtime preview, publish safety
1.328/04/2026PO/BABổ sung wireframe ASCII với dữ liệu mẫu spa, copy tiếng Việt
1.024/03/2026PO/BAInitial

Đầu vào chuẩn (Canonical Inputs)

FileVai tròNếu xung đột
SOURCE_OF_TRUTH.mdNguồn sự thật chuẩn + Phương án đã chốt (Solution Lock)Ưu tiên cao nhất
EVIDENCE_PACK.mdLayout UI hiện tại + kiểm kê + reuse candidateƯu tiên bằng chứng code/screen
prd.mdFR/AC, Decision Log Z, formula A10, lifecycleTheo truth đã khóa
decision-brief.mdCửa vào package cho PO/TLTóm tắt; trỏ về owner

Quy tắc: UI Spec là tài liệu dẫn xuất, không phát minh rule. Thành phần hiện hữu vắng mặt trong target → log UI Spec gap/drift, không phải removal. Mọi REMOVE/HIDE phải có DEC/FR + PO approval.


B-PRE) Kiểm tra discovery (BẮT BUỘC trước khi viết UI Spec)

Mục đích: ép kiểm tra codebase + screen hiện hữu trước khi viết spec. Bỏ bước này → spec dễ thiếu context, state hoặc field hiện hữu.

B-PRE.1) Tìm component / page hiện có liên quan

bash
# FE component liên quan KM/wallet/promotion/prepaid
grep -rE "(wallet_promotion|promotion-wallet|VND_PROMOTION|prepaid_card|allow_promo_wallet)" diva-admin/src/modules/

# Page settings / prepaid / customer / report
find diva-admin/src/modules/{settings,ecommerce,user,report}/pages -type f \( -name "*.tsx" -o -name "*.vue" \)

# GraphQL liên quan wallet
grep -rE "wallet_stats|wallet_type|wallet_promotion" diva-admin/src/modules/*/graphql/

# Refund flow hiện có
grep -rE "transaction_request|refund_order|WithdrawForm|withdraw_request" diva-admin/src/modules/ diva-backend/

# BE wallet hardcode
grep -rE "VND_PROMOTION|wallet_promotion|GetWalletTypeFromPaymentMethod|CheckWalletBalanceEnough" diva-backend/

# Scheduler
grep -rE "scheduler/event.go|cron_trigger|cronjob" diva-backend/services/wallet-api/

B-PRE.2) Bảng inventory đã hoàn tất discovery

Hạng mục cần checkĐã check?File / màn hiện cóGhi chú
Page / route hiện có cho settings KMdiva-admin/src/modules/settings/pages/PromotionWallet.tsx (route /s/internal-settings/promotion-wallet)Reuse shell, extend thêm config KM2
Component reuse: master data Gói Ví KMsettings/pages/PrepaidCardCreate.tsx, components/prepaid-card/PrepaidCardForm.tsx, PrepaidCardTable.tsxExtend form + table + view
Component reuse: bán Gói Ví / đơn nạp tiềnecommerce/pages/PrepaidOrderCreate.tsx, components/prepaid-order/PrepaidOrderForm.tsx + subcomponentsLayout 2 panel hiện hữu
Component reuse: payment method chipecommerce/components/order-payment/OrderPaymentMethodRadio.tsx, PrepaidOrderPaymentFormMultiple.tsxThêm chip Ví KM 2 cùng pattern
Component reuse: customer profile + walletuser/pages/CustomerDetail.tsx, user/components/customer/CustomerEWalletInformation.tsx, StatisticCustomerWallets.tsxThêm card/tab Ví KM 2
Component reuse: refund requestWithdrawForm.tsx, OrderReceiverInfo.tsx, list/detail transaction_requestThêm option refund_km2_wallet
Component reuse: report shellreport/pages/PrepaidCardReport.tsx, components/prepaid-card/*Reuse cards/filter/table pattern
Component reuse: print templatesettings/components/invoice-template/InvoiceTemplatePopupPrint.tsxThêm biến payment_wallet_promotion_2_code
Component reuse: fund tableecommerce/components/fund-management/FundTable.tsx, FundInvoicePopup.tsxThêm cột conditional wallet_promotion_2_amount
Field/cột hiện có trên màn targetLiệt kê đầy đủ ở B0.1
CTA hiện có (primary + secondary)Lưu, Huỷ, Tạo Mới, Hoàn thành, Thanh toán, Xuất ExcelReuse, không tạo CTA mới ngoài "Hoàn ví KM2"
Filter / search hiện cóKhoảng thời gian, chi nhánh (đa số report)Default theo pattern hiện có
State hiện có (loading/empty/error/permission)Skeleton + toast pattern hiện có ở mọi mànTuân theo pattern, mở rộng nếu cần
Permission/role gating hiện cóuseGlobalStore.hasPermission, module_permission_action, role_module.actionsDynamic Permission v2
Notification trigger hiện cótransaction_insert.go pipeline ZNS/SMS, notification_templateExtend trigger KM2
Export columns hiện cóExport* components, report_prepaid_card_wallet_viewThêm cột KM2 conditional
Tooltip / hint hiện cóPattern (i) + QTooltipThêm tooltip KM2
Mobile / responsive behavior hiện cóDiva Admin desktop-first, POS tablet ≥ 768pxKhông có pattern mobile dưới 768px
Analytics events hiện cótransaction_insert, order_payment eventsThêm event KM2 prefix

Kết luận: discovery 100% đã pass cho 4 nhóm subagent (FE/BE/DB/Notification-Export). Không có ô nào còn [ ].

B-PRE.3) Phân loại reuse cho mỗi phần feature

Phần featurePhân loạiEvidence (file:line / màn hiện có)Delta cần
Bật/tắt phương thức thanh toán & cấu hình KM2🔧 ExtendPromotionWallet.tsx, payment_method.id='wallet_promotion'Thêm config block KM2 (max%, KM1+KM2, refund policy), readiness check
Master data Gói Ví KM2🔧 ExtendPrepaidCardForm.tsx, prepaid_card table, PrepaidCardTable.tsxThêm radio wallet_target + field expiry_months
Bán Gói Ví KM2🔧 ExtendPrepaidOrderForm.tsx, PrepaidOrderFormGeneral, PrepaidOrderFormPaidInformation, PrepaidOrderFormPaymentThêm cột "NẠP VÍ KM 2" conditional + dòng summary tương ứng
Multi-payment popup khi mua Gói Ví KM2🔧 ExtendPrepaidOrderPaymentFormMultiple.tsx (đang filter bỏ wallet_promotion)Mở method wallet_promotion_2; chặn installment cho dòng KM2
Thanh toán đơn DV/SP bằng KM2🔧 ExtendOrderPaymentMethodRadio.tsx, payment chip Ví khuyến mãi (KM1)Thêm chip "Ví KM 2", auto-fill, FIFO trigger backend
Eligible flag SP/DV🔧 ExtendProductDetail.tsx, ServiceDetailDetail.tsx toggle allow_promo_walletThêm toggle độc lập allow_promo_wallet_2
Tab Ví KM 2 trong customer profile🔧 ExtendCustomerDetail.tsx, CustomerEWalletInformation.tsx, wallet_stats (giữ cho ví khác) + action get_customer_km2_balance (DEC-029, realtime cho card KM 2)Thêm card + tab/popup chi tiết lot
Engine bản ghi từng lần mua + audit🆕 Build mớiKhông có asset hiện hữu cho lot lifecycle theo góiwallet_km2_lot, wallet_km2_lot_deduction + UI tương ứng
Hoàn ví KM2🔧 ExtendWithdrawForm.tsx, OrderReceiverInfo.tsx, behavior refund_order/refund_order_cosmeticThêm option/behavior refund_km2_wallet, dialog tính toán
Cảnh báo hết hạn (in-app text)🆕 Build mớiChưa có banner cảnh báo riêng cho lot expiry trong profileBanner warning/danger trong tab Ví KM 2
Expiry scheduler🆕 Build mớiwallet-api/scheduler/event.go chỉ có helloCron handler mới (UI không trực tiếp)
Report dashboard KM2🔧 ExtendPrepaidCardReport.tsx shell + cards/filter/tableReuse shell sau PD-001
Hoá đơn / fund / report KM2 (FR-012)🔧 ExtendInvoiceTemplatePopupPrint.tsx, FundTable.tsx, ServiceReportTable.tsx, EmployeeRevenueReportDoughnutChart.tsx, WithdrawRequestDetail.tsx, CustomerEWalletInformation.tsxThêm label/cột wallet_promotion_2_* conditional

Phân loại trên đã được auto-validate ở Phase 3 — không có "build mới" nào trùng pattern hiện có.


B0) Hiện trạng UI và hợp đồng thay đổi (As-Is + Delta Contract)

Quy tắc: lấy 100% UI hiện tại làm baseline. Mọi block/field/button/state hiện hữu phải có Delta Status. Thành phần hiện hữu vắng mặt khỏi target = UI Spec gap/drift, không phải removal.

B0.1) Bảng kiểm kê UI hiện tại đầy đủ

UI IDMàn / routeSectionBlock / field / actionThứ tựHành vi hiện tạiPermission/conditionMobile?Delta StatusHành vi đíchEvidence (file:line)
SCR-01-CARD-01/s/internal-settings/promotion-walletCard "PHƯƠNG THỨC THANH TOÁN"Toggle Ví khuyến mãi (KM1) + nút Huỷ / Lưu1Bật/tắt visibility của payment_method.id='wallet_promotion'; lưu qua mutation cập nhật payment_method.disabledinternal_configuration:update, portal AdminKhôngKEEPGiữ toggle KM1; thêm toggle KM2 song song trong cùng card, không đổi behavior KM1PromotionWallet.tsx:18-72
SCR-01-NEW-01/s/internal-settings/promotion-walletCard "PHƯƠNG THỨC THANH TOÁN"Toggle Ví khuyến mãi 2 (KM2)2— (chưa có)internal_configuration:update, portal AdminKhôngNEWBật/tắt visibility payment_method.id='wallet_promotion_2'; mặc định tắt sau migrationPromotionWallet.tsx:18-72 (vị trí chèn)
SCR-01-NEW-02/s/internal-settings/promotion-walletCard "CẤU HÌNH CHUNG"Block: input max_percent_per_order, toggle allow_combine_km13internal_configuration:updateKhôngNEWCấu hình max%/cho phép KM1+KM2 cho wallet_km2_config; áp dụng ngay khi lưuPromotionWallet.tsx (chèn dưới card hiện có)
SCR-01-NEW-03/s/internal-settings/promotion-walletCard "CHÍNH SÁCH HOÀN VÍ KM2"Block: toggle allow_refund + 3 input4internal_configuration:updateKhôngNEWBật/tắt hoàn ví KM2 + 3 chính sách (phí xử lý, thời hạn, gia hạn refund DV)PromotionWallet.tsx (chèn cuối)
SCR-01-CTA-01/s/internal-settings/promotion-walletFooterNút Huỷ / Lưu5Lưu config payment method visibilityinternal_configuration:updateKhôngUPDATELưu cùng lúc payment method visibility + wallet_km2_config; dirty check + confirm rời trangPromotionWallet.tsx:74-92
SCR-02-FORM-01/settings/prepaid-card/create (dialog slide-right)Cột trái form thẻ trả trướccode, name, description1-3Input bắt buộc, validate trùng mã thẻinternal_configuration:update, portal AdminKhôngKEEPGiữ nguyên field + behaviorPrepaidCardForm.tsx:32-78
SCR-02-FORM-02/settings/prepaid-card/createCột phải form thẻ trả trướccardPrice (Mệnh giá), actualAmount (Số tiền nạp), status (chỉ khi sửa)4-6Currency input, validate actualAmount >= cardPriceinternal_configuration:updateKhôngKEEPGiữ nguyênPrepaidCardForm.tsx:80-130
SCR-02-NEW-01/settings/prepaid-card/createCột trái form (mới, dưới description)Radio wallet_target (Ví VND / Ví KM 2)7internal_configuration:updateKhôngNEWBắt buộc; chọn Ví KM 2 → unlock field expiry_months; mặc định Ví VND (giữ behavior hiện tại)PrepaidCardForm.tsx (chèn)
SCR-02-NEW-02/settings/prepaid-card/createCột phải form (mới, dưới actualAmount)Input expiry_months (Hạn sử dụng)8internal_configuration:updateKhôngNEW (hiện theo điều kiện)Số tháng > 0; chỉ hiện khi wallet_target='VND_PROMOTION_2'; mặc định 6PrepaidCardForm.tsx (chèn)
SCR-02-TBL-01/settings/prepaid-cardBảng danh sách thẻ trả trướcCột Mã thẻ, Tên, Mệnh giá, Nạp ví, Trạng thái, actionRender từ prepaid_card_viewinternal_configuration:accessKhôngUPDATEThêm cột "Ví đích" với badge KM 2 / VND; thêm cột "HSD" hiện số tháng nếu KM2PrepaidCardTable.tsx:24-110
SCR-03-LEFT-01/ecommerce/prepaid-card-order/createPanel trái — PrepaidOrderFormGeneralChi nhánh, mã giới thiệu, lịch hẹn, nguồn ĐH, chiến dịch, ghi chú1Form chung đơn nạp tiềnprepaid_order:create, portal POSTablet ≥ 768pxKEEPKhông đổiPrepaidOrderFormGeneral.tsx
SCR-03-LEFT-02/ecommerce/prepaid-card-order/createPanel trái — PrepaidOrderFormPaidInformation (THÔNG TIN NẠP TIỀN)Bảng Loại thẻ, SL, Cần TT, Nạp ví DIVA, Nạp ví KM + nút + Thêm thẻ nạp2Mỗi dòng = 1 dropdown thẻ; hệ thống tính nạp ví theo prepaid_card_viewprepaid_order:createTabletUPDATEThêm cột "NẠP VÍ KM 2" conditional khi có ≥1 thẻ wallet_target=VND_PROMOTION_2; cột DIVA/KM = 0 cho dòng KM2PrepaidOrderFormPaidInformation.tsx
SCR-03-LEFT-03/ecommerce/prepaid-card-order/createPanel trái — Phân bổ hoa hồngCột Loại thẻ, Số tiền TT, nút [+]3Phân bổ hoa hồng theo từng dòng thẻprepaid_order:createTabletKEEPGiữ logic hoa hồng theo giá mua góiPrepaidOrderFormCommission.tsx
SCR-03-RIGHT-01/ecommerce/prepaid-card-order/createPanel phải — PrepaidOrderFormPaymentAvatar khách, payment method buttons (Chuyển khoản / Thẻ / Tiền mặt / Trả góp), summary1Sticky right, render summary tiền vào ví DIVA + KMprepaid_order:paymentTabletUPDATEThêm dòng "Tiền vào ví KM 2" conditional; chặn nút "Trả góp" nếu trong đơn có dòng KM2PrepaidOrderFormPayment.tsx
SCR-03-RIGHT-02/ecommerce/prepaid-card-order/createPopup multi-paymentDanh sách payment method (loại bỏ wallet, wallet_promotion)Cho thanh toán nhiều phương thứcprepaid_order:paymentTabletUPDATEMở method wallet_promotion_2 cho đơn KM2 (theo policy mới); chặn installment cho dòng KM2PrepaidOrderPaymentFormMultiple.tsx
SCR-04-CHIP-01/ecommerce/order/:id/paymentHàng chip phương thức thanh toánChip Tiền mặt, CK, Thẻ, Ví VND, Ví khuyến mãi (KM1)OrderPaymentMethodRadio selected state + amount inputservice_order:payment / product_order:paymentTabletKEEPGiữ chip hiện cóOrderPaymentMethodRadio.tsx
SCR-04-NEW-01/ecommerce/order/:id/paymentHàng chip phương thức thanh toán (mới)Chip Ví KM 2service_order:payment / product_order:paymentTabletNEWHiện khi config bật + số dư > 0 + ≥1 item eligible; chọn → mở block KM2 với auto-fill readonlyOrderPaymentMethodRadio.tsx (chèn)
SCR-04-NEW-02/ecommerce/order/:id/paymentBlock tóm tắt KM2 (mới)Số dư KM2, Eligible total, Max%, Số tiền KM2 áp dụng (readonly), Còn lại cần trảTabletNEWAuto-fill = MIN(eligible_total × max%/100, balance); readonlyComponent mới OrderKm2PaymentBlock.tsx
SCR-05-TGL-01/ecommerce/product/:id, /ecommerce/service/:idBlock "Trạng thái"Toggle Cho phép Ví khuyến mãi (KM1)Cập nhật allow_promo_wallet qua mutationinternal_configuration:update, portal AdminKhôngKEEPGiữ toggle KM1ProductDetail.tsx, ServiceDetailDetail.tsx (ProductAllowPromoWalletToggle)
SCR-05-NEW-01/ecommerce/product/:id, /ecommerce/service/:idBlock "Trạng thái" (mới, dưới toggle KM1)Toggle Cho phép Ví KM 2internal_configuration:update, portal AdminKhôngNEWToggle độc lập allow_promo_wallet_2; hidden cho POS portal(chèn vào component pattern KM1)
SCR-06-CARD-01/user/customer/:idWallet stats sidebar tráiCard Doanh số, Thực thu, Công nợ, Ví CTV, Ví Diva, Ví KM 1Render từ wallet_statscustomer_management:accessTabletKEEPGiữ card hiện cóStatisticCustomerWallets.tsx
SCR-06-NEW-01/user/customer/:idWallet stats sidebar trái (mới)Card Ví KM 2: {balance}đcustomer_management:accessTabletNEW (conditional)Hiện khi khách có wallet VND_PROMOTION_2; click → mở tab/popup chi tiết lotCustomerEWalletInformation.tsx (chèn)
SCR-06-NEW-02/user/customer/:idTab/popup Ví KM 2 (mới)Banner cảnh báo + 2 bảng (đang hoạt động / đã hết / hoàn) + filter trạng thái + filter Gói Ví KM2 + CTA "Hoàn ví KM2" per rowcustomer_management:access + (CTA cần refund_request_management_submenu:create)TabletNEWRender từ action get_customer_km2_lots (least-data); CTA mở SCR-07Component mới CustomerKm2WalletPopup.tsx
SCR-06-NEW-03/user/customer/:idPopup "Lịch sử giao dịch Ví Khuyến Mãi 2" (mới — parity với popup KM 1 hiện hữu)Bảng transaction với cột (Ngày / Loại GD / Số tiền / ĐH liên quan / Chi nhánh / Số dư sau) + filter date range + filter chi nhánh + dropdown loại giao dịch (Tất cả / Thanh toán / Hoàn / Hết hạn / Sự kiện) + paginationQuery useGetTransactionQuery với filter wallet_type_id: { _eq: "VND_PROMOTION_2" } (đồng dạng popup KM 1)customer_management:accessTabletNEWComponent mới riêng (Option α — không refactor popup KM 1); pattern copy y nguyên từ StatisticWalletPromotionPopup.tsx; trigger: click vào balance trong card Ví KM 2 HOẶC nút "Xem lịch sử" trong popup CustomerKm2WalletPopupComponent mới StatisticWalletPromotion2Popup.tsx (parity StatisticWalletPromotionPopup.tsx:79-130)
SCR-07-DLG-01Dialog từ SCR-06 hoặc menu Yêu cầu hoàn tiềnForm Hoàn ví KM23 block: Thông tin Gói KM2 + Tính toán hoàn tiền + Tạo yêu cầurefund_request_management_submenu:createTabletNEW (extend luồng request hiện có)Tạo transaction_request behavior refund_km2_wallet; tính refund theo FORMULA-002Component mới RefundKm2WalletDialog.tsx
SCR-07-LIST-01Màn Yêu cầu hoàn tiền hiện cóDanh sách + filter typeCột STT, mã đơn, khách, lý do, số tiền, trạng thái, action duyệtHiển thị transaction_request các loại hiện córefund_request_management_submenu:accessTabletUPDATEThêm filter "Loại = Hoàn ví KM2"; cột "Loại" hiển thị badge "Hoàn ví KM2" cho behavior refund_km2_walletList page hiện có
SCR-08-CARD-01Route report KM2 (PD-001 — Phase 3)KPI Cards4 KPI: Gói bán ra / Doanh thu / Tỉ lệ dùng / Sắp cháyModule report action access (PD-001)TabletNEWTổng hợp từ wallet_km2_lot + deductionReuse PrepaidCardReport shell
SCR-08-TBL-01Route report KM23 bảng: Theo Gói / Sắp hết hạn / Top kháchTabletNEWReuse pattern report prepaid hiện có(sau PD-001)
SCR-FR012-INV-01InvoiceTemplatePopupPrint.tsxHoá đơn in: dòng PTTTDòng "Ví khuyến mãi: {amount}"Render khi wallet_promotion_amount > 0PrintKEEPGiữ dòng KM1InvoiceTemplatePopupPrint.tsx
SCR-FR012-INV-02InvoiceTemplatePopupPrint.tsxHoá đơn in (mới)Dòng "Ví KM 2: {amount}"PrintNEWHiện khi wallet_promotion_2_amount > 0(chèn dưới dòng KM1)
SCR-FR012-FUND-01FundTable.tsxCột tiền vào víCột "Vào ví khuyến mãi" hiện hữuRender wallet_promotion_amountfund_management:accessKhôngKEEPGiữFundTable.tsx
SCR-FR012-FUND-02FundTable.tsxCột tiền vào ví (mới)Cột "Vào Ví KM 2"fund_management:accessKhôngNEW (conditional)Hiện khi tổng wallet_promotion_2_amount > 0 trong khoảng đang xem(chèn cạnh cột KM1)
SCR-FR012-RPT-01ServiceReportTable.tsx, EmployeeRevenueReportDoughnutChart.tsxCột/segment Ví khuyến mãiCột wallet_promotion_revenueSum theo nhân viên / dịch vụreport:accessKhôngKEEPGiữ(component report hiện có)
SCR-FR012-RPT-02ServiceReportTable.tsx, doughnut chartCột/segment Ví KM 2 (mới)Cột wallet_promotion_2_revenue + segmentreport:accessKhôngNEW (conditional)Hiện khi có dữ liệu KM2 trong range(chèn cạnh KM1)
SCR-FR012-CRM-01CustomerEWalletInformation.tsx (CRM portal section)Block ví kháchDòng Ví khuyến mãi: {balance}đRender từ wallet_statscustomer_management:accessTabletKEEPGiữCustomerEWalletInformation.tsx
SCR-FR012-CRM-02CustomerEWalletInformation.tsxBlock ví khách (mới)Dòng Ví KM 2: {balance}đcustomer_management:accessTabletNEW (conditional)Hiện khi khách có wallet KM2(chèn dưới dòng KM1)
SCR-FR012-NEG-01NegativeInvoicesTable.tsxCột "Tiền nạp ví khuyến mãi"Cột render wallet_promotion_amount âm khi hoàn đơnRender từ invoice negativenegative_payment:accessKhôngKEEPGiữ cột KM 1NegativeInvoicesTable.tsx (cột hiện hữu)
SCR-FR012-NEG-02NegativeInvoicesTable.tsxCột "Vào Ví KM 2" (mới)negative_payment:accessKhôngNEW (conditional)Hiện khi tổng wallet_promotion_2_amount < 0 trong range; render value âm(chèn cạnh cột KM 1)
SCR-FR012-SMS-01Template vnd_wallet_balance (notification_template table)Placeholder mới {{.wallet_promotion_2_amount}}Conditional render dòng "Vi KM 2 con lai: X" sau invoice completenotification:send (backend)KhôngUPDATEChỉ render dòng KM 2 khi balance > 0; tránh SMS dài cho khách KM1-onlyMigration update notification_template.content
SCR-FR012-AFF-01AffiliateConfiguration.tsx matrix (/s/internal-settings/affiliate)Cột "VÍ KHUYẾN MÃI 2" (mới, auto-render)UI tự render cột khi payment_method có row wallet_promotion_2; admin tick/untickinternal_configuration:updateKhôngNEW (auto)Migration seed 3 row `affiliate_config(servicecosmetic
SCR-FR012-COMM-01OrderCommissionItem.tsxFilter wallet_type_idHiện chỉ filter === "VND_PROMOTION"Hiển thị row commission của khách trong đơnorder:viewTabletUPDATEMở rộng filter include cả "VND_PROMOTION_2" để chuẩn bị cho trường hợp admin bật affiliate_config cho KM 2OrderCommissionItem.tsx (1 dòng filter)
SCR-FR012-SMS-02Template prepaid_card (notification)Placeholder {{.wallet_promotion_2_amount}} cho lần nạp KM 2Render khi đơn nạp có Gói KM 2notification:send (backend)KhôngUPDATE (conditional)Đồng bộ với SMS-01; trigger sau prepaid_order complete có dòng KM 2Migration update notification_template.content
SCR-09-MOBILE-01App khách (Flutter) — Tab Ví khuyến mãi 2 thứ 3 trong wallet_screen.dartwallet_screen.dart:213-227 (hiện FolderTabs 2 tab: Ví thẻ trả trước + Ví khuyến mãi)Tab thứ 3 cùng cấp; balance + 2 bảng lot (active/inactive) + drawer detail + filter chipcustomer:authenticated (mobile app token)Mobile-onlyEXTENDRead-only Phase 1; thêm tab cùng cấp với 2 tab hiện hữu (DEC-031); pattern parity với tab Ví khuyến mãi (KM 1); dev tự chốt widget (refactor FolderTabs 3 tab HOẶC swap CustomTabbar/TabPageWidget — đã có trong core/)wallet_screen.dart:213-227, page mới promotion_wallet_2_page.dart (parity promotion_wallet_page.dart), wallet_bloc.dart:43-62 extend 3 event/state cho KM2, WalletType.promotion2 enum
SCR-09-MOBILE-02App khách — Home widget card "Ví khuyến mãi 2"Card thứ 5 trong list wallets của home_wallet.dart:49-77Hiển thị balance + HSD gần nhất; tap mở wallet_screen.dart với initialTabIndex trỏ tab Ví khuyến mãi 2customer:authenticatedMobile-onlyNEW (conditional)Chèn cuối list 4 card hiện có (DIVA/Commission/Reward Point/Bonus Point — KHÔNG có ví KM 1 trên Home); hiện khi balance > 0home_wallet.dart:49-77
SCR-09-MOBILE-03App khách + Staff — WalletType enum + l10n keycore/lib/common/constants/server_constants.dart:300-346 (enum) + 4 ARB file (intl_vi.arbintl_en.arb cho cả customer + staff)Enum value promotion2('VND_PROMOTION_2') + key promotionWallet2Mapping JSON code ↔ display VI/EN; dùng cho mọi screen có hiện wallet typeN/A (shared constant)Mobile-onlyNEWThêm promotion2 vào enum + 4 ARB; regen .g.dart các file model (balance.g.dart, staff.g.dart, wallet_balance.g.dart)server_constants.dart:300-346, intl_vi.arb:166, intl_en.arb:166
SCR-10-STAFF-01App staff (Flutter) — Customer detail balance KM 2staff/lib/data/data_source/remote/customer/customer_repository.impl.dart:473-481 + customer detail UI hiện cóThêm 1 API call getBalances(WalletType.promotion2); UI hiện thêm row "Ví KM 2: {balance}đ"Staff lookup balance KM 2 khi xem customer detailcustomer_management:access + branch scope (đồng bộ DEC-013)TabletUPDATE (conditional)Hiện row khi balance > 0 (parity với pattern row Ví KM 1 hiện có); ẩn nếu khách chưa có lot KM 2customer_repository.impl.dart:473-481

Tiêu chí pass B0.1: mỗi UI ID có Delta Status, mỗi NEW/UPDATE có evidence file/component. Không có REMOVE trong scope. KEEP được dùng cho UI hiện hữu phải xuất hiện ở target wireframe.

B0.2) Từ điển Delta Status

StatusÝ nghĩaEvidence bắt buộc
KEEPGiữ nguyên thành phần / hành vi hiện tạiVẫn xuất hiện ở target wireframe; không mô tả lại detail
UPDATEĐổi data mapping / validation / copy / điều kiện hiển thịFR/DEC/AC + ref file:line
MOVEĐổi vị trí, giữ behaviorUX flow ghi rõ trước/sau khu vực nào
NEWThêm mớiFR/AC ref + tooltip / state / variant
REMOVEBỏ thành phầnDEC + FR/AC + PO approval (≥2 evidence) — KHÔNG có trong scope hiện tại
HIDEẨn theo điều kiệnPermission/state/lifecycle rõ trong PRD/SOURCE_OF_TRUTH

B0.3) Tiêu chí hoàn thành B0

  • [x] 100% UI hiện tại của các màn bị đụng đã được inventory ở B0.1 (kèm evidence)
  • [x] Không có block/field/action hiện hữu nào thiếu Delta Status
  • [x] Mọi REMOVE/HIDE có ≥2 evidence — N/A vì không có REMOVE; HIDE đều gắn permission/state
  • [x] Target wireframe ở B2 hiển thị các vùng KEEP liên quan, không chỉ vẽ phần update

B0.4) Ma trận Field × Surface

Mỗi field mới hoặc field bị update phải có 12 cột. Cell trống = fail.

FieldList tableDetail/FormPopup/ModalExport ExcelSearchFilterPermission gatingMobile (tablet POS)NotificationDefaultValidationTooltip
wallet_km2_config.disabledKhôngSCR-01 toggleKhôngKhôngKhôngKhônginternal_configuration:updateKhông cần (Admin web)Khôngtrue (sau migration)Boolean"Bật cho POS sau khi readiness pass"
wallet_km2_config.max_percent_per_orderKhôngSCR-01 inputKhôngKhôngKhôngKhônginternal_configuration:updateKhôngKhông20Integer 1-100, bắt buộc"Mỗi đơn được trả bằng KM2 tối đa X% phần eligible"
wallet_km2_config.allow_combine_km1KhôngSCR-01 toggleKhôngKhôngKhôngKhônginternal_configuration:updateKhôngKhôngfalseBoolean"Bật = khách dùng cả 2 ví trong 1 đơn"
wallet_km2_config.allow_refundKhôngSCR-01 toggleKhôngKhôngKhôngKhônginternal_configuration:updateKhôngKhôngtrueBoolean"Tắt = ẩn CTA Hoàn ví KM2 toàn hệ thống"
wallet_km2_config.refund_fee_percentKhôngSCR-01 inputHiện ở SCR-07 (calc)KhôngKhôngKhônginternal_configuration:updateKhôngKhông20Integer 0-100"Phí xử lý hoàn ví, tính trên giá mua gói"
wallet_km2_config.refund_deadline_daysKhôngSCR-01 inputHiện ở SCR-07 (max date)KhôngKhôngKhônginternal_configuration:updateKhôngKhông30Integer > 0"Thời hạn cho phép tạo yêu cầu hoàn ví KM2"
wallet_km2_config.refund_extend_daysKhôngSCR-01 inputKhông (engine)KhôngKhôngKhônginternal_configuration:updateKhôngKhông30Integer > 0"Khi refund DV gia hạn lot hết hạn thêm N ngày"
prepaid_card.wallet_targetSCR-02 cột "Ví đích" badgeSCR-02 radioKhôngKhôngKhôngCó (filter Gói KM2 ở SCR-02 list)internal_configuration:accessKhôngKhôngVNDEnum VND / VND_PROMOTION_2, bắt buộc"Chọn KM2 để biến thẻ này thành Gói Ví KM2"
prepaid_card.expiry_monthsSCR-02 cột "HSD"SCR-02 input (conditional)KhôngKhôngKhôngKhônginternal_configuration:updateKhôngKhông6Integer > 0 khi wallet_target=VND_PROMOTION_2, ẩn khi VND"Số tháng từ ngày khách trả lần đầu"
product.allow_promo_wallet_2 / service.allow_promo_wallet_2KhôngSCR-05 toggleConfirm dialogKhôngKhôngKhông (Phase 1)internal_configuration:update, portal AdminKhôngKhôngfalseBoolean"Cho phép thanh toán bằng Ví KM 2 trên item này"
wallet_km2_lot.balanceSCR-06 bảng cột "Còn lại"; SCR-08 KPI/bảngSCR-06 detail rowSCR-07 (snapshot)Có (Sheet 2)KhôngKhông (filter trạng thái có)customer_management:access (qua action)TabletKhôngCurrency ≥ 0"Số dư còn lại của lần mua này"
wallet_km2_lot.expired_atSCR-06 cột "Hết hạn"; SCR-08 bảng sắp hết hạnSCR-06 detailSCR-07 (snapshot)Có (Sheet 2)KhôngCó (Phase 3 report)customer_management:accessTabletCó (cron schedule, không UI)end_of_day(+ N tháng)Date sau ngày active"Sau 23:59:59 ngày này, số dư bị xoá"
wallet_km2_lot.statusSCR-06 (badge); SCR-08 bảngSCR-06 detailSCR-07 (đọc snapshot)Có (Sheet 1+2)KhôngCó (filter trạng thái)customer_management:accessTabletKhôngactive (lot chỉ được tạo khi khách trả ≥ 1 đồng cho đơn nạp Gói Ví KM2)Enum lifecycle"Đang hoạt động / Đã hết / Đã dùng hết / Đã hoàn"
wallet_km2_lot.package_name (snapshot)SCR-06; SCR-08SCR-06 detailSCR-07 (Tên Gói)Có (Sheet 1)Có (recent searches)Có (filter Gói Ví KM2)customer_management:accessTabletZNS biến {package_name}Theo prepaid_card.name tại thời điểm bánNotNull"Tên Gói Ví KM2 lúc bán; không đổi khi Admin sửa thẻ sau"
wallet_km2_lot.package_price (snapshot)Không (debug)SCR-06 detail (tooltip)SCR-07 (Giá mua gói)Có (Sheet 2)KhôngKhôngcustomer_management:accessTabletKhôngTheo prepaid_card.valueNotNull"Giá khách trả cho Gói Ví KM2"
wallet_km2_lot.wallet_value (snapshot)KhôngSCR-06 (tooltip)SCR-07 (Giá trị ví)Có (Sheet 2)KhôngKhôngcustomer_management:accessTabletZNS biến {wallet_value}Theo prepaid_card.value_into_walletNotNull"Số tiền vào ví KM2 cho Gói Ví KM2 này"
wallet_km2_lot_deduction.amountSCR-06 lịch sửKhôngKhôngCó (Sheet 1)KhôngKhôngcustomer_management:accessTabletKhôngCurrency > 0"Số tiền bị trừ trong giao dịch tham chiếu"
wallet_km2_lot_deduction.deduction_typeSCR-06 lịch sử badgeKhôngKhôngKhôngKhôngcustomer_management:accessTabletKhôngEnum payment / refund_back / refund_request"Loại bút toán: trừ thanh toán / hoàn ngược / hoàn ví KM2"
Payment chip Ví KM 2 (UI)KhôngSCR-04 chipSCR-04 block khi chọnKhôngKhôngKhôngservice_order:payment / product_order:paymentTablet POSKhôngHiddenConditional: config bật + balance > 0 + eligible > 0"Ví KM 2 chỉ áp dụng cho item bật Ví KM 2"
Cột NẠP VÍ KM 2 (SCR-03)SCR-03 cột conditionalKhôngCó (in receipt)KhôngKhôngprepaid_order:createTablet POSKhôngẨn (chỉ hiện khi có dòng KM2)Number ≥ 0"Số tiền sẽ vào Ví KM 2 cho khách (auto-tính theo Gói)"
Số tiền KM2 áp dụng (SCR-04 readonly)KhôngSCR-04 input readonlyKhôngCó (in receipt)KhôngKhôngservice_order:payment / product_order:paymentTabletKhôngAuto-fill MIN(eligible×max%, balance)Readonly, không cho NV nhập tự do"Tự động tính, NV không sửa"
transaction_request.behavior_id='refund_km2_wallet'SCR-07 list (badge "Loại")KhôngSCR-07 dialog (readonly)Có (export request)KhôngCó (filter loại)refund_request_management_submenu:accessTabletKhôngEnum cố định"Đánh dấu yêu cầu thuộc luồng Hoàn ví KM2"
transaction_request.lot_id (mới)KhôngKhôngSCR-07 dialog (snapshot)Có (audit)KhôngKhôngrefund_request_management_submenu:accessTabletKhôngUUID NotNull khi behavior=refund_km2_wallet"Tham chiếu lot bị hoàn"
transaction_request.km2_deduct_amountKhôngKhôngSCR-07 dialogCó (audit)KhôngKhôngrefund_request_management_submenu:accessTabletKhônglot.balance tại thời điểm tạoCurrency ≥ 0"Số dư KM2 bị tất toán khi yêu cầu duyệt"
transaction_request.suggested_refund_amountKhôngKhôngSCR-07 dialog "Khách nhận"Có (audit)KhôngKhôngrefund_request_management_submenu:accessTabletKhôngTheo FORMULA-002Currency ≥ 0 (clamp 0)"Số tiền dự kiến hoàn cho khách"
transaction_request.refund_methodKhôngKhôngSCR-07 radioKhôngCó (filter cho kế toán)refund_request_management_submenu:createTabletKhôngcashEnum cash / wallet / bank_transfer"Hoàn vào tiền mặt / Ví VND / chuyển khoản"
transaction_request.policy_snapshotKhôngKhôngSCR-07 (debug)Có (audit)KhôngKhôngrefund_request_management_submenu:accessTabletKhôngJSON snapshot wallet_km2_configNotNull"Bản sao chính sách lúc tạo, dùng cho audit"
Banner cảnh báo expiry (SCR-06)KhôngSCR-06 bannerKhôngKhôngKhôngKhôngcustomer_management:accessTabletKhông (text trong UI, không push)Hiện khi có lot ≤ 30 ngàyFormat DD/MM/YYYY"Cảnh báo NV đề xuất khách dùng trước hết hạn"

B0.5) Ma trận State × Screen (6 state mandatory cho mọi màn có data)

Màn / khốiDefaultLoadingEmptyError (retry)No permissionPartial / Pending
SCR-01 form SettingsHiện toggle/input theo config hiện tạiSkeleton input + toggle 200msN/A (form luôn có 1 record config)Toast "Lưu thất bại, vui lòng thử lại" + nút "Thử lại"; giữ inputẨn menu sidebar Ví KM 2 (route guard)N/A — config save toàn vẹn (atomic)
SCR-02 form Gói Ví KM2Form rỗng (tạo) hoặc fill từ record (sửa)Skeleton 4 ô inputN/A (form)Inline error per field + banner "Mã thẻ này đã tồn tại"; giữ inputẨn CTA Tạo mới / Sửa ở listN/A
SCR-02 list Gói Ví KM250 dòng đầu, sort theo created_at descSkeleton 5 row"Chưa có thẻ trả trước/Gói Ví KM2 nào" + CTA "Tạo mới""Không thể tải. Vui lòng thử lại" + nút "Thử lại"Ẩn route /settings/prepaid-cardN/A
SCR-03 bảng nạp tiềnBảng rỗng (chưa có dòng), nút + Thêm thẻ nạpSkeleton 1 dòng khi load thẻ"Chưa chọn thẻ nào. Bấm + Thêm thẻ nạp để bắt đầu"Toast "Không thể tải danh sách thẻ" + nút "Tải lại"Ẩn nút Tạo Mới (route guard)N/A — đơn nạp tiền hoặc tạo thành công hoặc lỗi
SCR-04 block KM2 trong paymentHiện chip Ví KM 2 chỉ khi đủ điều kiện (config bật + available_amount > 0 + eligible > 0)Skeleton chip 100ms khi load action get_customer_km2_balance (DEC-029)Ẩn chip nếu eligible_total = 0 (không hiển thị empty state)Toast "Số dư không đủ, vui lòng thử lại" + refetch action; nếu deducted_amount < requested_amount do lot vừa expired → toast cảnh báo và cập nhật balanceẨn chip Ví KM 2 (no-render)Backend trả 207 partial khi multi-payment có 1 method fail → toast "Đã trừ KM2 thành công, phần tiền mặt thất bại. Vui lòng thử lại phần còn lại."
SCR-05 toggle SP/DVToggle theo allow_promo_wallet_2 hiện tạiSkeleton toggleN/AToast "Cập nhật thất bại" + revert togglePOS portal: ẩn action update; toggle readonlyN/A
SCR-06 card Ví KM 2 wallet statsHiện available_amount từ action get_customer_km2_balance (DEC-029, realtime)Skeleton card 200msCard vẫn hiện "0đ" khi available_amount=0; click vẫn mở popupToast lỗi load action + retryẨn card (không render)Banner cảnh báo nearest_expiry_at ≤ 30 ngày hoặc expired_unswept_amount > 0 (admin xem được phần chờ scheduler clean)
SCR-06 popup chi tiết lotHiện 2 bảng đang HĐ + đã hết, banner cảnh báo nếu cóSkeleton 5 row x 2 bảng + banner skeleton"Khách chưa có Gói Ví KM2 nào trong Ví KM 2" + lý do"Không thể tải danh sách. Thử lại" + nút retryẨn popup, ẩn cardLot có credited_amount < initial_amount (đơn còn nợ) — hiện inline label "Đã nạp X/Y" trong bảng "Đang hoạt động"
SCR-07 dialog hoàn ví KM2Dialog hiện 3 block với calcSpinner overlay khi tính refundN/A (chỉ mở khi có lot eligible)Toast "Tạo yêu cầu thất bại" + giữ formKhông mở được dialog (CTA ẩn ở SCR-06)"Lot vừa hết hạn race condition" — backend 409 → modal "Lot đã hết hạn. Vui lòng tải lại"
SCR-08 dashboard reportKPI + 3 bảng theo filter mặc địnhSkeleton 4 KPI + 3 bảngKPI = "0"/"—", bảng "Chưa có dữ liệu"Toast lỗi + nút "Tải lại"Ẩn menu route, ẩn nút export"Đang xử lý export…" với async > 5,000 dòng
SCR-FR012 ví trong customer (CRM)Dòng Ví KM 2 hiện khi available_amount > 0 từ action get_customer_km2_balance (DEC-029)Skeleton 1 dòngẨn dòng (không hiện 0đ trong CRM)Toast lỗi load action + retryẨn cả khối ví kháchN/A
SCR-FR012 hoá đơn inDòng PTTT KM2 hiện khi cóPrint spinner trước renderẨn dòng"Không thể tải. Vui lòng tải lại"Backend không trả số → ẩnN/A
SCR-FR012 Fund/QuỹCột Vào Ví KM 2 hiện khi cóSkeleton bảngẨn cột nếu range = 0Toast lỗi + retryẨn module FundN/A

Quy tắc fail: màn có data nhưng thiếu Empty / Loading / Error → fail G3.7. Đã cover toàn bộ 13 màn/khối ở trên.

B0.6) Quy ước chất lượng wireframe (Wireframe Quality Contract)

Quy tắcÁp dụng cho package này
Số cột bảng ASCIIMọi wireframe ASCII trong B2.x dùng cùng số cách đều; không lệch quá 1 ký tự
Số bullet ↔ labelStepper / step indicator của setup readiness ở SCR-01 phải có 4 chấm + 4 label rõ
Dữ liệu mẫuTên KH Nguyễn Thị Lan, Trần Văn Nam, Lê Thị Hoa, Phạm Minh Đức (VN có dấu); SĐT 0909 123 456; tên Gói Gói Gold, Gói Silver, Gói Diamond; dịch vụ Massage body 90p, Liệu trình trị mụn 5 buổi; tiền 1.250.000đ, 5.000.000đ; ngày 24/03/2026, 15/04/2026
Không TBD/LoremMọi wireframe phải có dữ liệu thật, không {placeholder} / TBD / Lorem
Không hard-code styleSpec dùng intent (warning, success, muted, danger, negative-value); không "màu cam đỏ", "font 12px", "icon 🎁" trừ khi đã chốt design system
Buttons labelMọi nút trong wireframe đều có dòng tương ứng trong B6 từ điển; danh sách CTA: Lưu cài đặt, Lưu, Huỷ, Tạo Mới, + Thêm thẻ nạp, Hoàn thành, Thanh toán, Tạo yêu cầu hoàn tiền, Hoàn ví KM2, Xác nhận, Đóng, Xuất Excel, Tải lại, Thử lại
TruncationTên Gói dài → cell hiển thị Gói Premium Diamond V… với ; cột "Tên" trong bảng width 180px
Mobile previewDiva Admin desktop-first; POS chạy tablet ≥ 768px → wireframe POS có chú thích "Tablet POS"; không có pattern mobile < 768px

Lint nhanh áp dụng cho file này:

bash
rg -n "TBD|TODO|Lorem|\{placeholder\}|XXX|FIXME" docs/features/vi-km-2/ui-spec.md  # phải rỗng
rg -n '"(Submit|Confirm|Cancel|OK|Loading|No data|Error|Success|Save|Delete|Edit|View)"' docs/features/vi-km-2/ui-spec.md  # phải rỗng

B0.7) Cặp Code ↔ Display VI (Bilingual Pairing)

CodeDisplay VI canonicalPhân biệt vớiDùng ở section
VND_PROMOTION_2Ví KM 2 (Promotion Wallet 2)VND_PROMOTION (Ví KM 1)B0.4, B2.x, dev-spec C4
wallet_promotion_2Phương thức thanh toán "Ví KM 2"wallet_promotion (KM1)B0.4, B2.4, B-FR012
prepaid_card.wallet_targetVí đíchB2.2
wallet_target='VND_PROMOTION_2'Ví đích = Ví KM 2VND (Ví VND mặc định)B2.2, B2.3
wallet_km2_lot.status='active'Đang hoạt độngexpired (hết hạn), exhausted (dùng hết), refunded (đã hoàn)B2.6 badge
wallet_km2_lot.status='exhausted'Đã dùng hếtactive, expiredB2.6 badge
wallet_km2_lot.status='expired'Đã hết hạnexhausted (hết do dùng), refunded (đã hoàn)B2.6 badge, banner cảnh báo
wallet_km2_lot.status='refunded'Đã hoànexpired, exhaustedB2.6 badge
deduction_type='payment'Trừ thanh toánrefund_back (hoàn ngược), refund_request (hoàn ví KM2)B2.6 lịch sử
deduction_type='refund_back'Hoàn ngược (refund đơn DV/SP)payment, refund_requestB2.6
deduction_type='refund_request'Hoàn ví KM2payment, refund_backB2.6, B2.7
transaction_request.behavior_id='refund_km2_wallet'Loại yêu cầu "Hoàn ví KM2"refund_order (hoàn đơn DV), refund_order_cosmetic (hoàn đơn SP)B2.7, B-FR012
refund_method='cash'Hoàn tiền mặtwallet, bank_transferB2.7 radio
refund_method='wallet'Hoàn vào Ví VNDcash, bank_transferB2.7 radio
refund_method='bank_transfer'Chuyển khoảncash, walletB2.7 radio
allow_promo_wallet_2=trueCho phép Ví KM 2allow_promo_wallet (KM1)B2.5 toggle
wallet_km2_config.disabled=trueTính năng KM2 đang tắtdisabled=false (đang hoạt động)B2.1 banner

Quy tắc:

  • Trong văn xuôi: display VI trước, code backtick sau — Đang hoạt động (active)
  • Trong wireframe ASCII / Mermaid: dùng code raw active, expired (không dịch)
  • Trong filter dropdown: hiện display VI; nếu cần đối chiếu code → ghi tooltip

B0.8) Đối soát schema (Schema Cross-Check)

Đảm bảo UI không cho phép null/blank ở field schema NOT NULL.

Field UI cho phép trống / nullCột schema (dev-spec C4)NOT NULL?Cách UI xử lý
prepaid_card.expiry_monthsexpiry_months INTNULL khi wallet_target='VND', NOT NULL khi wallet_target='VND_PROMOTION_2'UI ẩn field khi wallet_target='VND' để tránh null không hợp lệ; hiện và bắt buộc nhập khi wallet_target='VND_PROMOTION_2' (validate FE + BE check)
wallet_km2_lot.branch_idbranch_id UUID NOT NULLNOT NULLLấy auto từ prepaid_order.branch_id lúc bán; không cho NV để trống branch trong SCR-03
wallet_km2_lot.activated_atactivated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()NOT NULL (lot chỉ được tạo khi khách trả ≥ 1 đồng, BE set activated_at = NOW() ngay lần trả đầu)UI luôn có giá trị; không có UI state "Chưa kích hoạt" cho lot
wallet_km2_lot.package_name, package_price, wallet_valueNOT NULLNOT NULLSnapshot từ prepaid_card lúc tạo lot; UI không cho sửa
transaction_request.lot_idNULL với behavior khác, NOT NULL với behavior refund_km2_walletConditional NOT NULLSCR-07 luôn lấy lot_id từ context khi mở dialog; không cho submit nếu thiếu
wallet_km2_lot_deduction.order_idNOT NULL với type payment/refund_back; NULL với type refund_requestConditional NOT NULLUI không can thiệp; backend tự fill

Quy tắc: trước khi viết B2 form, đã đối chiếu với dev-spec.md C4. UI không có field NOT NULL nào cho phép null trống.

bash
# Lint xác minh
rg -n "_id=NULL|=NULL OK|null được|NULL được|=null" docs/features/vi-km-2/ui-spec.md  # phải rỗng (đã pass)
rg -n "NOT NULL" docs/features/vi-km-2/dev-spec.md | head -50

B0.9) Bảng kiểm kê tương tác (Interaction Inventory — BẮT BUỘC profile L)

Liệt kê 100% interactive elements per SCR. Cột "Loại tương tác" PHẢI dùng 1 trong 10 enum canonical: Primary CTA / Secondary CTA / Drill-down / Hover state / Context menu / Inline edit / Tab switch / Load more / Overlay / Expand-Collapse.

Quy tắc kiểm kê:

  • Scan <q-btn>, <button>, @click, v-on:click, q-toggle, q-chip, q-input, q-select, hover states, modal triggers trong từng SCR
  • Mỗi row = 1 interactive element (KHÔNG group nhiều element)
  • Element không có TC tương ứng → fail QA coverage gate
  • Display-only element (banner, toast read-only, polling background, display row/column) KHÔNG kê ở đây — đã cover ở B2.x State Matrix

SCR-01: Cài đặt Ví KM 2

Element IDVị tríLoại tương tácBehavior summaryState coverageRef Quy ướcRef QA TC
btn_save_settingsCard "CẤU HÌNH CHUNG" → footerPrimary CTAValidate max_percent_per_order 1-100; gọi mutation update config; cần internal_configuration:updateDefault / Loading khi submit / Disabled khi dirty=false / Error inline 4xxB2.1.7BTC-001-01..04
tgl_enable_km2Card "CẤU HÌNH CHUNG" headerInline edit (field)Toggle disabled=true/false; confirm dialog khi tắt nếu khách đang giữ balance > 0Default / Loading / Disabled khi readiness fail / Confirm OverlayB2.1.7CTC-001-05
tgl_allow_combine_km1Card "CẤU HÌNH CHUNG"Inline edit (field)Toggle cho phép dùng cả 2 ví trong 1 đơnDefault / LoadingB2.1.7BTC-004-08
tgl_allow_refundCard "CHÍNH SÁCH HOÀN VÍ KM2"Inline edit (field)Toggle bật/tắt refund flow; hide refund fields khi tắtDefault / Loading / Hide dependent fields khi tắtB2.1.7BTC-009-ALLOW-REFUND
input_max_percent_per_orderCard "CẤU HÌNH CHUNG"Inline edit (field)Range 1-100 integer; block keystroke > 100Default / Error inline / Disabled khi tắt KM2B2.1.6TC-001-03..04
input_refund_fee_percentCard "CHÍNH SÁCH HOÀN VÍ KM2"Inline edit (field)Range 0-100 integerDefault / Error inline / Hidden khi allow_refund=falseB2.1.6TC-009-FEE-100
input_refund_deadline_daysCard "CHÍNH SÁCH HOÀN VÍ KM2"Inline edit (field)Integer > 0Default / Error inlineB2.1.6TC-009-PERIOD-0
input_refund_extend_daysCard "CHÍNH SÁCH HOÀN VÍ KM2"Inline edit (field)Integer > 0Default / Error inlineB2.1.6TC-008-EXTEND
link_readiness_setupStepper card (per step)Drill-downClick mở route setup tương ứng (prepaid-card, product, service, payment_gateway)Default / Disabled khi step đã passB2.1.6TC-001-READINESS-LINK
tooltip_max_percent_hintInput max_percent_per_orderHover stateTooltip giải thích "% tối đa Ví KM 2 được trả trên phần eligible mỗi đơn"Hover triggerB2.1.6

SCR-02: Tạo / sửa Gói Ví KM2

Element IDVị tríLoại tương tácBehavior summaryState coverageRef Quy ướcRef QA TC
btn_create_packageHeader listPrimary CTAMở form tạo mới; cần internal_configuration:updateDefault / Disabled khi POS portalB2.2.7BTC-002-01
btn_save_packageForm footerPrimary CTAValidate form đầy đủ; submit mutationDefault / Loading / Error 4xxB2.2.7BTC-002-01..04
btn_cancel_packageForm footerSecondary CTAConfirm Overlay nếu form dirtyDefault / Confirm OverlayB2.2.7BTC-002-CANCEL-DIRTY
radio_wallet_targetForm bodyInline edit (field)Switch VND / VND_PROMOTION_2; show/hide expiry_monthsDefault / Disabled khi sửa thẻ đang bánB2.2.6TC-002-01..02
input_codeForm bodyInline edit (field)Unique check qua API debounce 500msDefault / Error duplicate / LoadingB2.2.6TC-002-DUP
input_nameForm bodyInline edit (field)Bắt buộcDefault / Error requiredB2.2.6TC-002-01
input_valueForm bodyInline edit (field)Bắt buộc, > 0Default / ErrorB2.2.6TC-002-VALUE
input_value_into_walletForm bodyInline edit (field)valueDefault / Error "phải ≥ mệnh giá"B2.2.6TC-002-04
input_expiry_monthsForm bodyInline edit (field)Conditional: bắt buộc khi wallet_target='VND_PROMOTION_2'Default / Hidden khi VND / ErrorB2.2.6TC-002-03
filter_wallet_target_listList headerInline edit (field)Filter dropdown All / VND / VND_PROMOTION_2Default / All optionB2.2.6TC-002-LIST-FILTER
row_click_editList bodyDrill-downMở form sửaDefault / Disabled khi no-permissionB2.2.6TC-002-EDIT

SCR-03: Bán Gói Ví KM2

Element IDVị tríLoại tương tácBehavior summaryState coverageRef Quy ướcRef QA TC
btn_create_orderPanel phải footerPrimary CTAValidate; gọi mutation tạo prepaid_order + payment; cần prepaid_order:create + prepaid_order:paymentDefault / Loading / Disabled khi form invalid / Error 4xx + correlation_idB2.3.7BTC-003-01..09
btn_add_card_rowPanel trái bảngSecondary CTAThêm row trống vào bảng thẻ nạpDefaultB2.3.7BTC-003-ADD-ROW
btn_remove_card_rowMỗi row bảngSecondary CTAXoá row; nếu row cuối có KM2 → ẩn cột NẠP VÍ KM 2Default / Confirm Overlay khi row cuốiB2.3.7BTC-003-REMOVE-LAST-KM2
btn_cancel_orderPanel phải footerSecondary CTAConfirm Overlay leave dirtyDefault / Confirm OverlayB2.3.7BTC-003-CANCEL-DIRTY
select_prepaid_card_typeMỗi row bảngInline edit (field)Filter disabled=false; badge "Gói Ví KM2" cho wallet_target='VND_PROMOTION_2'Default / Loading / Empty "Chưa có Gói"B2.3.6TC-003-SELECT
input_qtyMỗi row bảngInline edit (field)> 0 integerDefault / ErrorB2.3.6TC-003-06
chip_payment_methodPanel phảiInline edit (field)Chip group; disable installment khi có dòng KM2 (DEC-017)Default / Disabled với tooltipB2.3.7BTC-003-INSTALLMENT-BLOCKED
input_paid_amountPanel phảiInline edit (field)≤ tổng đơnDefault / ErrorB2.3.6TC-003-DEBT
select_attached_appointmentPanel tráiInline edit (field)Lấy lịch hẹn liên quan của kháchDefault / EmptyB2.3.6TC-003-APPT

SCR-04: Thanh toán bằng Ví KM 2

Element IDVị tríLoại tương tácBehavior summaryState coverageRef Quy ướcRef QA TC
chip_pay_km2Block payment methodInline edit (field)Chip toggle Ví KM 2; hiện khi config bật + available_amount > 0 + eligible_total > 0; click = gen payment_attempt_id UUID v4Default / Selected / Disabled / Hidden (no-render)B2.4.7BTC-004-01..02, TC-004-05..06
btn_unselect_km2Block KM2 calcSecondary CTABỏ chọn chip → reset payment_attempt_idDefault / Disabled khi submittingB2.4.7BTC-004-UNSELECT
btn_submit_paymentPanel phảiPrimary CTAValidate đủ tiền; gửi deduct_km2_payment với payment_attempt_idDefault / Loading / Disabled / Error 409 idempotent_replayB2.4.7BTC-004-11..13, TC-IDEMPOTENT-*
hover_balance_tooltipBlock KM2 calc — Số dưHover stateTooltip "Số dư realtime từ get_customer_km2_balance — không tính lot expired"Hover triggerB2.4.6TC-004-TOOLTIP
hover_max_percent_tooltipBlock KM2 calc — Max%Hover stateTooltip giải thích phần eligible × max%Hover triggerB2.4.6
confirm_partial_deductionSau submit nếu deducted < requestedOverlayToast Overlay cảnh báo lot vừa expired; option retry hoặc dùng phương thức khácDefault / Auto-show 5s + manual closeB2.4.10TC-BALANCE-RACE-01

SCR-05: Flag eligible per SP/DV

Element IDVị tríLoại tương tácBehavior summaryState coverageRef Quy ướcRef QA TC
tgl_allow_promo_wallet_2Card "Trạng thái"Inline edit (field)Toggle allow_promo_wallet_2; cần internal_configuration:update, portal AdminDefault / Loading / Confirm Overlay / Readonly khi POS portalB2.5.7BTC-005-01..04
confirm_modal_toggleKhi click toggleOverlayConfirm Yes/No "Bạn có chắc muốn cho phép thanh toán bằng Ví KM 2..."Default / LoadingB2.5.7BTC-SCR05-D-01

SCR-06: Tab Ví KM 2 trong customer profile

Element IDVị tríLoại tương tácBehavior summaryState coverageRef Quy ướcRef QA TC
card_km2_balanceWallet stats sidebar tráiDrill-downClick card → mở popup chi tiết; load action get_customer_km2_balanceDefault / Loading skeleton 200ms / Empty "0đ" / Error retry / Hidden no-permissionB2.6.7TC-006-01..06
popup_lot_detailClick cardOverlayPopup 60vw desktop / 90vw tablet; trap focusDefault / Loading / Empty / Error / 2 bảng lotB2.6.7BTC-SCR06-A..J
btn_close_popupPopup headerSecondary CTAĐóng popup; confirm Overlay nếu dirtyDefaultB2.6.7BTC-006-CLOSE
filter_lot_statusPopup headerInline edit (field)Select Tất cả / Đang HĐ / Đã hết hạn / Đã dùng hết / Đã hoànDefault / Re-render clientB2.6.6TC-006-FILTER
filter_package_namePopup headerInline edit (field)Select tất cả Gói khách đã muaDefaultB2.6.6TC-006-FILTER-PKG
row_click_drawerBảng lotDrill-downClick row mở drawer "Lịch sử giao dịch lot"Default / LoadingB2.6.7BTC-006-DRAWER
btn_refund_lotRow "Đang hoạt động"Primary CTAMở SCR-07 dialog; cần refund_request_management_submenu:create; lot eligibleDefault / Hidden no-permission / Disabled với tooltip khi expired/refundedB2.6.10TC-009 + TC-PERM-SCR06
hover_tooltip_balanceCard title ⓘHover stateTooltip "Tổng số dư các Gói đang hoạt động. Không tính lot hết hạn / dùng hết / hoàn"Hover triggerB2.6TC-006-TOOLTIP
hover_tooltip_expired_unsweptBanner phần expired_unswept_amount (admin)Hover stateTooltip giải thích "Số dư đang chờ scheduler clean — sẽ về 0 sau cron 00:05"Hover triggerB2.6TC-006-UNSWEPT

SCR-07: Form Hoàn ví KM2

Element IDVị tríLoại tương tácBehavior summaryState coverageRef Quy ướcRef QA TC
dialog_refund_formStack trên popup SCR-06OverlayModal dialog trap focus; Esc đóng nếu không dirtyDefault / Loading / Error raceB2.7.7BTC-009-01..08
input_km2_deduct_amountForm bodyInline edit (field)0 < x ≤ lot.balance; auto-fill = lot.balanceDefault / Error / Readonly khi approver thiếu updateB2.7.6TC-009-AMOUNT
input_customer_refund_amountForm bodyInline edit (field)0 ≤ x ≤ totalPaidAmount; auto-fill từ FORMULA-002Default / Error / ReadonlyB2.7.6TC-009-REFUND
radio_refund_methodForm bodyInline edit (field)Radio cash/wallet/bank_transfer; bắt buộcDefault / ErrorB2.7.6TC-009-METHOD
btn_submit_requestForm footerPrimary CTAValidate đủ; tạo transaction_requestDefault / Loading / Error 409B2.7.7BTC-009
btn_cancel_requestForm footerSecondary CTAConfirm Overlay leave dirtyDefault / Confirm OverlayB2.7.7BTC-009-CANCEL-DIRTY
confirm_refund_zeroKhi suggested_refund = 0OverlayConfirm "Khách KHÔNG nhận tiền nhưng Gói {tên} sẽ tất toán"DefaultB2.7.10TC-009-ZERO
hover_tooltip_fee_calcField "Phí xử lý"Hover stateTooltip công thức "phí = giá mua gói × refund_fee_percent / 100"Hover triggerB2.7.6TC-009-TOOLTIP

SCR-08: Report dashboard (Phase 3)

Element IDVị tríLoại tương tácBehavior summaryState coverageRef Quy ướcRef QA TC
filter_date_rangeHeader dashboardInline edit (field)Date picker range; default 30 ngày gần nhấtDefault / Validate range hợp lệB2.8.6TC-010-FILTER
filter_branchHeaderInline edit (field)Select theo branch_mode permissionDefault / All option (admin only)B2.8.6TC-PERM-SCR08
btn_export_excelHeaderSecondary CTACần report:export; async > 5K rowsDefault / Loading / ErrorB2.8.7BTC-EXPORT
row_click_drilldown_customerTop khách tableDrill-downClick row mở SCR-06 popup khách đóDefaultB2.8.6TC-010-DRILLDOWN
tab_kpi_periodHeader tabsTab switchHôm nay / 7 ngày / 30 ngày / Tuỳ chỉnhDefault / SelectedB2.8.6TC-010-TAB

SCR-FR012: Tích hợp KM 2 vào màn hình hiện có

Element IDVị tríLoại tương tácBehavior summaryState coverageRef Quy ướcRef QA TC
dropdown_request_type_km2Yêu cầu hoàn tiền formInline edit (field)Option "Hoàn ví KM2" trong dropdown loại yêu cầu; hiện khi refund_request_management_submenu:createDefault / Hidden no-permissionB-FR012TC-FR012-REFUND-TYPE
row_crm_km2_wallet_expandCRM CustomerEWalletInformation.tsxExpand-CollapseMở/đóng dòng "Ví KM 2" trong khối ví khách; load action get_customer_km2_balanceDefault / Loading / Hidden khi available_amount = 0B-FR012TC-FR012-CRM
hover_tooltip_invoice_km2Hoá đơn in / preview — dòng KM2Hover stateTooltip "Tiền trừ từ Ví KM 2 — không tính thực thu"Hover triggerB-FR012TC-FR012-INVOICE-TOOLTIP
filter_fund_km2Quỹ / Fund table headerInline edit (field)Filter cột "Vào Ví KM 2" on/offDefault / Hidden khi range = 0B-FR012TC-FR012-FUND

Tổng kết kiểm kê: 9 SCR × ~5-11 element = ~60 interactive elements, tất cả map về 10 enum canonical. Lint lint-interaction-coverage.sh pass khi section này tồn tại đầy đủ + mọi row dùng enum.

Quy tắc maintenance: mỗi khi thêm/sửa interactive element trong B2.x, PHẢI cập nhật row tương ứng ở B0.9 (single source). Element không có ở B0.9 = fail QA coverage gate.


B1) Bản đồ màn hình và hành trình

B1.0) Mục tiêu màn hình và CTA chính (quick-scan)

Bảng tổng hợp 8 SCR trong 1 chỗ để PO/UX/FE scan nhanh trước khi đọc chi tiết B2.x.

SCRMục tiêu màn hìnhPrimary CTASecondary CTAKhối thông tin nổi bậtKhi không đủ quyền / chưa đủ điều kiện
SCR-01Admin cấu hình KM 2 an toàn trước khi bật phương thức thanh toán cho POSLưu cài đặt, Bật Ví KM 2 cho POS (sau readiness 4 bước pass)Huỷ, Xem checklist sẵn sàng, toggle bật/tắtTrạng thái bật/tắt + readiness 4 bước; max% đơn hàng; KM1+KM2; chính sách Hoàn ví KM2Ẩn menu với Staff/POS; nếu readiness chưa pass thì chip danger "Hoàn tất 4 bước trước"
SCR-02Admin tạo Gói Ví KM 2 với ví đích KM 2, hạn sử dụng và giá trị ví rõ ràngLưuHuỷ, ĐóngVí đích, mệnh giá, giá trị nạp vào ví, hạn sử dụngStaff/POS không thấy CTA Tạo mới; khi wallet_target=VND thì ẩn Hạn sử dụng; khi đã có lot, 4 field bị readonly
SCR-03Staff/Sale bán Gói Ví KM 2 bằng flow thẻ trả trước hiện hữu (mix với thẻ VND)Tạo Mới+ Thêm thẻ nạp, Xoá dòng, HuỷBảng nạp tiền (cột "NẠP VÍ KM 2" conditional) + summary "Tiền vào Ví KM 2" + cảnh báo chặn trả gópẨn Gói Ví KM 2 khỏi dropdown nếu config tắt hoặc Gói disabled; chặn Trả góp cho dòng KM 2 (DEC-017)
SCR-04Staff/Sale dùng KM 2 để thanh toán một phần đơn DV/SP theo max% và FIFOThanh toánBỏ chọn Ví KM 2, HuỷSố dư KM 2; eligible total; số tiền KM 2 áp dụng (auto-fill, readonly); còn lại cần trảẨn chip Ví KM 2 nếu config tắt / số dư = 0 / eligible = 0 / thiếu *:payment
SCR-05Admin đánh dấu SP/DV nào được phép trả bằng Ví KM 2Toggle (auto-save sau confirm)Toggle KM 1 hiện hữu cạnh toggle KM 2 mớiPOS portal: toggle readonly; thiếu internal_configuration:update thì ẩn action
SCR-06Staff/Manager xem tình trạng lot KM 2 của khách để tư vấn dùng trước hạn / hoàn tiềnHoàn ví KM2 (per row, conditional)Đóng, filter Trạng thái, filter Tên GóiTổng số dư KM 2; banner cảnh báo lot ≤ 7/30 ngày; bảng lot active + lot đã hết/hoànCard ẩn nếu thiếu customer_management:access; CTA Hoàn ẩn nếu thiếu refund_request_management_submenu:create hoặc lot không eligible
SCR-07Người có quyền tạo Yêu cầu Hoàn ví KM 2 đúng FORMULA-002 và policyTạo yêu cầu hoàn tiềnHuỷ, Đóng, Xem công thức (popover)Snapshot Gói (giá mua, giá trị ví, tỉ lệ); calc 3 dòng (hoàn theo tỉ lệ, phí, KHÁCH NHẬN); radio Hoàn vào (cash/wallet/bank)Chặn nếu thiếu refund_request_management_submenu:create hoặc lot expired/refunded/exhausted
SCR-08Manager/Admin theo dõi hiệu quả bán/dùng/hết hạn KM 2 theo branch × timeXuất ExcelFilter khoảng thời gian, BranchSelect, refresh4 KPI (Đã bán / Doanh thu / Tỉ lệ dùng / Sắp cháy); 3 bảng (theo Gói / sắp hết hạn / top khách)Phase 3 — chỉ bật sau khi PD-001 chốt route; ẩn menu nếu thiếu report:access; Manager chỉ thấy branch của mình

B1.1) Danh sách màn

SCRTênRouteLoạiMô tả
SCR-01Cài đặt Ví KM 2/s/internal-settings/promotion-wallet🔧 ExtendBật/tắt phương thức + cấu hình max%, KM1+KM2, refund policy
SCR-02Tạo/sửa Gói Ví KM2 (mở rộng PrepaidCard)/settings/prepaid-card, /settings/prepaid-card/create🔧 ExtendThêm wallet_target + expiry_months
SCR-03Bán Gói Ví KM2 (mở rộng đơn nạp tiền)/ecommerce/prepaid-card-order/create🔧 ExtendThêm cột "NẠP VÍ KM 2" conditional + summary tương ứng
SCR-04Thanh toán đơn DV/SP bằng KM2/ecommerce/order/:id/payment, /ecommerce/cosmetic-order/:id/payment🔧 ExtendThêm chip "Ví KM 2" + block auto-fill
SCR-05Flag eligible per SP/DV/ecommerce/product/:id, /ecommerce/service/:id🔧 ExtendThêm toggle allow_promo_wallet_2
SCR-06Tab Ví KM 2 trong customer profile/user/customer/:id🔧 ExtendCard + popup chi tiết lot + cảnh báo
SCR-07Form Hoàn ví KM2 trong Yêu cầu hoàn tiềnDialog từ SCR-06 hoặc menu Yêu cầu hoàn tiền hiện có🔧 ExtendTạo transaction_request behavior refund_km2_wallet
SCR-08Report dashboard Ví KM 2Theo PD-001 (Phase 3)🔧 ExtendReuse shell PrepaidCardReport; Phase 3
SCR-09Tab Ví khuyến mãi 2 trong wallet_screen.dart + Home widget + WalletType enum trên app khách (Flutter)wallet_screen.dart + Home🔧 Extend (mobile)Tab thứ 3 cùng cấp 2 tab hiện hữu (DEC-031); read-only Phase 1; PD-002 chốt in-scope
SCR-10Staff app — Customer detail balance KM 2Customer detail page (staff app)🔧 Extend (mobile)Thêm fetch + hiển thị balance KM 2 trong customer detail; parity pattern Ví KM 1 hiện có
SCR-FR012Tích hợp KM2 vào hoá đơn / fund / report / CRM / SMS / affiliate config hiện cóNhiều route🔧 ExtendChỉ delta — xem B-FR012

B1.2) Hành trình end-to-end (theo vai trò)

Hành trình theo vai trò:

Vai tròEntry pointĐường mànKết quả đầu ra
AdminSettings sidebar → Ví khuyến mãiSCR-01 → SCR-02 → SCR-05Cấu hình KM2 hoàn chỉnh, sẵn sàng cho POS
Staff/Sale (POS)POS → Nạp Tiền → Tạo MớiSCR-03 → (chọn khách)Đơn nạp tiền tạo lot KM2, ZNS gửi (nếu FR-011 bật)
Staff/Sale (POS)Đơn DV/SP → Thanh toánSCR-04 (chip KM2)Đơn được thanh toán một phần bằng KM2, FIFO trừ lot
Staff/Manager (CRM/POS)Profile khách → card Ví KM 2SCR-06 → SCR-07 (nếu hoàn)Tư vấn khách dùng trước hạn / tạo yêu cầu hoàn ví KM2
Kế toán / Manager (Admin/POS)Menu Yêu cầu hoàn tiềnSCR-07 list (filter loại "Hoàn ví KM2")Duyệt + thanh toán hoàn ví KM2
Manager / AdminMenu Report (Phase 3, sau PD-001)SCR-08Theo dõi hiệu quả KPI Ví KM 2

B1.3) Bản đồ phụ thuộc màn hình

Màn trướcMở khoá / ảnh hưởngMàn sauĐiều kiện
SCR-01 Cài đặt KM2wallet_km2_config.disabled=false, max_percent_per_order, refund policySCR-03, SCR-04, SCR-06, SCR-07Chỉ bật ở go-live sau khi E1-E4 pass
SCR-02 Loại Gói Ví KM2prepaid_card.wallet_target='VND_PROMOTION_2', expiry_months>0, disabled=falseSCR-03 Bán Gói Ví KM2Có ≥ 1 Gói Ví KM2 active
SCR-05 Flag SP/DVallow_promo_wallet_2=trueSCR-04 Thanh toánItem phải eligible mới tính vào eligible_total
SCR-03 Bán Gói Ví KM2Tạo wallet_km2_lot ngay lần trả đầu với status='active'; mỗi lần trả tiếp credited_amount tăng, balance tăng theo tỉ lệ đã trảSCR-04, SCR-06, SCR-08Đơn đã thanh toán theo rule multi-payment
SCR-06 Profile KM2Chọn lot eligible (active + allow_refund=true + còn balance)SCR-07 Hoàn ví KM2Quyền refund_request_management_submenu:create

B1.4) Ma trận liên kết màn hình

Điểm xuất phátDeeplink / routeĐường quay lạiGhi chú
SCR-01 readiness thiếu Gói Ví KM2/settings/prepaid-card/create?wallet_target=VND_PROMOTION_2Quay lại /s/internal-settings/promotion-walletMở form tạo Gói Ví KM2 với wallet_target pre-fill
SCR-01 readiness thiếu item eligible/ecommerce/product/:id hoặc /ecommerce/service/:idQuay lại settings KM2 (browser back hoặc breadcrumb)Day-1 không có bulk enable; user tự bật từng item
SCR-06 dòng Gói Ví KM2 → Hoàn ví KM2Dialog SCR-07 (overlay trên SCR-06)Đóng dialog → reload tab Ví KM 2Không tạo route mới; dùng dialog overlay
SCR-06 dòng Gói Ví KM2 → Lịch sử deductionDrawer/popup expand rowĐóng drawerKhông rời route customer detail
Yêu cầu hoàn tiền (list) → Chi tiết KM2Click row trong list → modal/route detail hiện có với behavior refund_km2_walletBack listReuse màn detail request hiện có
SCR-08 → customer detail/user/customer/:idBrowser back / breadcrumbÁp dụng sau khi PD-001 chốt route report

B1.5) Điều hướng setup (Setup Navigator)

BướcUI hiển thịKiểm tra sẵn sàngHành động chínhKhi chưa đạt
1SCR-01 Cài đặt KM2 — banner readinesswallet_km2_config tồn tại, mặc định disabled=true sau migrationAdmin tích vào checklist 4 bước, bấm Bật cuối cùngNút Bật chip danger "Hoàn tất 4 bước trước"
2SCR-02 Loại Gói Ví KM2Có ≥ 1 prepaid_card wallet_target='VND_PROMOTION_2', disabled=falseTạo / sửa Gói Ví KM2Banner "Chưa có Gói Ví KM2 nào" + CTA Tạo Gói Ví KM2
3SCR-05 Eligible itemCó ≥ 1 product / service allow_promo_wallet_2=trueBật flag ở chi tiết SP/DVBanner "Chưa có SP/DV cho phép Ví KM 2" + CTA Cấu hình SP/DV
4SCR-03/SCR-04 POS runtime previewKhách có balance KM2 (đã bán test) + đơn có item eligibleBán test 1 đơn nội bộHiện sample preview "Khách trả 500k → vào ví 5tr; đơn 1tr eligible 700k → KM2 áp dụng tối đa 140k"

Self-serve ergonomics: Day-1 chưa có preset/copy config giữa branch (deferred). Bulk enable SP/DV chưa có (deferred). Admin tự enable từng item.

B1.6) Ma trận Dynamic Permission v2 và refresh quyền

Khu vực UIModulePortalActionDefault seedHành vi UI khi thiếu quyềnSau khi grant/revoke
Cài đặt KM2 (SCR-01)internal_configurationadminupdateAdmin / owner cấu hình hệ thốngẨn menu trong sidebar Settings; route guard redirect; KHÔNG disable nút LưuRefetch permission state hoặc relogin (theo cơ chế Dynamic Permission hiện có)
Tạo / sửa Gói Ví KM2 (SCR-02)internal_configurationadminupdateAdmin / owner cấu hình hệ thốngẨn CTA Tạo mới / Sửa; route guardTương tự — refetch sau khi đổi quyền
Bán Gói Ví KM2 (SCR-03)prepaid_orderposcreate, paymentPOS/Sale/Manager theo portal POSẨn CTA Tạo Mới / Hoàn thành; backend chặn mutationRefresh route + payment store
Thanh toán DV bằng KM2 (SCR-04)service_orderpospaymentPOS/Sale/Manager theo portal POSẨn chip Ví KM 2; backend trả permission error nếu gọi trực tiếpRefetch permission + reload order payment state
Thanh toán SP/mỹ phẩm bằng KM2 (SCR-04)product_orderpospaymentPOS/Sale/Manager theo portal POSẨn chip Ví KM 2; backend chặnTương tự
Toggle eligible flag SP/DV (SCR-05)internal_configurationadminupdateAdminPOS portal: ẩn action update; toggle readonlyRefetch sau grant
Xem tab KM2 customer profile (SCR-06)customer_managementadmin,pos,crmaccess, view_all (cho all-branch)Theo quyền customer hiện cóẨn card + tab; action get_customer_km2_lots trả no-leak (forbidden)Refetch profile + lot action
CTA Hoàn ví KM2 (SCR-06)refund_request_management_submenuadmin,posaccess, createKế toán/Manager/Admin theo reviewer configẨn nút Hoàn ví KM2; backend chặn mutation transaction_request_insert cho behavior refund_km2_walletRefresh permission + reload tab
Cập nhật / duyệt / thanh toán Hoàn ví KM2 (SCR-07)refund_request_management_submenuadmin,posupdate, approve, paymentReviewer config + kế toánAction duyệt/thanh toán ẩn ở list/detail; backend từ chối changeStatusTransactionRefresh request list + permission
Report KM2 (SCR-08)Module/route theo PD-001adminaccessManager/Admin Phase 3Ẩn menu route, ẩn nút exportRefetch route permission

Cross-branch usage không = cross-branch visibility: khách mua ở chi nhánh A có thể dùng KM2 ở chi nhánh B (engine xử lý), nhưng UI chỉ trả dữ liệu theo branch_mode của user (Staff: self only / Manager: branch / Admin: all). Cross-branch redeem được Go action deduct_km2_payment xử lý bằng service permission, không expose qua GraphQL select.

B1.7) Ma trận ảnh hưởng UI của tính năng liên quan

Tính năng / màn hiện cóẢnh hưởng UIBổ sung hiển thị / thao tácKhông đổi UI
Settings Ví khuyến mãiThêm toggle KM2 + 2 block config (chung + refund) + readiness checklist trong shell hiện cóKhông tạo route settings mới
Master data thẻ trả trướcThêm radio Ví đích, field Hạn sử dụng, cột "Ví đích" + "HSD" trong listField VND/KM1 giữ vị trí
POS bán Gói Ví KM2 / prepaid orderCột "NẠP VÍ KM 2" + dòng "Tiền vào Ví KM 2" conditional; chặn Trả góp cho dòng KM2Layout 8/4 hiện hữu giữ nguyên
Thanh toán đơn DV/SPChip Ví KM 2 + block auto-fill readonly + warning khi KM1+KM2 cùng đơnChip cash/CK/thẻ/VND/KM1 không đổi
Product/Service detailToggle Cho phép Ví KM 2 cạnh KM1, chỉ Admin sửaToggle KM1 giữ nhãn/hành vi
Customer profileCard Ví KM 2: {balance}đ + popup chi tiết lot + cảnh báo expiry + CTA "Hoàn ví KM2"Bố cục thông tin khách giữ nguyên
Yêu cầu hoàn tiềnCó, bắt buộcFilter "Loại = Hoàn ví KM2", badge "Hoàn ví KM2", dialog tạo có 3 block snapshot/calc/methodKhông tạo approval module riêng
Hoá đơn in / previewThêm dòng "Ví KM 2: {amount}" conditional dưới dòng "Ví khuyến mãi"Không đổi biến/label KM1
Fund / QuỹCột "Vào Ví KM 2" conditional cạnh cột KM1Cột KM1 giữ
Report DV/NV / employee revenueCột/segment wallet_promotion_2_revenue conditionalCột KM1 giữ
CRM customer víDòng "Ví KM 2: {balance}đ" conditionalDòng KM1 giữ
Notification / ZNS / SMSCó điều kiện (FR-011)Thêm template km2_lot_activated + km2_lot_expiring_7dTemplate KM1 không đổi
Rank / loyalty / appointment / kho / affiliate / CMS gift / HRKhông trực tiếpKhông thêm field UI; chỉ kiểm tra regression nếu màn đang hiển thị payment method/wallet amount

B2.1) SCR-01: Cài đặt Ví KM 2

B2.1.1) Ngữ cảnh nghiệp vụ

Câu hỏiQuyết định cho UI/UX
Ai dùng?Admin / owner cấu hình hệ thống — portal Admin, action internal_configuration:update
Vào màn để quyết định gì?Bật/tắt Ví KM 2 cho POS, chốt max%, có cho dùng KM1+KM2 cùng đơn, chính sách Hoàn ví KM2
Dữ liệu chínhwallet_km2_config (1 record global) + payment_method.id='wallet_promotion_2' visibility
CTA chính / phụPrimary: Lưu cài đặt. Secondary: Huỷ, Bật Ví KM 2 cho POS (sau readiness pass)
Điều không được hiểu nhầm"Bật Ví KM 2" không tự áp dụng cho mọi SP/DV — vẫn cần bật allow_promo_wallet_2 per item ở SCR-05

B2.1.1A) Nguyên tắc UX bắt buộc

Nguyên tắcQuyết định cho UI/UX
Mục tiêu user trong 5 giâyAdmin biết ngay Ví KM 2 đang Tắt (default) hay Đang hoạt động, và còn bao nhiêu bước readiness chưa pass
Thứ tự ưu tiên thông tin(1) Trạng thái bật/tắt + readiness banner → (2) Cấu hình chung (max%, KM1+KM2) → (3) Chính sách Hoàn ví KM2 → (4) Footer CTA
Dữ liệu nhạy cảmKhông có dữ liệu khách / tiền cụ thể; chỉ config — không cần masking
Pending / partial dataSau migration disabled=true không phải "0% disable", phải hiển thị "Tính năng đang tắt — Bấm Bật Ví KM 2 cho POS sau khi readiness 4 bước pass"
Thuật ngữ canonical"Ví KM 2", "Hoàn ví KM2", "Phí xử lý hoàn ví", "Gia hạn refund DV" — không dùng "voucher", "Ticket KM2", "Refund ticket"
Thiết kế thị giácTrạng thái đang hoạt động: intent success; Trạng thái tắt: intent muted; Cảnh báo readiness: intent warning; Phí > 0 trong tooltip refund: intent negative-value
Điều không được tự suy diễnKHÔNG thêm tab / route con / màn config riêng cho từng branch (Day-1 global-first); KHÔNG cho disable nút Lưu nếu validation pass; KHÔNG để publish workflow / draft / effective date (Day-1 save-now)

B2.1.2) Bố cục — Demo gắn vào UI hiện tại

/s/internal-settings/promotion-wallet (extend PromotionWallet.tsx)

text
┌──────────────────────────────────────────────────────────────────────────────┐
│ SETTINGS > VÍ KHUYẾN MÃI                                                     │
├──────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│ KIỂM TRA SẴN SÀNG (chỉ hiện khi disabled=true HOẶC còn bước thiếu)           │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ Hoàn tất 4 bước để bật Ví KM 2 cho POS:                                  │ │
│ │   ① Bật phương thức Ví KM 2                              [Đạt]            │ │
│ │   ② Có ≥ 1 Gói Ví KM2 đang hoạt động   [Cấu hình →]    [Chưa đạt]       │ │
│ │   ③ Có ≥ 1 SP/DV cho phép Ví KM 2      [Cấu hình →]    [Chưa đạt]       │ │
│ │   ④ Bán test thành công 1 đơn POS                       [Chưa đạt]       │ │
│ │                                                                          │ │
│ │   [ Bật Ví KM 2 cho POS ]  ← chip danger nếu chưa đủ; chip success nếu đủ│ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│                                                                              │
│ PHƯƠNG THỨC THANH TOÁN                                                       │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │  Ví khuyến mãi (KM 1)                                  [▣] Bật           │ │
│ │  Ví khuyến mãi 2 (KM 2)                                [□] Tắt   ★ MỚI   │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│                                                                              │
│ CẤU HÌNH CHUNG    ★ MỚI                                                      │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │  Tối đa % trên đơn hàng        [  20  ] %                                │ │
│ │  ⓘ Mỗi đơn được trả bằng KM2 tối đa X% phần eligible                      │ │
│ │                                                                          │ │
│ │  Cho phép dùng KM1 + KM2 cùng đơn      [□] Tắt                           │ │
│ │  ⓘ Bật = khách dùng cả 2 ví trong 1 đơn                                  │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│                                                                              │
│ CHÍNH SÁCH HOÀN VÍ KM2    ★ MỚI                                              │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │  Cho phép Hoàn ví KM2                  [▣] Bật                           │ │
│ │                                                                          │ │
│ │  Phí xử lý hoàn ví (% giá mua gói)     [  20  ] %                        │ │
│ │  Thời hạn hoàn (ngày)                  [  30  ] ngày                     │ │
│ │  Gia hạn khi refund DV (ngày)          [  30  ] ngày                     │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
│                                                                              │
│                                                  [ Huỷ ]  [ Lưu cài đặt ]   │
└──────────────────────────────────────────────────────────────────────────────┘

Vùng KEEP: card "PHƯƠNG THỨC THANH TOÁN" + nút Huỷ/Lưu cài đặt đã có. Vùng NEW: banner readiness + 2 card mới + toggle KM2 + 1 nút "Bật Ví KM 2 cho POS".

B2.1.2.0) Ma trận variant bắt buộc

Variant IDĐiều kiện kích hoạtAi thấyWireframe / contractField/block không renderQA ref
Variant ADefault — đã pass readiness, disabled=falseAdminNhư demo trên, banner readiness ẩn (đã đủ); toggle KM2 = BậtTC-SCR01-A-01
Variant Bdisabled=true (Day-1 sau migration)AdminBanner readiness hiện đầy đủ 4 bước; toggle KM2 = Tắt; CTA Bật Ví KM 2 cho POS chip dangerToggle KM2 không cho user bật trực tiếp; phải qua nút "Bật Ví KM 2 cho POS" sau readinessTC-SCR01-B-01
Variant CNo permission internal_configuration:updateStaff/Manager/POSRoute guard 403 → redirect Settings home + toast "Bạn không có quyền xem trang này"Toàn bộ trangTC-PERM-SCR01-01
Variant D — LoadingMở trang → đang fetch wallet_km2_config + prepaid_card count + eligible countAdminSkeleton 4 banner row + 3 card; CTA disabledTC-SCR01-D-01
Variant D — ErrorFetch lỗi (5xx)AdminBanner đỏ "Không thể tải. Vui lòng thử lại" + nút Thử lại ở vị trí banner readinessToàn bộ formTC-SCR01-D-02
Variant EKhách đang có balance KM2 nhưng Admin tắt configAdminHiển thị banner warning "Đang có {N} khách giữ tổng số dư {X}đ Ví KM 2. Tắt sẽ không xoá số dư hiện có nhưng POS sẽ ẩn phương thức Ví KM 2" + CTA Tắt/Giữ bậtTC-SCR01-E-01
Variant FLifecycle config "Đang chuyển" — vừa lưu config, đang refetchAdminToast Đã lưu cài đặt; form vẫn cho thao tác tiếp; readiness banner refetchTC-SCR01-F-01

B2.1.3) Phân loại reuse + điểm update

Phân loạiFile hiện cóVị trí updateLý do vị trí
🔧 ExtendPromotionWallet.tsx (route /s/internal-settings/promotion-wallet)Chèn banner readiness lên đầu (trước card "PHƯƠNG THỨC THANH TOÁN"); thêm dòng toggle KM2 vào card hiện có; chèn 2 card mới ngay dưới card phương thức; giữ nguyên footer Huỷ/Lưu cài đặtAdmin đã quen route này cho cấu hình ví khuyến mãi; giữ shell tránh tạo route lạ

B2.1.4) Quy ước field / cột / CTA mới hoặc thay đổi

Trace cross-ref về B0.4 — các field SCR-01 đã có dòng đầy đủ ở B0.4.

Field/CTALoạiHiển thị ở đâuMặc địnhValidationĐiều kiện hiển thịTooltipRef B0.4
Toggle Ví khuyến mãi 2 (KM 2)QToggleTrong card "PHƯƠNG THỨC THANH TOÁN"TắtBooleanLuôn"Bật → POS thấy phương thức Ví KM 2"wallet_km2_config.disabled
Input Tối đa %XInput numberCard "CẤU HÌNH CHUNG"20Integer 1-100, bắt buộcHiện khi card config visible"Mỗi đơn tối đa X% phần eligible"max_percent_per_order
Toggle Cho phép KM1+KM2QToggleCard "CẤU HÌNH CHUNG"TắtBooleanLuôn"Bật = khách dùng cả 2 ví trong 1 đơn"allow_combine_km1
Toggle Cho phép Hoàn ví KM2QToggleCard "CHÍNH SÁCH HOÀN VÍ KM2"BậtBooleanLuôn"Tắt = ẩn CTA Hoàn ví KM2 toàn hệ thống"allow_refund
Input Phí xử lý hoàn víXInput numberCard "CHÍNH SÁCH HOÀN VÍ KM2"20Integer 0-100Hiện khi allow_refund=true"Phí tính trên giá mua gói"refund_fee_percent
Input Thời hạn hoàn (ngày)XInput numberCard "CHÍNH SÁCH HOÀN VÍ KM2"30Integer > 0Hiện khi allow_refund=true"Trong N ngày kể từ ngày bán"refund_deadline_days
Input Gia hạn refund DV (ngày)XInput numberCard "CHÍNH SÁCH HOÀN VÍ KM2"30Integer > 0Hiện khi allow_refund=true"Gia hạn lot hết hạn khi refund DV"refund_extend_days
CTA Bật Ví KM 2 cho POSQBtn (chip primary)Cuối banner readinessDisabled khi readiness chưa đủ; enabled khi đủ 4 bướcHiện khi disabled=true"Cần hoàn tất 4 bước trước"
CTA Lưu cài đặtQBtn primaryFooterDisabled khi không dirtyValidation passLuôn

B2.1.5) Quy ước setup / readiness

Mỗi bước mở deeplink rõ ràng + đường quay lại.

BướcDeeplink / vị tríĐiều kiện đạtKhi chưa đạt
① Bật phương thức Ví KM 2Toggle ngay trong cùng trangpayment_method.id='wallet_promotion_2' disabled=falseBanner readiness chip xám "Chưa đạt" + nhấn vào toggle
② Có ≥ 1 Gói Ví KM2 active[Cấu hình →]/settings/prepaid-card?wallet_target=VND_PROMOTION_2COUNT(prepaid_card WHERE wallet_target='VND_PROMOTION_2' AND disabled=false) ≥ 1Banner chip xám + CTA mở list filtered
③ Có ≥ 1 SP/DV cho phép Ví KM 2[Cấu hình →] → tab "Sản phẩm/Dịch vụ" của Settings + filter allow_promo_wallet_2=trueCOUNT(SP/DV allow_promo_wallet_2=true) ≥ 1CTA mở list SP/DV để Admin tự bật
④ Bán test thành công 1 đơn POSVào POS → Nạp Tiền → tạo đơn KM2 nội bộĐã có ≥ 1 lot active trong vòng 7 ngày quaHint "Vào POS bán test 1 đơn nội bộ" + link mở /ecommerce/prepaid-card-order/create

B2.1.6) Quy ước tương tác (Interaction)

Tình huốngHành vi
Chế độ lưuLưu thủ công bằng nút Lưu cài đặt (không autosave)
Có thay đổi chưa lưuTitle hiển thị dấu ; rời trang → confirm modal "Thay đổi chưa được lưu. Bạn có muốn rời trang?"
Search/filter applyN/A (form đơn)
Reset dây chuyềnTắt Cho phép Hoàn ví KM2 → ẩn 3 input phí/thời hạn/gia hạn (giữ value cũ trong state, chỉ ẩn UI)
Cập nhật asyncPessimistic: bấm Lưu → spinner trên nút → đợi BE response → toast
Tác vụ chạy lâuN/A (config save < 1s)

B2.1.7B) Quy ước tương tác form

Khía cạnhQuy ước
Trigger validationOn blur cho input number; on submit luôn re-validate
Debounce ô tìm kiếmN/A
AutosaveKhông có (DEC: config save phải explicit để tránh accidental disable)
Manual saveCtrl+S phím tắt = bấm Lưu cài đặt; không có "Lưu nháp"
Char counterN/A (không có textarea trong scope SCR-01)
Max length cứngInput number max_percent_per_order block keystroke khi > 100; toast 1 lần "Tối đa 100"
IME composingN/A (chỉ input number)
Paste rulesCho phép paste cho input number; auto-strip non-digit
Auto-formatKhông format ngàn (input < 1000); không format phần trăm khi gõ
Required markerDấu * đỏ sau label cho input bắt buộc; aria-required=true
Inline errorHiện ngay dưới field, icon đỏ; aria-live="assertive"
Field disabled vs readonly3 input refund: disabled (greyed) khi allow_refund=false, readonly khi user thiếu quyền

B2.1.7C) Quy ước đồng thời (Concurrency UI)

SCR-01 ít có 2 user cùng sửa, nhưng vẫn cần guard.

Tình huốngHành vi
2 Admin cùng mở configKhông hiện presence badge (config ít sửa); last-write-wins
2 Admin lưu cùng lúcBE dùng updated_at optimistic concurrency: nếu B lưu sau A, B nhận 409 Conflict → modal "Cấu hình đã thay đổi từ thiết bị khác. Vui lòng tải lại." + nút Tải lại
Quyền bị thu hồi giữa sessionRequest kế tiếp 401/403 → toast "Quyền của bạn đã thay đổi. Vui lòng tải lại." + redirect Settings home
HeartbeatKhông cần (config trang); chỉ refetch khi user mở trang

B2.1.7D) Khả năng chống đứt mạng (Network Resilience)

Tình huốngHành vi
Mất mạng giữa lúc lưuDisable nút Lưu cài đặt, hiện "Đang chờ kết nối…"; không mất nội dung input; auto retry khi có mạng
Có mạng lạiAuto retry POST 1 lần; toast "Đã lưu cài đặt" hoặc "Lưu thất bại — Vui lòng thử lại"
Response > 5sSpinner + "Mạng yếu, đang xử lý…" + nút Huỷ sau 10s
Response > 30sAuto huỷ + thông báo "Quá lâu. Đã huỷ. Vui lòng thử lại."

B2.1.8) Ma trận phân quyền (B2.x.8)

PortalVai trò default seedAction cầnHiển thịKhi thu hồi quyền
adminAdmin / owner cấu hìnhinternal_configuration:updateToàn bộ trang + nút LưuẨn menu sidebar; route guard redirect; refresh quyền cập nhật ngay
pos / crm / staffKhông hiện menu trong SettingsAPI mutation backend hard-deny

B2.1.8A) Ma trận Role × Variant

Role / nhóm userPortalPermission/actionVariant UIField/block được thấyField/block không renderKhi thu hồi quyền
Admin có internal_configuration:updateadminupdateVariant A/B/E/FToàn bộ form + readinessẨn menu, route guard
Manager / Staffadmin/pos/crmKhông cóVariant CToàn bộ trangN/A — không vào được
POS onlyposCó thể có customer_management:access nhưng không có internal_configurationVariant CTrang Settings ẩn khỏi menu

B2.1.9) Ma trận trạng thái (ref B0.5)

Trạng tháiHiển thị
Đang tảiSkeleton banner readiness + 3 card
Default — đã pass readiness, đang hoạt độngToggle KM2 bật, banner readiness ẩn, 2 card config hiện đầy đủ
Default — chưa pass readinessBanner readiness hiện 4 bước, toggle KM2 tắt, CTA "Bật Ví KM 2 cho POS" chip danger
Lưu thành côngToast Đã lưu cài đặt (intent success, 3s auto-dismiss); reset dirty state
Lưu thất bạiToast Không thể lưu cài đặt. Vui lòng thử lại (intent danger) + giữ input + nút Thử lại
Tắt KM2 khi khách còn balanceModal warning trước khi confirm tắt, nội dung như Variant E
Cho phép Hoàn ví KM2 = tắtẨn 3 input phí/thời hạn/gia hạn
Không có quyềnẨn menu trong Settings sidebar
Conflict 409 (concurrency)Modal Cấu hình đã thay đổi từ thiết bị khác + nút Tải lại

B2.1.10) Phản hồi sau thao tác (Action Feedback)

Hành độngPhản hồi UICopy mẫuHành động tiếp
Lưu cài đặt thành côngToast intent successĐã lưu cài đặt Ví KM 2Reset dirty, refetch readiness
Validation lỗi (max% < 1 hoặc > 100)Inline error dưới fieldVui lòng nhập giá trị từ 1 đến 100Focus field lỗi
Bấm "Tắt KM2" khi có khách còn balanceConfirm modalHiện có {N} khách đang giữ tổng {X}đ trong Ví KM 2. Tắt sẽ ẩn phương thức cho POS, không xoá số dư. Tiếp tục?Tiếp tục tắt / Giữ bật
Bấm "Bật Ví KM 2 cho POS" khi readiness chưa đủModal hintHoàn tất 4 bước trước khi bật cho POS + danh sách bước thiếu + CTA mở deeplinkMở deeplink theo bước
Conflict 409ModalCấu hình đã thay đổi từ thiết bị khác. Vui lòng tải lại trước khi lưu.Tải lại
Lỗi network 5xxToastKhông thể lưu cài đặt. Mã sự cố: {trace_id}Báo support

B2.1.10A) Phân loại lỗi (Error Taxonomy)

Loại lỗiUI patternRecoveryVí dụ copy
Validation client (max% out of range)Inline dưới fieldUser sửaVui lòng nhập giá trị từ 1 đến 100
Validation server (business rule)Banner đỏ trên cùngUser sửaCần ít nhất 1 Gói Ví KM2 đang hoạt động trước khi bật. [Cấu hình →]
Quyền (403)Toast + redirectLiên hệ AdminBạn không có quyền cập nhật cấu hình này
Conflict (409)ModalTải lạiCấu hình đã thay đổi từ thiết bị khác. Vui lòng tải lại trước khi lưu.
Network / timeoutBanner + nút Thử lạiRetry tự động + thủ côngKhông thể kết nối. Vui lòng thử lại.
Server 5xxToast + correlation_idBáo supportCó sự cố từ hệ thống. Mã sự cố: {trace_id}. Vui lòng báo bộ phận hỗ trợ.

B2.1.11) Mapping UI theo vòng đời config

Trạng thái configCTA hiển thịCTA ẩn / khoáField sửa đượcBadge / copy
disabled=true (Day-1 default)Bật Ví KM 2 cho POS (sau readiness)Toggle KM2 không cho bật trực tiếpCấu hình chung + refund vẫn sửa đượcBanner readiness + chip muted "Đang tắt"
disabled=false (đang hoạt động)Lưu cài đặt, toggle bật/tắtTất cảChip success "Đang hoạt động"
disabled=false + đang có khách giữ balanceLưu cài đặt, toggle bật/tắt + warningTất cảBanner warning khi user định tắt

B2.2) SCR-02: Tạo / sửa Gói Ví KM2 (mở rộng PrepaidCard)

B2.2.1) Ngữ cảnh nghiệp vụ

Câu hỏiQuyết định cho UI/UX
Ai dùng?Admin / owner cấu hình hệ thống — portal Admin, action internal_configuration:update
Vào màn để quyết định gì?Tạo loại Gói Ví KM2 (Silver/Gold/Diamond) với mệnh giá, giá trị vào ví, hạn sử dụng
Dữ liệu chínhprepaid_card (mở rộng với wallet_target + expiry_months)
CTA chính / phụPrimary: Lưu. Secondary: Huỷ, Đóng
Điều không được hiểu nhầmMệnh giá thẻ = giá khách trả; Số tiền nạp vào ví = giá trị khách nhận trong Ví KM 2. Hai số này khác nhau (mệnh giá < số tiền nạp khi là Gói Ví KM2)

B2.2.1A) Nguyên tắc UX bắt buộc

Nguyên tắcQuyết định cho UI/UX
Mục tiêu user trong 5 giâyAdmin biết đây là form thẻ trả trước; chọn Ví đích KM2 → unlock thêm field hạn sử dụng
Thứ tự ưu tiên thông tin(1) Code/Tên thẻ → (2) Mệnh giá + Số tiền nạp → (3) Ví đích → (4) Hạn sử dụng (nếu KM2) → (5) Mô tả → (6) Trạng thái (sửa)
Dữ liệu nhạy cảmKhông có (master data)
Pending / partial dataKhi Ví đích = VND, ẩn field Hạn sử dụng (không phải "0 tháng")
Thuật ngữ canonical"Gói Ví KM2", "Ví đích", "Hạn sử dụng" — không dùng "Wallet target", "TTL"
Thiết kế thị giácChip badge "KM 2" intent primary emphasis; Chip "VND" intent muted
Điều không được tự suy diễnKHÔNG cho sửa Ví đích sau khi đã có lot bán ra (FE disabled + tooltip lý do); KHÔNG cho sửa mệnh giá/số tiền nạp khi đã có lot

B2.2.2) Bố cục — Demo gắn vào UI hiện tại

/settings/prepaid-card/create (extend PrepaidCardForm.tsx — dialog slide-right)

text
┌─────────────────────────────────────────────────────────────────────┐
│ TẠO THẺ TRẢ TRƯỚC                                       [ × Đóng ]  │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│ ┌─── Cột trái ────────────┐  ┌─── Cột phải ────────────┐            │
│ │                         │  │                         │            │
│ │ Mã thẻ *                │  │ Mệnh giá thẻ *          │            │
│ │ [ VE_GOLD       ]       │  │ [ 500.000     ] đồng    │            │
│ │                         │  │                         │            │
│ │ Tên thẻ *               │  │ Số tiền nạp vào ví *    │            │
│ │ [ Gói Gold 500K ]       │  │ [ 5.000.000   ] đồng    │            │
│ │                         │  │                         │            │
│ │ Mô tả                   │  │ Trạng thái (chỉ khi sửa)│            │
│ │ [ Mua 500K được 5tr  ]  │  │ ( ) Đang hoạt động      │            │
│ │ [ dùng dịch vụ spa   ]  │  │ ( ) Ngưng hoạt động     │            │
│ │                         │  │                         │            │
│ │ ★ Ví đích *             │  │ ★ Hạn sử dụng (tháng) * │            │
│ │ ( ) Ví VND (mặc định)   │  │ [   6  ] tháng           │            │
│ │ (•) Ví KM 2             │  │ (chỉ hiện khi chọn KM 2)│            │
│ │                         │  │                         │            │
│ └─────────────────────────┘  └─────────────────────────┘            │
│                                                                     │
│                                       [ Huỷ ]    [ Lưu ]            │
└─────────────────────────────────────────────────────────────────────┘

Vùng KEEP: Mã thẻ / Tên thẻ / Mô tả / Mệnh giá / Số tiền nạp / Trạng thái. Vùng NEW: Radio "Ví đích" + Input "Hạn sử dụng" (conditional).

Demo gắn vào danh sách hiện có (/settings/prepaid-card):

text
┌────┬──────────┬──────────────────┬──────────┬───────────┬────────────┬─────┬──────────┐
│STT │ Mã thẻ   │ Tên thẻ          │ Mệnh giá │ Nạp vào ví│ Ví đích ★  │ HSD★│ Trạng thái│
├────┼──────────┼──────────────────┼──────────┼───────────┼────────────┼─────┼──────────┤
│ 1  │ VE_GOLD  │ Gói Gold 500K     │ 500.000đ │ 5.000.000đ│ [KM 2]     │ 6 th│ Đang HĐ  │
│ 2  │ VE_SILVER│ Gói Silver 200K   │ 200.000đ │ 1.500.000đ│ [KM 2]     │ 3 th│ Đang HĐ  │
│ 3  │ TT_10M   │ Thẻ trả trước 10tr│10.000.000│10.000.000đ│ [VND]      │  —  │ Đang HĐ  │
└────┴──────────┴──────────────────┴──────────┴───────────┴────────────┴─────┴──────────┘

B2.2.2.0) Ma trận variant bắt buộc

Variant IDĐiều kiệnAi thấyWireframe / contractField/block không renderQA ref
Variant A — Tạo VNDChọn Ví đích=VNDAdminForm như demo, ẩn "Hạn sử dụng"Hạn sử dụngTC-SCR02-A-01
Variant B — Tạo KM2Chọn Ví đích=Ví KM 2AdminForm như demo, hiện "Hạn sử dụng" với default 6TC-SCR02-B-01
Variant C — Sửa khi chưa có lotMở record sửa, chưa có lot bán raAdminForm như demo + radio Trạng tháiTC-SCR02-C-01
Variant D — Sửa khi đã có lotMở record sửa, đã có ≥ 1 lot active hoặc historicalAdminMệnh giá / Số tiền nạp / Ví đích / Hạn sử dụng → readonly + tooltip "Đã có lot bán ra; chỉ sửa Tên / Mô tả / Trạng thái"TC-SCR02-D-01
Variant E — LoadingĐang fetch detailAdminSkeleton formTC-SCR02-E-01
Variant F — Mã trùngSubmit với code đã tồn tạiAdminInline error trên field Mã thẻ "Mã thẻ này đã tồn tại" + banner đỏ trên cùngTC-SCR02-F-01
Variant G — No permissionStaff/POSẨn CTA Tạo mới/Sửa ở list, route guard cho dialogToàn bộ formTC-PERM-SCR02-01

B2.2.3) Phân loại reuse + điểm update

Phân loạiFile hiện cóVị trí updateLý do vị trí
🔧 ExtendPrepaidCardForm.tsxChèn radio Ví đích vào cột trái (dưới Mô tả); chèn input Hạn sử dụng vào cột phải (dưới Số tiền nạp); list table thêm 2 cột "Ví đích" + "HSD" sau cột "Nạp vào ví"Pattern 2 cột hiện hữu, không vẽ form mới

B2.2.4) Quy ước field / cột / CTA

Field/CTALoạiHiển thị ở đâuMặc địnhValidationĐiều kiện hiển thịTooltipRef B0.4
Radio Ví đíchXOptionGroupCột trái formVNDBắt buộc; enum VND / VND_PROMOTION_2Luôn (form)"Chọn KM 2 để biến thẻ này thành Gói Ví KM2"prepaid_card.wallet_target
Input Hạn sử dụng (tháng)XInput numberCột phải form6Integer > 0; bắt buộc khi wallet_target='VND_PROMOTION_2'Hiện khi wallet_target='VND_PROMOTION_2'"Số tháng từ ngày khách trả lần đầu"prepaid_card.expiry_months
Cột Ví đích (list)Badge textBảng /settings/prepaid-cardLuônprepaid_card.wallet_target
Cột HSD (list)Number textBảng /settings/prepaid-cardHiện giá trị tháng nếu KM2; nếu VNDprepaid_card.expiry_months

B2.2.5) Filter (list /settings/prepaid-card)

#ComponentLoạiMặc địnhHành viReset dây chuyền
1Tìm kiếmXInput searchRỗngDebounce 300ms, fuzzy code/name
2Trạng tháiQSelectTất cảOptions: Tất cả / Đang hoạt động / Ngưng hoạt động
3Ví đích ★ MỚIQSelectTất cảOptions: Tất cả / Ví VND / Ví KM 2

B2.2.6) Bảng danh sách

CộtWidthCăn lềĐịnh dạngStickySortable
STT50pxCenterAutoNoNo
Mã thẻ110pxLeftTextNo
Tên thẻ180pxLeftTextNo
Mệnh giá120pxRightCurrencyNo
Nạp vào ví130pxRightCurrencyNo
Ví đích ★100pxCenterBadge KM 2 (primary) / VND (muted)No
HSD ★80pxCenterN tháng hoặc No
Trạng thái100pxCenterBadgeNo
Action80pxCenterSửa / NgưngNoNo

B2.2.7) Quy ước tương tác

Tình huốngHành vi
Chế độ lưuLưu thủ công bằng Lưu
Có thay đổi chưa lưuĐóng dialog → confirm "Thay đổi chưa được lưu. Tiếp tục đóng?"
Đổi Ví đích từ VND → KM 2Hiện ngay field Hạn sử dụng với default 6, focus vào field
Đổi Ví đích từ KM 2 → VNDẨn field Hạn sử dụng (giữ value cũ trong state, BE bỏ qua khi VND)
Mã thẻ trùngInline error + banner đỏ trên cùng
Submit → 409 (concurrency)Modal "Thẻ này vừa được sửa từ thiết bị khác. Vui lòng tải lại."
Sửa record đã có lot4 field readonly (Mệnh giá / Số tiền nạp / Ví đích / Hạn sử dụng) + tooltip lý do

B2.2.7B) Quy ước tương tác form

Khía cạnhQuy ước
Trigger validationOn blur (input number, code uniqueness async); on submit re-validate
Debounce code uniqueness check500ms
Required marker* đỏ sau label
Auto-format tiềnFormat 1.250.000 khi blur, raw khi gõ
IME composingBắt event compositionstart/end cho field Tên thẻ / Mô tả
Paste rulesCho phép paste; auto-strip non-digit cho input number

B2.2.7C) Quy ước đồng thời

Tình huốngHành vi
2 Admin cùng sửa 1 thẻBE optimistic concurrency; B nhận 409 → modal "Đã thay đổi từ thiết bị khác" + nút Tải lại
Admin sửa thẻ đang có lotFE readonly các field bị khoá; tooltip giải thích

B2.2.7D) Network resilience

Tình huốngHành vi
Mất mạng giữa lúc lưuDisable nút Lưu, hiện "Đang chờ kết nối…"; auto retry 1 lần
Response > 5sSpinner + nút Huỷ sau 10s

B2.2.8) Ma trận phân quyền

PortalVai tròActionHiển thịKhi thu hồi quyền
adminAdmin / ownerinternal_configuration:updateList + form tạo/sửaẨn CTA Tạo mới / Sửa
pos / crm / staffKhông cóList read-only nếu có internal_configuration:access, ẩn nút sửaẨn route nếu thiếu access

B2.2.8A) Ma trận Role × Variant

RolePortalPermissionVariant UIField thấyField không render
Admin có internal_configuration:updateadminupdateVariant A/B/C/DToàn bộ
User chỉ có internal_configuration:accessadminaccessList read-onlyListForm (không mở được)
POS/StaffVariant GToàn bộ

B2.2.9) Ma trận trạng thái

Trạng tháiHiển thị
Đang tải formSkeleton 4 ô input
Tạo mới — chưa nhậpForm rỗng, default Ví đích=VND, ẩn Hạn sử dụng
Tạo mới — chọn KM 2Hiện Hạn sử dụng default 6
Sửa — chưa có lotToàn bộ field editable
Sửa — đã có lot4 field readonly + tooltip
Mã thẻ trùngInline error
Số tiền nạp < Mệnh giáInline error "Số tiền nạp phải lớn hơn hoặc bằng mệnh giá thẻ"
Lưu thành côngĐóng dialog, reload list, toast Đã lưu Gói Ví KM2 {tên}
Conflict 409Modal Tải lại

B2.2.10) Phản hồi sau thao tác

Hành độngPhản hồi UICopy mẫuHành động tiếp
Lưu tạo mới thành côngToast successĐã tạo Gói Ví KM2 "{tên}"Đóng dialog, reload list
Lưu sửa thành côngToast successĐã cập nhật Gói "{tên}"Reload list
Validation lỗiInline + banner(theo từng field)Focus field lỗi
Mã thẻ trùngInline + bannerMã thẻ "{code}" đã tồn tại. Vui lòng dùng mã khác.Focus field Mã thẻ
Confirm Ngưng hoạt độngModalNgưng "{tên}" sẽ ẩn khỏi POS bán Gói Ví KM2. Số dư khách hiện có không bị ảnh hưởng. Tiếp tục?Ngưng / Huỷ

B2.2.10A) Error Taxonomy

Loại lỗiUI patternRecoveryCopy
Validation clientInlineUser sửa(xem B2.2.9)
Validation server (mã trùng)Inline + bannerUser đổi mãMã thẻ "{code}" đã tồn tại
Quyền 403Toast + redirectLiên hệ AdminBạn không có quyền chỉnh sửa Gói Ví KM2
Conflict 409ModalTải lạiThẻ vừa được sửa từ thiết bị khác
Network/timeoutToast retryRetryKhông thể kết nối. Vui lòng thử lại.

B2.2.11) Mapping UI theo lifecycle Gói

Trạng tháiCTA hiệnCTA ẩnField sửa đượcBadge
disabled=false, chưa có lotSửa, NgưngTất cả"Đang hoạt động"
disabled=false, đã có lotSửa (giới hạn), NgưngChỉ Tên / Mô tả / Trạng thái"Đang hoạt động"
disabled=trueBật lại, SửaNgưngTất cả nếu chưa có lot, hạn chế nếu có"Ngưng hoạt động"

B2.3) SCR-03: Bán Gói Ví KM2 (extend đơn nạp tiền)

B2.3.1) Ngữ cảnh nghiệp vụ

Câu hỏiQuyết định
Ai dùng?Staff / Sale (POS) — prepaid_order:create + prepaid_order:payment
Vào màn để quyết định gì?Bán Gói Ví KM2 (đơn lẻ hoặc mix với thẻ trả trước VND) cho 1 khách, thu tiền và kích hoạt lot
Dữ liệu chínhprepaid_order + tạo wallet_km2_lot (status active ngay lần trả đầu, không tạo lot nếu khách chưa trả đồng nào)
CTA chính / phụPrimary: Tạo Mới. Secondary: + Thêm thẻ nạp, Huỷ
Điều không được hiểu nhầmKhách trả 500k mua Gói Gold KM2 → thực thu 500k, KHÔNG phải 5tr; ví khách +5tr (trong KM 2, không phải VND)

B2.3.1A) Nguyên tắc UX bắt buộc

Nguyên tắcQuyết định
Mục tiêu user trong 5 giâyNV biết tổng số tiền khách phải trả, tổng tiền vào từng ví, có còn nợ không
Thứ tự ưu tiên thông tin(1) Khách + chi nhánh → (2) Bảng thẻ nạp → (3) Summary tiền vào ví (KM 2 conditional) → (4) Phương thức thanh toán → (5) CTA Tạo Mới
Dữ liệu nhạy cảmTên khách + số dư hiện có — render least-data, chỉ thấy khách trong scope NV
Pending / partial dataĐơn chưa trả đồng nào → KHÔNG tạo lot, hiện badge "Đơn còn nợ" trên prepaid_order (không phải lot); đơn đã trả ≥ 1 đồng → tạo lot active với credited_amount = phần đã trả tương ứng, balance tỉ lệ
Thuật ngữ canonical"Tiền vào Ví KM 2", "Cần TT", "Còn nợ", "Tiền thừa trả khách" — không "Top-up KM2"
Thiết kế thị giácCột "NẠP VÍ KM 2" primary emphasis khi conditional; dòng summary "Tiền vào ví KM 2" intent primary chỉ khi > 0
Điều không được tự suy diễnKHÔNG đổi layout 8/4 hiện hữu; KHÔNG cho chọn Trả góp khi có dòng KM2; KHÔNG tự ẩn cột DIVA/KM khi có dòng KM2 (cột vẫn hiện, chỉ giá trị cho dòng đó = 0)

B2.3.2) Bố cục — Demo gắn vào UI hiện tại

/ecommerce/prepaid-card-order/create (extend PrepaidOrderForm.tsx — layout 8/4)

text
┌────────────────────────────────────────────────────────────────────────────────────────┐
│ Nạp Tiền > Tạo Mới Nạp Tiền                                          POS PHÒNG IT      │
├────────────────────────────────────────────────────────────────────────────────────────┤
│ ┌─── Panel trái (8) ─────────────────────────┐  ┌─── Panel phải (4) ──────────────────┐│
│ │                                            │  │                                    ││
│ │ Chi nhánh [ Chi Nhánh IT DIVA   v ]        │  │ Khách hàng                         ││
│ │ Mã giới thiệu [____________]               │  │ ┌────────────────────┐             ││
│ │ Lịch hẹn [____________ v ]                 │  │ │ [👤] Nguyễn Thị Lan │             ││
│ │ Nguồn ĐH [_v]  Chiến dịch [_v]             │  │ │ 0909 123 456       │             ││
│ │ Ghi chú [______________________]           │  │ └────────────────────┘             ││
│ │                                            │  │                                    ││
│ │ THÔNG TIN NẠP TIỀN                         │  │ PHƯƠNG THỨC THANH TOÁN *           ││
│ │ ┌───────────────────────┬──┬────────┬──────│  │ [Chuyển khoản] [Thẻ]               ││
│ │ │ LOẠI THẺ NẠP          │SL│CẦN TT  │NẠP DI│  │ [Tiền mặt ✓]   [Trả góp ✕]        ││
│ │ ├───────────────────────┼──┼────────┼──────│  │                                    ││
│ │ │ Thẻ trả trước 10tr    │1 │10.000K │10.000│  │ ⓘ Trả góp bị chặn vì đơn có Gói    ││
│ │ │ Gói Gold (6 tháng) ★  │2 │ 1.000K │   0  │  │   Ví KM2                           ││
│ │ ├───────────────────────┼──┼────────┼──────│  │                                    ││
│ │ │ TỔNG                  │  │11.000K │10.000│  │ Số tiền khách gửi [    0 ] đ       ││
│ │ └───────────────────────┴──┴────────┴──────│  │                                    ││
│ │   (cột tiếp theo: NẠP KM, NẠP KM 2 ★ )     │  │ Tiền vào ví DIVA:    10.000.000đ   ││
│ │                                            │  │ Tiền vào ví KM:       1.500.000đ   ││
│ │ [+ Thêm thẻ nạp]                           │  │ Tiền vào Ví KM 2:    10.000.000đ ★ ││
│ │                                            │  │ ─────────────────────────────────  ││
│ │ PHÂN BỔ HOA HỒNG                           │  │ Số tiền cần TT:      11.000.000đ   ││
│ │ ┌──────────────┬───────────┬──────┐        │  │ Đã thanh toán:               0đ    ││
│ │ │ LOẠI THẺ     │ SỐ TIỀN TT│ HOA H│        │  │ Còn nợ:              11.000.000đ   ││
│ │ ├──────────────┼───────────┼──────┤        │  │ Tiền thừa trả khách:         0đ    ││
│ │ │ Thẻ 10tr     │     0 đ   │ [+]  │        │  │                                    ││
│ │ │ Gói Gold     │     0 đ   │ [+]  │        │  │ [ Huỷ ]    [ Tạo Mới ]             ││
│ │ └──────────────┴───────────┴──────┘        │  │                                    ││
│ └────────────────────────────────────────────┘  └────────────────────────────────────┘│
└────────────────────────────────────────────────────────────────────────────────────────┘
★ Cột "NẠP VÍ KM 2" + dòng summary "Tiền vào Ví KM 2" + chip cảnh báo "Trả góp ✕" chỉ
  hiện khi đơn có ≥ 1 dòng `prepaid_card.wallet_target = VND_PROMOTION_2`

Vùng KEEP: layout 8/4, panel trái (general info, bảng thẻ, hoa hồng), panel phải (avatar khách, phương thức thanh toán, summary tiền). Vùng UPDATE: bảng thông tin nạp tiền + summary tiền vào ví + chip "Trả góp" disabled. Vùng NEW: cột "NẠP VÍ KM 2" + dòng summary + tooltip chặn trả góp.

B2.3.2.0) Ma trận variant bắt buộc

Variant IDĐiều kiệnAi thấyWireframe / contractField/block không renderQA ref
Variant A — Chỉ thẻ VNDĐơn không có dòng KM2Staff/SaleUI hiện hữu, không hiện cột "NẠP VÍ KM 2", không hiện dòng summary KM 2, "Trả góp" enabledCột KM2, dòng summary KM 2TC-SCR03-A-01
Variant B — Có dòng Gói Ví KM2≥ 1 dòng wallet_target=VND_PROMOTION_2Staff/SaleHiện cột "NẠP VÍ KM 2", dòng summary, chip "Trả góp" disabled với tooltipTC-SCR03-B-01
Variant C — MixMix dòng VND + dòng KM2Staff/SaleCùng Variant B; dòng VND có cột KM 2 = 0; dòng KM2 có cột DIVA + KM = 0TC-SCR03-C-01
Variant D — LoadingĐang fetch danh sách thẻStaff/SaleSkeleton 1 dòng bảng, skeleton summaryTC-SCR03-D-01
Variant E — EmptyChưa thêm dòng nàoStaff/SaleBảng rỗng + nút + Thêm thẻ nạp; CTA Tạo Mới disabledSummary chưa có dữ liệu (= 0)TC-SCR03-E-01
Variant F — Multi-payment popupNV bấm "Thanh toán nhiều phương thức"Staff/SalePopup PrepaidOrderPaymentFormMultiple mở method wallet_promotion_2; chặn installment cho dòng KM2TC-SCR03-F-01
Variant G — Race conditionSubmit 2 lần liên tục (double click)Staff/SaleNút Tạo Mới disabled sau click đầu (idempotency key); toast "Đang xử lý..."TC-SCR03-G-01
Variant H — No permissionNV không có prepaid_order:createStaff khácRoute guard, redirectToàn bộ trangTC-PERM-SCR03-01

B2.3.3) Phân loại reuse

Phân loạiFile hiện cóVị trí updateLý do
🔧 ExtendPrepaidOrderForm.tsx + PrepaidOrderFormPaidInformation.tsx + PrepaidOrderFormPayment.tsx + PrepaidOrderPaymentFormMultiple.tsxMở rộng cột bảng + summary + popup multi-paymentLayout 8/4 hiện hữu chuẩn POS, không tạo flow rời

B2.3.4) Quy ước cột bảng / field

Bảng "THÔNG TIN NẠP TIỀN" (panel trái):

CộtWidthAlignFormatĐiều kiện hiệnTooltip
LOẠI THẺ NẠP220pxLeftDropdown prepaid_card_viewLuôn"Chọn từ danh sách Gói/Thẻ Admin đã tạo"
SỐ LƯỢNG80pxCenterStepper [-] N [+]Luôn
CẦN THANH TOÁN130pxRightCurrencyLuôn"Số tiền khách phải trả cho dòng này = Mệnh giá × SL"
NẠP VÍ DIVA130pxRightCurrencyLuôn (= 0 nếu dòng KM2)"Số tiền vào ví VND của khách"
NẠP VÍ KM130pxRightCurrencyLuôn (= 0 nếu dòng KM2)"Số tiền vào ví KM 1 (KM cũ)"
NẠP VÍ KM 2130pxRightCurrencyChỉ hiện khi có ≥ 1 dòng KM2"Số tiền vào Ví KM 2 cho khách (lot mới)"
GHI CHÚ200pxLeftTextarea inlineLuôn
ACTION60pxCenterXoá dòngLuôn

Bảng "PHÂN BỔ HOA HỒNG":

CộtWidthAlignFormatGhi chú
LOẠI THẺ200pxLeftText rút gọnReuse
SỐ TIỀN TT120pxRightCurrency inputReuse — hoa hồng tính trên giá mua gói (Cần TT)
HOA HỒNG60pxCenterNút [+]Reuse

Summary panel phải (sticky):

DòngFormatĐiều kiện hiện
Tiền vào ví DIVACurrencyLuôn
Tiền vào ví KMCurrencyLuôn
Tiền vào Ví KM 2 ★CurrencyChỉ khi tổng > 0
Số tiền cần TTCurrencyLuôn
Đã thanh toánCurrencyLuôn
Còn nợCurrency, intent negative-value nếu > 0Luôn
Tiền thừa trả kháchCurrencyHiện khi khách trả dư

B2.3.5) Filter

N/A (form tạo, không có filter).

B2.3.6) Quy ước tương tác

Tình huốngHành vi
Chọn loại thẻ KM2 trong dropdownCột "NẠP VÍ KM 2" auto hiện trên toàn bảng; dòng KM2 cột DIVA + KM = 0
Xoá dòng KM2 cuối cùngCột "NẠP VÍ KM 2" ẩn, dòng summary "Tiền vào Ví KM 2" ẩn, chip "Trả góp" enabled trở lại
Đổi SL dòng KM2"CẦN TT" + "NẠP VÍ KM 2" tự cập nhật theo mệnh giá × SLsố tiền nạp × SL
Bấm chip "Trả góp" khi có dòng KM2Chip disabled với tooltip "Trả góp không áp dụng cho đơn có Gói Ví KM2" — ngăn click
Bấm Tạo Mới 2 lần liên tụcNút disabled sau click đầu, idempotency key gửi BE; toast nếu đã tạo
Mở popup multi-paymentHiện thêm method "Ví KM 2" cho phần điều kiện cho phép; vẫn ẩn installment

B2.3.7B) Form Interaction Deep

Khía cạnhQuy ước
Trigger validationOn blur cho input số; on submit re-validate
Debounce search khách300ms (autocomplete)
AutosaveKhông có (tạo đơn explicit)
Char counterCell "GHI CHÚ" max 255, hiện counter {X}/255 khi > 200
Max length cứngBlock keystroke khi đạt 255
IME composingBắt event cho field Ghi chú
Required marker* đỏ sau "PHƯƠNG THỨC THANH TOÁN"
Auto-format tiềnFormat 1.250.000 khi blur

B2.3.7C) Concurrency

Tình huốngHành vi
2 NV cùng tạo đơn cho 1 kháchCho phép song song; mỗi đơn là độc lập
Idempotency double clickBackend dùng key client_request_id (UUID gen client); duplicate → trả result đã tạo, không tạo mới

B2.3.7D) Network resilience

Tình huốngHành vi
Mất mạng giữa lúc bấm Tạo MớiDisable nút, hiện "Đang chờ kết nối…"; giữ form data; auto retry với cùng client_request_id
Có mạng lại + đã tạoToast Đơn đã được tạo trước đó: {mã đơn}, redirect detail
Response > 5sSpinner + nút Huỷ sau 10s
Response > 30sAuto huỷ + toast lỗi

B2.3.8) Phân quyền

PortalVai tròActionHiển thịKhi thu hồi
posSale/Manager/Admin (default seed)prepaid_order:createToàn bộ formẨn nút Tạo Mới
posSale (thiếu payment)prepaid_order:create onlyForm vẫn hiện; nút Tạo Mới disabled với tooltip "Cần quyền thu tiền"
admin/crm/staff ngoài POSKhông vào được route POS

B2.3.8A) Role × Variant

RolePermissionVariant
Sale POS có create+paymentcreate, paymentA/B/C/D/E/F/G
Sale POS có create onlycreateForm hiện, nút Tạo Mới disabled
Manager POScreate, paymentA-G + có thể xem all-branch (nếu branch_mode=all)
User ngoài POSH — không vào được

B2.3.9) Trạng thái (ref B0.5)

Trạng tháiHiển thị
Đang tải khách + thẻSkeleton avatar + skeleton bảng
Empty (chưa thêm dòng)Bảng rỗng + nút + Thêm thẻ nạp highlight; nút Tạo Mới disabled
Đã thêm ≥ 1 dòng VNDVariant A
Đã thêm ≥ 1 dòng KM2Variant B/C — cột KM 2 hiện, dòng summary hiện
Tổng cần TT > tổng đã thanh toánCòn nợ intent negative-value; cho phép submit (đơn nợ — pattern hiện hữu)
Submit thành côngRedirect /ecommerce/prepaid-card-order/:id + toast Đã tạo đơn nạp tiền {mã đơn}
Submit lỗi 5xxToast lỗi + giữ form + nút Thử lại
Race condition double clickNút disabled, không tạo trùng

B2.3.10) Action Feedback

Hành độngPhản hồiCopy
Tạo đơn thành côngToast success + redirectĐã tạo đơn nạp tiền {mã đơn}. ZNS đã gửi cho khách (nếu bật).
Tạo đơn nợ (chưa đủ tiền)Toast infoĐã tạo đơn nạp tiền {mã đơn}. Đơn còn nợ {X}đ — Gói Ví KM2 chưa kích hoạt cho đến khi thu đủ.
Lỗi mạngToast retryKhông thể tạo đơn. Vui lòng thử lại.
Bấm "Trả góp" khi có dòng KM2Tooltip không cho clickTrả góp không áp dụng cho đơn có Gói Ví KM2 (DEC-017)
Bấm Xoá dòng cuối cùng có KM2Cột KM 2 ẩn ngay không cần confirm

B2.3.10A) Error Taxonomy

LoạiUIRecoveryCopy
Validation client (SL ≤ 0)InlineUser sửaSố lượng phải lớn hơn 0
Validation server (Gói disabled)Banner đỏUser chọn Gói khácGói "{tên}" đã ngưng hoạt động. Vui lòng chọn Gói khác.
Quyền 403Toast + redirectLiên hệ ManagerBạn không có quyền tạo đơn nạp tiền
Conflict 409ModalTải lạiĐơn đã được tạo từ thiết bị khác. Vui lòng tải lại.
Network/timeoutBanner + retryAuto/manualMạng yếu, đang xử lý...
5xxToast + correlation_idBáo supportCó sự cố từ hệ thống. Mã: {trace_id}

B2.3.11) Lifecycle UI mapping (cho lot tạo từ đơn này)

Tình huống đơn nạpUI ở SCR-03UI ở SCR-06
Đơn nạp tạo nhưng khách chưa trả đồng nàoToast info "Đơn nạp đã tạo — chưa thanh toán, lot chưa tạo"; badge "Đơn còn nợ" trên dòng prepaid_orderKHÔNG có lot trong SCR-06 cho đến khi khách trả lần đầu
Đã trả ≥ 1 lần (kể cả từng phần)Toast success + ZNS gửi nếu bậtBảng SCR-06 hiện lot active, balance theo tỉ lệ đã trả (credited_amount / initial_amount)

B2.4) SCR-04: Thanh toán đơn DV/SP bằng Ví KM 2

B2.4.1) Ngữ cảnh nghiệp vụ

Câu hỏiQuyết định
Ai dùng?Staff/Sale POS — service_order:payment hoặc product_order:payment
Vào màn để quyết định gì?Dùng KM2 để thanh toán một phần đơn DV/SP/mỹ phẩm theo max% và FIFO
Dữ liệu chínhAction get_customer_km2_balance cho available_amount realtime (DEC-029, KHÔNG đọc wallet.amount trực tiếp); wallet_km2_config (max_percent_per_order, allow_combine_km1); item eligible (allow_promo_wallet_2=true); BE deduct_km2_payment xử lý FIFO theo activated_at trong cùng transaction
CTA chính / phụPrimary: Thanh toán. Secondary: Bỏ chọn Ví KM 2, Huỷ
Điều không được hiểu nhầmSố tiền KM2 áp dụng = MIN(eligible × max%, balance) — readonly. NV không tự nhập số tiền KM2

B2.4.1A) Nguyên tắc UX bắt buộc

Nguyên tắcQuyết định
Mục tiêu user trong 5 giâyNV nhìn thấy: số dư KM2 khách, số tiền KM2 áp dụng (auto), số còn lại phải trả
Thứ tự ưu tiên(1) Chip phương thức Ví KM 2 → (2) Block calc auto → (3) Còn lại cần trả
Dữ liệu nhạy cảmSố dư KM2 khách — chỉ render khi có quyền thanh toán đơn của khách
Pending / partial dataLot active nhưng credited_amount < initial_amount (đơn còn nợ): vẫn dùng được, balance chỉ tính phần đã nạp; lot expired không dùng dù scheduler chưa kịp đổi status
Thuật ngữ canonical"Số dư Ví KM 2", "Số tiền KM2 áp dụng", "Còn lại cần trả"
Thiết kế thị giácSố tiền KM2 áp dụng intent primary emphasis; "Còn lại cần trả" intent negative-value nếu > 0
Điều không được tự suy diễnKHÔNG cho NV nhập tự do số tiền KM2; KHÔNG hiển thị chip nếu không đủ điều kiện (config tắt / balance = 0 / eligible = 0)

B2.4.2) Bố cục — Demo gắn vào UI hiện tại

/ecommerce/order/:id/payment (extend OrderPaymentMethodRadio.tsx + thêm OrderKm2PaymentBlock.tsx)

text
┌──────────────────────────────────────────────────────────────────────────┐
│ THANH TOÁN ĐƠN HÀNG — #DH-20260424-001                                   │
├──────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│ CHI TIẾT ĐƠN HÀNG                                                        │
│ ┌────┬────────────────────────────┬───────────┬─────┬──────────┐         │
│ │STT │ Dịch vụ / Sản phẩm         │ Đơn giá   │ SL  │ KM 2 ?   │         │
│ ├────┼────────────────────────────┼───────────┼─────┼──────────┤         │
│ │ 1  │ Massage body 90 phút       │ 500.000đ  │ 1   │ ✓ Có     │         │
│ │ 2  │ Liệu trình trị mụn 5 buổi  │ 4.500.000đ│ 1   │ ✓ Có     │         │
│ │ 3  │ Kem chống nắng XYZ          │   300.000đ│ 1   │ ✕ Không │         │
│ └────┴────────────────────────────┴───────────┴─────┴──────────┘         │
│ Tổng đơn:           5.300.000đ                                           │
│ Eligible KM 2:      5.000.000đ  (Massage + Liệu trình)                   │
│                                                                          │
│ PHƯƠNG THỨC THANH TOÁN                                                   │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ [Tiền mặt] [Chuyển khoản] [Thẻ] [Ví VND]  [Ví KM 1]  [★ Ví KM 2 ✓ ] │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│                                                                          │
│ ┌─── Khi chọn "Ví KM 2" ───────────────────────────────────────────────┐ │
│ │                                                                      │ │
│ │  Số dư Ví KM 2:           4.600.000đ                                 │ │
│ │  Eligible total:          5.000.000đ                                 │ │
│ │  Tối đa (20%):            1.000.000đ                                 │ │
│ │                                                                      │ │
│ │  Số tiền KM 2 áp dụng:    [ 1.000.000 ] đ   (tự động tính, khoá)    │ │
│ │  ⓘ MIN(Eligible × max%, Số dư)                                        │ │
│ │                                                                      │ │
│ │  Còn lại cần trả:         4.300.000đ                                 │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│                                                                          │
│ [Tiền mặt]: [ 4.300.000 ] đ                                              │
│                                                                          │
│                                       [ Huỷ ]    [ Thanh toán ]          │
└──────────────────────────────────────────────────────────────────────────┘

Vùng KEEP: chi tiết đơn hàng + 5 chip thanh toán hiện hữu + input tiền mặt. Vùng NEW: chip "Ví KM 2" + block calc bên dưới.

B2.4.2.0) Ma trận variant bắt buộc

Variant IDĐiều kiệnAi thấyWireframe / contractField/block không renderQA ref
Variant A — Đủ điều kiệnconfig bật + balance > 0 + eligible > 0Sale POSChip hiện, block calc hiện khi chọnTC-SCR04-A-01
Variant B — Eligible = 0Đơn không có item eligibleSale POSChip ẩn (không hiển thị empty state)Chip Ví KM 2 + blockTC-SCR04-B-01
Variant C — Balance = 0Khách không có lot activeSale POSChip ẩnChip + blockTC-SCR04-C-01
Variant D — Config tắtwallet_km2_config.disabled=trueSale POSChip ẩn toàn cụcChip + blockTC-SCR04-D-01
Variant E — KM1+KM2 cùng đơnallow_combine_km1=true + khách có cả 2 víSale POSCho chọn cả 2 chip; tính KM 2 trước, KM 1 trên phần còn lạiTC-SCR04-E-01
Variant F — KM1+KM2 không choallow_combine_km1=false + chọn KM 2 sau khi đã chọn KM 1Sale POSTự động bỏ chọn KM 1 + toast "Cấu hình không cho phép dùng cả 2 ví trong 1 đơn"TC-SCR04-F-01
Variant G — Số dư < eligible × max%balance = 50.000, eligible × max% = 100.000Sale POSAuto-fill = 50.000 (dùng hết số dư)TC-SCR04-G-01
Variant H — LoadingĐang fetch wallet statsSale POSSkeleton chip + blockTC-SCR04-H-01
Variant I — Race conditionSubmit khi lot vừa hết hạnSale POSToast "Số dư không đủ. Vui lòng thử lại" + reload số dưTC-SCR04-I-01
Variant J — Partial successMulti-payment có 1 method failSale POSToast "Đã trừ KM2. Phần tiền mặt thất bại. Vui lòng thử lại phần còn lại."TC-SCR04-J-01
Variant K — No permissionSale không có *:paymentChip ẩn, nút Thanh toán disabledTC-PERM-SCR04-01

B2.4.3) Phân loại reuse

Phân loạiFile hiện cóVị trí updateLý do
🔧 ExtendOrderPaymentMethodRadio.tsx, payment form đơn DV/SP/mỹ phẩmThêm chip "Ví KM 2" cùng pattern KM 1; thêm component OrderKm2PaymentBlock.tsx hiện khi chip selectedPattern chip + block hiện hữu cho KM 1

B2.4.4) Quy ước field / chip / block

FieldLoạiHiển thịMặc địnhValidationĐiều kiện hiệnTooltip
Chip "Ví KM 2"QChip (OrderPaymentMethodRadio)Cùng hàng các chip khácChưa selectedconfig bật + balance > 0 + eligible > 0"Khách còn {X}đ trong Ví KM 2"
Số dư Ví KM 2DisplayBlockKhi chip selected
Eligible totalDisplayBlockKhi chip selected"Tổng giá trị các SP/DV bật Cho phép Ví KM 2"
Tối đa (max%)DisplayBlockKhi chip selected"Eligible × {max%} = số tiền KM2 tối đa cho đơn này"
Số tiền KM 2 áp dụngDisplay readonlyBlockAuto-fillReadonlyKhi chip selected"Tự động tính = MIN(Eligible × max%, Số dư)"
Còn lại cần trảDisplayBlockKhi chip selected"Tổng đơn - Số tiền KM 2 áp dụng"

B2.4.5) Filter

N/A.

B2.4.6) Quy ước tương tác

Tình huốngHành vi
Bấm chip "Ví KM 2"Block calc hiện ngay; auto-fill số tiền; không cho NV sửa
Bấm chip lần 2 (deselect)Ẩn block, restore phương thức thanh toán theo input cash hiện tại
Bấm chip KM 1 khi đang chọn KM 2 (config allow_combine_km1=false)Tự bỏ chọn KM 2 + toast giải thích
Bấm chip KM 1 khi đang chọn KM 2 (config allow_combine_km1=true)Cả 2 chip selected; KM 2 ưu tiên tính trước
Item bật/tắt eligible giữa sessionChip refetch tự động khi user reload đơn; FE không cache cứng
SubmitBackend deduct_km2_payment action với idempotency_key; nếu thành công → trừ FIFO + ghi wallet_km2_lot_deduction
Race condition (lot vừa hết hạn)BE 409 hoặc 422 → toast + reload số dư

B2.4.7B) Form Interaction Deep

Khía cạnhQuy ước
Trigger validationOn submit
Idempotencyclient_request_id UUID gen client; backend dedupe
Required markerN/A

B2.4.7C) Concurrency

Tình huốngHành vi
2 NV thanh toán cùng kháchBE SELECT FOR UPDATE lot; user 2 nhận 409 → toast + reload
Khách dùng app cùng lúcKM 2 không có app self-serve Day-1; không xung đột

B2.4.7D) Network resilience

Tình huốngHành vi
Mất mạng giữa lúc thanh toánDisable nút, hiện "Đang chờ kết nối…"; auto retry với cùng client_request_id; nếu BE đã trừ → idempotent return cùng kết quả
Response > 5sSpinner + nút Huỷ sau 10s
Response > 30sAuto huỷ + toast lỗi

B2.4.8) Phân quyền

PortalVai tròActionHiển thịKhi thu hồi
posSale/Manager/Adminservice_order:payment / product_order:paymentChip hiệnẨn chip, nút Thanh toán disabled
posKhácChip ẩn

B2.4.8A) Role × Variant

RolePermissionVariant
Sale POS*:paymentA-J
Manager POS*:payment + view_allCùng + có thể xem khách all-branch
Sale POS thiếu paymentK — chip ẩn

B2.4.9) Trạng thái

Trạng tháiHiển thị
Đang tải wallet statsSkeleton chip 200ms
Variant AChip + block hoạt động
Variant B/C/DChip ẩn (no-render)
Variant E2 chip selected, block KM 2 hiện trước
Variant FToast giải thích, KM 1 chip bỏ chọn
Variant GAuto-fill = balance
Variant HSkeleton
Variant IToast race + reload
Variant JToast partial success
LỗiToast lỗi + nút Thử lại

B2.4.10) Action Feedback

Hành độngPhản hồiCopy
Thanh toán thành côngToast successĐã thanh toán {tổng}đ. KM 2 áp dụng: {km2}đ. Số dư còn: {còn}đ.
Thanh toán thất bại 422 (số dư)Toast danger + reloadSố dư Ví KM 2 không đủ. Vui lòng thử lại.
Thanh toán thất bại 409 (race)Toast danger + reloadSố dư đã thay đổi từ thiết bị khác. Đã tải lại số dư mới.
Multi-payment partialToast warningĐã trừ {km2}đ Ví KM 2. Phần tiền mặt {x}đ thất bại. Vui lòng thử lại phần còn lại.
Lỗi networkToast retryMạng yếu, đang xử lý...

B2.4.10A) Error Taxonomy

LoạiUIRecoveryCopy
Validation server (lot expired)Toast + reloadUser chọn lạiMột lot vừa hết hạn. Đã tính lại số dư. Vui lòng thử lại.
Validation server (eligible thay đổi)Toast + reloadUser reload đơnSP/DV vừa thay đổi. Vui lòng tải lại đơn.
Quyền 403ToastLiên hệ ManagerBạn không có quyền thanh toán đơn này
Conflict 409Toast + reloadTự độngSố dư đã thay đổi. Đã tải lại.
422 (insufficient)ToastReload + thử lạiSố dư Ví KM 2 không đủ
Network/timeoutToast retryAuto/manualKhông thể kết nối. Vui lòng thử lại.
5xxToast + correlation_idBáo supportCó sự cố từ hệ thống. Mã: {trace_id}

B2.4.11) Lifecycle UI mapping (cho lot bị trừ)

Trạng thái lot trước trừHành vi sau trừ
active, balance còn sau khi trừVẫn active, balance giảm
active, balance = 0 sau khi trừChuyển exhausted
expiredKhông trừ (FIFO skip), không hiển thị trong tính toán
refundedKhông trừ

B2.5) SCR-05: Flag eligible per SP/DV

B2.5.1) Ngữ cảnh nghiệp vụ

Câu hỏiQuyết định
Ai dùng?Admin / owner — internal_configuration:update
Vào màn để quyết định gì?Bật/tắt allow_promo_wallet_2 cho SP/DV để quyết định item nào được trừ KM 2
Dữ liệu chínhproduct.allow_promo_wallet_2, service.allow_promo_wallet_2
CTA chínhToggle (auto-save sau confirm)
Điều không được hiểu nhầmToggle KM 2 độc lập với toggle KM 1; bật KM 2 không tự bật KM 1 và ngược lại

B2.5.1A) Nguyên tắc UX bắt buộc

Nguyên tắcQuyết định
Mục tiêu user trong 5 giâyAdmin biết item này có cho phép trừ KM 2 hay không
Thứ tự ưu tiênToggle KM 1 (hiện hữu) → Toggle KM 2 (mới) — cùng card "Trạng thái"
Thuật ngữ canonical"Cho phép Ví KM 2" / "Cho phép Ví KM 1"
Thiết kế thị giácToggle bật intent success; toggle tắt intent muted
Điều không được tự suy diễnKHÔNG bulk enable Day-1 (deferred); Admin tự bật từng item

B2.5.2) Bố cục — Demo gắn vào UI hiện tại

/ecommerce/product/:id hoặc /ecommerce/service/:id (extend ProductDetail.tsx / ServiceDetailDetail.tsx)

text
┌──────────────────────────────────────────────────────────────────────┐
│ CHI TIẾT DỊCH VỤ — Massage body 90 phút                              │
├──────────────────────────────────────────────────────────────────────┤
│ ...                                                                  │
│                                                                      │
│ ┌─── Trạng thái ──────────────────────────────────────────────────┐ │
│ │  Hiển thị trên POS                       [▣] Bật                │ │
│ │  Cho phép Ví khuyến mãi (KM 1)            [▣] Bật               │ │
│ │  Cho phép Ví KM 2  ★ MỚI                  [□] Tắt               │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│                                                                      │
│ ...                                                                  │
└──────────────────────────────────────────────────────────────────────┘

Vùng KEEP: card Trạng thái + 2 toggle hiện có. Vùng NEW: 1 dòng toggle "Cho phép Ví KM 2".

B2.5.2.0) Ma trận Biến thể

Variant IDĐiều kiệnHiển thịQA ref
Variant A — Toggle bậtallow_promo_wallet_2=trueToggle xanh, label "Bật"TC-SCR05-A-01
Variant B — Toggle tắtallow_promo_wallet_2=false (default)Toggle xám, label "Tắt"TC-SCR05-B-01
Variant C — POS portalUser vào từ POSToggle readonly (chỉ xem)TC-SCR05-C-01
Variant D — Confirm dialogUser click toggleModal "Bạn có chắc muốn cho phép thanh toán bằng Ví KM 2?"TC-SCR05-D-01
Variant E — No permissionUser không có internal_configuration:updateToggle ẩn hoặc readonlyTC-PERM-SCR05-01

B2.5.3) Phân loại reuse

Phân loạiFileVị tríLý do
🔧 ExtendProductAllowPromoWalletToggle.tsx (pattern KM 1)Thêm 1 dòng toggle dưới toggle KM 1 trong cùng componentPattern hiện hữu chuẩn

B2.5.4-2.5.11) Field / Permission / State / Feedback

(Reuse pattern KM 1 — chỉ thêm allow_promo_wallet_2 field. Tất cả validation / state / feedback tương tự KM 1.)

Khía cạnhKM 2
Fieldproduct.allow_promo_wallet_2 / service.allow_promo_wallet_2 (Boolean)
Defaultfalse
Permissioninternal_configuration:update, portal Admin
Confirm dialog"Bạn có chắc muốn {bật/tắt} cho phép thanh toán bằng Ví KM 2?"
Toast successĐã cập nhật cho phép Ví KM 2 cho "{tên item}"
Toast lỗiCập nhật thất bại. Vui lòng thử lại.

B2.6) SCR-06: Tab Ví KM 2 trong customer profile

B2.6.1) Ngữ cảnh nghiệp vụ

Câu hỏiQuyết định
Ai dùng?Staff/Sale/Manager/Admin — customer_management:access (theo branch_mode)
Vào màn để quyết định gì?Xem tổng số dư + danh sách lot KM 2 (đang HĐ + đã hết/hoàn) + cảnh báo expiry + tạo yêu cầu Hoàn ví KM2
Dữ liệu chínhAction get_customer_km2_balance cho card tổng số dư realtime (DEC-029); action get_customer_km2_lots (least-data, kiểm tra branch_mode) cho 2 bảng chi tiết; wallet_stats cho các ví khác (không phải KM 2)
CTA chính / phụPrimary (per row): Hoàn ví KM2. Secondary: filter, breadcrumb back
Điều không được hiểu nhầmSố dư = tổng lot.balance trạng thái active với expired_at > NOW(); không tính expired/exhausted/refunded

B2.6.1A) Nguyên tắc UX bắt buộc

Nguyên tắcQuyết định
Mục tiêu user trong 5 giâyNV thấy: tổng số dư KM 2 + lot nào sắp hết hạn cần đề xuất khách dùng
Thứ tự ưu tiên(1) Banner cảnh báo (nếu có lot ≤ 30 ngày) → (2) Tổng số dư → (3) Bảng lot active → (4) Bảng lot đã hết/hoàn
Dữ liệu nhạy cảmSố dư tiền — render least-data theo customer/branch scope
Pending / partial dataLot active nhưng credited_amount < initial_amount (đơn còn nợ): hiện inline label "Đã nạp X/Y" trong cột "Đã nạp"; vẫn cộng balance (phần đã nạp) vào tổng số dư vì khách đã thanh toán phần này
Thuật ngữ canonical"Đang hoạt động", "Đã hết hạn", "Đã dùng hết", "Đã hoàn"
Thiết kế thị giácBanner ≤ 7 ngày intent danger; ≤ 30 ngày intent warning; cột Còn lại intent primary emphasis
Điều không được tự suy diễnKHÔNG mở direct GraphQL select toàn bộ lot/deduction; chỉ qua action có check quyền customer/branch

B2.6.2) Bố cục — Demo gắn vào UI hiện tại

/user/customer/:id (extend CustomerEWalletInformation.tsx + StatisticCustomerWallets.tsx + 2 popup mới: CustomerKm2WalletPopup.tsx + StatisticWalletPromotion2Popup.tsx)

Sidebar Thống kê ví — bám sát StatisticCustomerWallets.tsx:30-58 (6 card hiện hữu + 1 card mới KM 2 thứ 7):

text
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ THÔNG TIN KHÁCH HÀNG — Nguyễn Thị Lan      [👤 Avatar]   0909 123 456                        │
├──────────────────────────────────────────────────────────────────────────────────────────────┤
│ ┌─── Section "Thống kê ví" — 7 card (6 hiện hữu + 1 mới) ───────────────────────────────┐  │
│ │                                                                                          │  │
│ │ Hàng 1 (4 card đầu):                                                                     │  │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐                          │  │
│ │ │ Doanh số  ⓘ │ │ Thực thu  ⓘ │ │ Công nợ   ⓘ │ │ Ví CTV    ⓘ │   ← KEEP                  │  │
│ │ │12.500.000đ  │ │ 8.200.000đ  │ │      0đ     │ │ 250.000đ    │      (bg: green/blue/    │  │
│ │ │             │ │             │ │             │ │  (clickable │       red/orange)         │  │
│ │ │             │ │             │ │             │ │   → withdraw│                           │  │
│ │ │             │ │             │ │             │ │   history)  │                           │  │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘                          │  │
│ │                                                                                          │  │
│ │ Hàng 2 (3 card ví + 1 card mới):                                                         │  │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐  ┌──────────────┐                       │  │
│ │ │ Ví Diva   ⓘ │ │ Ví Khuyến  ⓘ│ │ Ví Khuyến  ⓘ│  │              │                       │  │
│ │ │ 1.500.000đ  │ │ mãi         │ │ mãi 2    ★  │  │              │                       │  │
│ │ │ (clickable  │ │   800.000đ  │ │ 8.500.000đ  │  │   (trống)    │                       │  │
│ │ │  → popup    │ │ (clickable  │ │ (clickable  │  │              │                       │  │
│ │ │  Vnd lịch sử│ │  → popup KM1│ │  → popup KM2│  │              │                       │  │
│ │ │  hiện hữu)  │ │  lịch sử    │ │  lịch sử    │  │              │                       │  │
│ │ │             │ │  hiện hữu)  │ │  MỚI)       │  │              │                       │  │
│ │ │ bg: #FAF3E6 │ │ bg: #F6F5FF │ │ bg: #FFF0FA │  │              │                       │  │
│ │ └─────────────┘ └─────────────┘ └─────────────┘  └──────────────┘                       │  │
│ │                                                                                          │  │
│ │   KEEP            KEEP            NEW (card 7 mới — SCR-06-NEW-01)                       │  │
│ │                                                                                          │  │
│ └──────────────────────────────────────────────────────────────────────────────────────────┘  │
│ ...                                                                                          │
└──────────────────────────────────────────────────────────────────────────────────────────────┘

Reference statConfig hiện hữu (`StatisticCustomerWallets.tsx:30-37`):
  { name: "sales",          label: "Doanh số",      bgColor: "#E7F6E9" }
  { name: "actual_revenue", label: "Thực thu",      bgColor: "#F2F7FF" }
  { name: "debt",           label: "Công nợ",       bgColor: "#FDE8E6" }
  { name: "commission",     label: "Ví CTV",        bgColor: "#FFF3E0" }
  { name: "vnd",            label: "Ví Diva",       bgColor: "#FAF3E6" }
  { name: "vnd_promotion",  label: "Ví Khuyến mãi", bgColor: "#F6F5FF" }

Thêm row mới (SCR-06-NEW-01):
  { name: "vnd_promotion_2", label: "Ví Khuyến mãi 2", bgColor: "#FFF0FA" }    ★ NEW

handleClick mở rộng (`StatisticCustomerWallets.tsx:46-57`):
  if (itemName === "vnd_promotion_2") {
    promotion2Visible.value = true;                  ★ NEW state ref
  }

2 Popup khi click vào card Ví KM 2:

text
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ Khi click card "Ví Khuyến mãi 2" trong sidebar → 1 popup mở ra (PO chốt UX):                 │
│                                                                                              │
│ • PHƯƠNG ÁN HIỆN TẠI (đồng bộ pattern KM 1):                                                 │
│     Click card → mở thẳng `StatisticWalletPromotion2Popup` (lịch sử giao dịch)               │
│     Có nút "Xem chi tiết Gói" trong header → mở `CustomerKm2WalletPopup` (chi tiết lot)      │
│                                                                                              │
│ • TUỲ CHỌN (nếu PO muốn đảo):                                                                │
│     Click card → mở `CustomerKm2WalletPopup` trước (chi tiết lot — khác KM 1)                │
│     Có nút "Xem lịch sử giao dịch" trong popup → mở `StatisticWalletPromotion2Popup`         │
│                                                                                              │
│ (Spec không ràng buộc trigger nào trước, dev follow pattern KM 1 mặc định)                   │
└──────────────────────────────────────────────────────────────────────────────────────────────┘

(A) Popup CustomerKm2WalletPopup — chi tiết LOT (SCR-06-NEW-02):

text
┌──────────────────────────────────────────────────────────────────────────────┐
│ VÍ KHUYẾN MÃI 2 — Nguyễn Thị Lan          Số dư: 8.500.000đ      [ × Đóng ]  │
├──────────────────────────────────────────────────────────────────────────────┤
│ [ Xem lịch sử giao dịch ▸ ]  (mở popup B)                                    │
│                                                                              │
│ ⚠ CẢNH BÁO HẾT HẠN                                                           │
│ ┌────────────────────────────────────────────────────────────────────────┐  │
│ │ ⛔ Gói Silver #1 còn 500.000đ — hết hạn 01/04/2026 (còn  8 ngày) (đỏ)  │  │
│ │ ⚠ Gói Gold #1 còn 2.500.000đ — hết hạn 15/04/2026 (còn 22 ngày) (vàng)│  │
│ └────────────────────────────────────────────────────────────────────────┘  │
│                                                                              │
│ Lọc: [ Trạng thái: Tất cả  v ]  [ Gói: Tất cả  v ]                           │
│                                                                              │
│ GÓI ĐANG HOẠT ĐỘNG                                                           │
│ ┌────┬──────────┬───────────┬───────────┬───────────┬───────────┬─────────┐ │
│ │ #  │ Tên Gói   │ Đã nạp    │ Đã dùng   │ Còn lại   │ Hết hạn   │ Action  │ │
│ ├────┼──────────┼───────────┼───────────┼───────────┼───────────┼─────────┤ │
│ │ 1  │ Gói Gold  │5.000.000đ │2.500.000đ │2.500.000đ │15/04/2026 │[Hoàn]   │ │
│ │ 2  │ Gói Gold  │5.000.000đ │1.500.000đ │3.500.000đ │24/09/2026 │[Hoàn]   │ │
│ │ 3  │ Gói Silver│1.500.000đ │1.000.000đ │  500.000đ │01/04/2026 │[Hoàn]   │ │
│ └────┴──────────┴───────────┴───────────┴───────────┴───────────┴─────────┘ │
│                                                                              │
│ GÓI ĐÃ HẾT / ĐÃ HOÀN                                                         │
│ ┌────┬──────────┬───────────┬───────────┬──────────┬──────────┐              │
│ │ #  │ Tên Gói   │ Đã nạp    │ Đã dùng   │ Trạng    │ Ngày     │              │
│ ├────┼──────────┼───────────┼───────────┼──────────┼──────────┤              │
│ │ 4  │ Gói Silver│1.500.000đ │1.500.000đ │Đã dùng hết│01/01/2026│              │
│ │ 5  │ Gói Gold  │5.000.000đ │  500.000đ │Đã hoàn    │15/02/2026│              │
│ │ 6  │ Gói Silver│1.500.000đ │  800.000đ │Đã hết hạn │28/02/2026│              │
│ └────┴──────────┴───────────┴───────────┴──────────┴──────────┘              │
│                                                                              │
│ (Click 1 dòng → mở drawer Lịch sử giao dịch của lot này)                     │
└──────────────────────────────────────────────────────────────────────────────┘

(B) Popup StatisticWalletPromotion2Popup — lịch sử giao dịch toàn ví (SCR-06-NEW-03): xem chi tiết wireframe tại B2.6.6A.

Vùng KEEP: card wallet stats sidebar trái (6 card hiện hữu) + thông tin khách. Vùng NEW: 1 card thứ 7 "Ví Khuyến mãi 2" + 2 popup mới (CustomerKm2WalletPopup chi tiết lot + StatisticWalletPromotion2Popup lịch sử giao dịch toàn ví) + banner cảnh báo + filter + CTA Hoàn ví KM2 per row.

B2.6.2.0) Ma trận Biến thể

Variant IDĐiều kiệnHiển thịQA ref
Variant A — Có lot activeKhách có ≥ 1 lot active, balance > 0Card hiện balance + popup hiện 2 bảngTC-SCR06-A-01
Variant B — Có lot sắp hết hạn ≤ 30 ngày≥ 1 lot active có expired_at - NOW() ≤ 30 ngàyBanner warning trong popupTC-SCR06-B-01
Variant C — Có lot sắp hết hạn ≤ 7 ngày≥ 1 lot active ≤ 7 ngàyBanner dangerTC-SCR06-C-01
Variant D — Có lot active đang nợLot activecredited_amount < initial_amount (khách đã trả ≥ 1 lần nhưng chưa đủ)Card hiện balance theo phần đã nạp; popup bảng "Đang hoạt động" có cột "Đã nạp" hiển thị "X/Y" để NV biết khách còn nợTC-SCR06-D-01
Variant E — EmptyKhách chưa từng có lotCard vẫn hiện "0đ" (consistency); popup empty "Khách chưa có Gói Ví KM2 nào trong Ví KM 2"TC-SCR06-E-01
Variant F — LoadingĐang fetchSkeleton card + popupTC-SCR06-F-01
Variant G — Lot expired hôm nay≥ 1 lot vừa expired qua đêmBảng "Đã hết / hoàn" có dòng mới + banner đẩy lên section trênTC-SCR06-G-01
Variant H — No permission customerUser không có quyền xem kháchCard ẩn (no-render)TC-PERM-SCR06-01
Variant I — Có quyền xem KH nhưng thiếu refund_request:createStaff bình thườngCard + popup hiện đầy đủ; CTA Hoàn ví KM2 ẩn ở từng rowTC-PERM-SCR06-02
Variant J — Cross-branchLot mua ở branch A, user xem ở branch BHiển thị bình thường nếu user có quyền xem khách; cột "Branch mua" có thể xem trong drawer chi tiếtTC-SCR06-J-01

B2.6.3) Phân loại reuse

Phân loạiFile hiện cóVị trí updateLý do
🔧 ExtendCustomerEWalletInformation.tsx, StatisticCustomerWallets.tsxThêm card Ví KM 2 trong wallet stats; thêm component popup CustomerKm2WalletPopup.tsxPattern card + popup hiện hữu cho các ví khác

B2.6.4) Quy ước cột bảng

Bảng "Gói đang hoạt động":

CộtWidthAlignFormatSortable
#40pxCenterAutoNo
Tên Gói150pxLeftText snapshot package_name
Đã nạp130pxRightCurrency wallet_value
Đã dùng130pxRightCurrency (calc)
Còn lại130pxRightCurrency, intent primary emphasis
Hết hạn120pxCenterDD/MM/YYYY, intent danger ≤ 7 ngày, warning ≤ 30 ngày
Action110pxCenterNút Hoàn ví KM2 (conditional)No

Bảng "Đã hết / hoàn":

CộtWidthAlignFormatSortable
#40pxCenterAutoNo
Tên Gói150pxLeftText
Đã nạp130pxRightCurrency
Đã dùng130pxRightCurrency
Trạng thái120pxCenterBadge: Đã dùng hết (success), Đã hết hạn (muted), Đã hoàn (warning)
Ngày120pxCenterDD/MM/YYYY

B2.6.5) Filter

#ComponentMặc địnhOptions
1Trạng tháiTất cảTất cả / Đang hoạt động / Đã hết hạn / Đã dùng hết / Đã hoàn
2Tên GóiTất cảTất cả / + danh sách Gói KH đã mua

B2.6.6) Drawer "Lịch sử giao dịch lot" (click row)

Bảng lịch sử trong drawer phải hiện:

CộtFormatGhi chú
NgàyDD/MM/YYYY HH:mmTheo created_at deduction
LoạiBadgepayment (xanh), refund_back (vàng), refund_request (cam)
Số tiềnCurrencyTrừ thanh toán = âm; hoàn ngược = dương
Đơn / Yêu cầuLinkClick mở chi tiết đơn hoặc request
BranchTextChi nhánh phát sinh

B2.6.6A) Popup "Lịch sử giao dịch Ví Khuyến Mãi 2" (SCR-06-NEW-03 — parity với popup KM 1 hiện hữu)

Ngữ cảnh: Popup KM 1 hiện có (StatisticWalletPromotionPopup.tsx) đã hiển thị lịch sử giao dịch ví khuyến mãi (KM 1) cho khách. Parity cần thêm popup tương ứng cho Ví Khuyến Mãi 2.

Trigger:

  • Click vào card "Ví Khuyến Mãi 2" trong StatisticCustomerWallets.tsx (đồng bộ trigger KM 1) HOẶC
  • Nút "Xem lịch sử giao dịch" trong popup CustomerKm2WalletPopup (cùng tồn tại — popup chi tiết lot)

File mới: diva-admin/src/modules/user/components/customer/StatisticWalletPromotion2Popup.tsx — copy y nguyên pattern từ StatisticWalletPromotionPopup.tsx, đổi 3 chỗ:

Chỗ thay đổiKM 1 (gốc)KM 2 (mới)
Title header (StatisticWalletPromotionPopup.tsx:521)"LỊCH SỬ GIAO DỊCH VÍ KHUYẾN MÃI""LỊCH SỬ GIAO DỊCH VÍ KHUYẾN MÃI 2"
Filter GraphQL variables.where.wallet_type_id (:81-85){ _eq: "VND_PROMOTION" }{ _eq: "VND_PROMOTION_2" }
getTransactionDetailTypeLabel arg 2 (:426)"Ví Khuyến mãi""Ví Khuyến mãi 2"

Wireframe ASCII — bám sát popup KM 1 hiện hữu (10 cột table, width 1400px / 80vw):

text
Click "Ví Khuyến Mãi 2" 8.500.000đ trong sidebar StatisticCustomerWallets → mở popup:

┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ LỊCH SỬ GIAO DỊCH VÍ KHUYẾN MÃI 2 — Nguyễn Thị Lan                                                  [ × Đóng ] │
├────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│                                                                                                                │
│ ┌── Bộ lọc ───────────────────────────────────────────────────────────────────────────────────────────────┐  │
│ │  Từ ngày: [ 15/04/2026 ▾ ]    Đến ngày: [ 15/05/2026 ▾ ]    Chi nhánh: [ Tất cả ▾ ]    Trạng thái: [▾]  │  │
│ └──────────────────────────────────────────────────────────────────────────────────────────────────────────┘  │
│                                                                                                                │
│ Tham chiếu: Tổng số tiền theo bộ lọc:   +5.200.000đ                                                            │
│                                                                                                                │
│ ┌─────┬──────────────────┬─────────────────┬───────────────┬───────────────┬──────────────┬─────────────┬──────────────┬────────────┬───────────────┐  │
│ │ STT │ THỜI GIAN GIAO   │ MÃ THANH TOÁN   │ NGUỒN TIỀN    │ LOẠI GD       │ SỐ TIỀN      │ SỐ DƯ SAU   │ TRẠNG THÁI   │ ĐƠN HÀNG   │ CHI NHÁNH     │  │
│ │     │ DỊCH             │                 │               │               │              │             │              │            │               │  │
│ ├─────┼──────────────────┼─────────────────┼───────────────┼───────────────┼──────────────┼─────────────┼──────────────┼────────────┼───────────────┤  │
│ │  1  │ 14:30, 24/04/26  │ TT-20260424-001 │ Ví Khuyến mãi 2│ Thanh toán   │ -1.000.000đ │ 4.700.000đ │ ●Hoàn thành  │ DH-260424  │ CN Quận 1     │  │
│ │  2  │ 09:15, 22/04/26  │ TT-20260422-002 │ Ví Khuyến mãi 2│ Thanh toán   │   -500.000đ │ 5.700.000đ │ ●Hoàn thành  │ DH-260422  │ CN Quận 1     │  │
│ │  3  │ 16:45, 18/04/26  │ NT-20260418-003 │ Ví Khuyến mãi 2│ Nạp tiền     │ +5.000.000đ │ 6.200.000đ │ ●Hoàn thành  │ NT-260418  │ CN Quận 1     │  │
│ │  4  │ 11:00, 15/04/26  │ HV-20260415-004 │ Ví Khuyến mãi 2│ Hoàn ví KM2  │ +1.200.000đ │ 1.200.000đ │ ●Hoàn thành  │ YC-260415  │ CN Thủ Đức    │  │
│ │  5  │ 23:59, 12/04/26  │ HH-20260412-005 │ Ví Khuyến mãi 2│ Hết hạn      │   -800.000đ │     0đ     │ ●Hoàn thành  │ —          │ —             │  │
│ │ ... │                                                                                                                                            │  │
│ └─────┴──────────────────┴─────────────────┴───────────────┴───────────────┴──────────────┴─────────────┴──────────────┴────────────┴───────────────┘  │
│                                                                                                                │
│                       « Trang [ 1 ] [ 2 ] [ 3 ] »  (Hiển thị 20 / 47 giao dịch)                                │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

Reference: bám sát chính xác layout StatisticWalletPromotionPopup.tsx:509-547 — width 1400px max 80vw, 10 cột table, filter trên đỉnh, pagination ở dưới.

Bảng cột (theo StatisticWalletPromotionPopup.tsx:320-331):

CộtFormatGhi chú
STTNumberrowIndex + 1 (auto-generate per page)
THỜI GIAN GIAO DỊCHHH:mm, DD/MM/YYYYTheo transaction_request.created_at
MÃ THANH TOÁNCodetransaction_request.code (fallback --)
NGUỒN TIỀNText"Ví Khuyến mãi 2" (label gốc — getTransactionDetailTypeLabel(behavior, "Ví Khuyến mãi 2"))
LOẠI GIAO DỊCHBadgeThanh toán / Hoàn ví KM2 / Hết hạn / Nạp tiền (theo behavior.name)
SỐ TIỀNCurrency coloredÂm = đỏ #DE2C00; Dương = xanh #33B64F; 0 = đen
SỐ DƯ SAUCurrencywallet_transaction.after_amount (fallback --)
TRẠNG THÁIBadge pillHoàn thành (xanh) / Đang yêu cầu (vàng) / Đang xử lý (cam) / Thanh toán thất bại (đỏ) / Đã hủy (đỏ)
ĐƠN HÀNGLink xanh #2182DCClick mở chi tiết đơn (cùng tab _blank) — resolve theo order.order_kind
CHI NHÁNHTextbranch.name (fallback --)

Filter (theo StatisticWalletPromotionPopup.tsx:338-373):

  • Date range — Từ ngày + Đến ngày (mặc định 30 ngày gần đây); format ISO YYYY-MM-DDTHH:MM:SS+00:00_gte/_lte
  • Chi nhánh — Multi-select (theo branch scope của user); filter branch_id: { _in: [...] }
  • Trạng thái — Single-select dropdown: Tất cả / Hoàn thành (S) / Đang yêu cầu (R) / Đang xử lý (P) / Thanh toán thất bại (F) / Đã hủy (C/Reject)
  • PaginationDEFAULT_LIMIT per page (đồng bộ với popup KM 1); reset page về 1 khi đổi filter

Reference Amount (header bảng): Tổng số tiền theo bộ lọc = transaction_aggregate.aggregate.sum.amount (theo :315-317). Hiển thị màu xanh nếu dương, đỏ nếu âm.

Lưu ý implementation:

  • Option α (đã chốt): tạo file riêng StatisticWalletPromotion2Popup.tsx — copy pattern KM 1
  • KHÔNG refactor popup KM 1 thành generic (giữ minimal blast radius); nếu Phase 2+ thêm KM 3 thì cân nhắc refactor DRY
  • Reuse: XPagination, XSelect, QDialog, QTable, LuckyDrawWheelPrizeProductCreateHeader (header dùng chung), formatCurrency, formatDateTime

B2.6.7) Quy ước tương tác

Tình huốngHành vi
Click card "Ví KM 2"Mở popup overlay CustomerKm2WalletPopup (60vw desktop, 90vw tablet) — chi tiết LOT
Click row trong popup CustomerKm2WalletPopupMở drawer "Lịch sử giao dịch lot" (lot-scoped)
Click "Xem lịch sử giao dịch" trong CustomerKm2WalletPopup HOẶC click balance trong card Ví KM 2Mở popup StatisticWalletPromotion2Popup (transactional history toàn ví)
Click Hoàn ví KM2Mở SCR-07 dialog (xếp chồng popup)
Đổi filterRe-render bảng client-side (data đã fetch)
Banner cảnh báo click vào lotScroll bảng đến dòng tương ứng + highlight 2s
Đóng popupEsc hoặc nút ×

B2.6.7B) Form Interaction (drawer/popup)

Khía cạnhQuy ước
Focus trapPopup + drawer trap; Esc đóng nếu không có dirty
Keyboard navTab xuyên suốt; Enter chọn row; Esc đóng

B2.6.7C) Concurrency

Tình huốngHành vi
2 NV cùng mở profileCùng đọc, không xung đột
Lot vừa hết hạn khi user đang xemPolling 60s refresh (chỉ khi popup mở); banner cập nhật
Lot vừa bị refund từ user khácTương tự, polling cập nhật

B2.6.7D) Network resilience

Tình huốngHành vi
Mất mạng khi mở popupPopup empty + banner "Mất kết nối" + nút Tải lại
Có mạng lạiAuto refetch

B2.6.8) Phân quyền

PortalVai tròActionHiển thị cardHiển thị CTA Hoàn
admin/pos/crmStaff/Sale/Manager/Admincustomer_management:accessCó theo scopeTheo refund_request_management_submenu:create
admin/pos/crmAdmin all-branchcustomer_management:view_allToàn hệ thống

B2.6.8A) Role × Variant

RolePermissionVariant
Staff bình thườngcustomer_management:access (branch self)A-G; CTA Hoàn ẩn (Variant I)
Managercustomer_management:access + branch_mode=branchA-G + CTA Hoàn nếu có refund_request_management_submenu:create
Kế toánrefund_request_management_submenu:* đầy đủA-G + CTA Hoàn hoạt động
Admincustomer_management:view_allCross-branch (Variant J)
User ngoài quyền customerH — card ẩn

B2.6.9) Trạng thái

Trạng tháiHiển thị
Đang tảiSkeleton card + popup skeleton 5 row × 2 bảng
EmptyCard "0đ" + popup "Khách chưa có Gói Ví KM2 nào"
Có lot active2 bảng + banner cảnh báo nếu có
LỗiToast + nút Thử lại
No permissionCard ẩn

B2.6.10) Action Feedback

Hành độngPhản hồiCopy
Click Hoàn ví KM2 thiếu quyềnTooltip không cho clickCần quyền tạo Yêu cầu hoàn tiền
Click Hoàn ví KM2 lot expired (race)ModalLot vừa hết hạn. Vui lòng tải lại.
Đóng popup khi đang loadCho đóng

B2.6.10A) Error Taxonomy

LoạiUIRecoveryCopy
403 (no permission customer)Toast + redirect customer listLiên hệ AdminBạn không có quyền xem khách hàng này
410 (customer xoá)ToastQuay listKhách hàng không còn tồn tại
Network/timeoutBanner + retryAuto/manualKhông thể tải. Vui lòng thử lại.

B2.6.11) Lifecycle UI mapping

Trạng thái lotBảngAction buttonBadge
activeĐang hoạt độngHoàn ví KM2 (nếu eligible)"Đang hoạt động"
expiredĐã hết / hoàn"Đã hết hạn" (muted)
exhaustedĐã hết / hoàn"Đã dùng hết" (success)
refundedĐã hết / hoàn"Đã hoàn" (warning)

B2.7) SCR-07: Form Hoàn ví KM2 trong Yêu cầu hoàn tiền

B2.7.1) Ngữ cảnh nghiệp vụ

Câu hỏiQuyết định
Ai dùng?Người tạo: kế toán/Manager/Staff có refund_request_management_submenu:create. Người duyệt: reviewer hợp lệ với :approve. Người thanh toán: kế toán với :payment
Vào màn để quyết định gì?Tính toán tiền hoàn cho khách (theo FORMULA-002), chốt phương thức hoàn (cash/wallet/bank_transfer), tạo transaction_request behavior refund_km2_wallet đi vào luồng Yêu cầu hoàn tiền hiện có
Dữ liệu chínhSnapshot wallet_km2_lot + wallet_km2_config (policy_snapshot); kết quả tạo transaction_request
CTA chính / phụPrimary: Tạo yêu cầu hoàn tiền. Secondary: Huỷ, Đóng, Xem công thức
Điều không được hiểu nhầm"Khách nhận = MAX(remaining × tỉ_lệ_hoàn − phí_xử_lý, 0)"; tỉ lệ hoàn = giá mua gói / giá trị ví

B2.7.1A) Nguyên tắc UX bắt buộc

Nguyên tắcQuyết định
Mục tiêu user trong 5 giâyNgười tạo biết KHÁCH NHẬN bao nhiêu và phương thức hoàn
Thứ tự ưu tiên(1) Thông tin Gói KM2 → (2) Tính toán hoàn tiền (số to nhất "KHÁCH NHẬN") → (3) Chọn phương thức + ghi chú → (4) CTA Tạo yêu cầu
Dữ liệu nhạy cảmSố tiền cụ thể; chỉ user có quyền customer + refund mới mở được dialog
Pending / partial dataNếu phí > số hoàn → "Khách nhận = 0đ" + cảnh báo, vẫn cho submit (tất toán lot, khách không nhận tiền)
Thuật ngữ canonical"Hoàn ví KM2", "Phí xử lý hoàn ví", "Tỉ lệ hoàn", "Khách nhận"; KHÔNG dùng "Refund ticket" / "Hoàn vé"
Thiết kế thị giác"KHÁCH NHẬN" intent success nếu > 0; intent negative-value + warning nếu = 0; phí hiện trong block calc intent muted
Điều không được tự suy diễnKHÔNG tạo approval module riêng — request đi vào màn Yêu cầu hoàn tiền hiện có; duyệt/thanh toán xảy ra ở màn đó, không trong dialog này

B2.7.2) Bố cục

text
┌──────────────────────────────────────────────────────────────────────┐
│ HOÀN VÍ KM 2                                              [ × Đóng ] │
├──────────────────────────────────────────────────────────────────────┤
│                                                                      │
│ THÔNG TIN GÓI VÍ KM 2                                                │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │  Khách hàng:        Nguyễn Thị Lan (0909 123 456)                │ │
│ │  Tên Gói:           Gói Gold                                     │ │
│ │  Giá mua gói:       500.000đ                                     │ │
│ │  Giá trị ví:        5.000.000đ                                   │ │
│ │  Tỉ lệ hoàn:        10,00%   (= 500.000 / 5.000.000)             │ │
│ │  Đã dùng:           2.500.000đ                                   │ │
│ │  Còn lại:           2.500.000đ                                   │ │
│ │  Mua ngày:          24/03/2026                                   │ │
│ │  Hết hạn:           24/09/2026                                   │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│                                                                      │
│ TÍNH TOÁN HOÀN TIỀN  ⓘ Xem công thức                                 │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │  Hoàn theo tỉ lệ:   250.000đ   (= 2.500.000 × 10%)               │ │
│ │  Phí xử lý hoàn ví: 100.000đ   (= 500.000 × 20%)                 │ │
│ │  ─────────────────────────────────────────                       │ │
│ │  KHÁCH NHẬN:        150.000đ   (= MAX(250.000 − 100.000, 0))     │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│                                                                      │
│ TẠO YÊU CẦU HOÀN TIỀN                                                │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │  Loại yêu cầu:      [ Hoàn ví KM 2 ]   (readonly)                │ │
│ │  Hoàn vào:          (•) Tiền mặt                                 │ │
│ │                     ( ) Ví VND của khách                         │ │
│ │                     ( ) Chuyển khoản                             │ │
│ │  Ghi chú:           [ Khách yêu cầu hoàn ví KM2 Gold        ]    │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│                                                                      │
│                       [ Huỷ ]    [ Tạo yêu cầu hoàn tiền ]           │
└──────────────────────────────────────────────────────────────────────┘

Vùng NEW: toàn bộ dialog (component mới RefundKm2WalletDialog.tsx).

B2.7.2.0) Ma trận Biến thể

Variant IDĐiều kiệnHiển thịQA ref
Variant A — Khách nhận > 0tỉ_lệ × balance − phí > 0"KHÁCH NHẬN" intent success; CTA enabledTC-SCR07-A-01
Variant B — Khách nhận = 0 (phí ≥ hoàn)phí ≥ tỉ_lệ × balance"KHÁCH NHẬN: 0đ" intent danger + cảnh báo "Phí xử lý hoàn ví lớn hơn số tiền hoàn. Số dư Gói Ví KM2 sẽ được tất toán, khách không nhận tiền." Vẫn cho submitTC-SCR07-B-01
Variant C — LoadingĐang fetch lot snapshotSkeleton 3 blockTC-SCR07-C-01
Variant D — Lot vừa hết hạn (race)Backend trả 422 khi tạoModal "Lot vừa hết hạn. Vui lòng tải lại tab Ví KM 2." + đóng dialogTC-SCR07-D-01
Variant E — Lot đã refunded (race)422Modal "Lot này đã được hoàn từ thiết bị khác."TC-SCR07-E-01
Variant F — Quyền bị thu hồi giữa session403Toast + đóng dialogTC-PERM-SCR07-01
Variant G — NetworkTimeout submitBanner + auto retry với idempotencyTC-SCR07-G-01

B2.7.3) Phân loại reuse

Phân loạiFileVị tríLý do
🆕 Build mới (component) + 🔧 Extend (luồng)Component mới RefundKm2WalletDialog.tsx; engine reuse transaction_request + màn Yêu cầu hoàn tiền hiện cóDialog overlay từ SCR-06; sau submit, request xuất hiện trong list Yêu cầu hoàn tiền với behavior refund_km2_walletApproval/list/payment đã có pattern; chỉ thêm behavior + dialog tạo

B2.7.4) Quy ước field

FieldLoạiMặc địnhValidationTooltip
Loại yêu cầuReadonly badgeHoàn ví KM 2Fixed refund_km2_wallet"Không đổi được"
Hoàn vàoXOptionGroup radiocashBắt buộc; enum cash/wallet/bank_transfer
Ghi chúXInput textareaRỗngOptional, ≤ 500 ký tự

B2.7.5) Quy ước tương tác

Tình huốngHành vi
Mở dialogFetch snapshot lot + policy hiện tại; tính sẵn refund
Đóng dialog (Esc / × / Huỷ)Confirm "Bỏ thay đổi?" nếu đã đổi refund_method hoặc ghi chú
SubmitBackend tạo transaction_request + audit policy_snapshot; toast success; đóng dialog; reload SCR-06 + list Yêu cầu hoàn tiền
Click "Xem công thức"Mở popover hiển thị FORMULA-002 (PRD A10)

B2.7.7B/C/D) Form/Concurrency/Network

LoạiQuy ước
Form validationOn submit
Idempotencyclient_request_id UUID
ConcurrencyLot vừa expired/refunded → BE 422; modal đóng dialog
NetworkMất mạng → disable nút, retry với cùng key

B2.7.8) Phân quyền

PortalActionHành vi
admin,posrefund_request_management_submenu:createMở dialog + submit tạo request
admin,posrefund_request_management_submenu:updateSửa thông tin request trước duyệt (ở màn detail)
admin,posrefund_request_management_submenu:approveDuyệt/từ chối/hủy ở màn list/detail (không trong dialog này)
admin,posrefund_request_management_submenu:paymentThanh toán hoàn tiền thật ở màn detail (sau approve)

B2.7.8A) Role × Variant

RolePermissionVariant
Kế toán/Manager có :createA-E, G
Reviewer hợp lệ có :approveMở list/detail Yêu cầu hoàn tiền, không qua dialog này
User chỉ có customer_management:accessF — không mở được dialog

B2.7.9) Trạng thái

(Đã liệt kê ở Ma trận Biến thể.)

B2.7.10) Action Feedback

Hành độngPhản hồiCopy
Tạo yêu cầu thành côngToast success + đóng dialog + reloadĐã tạo Yêu cầu hoàn tiền KM2 #{request_id}. Vui lòng chờ duyệt.
Duyệt thành công (ở màn detail)ToastĐã duyệt Yêu cầu hoàn tiền KM2 #{request_id}
Thanh toán thực hiện thành côngToast + reloadĐã hoàn {x}đ cho khách. Lot {N} đã tất toán.
Khách nhận = 0đModal warning trước submitPhí xử lý lớn hơn số tiền hoàn. Khách KHÔNG nhận tiền nhưng số dư Gói {tên} sẽ tất toán. Tiếp tục?
Lot expired raceModalLot vừa hết hạn. Vui lòng đóng và tải lại tab Ví KM 2.

B2.7.10A) Error Taxonomy

LoạiUIRecoveryCopy
422 (lot expired/refunded)Modal đóng dialogReload tab(xem trên)
403 (no permission)Toast + đóng dialogLiên hệ ManagerBạn không có quyền tạo Yêu cầu hoàn ví KM2
409 (concurrency)ModalTải lạiLot đã được hoàn từ thiết bị khác
Network/timeoutBanner + retryAutoMạng yếu, đang xử lý...
5xxToast + correlation_idBáo supportCó sự cố. Mã: {trace_id}

B2.7.11) Lifecycle UI mapping (request)

Trạng thái requestUI ở danh sách Yêu cầu hoàn tiềnAction
pending (vừa tạo)Badge Chờ duyệt (warning)Reviewer: Duyệt / Từ chối / Hủy
approvedBadge Đã duyệt (info)Kế toán: Thanh toán
paidBadge Đã hoàn (success)— (đã đóng)
rejected / cancelledBadge mutedLot KHÔNG bị tất toán; có thể tạo lại request

B2.8) SCR-08: Report dashboard Ví KM 2 (Phase 3 — sau PD-001)

B2.8.1) Ngữ cảnh nghiệp vụ

Câu hỏiQuyết định
Ai dùng?Manager/Admin — report:access (route theo PD-001 chốt sau Phase 2)
Vào màn để quyết định gì?Theo dõi hiệu quả KPI Ví KM 2 (đã bán, doanh thu, tỉ lệ dùng, sắp cháy), top khách, sắp hết hạn
Dữ liệu chínhAggregate từ wallet_km2_lot + wallet_km2_lot_deduction + prepaid_order
CTA chính / phụPrimary: Xuất Excel. Secondary: filter, refresh
Điều không được hiểu nhầmDoanh thu = Σ giá mua gói (thực thu); KHÔNG phải Σ giá trị nạp ví

B2.8.1A) Nguyên tắc UX bắt buộc

Nguyên tắcQuyết định
Mục tiêu user trong 5 giâyManager nhìn 4 KPI biết: bao nhiêu Gói bán, doanh thu, tỉ lệ dùng, bao nhiêu tiền sắp cháy
Thứ tự ưu tiên(1) 4 KPI cards → (2) Bảng theo Gói → (3) Bảng sắp hết hạn → (4) Top khách
Dữ liệu nhạy cảmSố tiền tổng hợp; chỉ Manager/Admin có quyền
Pending / partial dataTỉ lệ dùng denominator = 0 → hiển thị "—" thay vì "0%" hay NaN
Thuật ngữ canonical"Đã bán", "Doanh thu", "Tỉ lệ dùng (%)", "Sắp cháy (30 ngày)"
Thiết kế thị giácKPI "Sắp cháy" intent warning; KPI "Doanh thu" intent primary emphasis
Điều không được tự suy diễnKHÔNG hardcode route Phase 1/2; chờ PD-001 chốt

B2.8.2) Bố cục

text
┌──────────────────────────────────────────────────────────────────────────────┐
│ BÁO CÁO VÍ KHUYẾN MÃI 2                                                      │
├──────────────────────────────────────────────────────────────────────────────┤
│ Khoảng [01/01/2026] - [30/04/2026]   Chi nhánh [ Tất cả  v ]                 │
│                                                                              │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐              │
│ │ ĐÃ BÁN      │ │ DOANH THU   │ │ TỈ LỆ DÙNG  │ │ SẮP CHÁY    │              │
│ │   156 Gói    │ │ 78.000.000đ │ │   35,00%    │ │ 12.500.000đ │              │
│ │              │ │ (giá mua    │ │ (đã dùng /  │ │ (30 ngày    │              │
│ │              │ │  gói)       │ │  tổng nạp)  │ │  tới)       │              │
│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘              │
│                                                                              │
│ THỐNG KÊ THEO GÓI                                                            │
│ ┌────┬───────────┬────────┬──────────────┬──────────────┬──────────────┬─────┐
│ │ #  │ Tên Gói   │ Số bán │ Doanh thu    │ Tổng nạp     │ Đã dùng      │Tỉ lệ│
│ ├────┼───────────┼────────┼──────────────┼──────────────┼──────────────┼─────┤
│ │ 1  │ Gói Gold  │ 80     │ 40.000.000đ  │ 400.000.000đ │ 140.000.000đ │35,00│
│ │ 2  │ Gói Silver│ 76     │ 15.200.000đ  │ 114.000.000đ │  39.900.000đ │35,00│
│ └────┴───────────┴────────┴──────────────┴──────────────┴──────────────┴─────┘
│                                                                              │
│ GÓI SẮP HẾT HẠN (30 NGÀY TỚI)                                                │
│ ┌────┬────────────────┬────────────┬───────────┬───────────┬────────────┐    │
│ │ #  │ Khách hàng     │ SĐT        │ Tên Gói   │ Số dư     │ Hết hạn    │    │
│ ├────┼────────────────┼────────────┼───────────┼───────────┼────────────┤    │
│ │ 1  │ Nguyễn Thị Lan │0909 123 456│ Gói Gold  │2.500.000đ │ 15/04/2026 │    │
│ │ 2  │ Trần Văn Nam   │0908 765 432│ Gói Silver│  500.000đ │ 01/04/2026 │    │
│ └────┴────────────────┴────────────┴───────────┴───────────┴────────────┘    │
│                                                                              │
│ TOP KHÁCH DÙNG NHIỀU NHẤT                                                    │
│ ┌────┬────────────────┬────────────┬──────────────┬──────────────┬────┬─────┐
│ │ #  │ Khách hàng     │ SĐT        │ Tổng nạp     │ Đã dùng      │SLần│Tỉ lệ│
│ ├────┼────────────────┼────────────┼──────────────┼──────────────┼────┼─────┤
│ │ 1  │ Nguyễn Thị Lan │0909 123 456│ 11.500.000đ  │  4.000.000đ  │ 12 │34,78│
│ │ 2  │ Lê Thị Hoa     │0907 234 567│  5.000.000đ  │  2.100.000đ  │  8 │42,00│
│ └────┴────────────────┴────────────┴──────────────┴──────────────┴────┴─────┘
│                                                                              │
│                                                       [ Xuất Excel ]         │
└──────────────────────────────────────────────────────────────────────────────┘

B2.8.2.0) Ma trận Biến thể

VariantĐiều kiệnHiển thịQA
A — Có dữ liệuRange có lotKPI + 3 bảng đầy đủTC-SCR08-A-01
B — Empty rangeRange không có lotKPI = "0"/"—", bảng Chưa có dữ liệuTC-SCR08-B-01
C — Tỉ lệ NaNdenominator = 0KPI Tỉ lệ hiện "—"TC-SCR08-C-01
D — Manager scopeManager branch_mode=branchBranchSelect chỉ branch của ManagerTC-SCR08-D-01
E — Admin all-branchAdmin branch_mode=allBranchSelect Tất cả + chọn đượcTC-SCR08-E-01
F — LoadingĐang fetchSkeleton KPI + 3 bảngTC-SCR08-F-01
G — Export async> 5,000 dòngToast "Đang xử lý, sẽ thông báo khi xong"TC-SCR08-G-01
H — No permissionUser thiếu report:accessRoute guardTC-PERM-SCR08-01

B2.8.3-2.8.11)

(Reuse pattern PrepaidCardReport.tsx — filter, table sortable, export pattern. Chi tiết cột bảng đã ở wireframe trên.)

Khía cạnhQuy ước
FilterKhoảng thời gian (DateRangePicker), Chi nhánh (BranchSelect)
SortableMọi cột số trong bảng
Empty"Chưa có dữ liệu"
LoadingSkeleton
ErrorToast retry
ExportAsync > 5,000 dòng; format theo B5A

B2.9) SCR-09: Tab Ví khuyến mãi 2 trong wallet_screen.dart + Home widget (Flutter)

B2.9.1) Ngữ cảnh nghiệp vụ

Khách mua Gói Ví KM 2 tại spa (qua NV bán POS — ASM-001). Sau khi mua, khách cần XEM trên app điện thoại của mình để:

  • Verify số dư Ví KM 2 còn lại (đối chiếu với lời NV)
  • Xem chi tiết từng lần mua Gói (lot) — Gói nào còn lại bao nhiêu, hết hạn ngày nào
  • Xem lịch sử giao dịch (đã chi tiêu / hoàn lại / hết hạn)
  • Nhận cảnh báo sắp hết hạn (đồng bộ ZNS km2_lot_expiring_7d)

Tham chiếu: PD-002 đã chốt "in-scope Phase 1". DEC-031 chốt thêm tab Ví khuyến mãi 2 thứ 3 vào wallet_screen.dart hiện có (cùng cấp với 2 tab hiện hữu "Ví thẻ trả trước" + "Ví khuyến mãi"). KHÔNG tạo screen riêng / route mới — đồng nhất UX với pattern KM 1.

B2.9.1A) Nguyên tắc UX bắt buộc

Nguyên tắcLý do
Read-only Phase 1ASM-001 — khách không tự mua Gói trên app; mọi mutation chỉ qua POS
Tab thứ 3 trong wallet_screen.dart — đồng cấp với 2 tab hiện hữuDEC-031 — đồng nhất UX khách: 1 entry point "Ví của tôi" cho mọi loại ví; pattern KM 1 hiện hữu đã quen với khách
Hiển thị balance realtimeDEC-029 — balance lấy từ get_customer_km2_balance action, không cached
Bilingual: code internal (VND_PROMOTION_2) + display VI (Ví khuyến mãi 2)Đồng bộ B0.7
Không SMS push từ appDEC-006 — Phase 1 chỉ banner trong UI, không OneSignal push
Empty/error state thân thiện"Bạn chưa có Gói Ví KM 2 nào. Đến spa để mua."
Implementation widget chọn bởi devDEC-031 note kỹ thuật — FolderTabs hiện cứng 2 tab; dev tự chốt: refactor FolderTabs mở rộng 3 tab HOẶC swap sang CustomTabbar/TabPageWidget (đã có trong core/). Spec KHÔNG prescribe widget cụ thể

B2.9.2) Bố cục — Demo gắn vào UI hiện tại

Home page — home_wallet.dart (chèn card mới sau Bonus Point):

Home — danh sách thẻ ví hiện tại (horizontal scroll):
┌────────────────────────────────────────────────────────────────────────────┐
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────────┐    │
│ │ Ví DIVA  │ │ Hoa hồng │ │ Điểm     │ │ Điểm     │ │ Ví KM 2  ★    │ » │
│ │ 2.5tr đ  │ │ 250.000đ │ │ thưởng   │ │ thưởng   │ │ 4.700.000 đ   │    │
│ │          │ │          │ │ 120 đ    │ │ 45 đ     │ │ HSD: 24/08/26 │    │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └────────────────┘    │
└────────────────────────────────────────────────────────────────────────────┘
★ Card "Ví KM 2" mới — chèn cuối list, conditional khi balance > 0
Reference: home_wallet.dart:49-77 — list 4 card hiện có (diva / commission / membership / bonusPoint).
KHÔNG có card "Ví KM 1" trên Home hiện tại; KM 2 là card thứ 5 mới.
Tap card → mở wallet_screen.dart với initialTabIndex trỏ tab Ví khuyến mãi 2 (tab thứ 3 — DEC-031).

wallet_screen.dart — thêm tab thứ 3 "Ví khuyến mãi 2" (DEC-031):

Wallet Screen "Ví của tôi" — 3 tab (sau khi thêm Ví KM 2):
┌────────────────────────────────────────────────────────────┐
│  ←  Ví của tôi                                      [⚙]   │
├────────────────────────────────────────────────────────────┤
│  Số dư:                                                    │
│        4.700.000 đ  (tab đang active = Ví KM 2)            │
│                                                            │
│  ╭─────────────────╮╭─────────────────╮╭────────────────╮  │
│  │ Ví thẻ trả trước││ Ví khuyến mãi   ││ Ví khuyến mãi 2★││
│  ╰─────────────────╯╰─────────────────╯╰────────────────╯  │
│                                                            │
│  Tab "Ví khuyến mãi 2" — content (active):                │
│  ⚠️ Gói Gold còn 1.200.000 đ — hết hạn 24/08/2026 (7 ngày) │
│                                                            │
│  ── Gói đang hoạt động (2) ─────────────────────          │
│  ┌──────────────────────────────────────────────┐         │
│  │ Gói Gold      │ Còn 1.200.000 đ │ HSD 24/08 ›│         │
│  │ Gói Diamond   │ Còn 3.500.000 đ │ HSD 15/11 ›│         │
│  └──────────────────────────────────────────────┘         │
│                                                            │
│  ── Đã dùng hết / hết hạn / đã hoàn ────────────          │
│  (Lazy load 10 row / page)                                 │
│                                                            │
│  [Tap row] → Drawer chi tiết lot                           │
│  [Filter chip] → Lọc theo loại giao dịch (như Ví KM 1)    │
└────────────────────────────────────────────────────────────┘
★ Tab "Ví khuyến mãi 2" mới — thứ 3 cùng cấp với 2 tab hiện hữu

Reference current state:
- wallet_screen.dart:213-227 hiện dùng `FolderTabs` (widget cứng 2 tab: leftTitle/rightTitle)
- core/lib/presentation/common_widget/folder_tabs.dart — `FolderTabController` chỉ accept value 0/1

Implementation note (dev tự chốt, KHÔNG ràng buộc PO):
- Phương án 1: Refactor `FolderTabs` mở rộng 3 tab — giữ visual identity vàng hiện có
- Phương án 2: Swap `FolderTabs` → `CustomTabbar` (chip pill) — đã có trong `core/`, support N tabs
- Phương án 3: Swap `FolderTabs` → `TabPageWidget` (Material TabBar) — đã có trong `core/`

B2.9.2.0) Ma trận Biến thể

VariantKhi nàoUI thay đổi
has_active_lotCó ≥ 1 lot status=activeHiện banner cảnh báo (nếu sắp hết hạn) + bảng lot
no_active_lot_has_historyHết tất cả lot, nhưng có lịch sửBảng "Đang hoạt động" empty; bảng "Đã dùng hết" có data
never_purchasedKhách chưa từng có Gói KM 2Empty state full screen: "Bạn chưa có Gói Ví KM 2 nào. Đến spa để mua."
balance_zero_but_activeCó lot active nhưng balance=0 (đã dùng hết, chưa scheduler mark exhausted)Hiển thị "0đ — đã dùng hết, chờ hệ thống cập nhật"
feature_disabledAdmin tắt KM 2 (wallet_km2_config.disabled=true)Tab ẩn hoàn toàn (không render) — đồng bộ với SCR-01 toggle

B2.9.3) Phân loại reuse + điểm update

PhầnLoạiFile / PatternDelta
wallet_screen.dart — thêm tab thứ 3🔧 Extendcustomer/lib/presentation/modules/wallet/listing/views/wallet_screen.dart:213-227 (hiện FolderTabs 2 tab)Thêm tab thứ 3 "Ví khuyến mãi 2" cùng cấp; widget chọn bởi dev (refactor FolderTabs 3 tab HOẶC swap CustomTabbar/TabPageWidget — đã có trong core/). Update _buildCard switch logic theo tab index 0/1/2
Wallet page content — file mới🆕 Newcustomer/lib/presentation/modules/wallet/listing/views/pages/promotion_wallet_2_page.dart (mới — parity với promotion_wallet_page.dart hiện có)Content tab Ví KM 2: balance display, 2 bảng (active/inactive lot), filter chip, drawer detail. Pattern copy từ promotion_wallet_page.dart
WalletBloc extend🔧 Extendcustomer/lib/presentation/modules/wallet/listing/bloc/wallet_bloc.dart:43-62 (hiện có pattern Prepaid + Promotion)Thêm 3 event class (GetKm2BalanceEvent, GetKm2TransactionsEvent, ChangeKm2FilterEvent), 3 state field (km2Balance, km2TransactionsInMonths, km2Filter), 1 nhánh _onFilter(WalletType.promotion2, ...). Pattern copy y nguyên từ promotion
Home wallet widget pattern🔧 Extendhome_wallet.dart:49-77 (đang render 4 card: DIVA/Commission/Reward Point/Bonus Point — KHÔNG có ví KM 1 trên Home)Thêm 1 card "Ví KM 2" làm phần tử thứ 5 trong list wallets, conditional khi state.promotion2?.balance > 0; tap mở wallet_screen.dart với initialTabIndex trỏ tab Ví KM 2 (đồng nhất UX)
Home interactor balance fetch🔧 Extendhome_page_interactor.impl.dart:87-134 (hiện gọi 2 API song song: diva + promotion)Thêm 1 API call WalletType.promotion2 qua Future.wait; fold sum nếu muốn show total
Balance fetch (tab Ví KM 2)🆕 NewAction get_customer_km2_balance (DEC-029)Repository wrap action call; KHÔNG đọc wallet.amount
Lot list query🆕 NewQuery GraphQL wallet_km2_lot WHERE customer_id=...Filter theo auth context; permission customer (xem SCR-API permission ở dev-spec)
Transaction history🆕 NewQuery wallet_km2_lot_deduction WHERE lot.customer_id=...Filter theo lot trong drawer chi tiết
Banner cảnh báo expiry🆕 NewPattern banner mới (không reuse từ KM 1 vì KM 1 không có banner expiry tương tự trên customer app)Hiện banner nếu có ≥ 1 lot expired_at ≤ NOW() + 7 ngày
Empty state🔧 ExtendEmpty state pattern hiện cóText mới "Bạn chưa có Gói Ví KM 2 nào..."

B2.9.4) Quy ước field / cột bảng

Bảng "Lot đang hoạt động":

CộtWidthRenderTap action
Tên GóiFlexpackage_name snapshot
Số dư còn lại30%balance currency VN
Hạn sử dụng30%expired_at format DD/MM/YYYY
Chevron (›)24pxIconMở drawer detail

Drawer detail (tap row):

  • Header: Tên Gói + Trạng thái badge (Đang hoạt động)
  • Block 1 — Thông tin: Giá mua gói, Giá trị ví, Số dư hiện tại, Ngày mua, Hạn sử dụng, Chi nhánh mua
  • Block 2 — Lịch sử giao dịch lot (list wallet_km2_lot_deduction):
    • Mỗi row: ngày + loại (Trừ thanh toán / Hoàn ngược / Hoàn ví KM 2) + số tiền + mã đơn

B2.9.5) Filter

FilterVị tríOptions
Trạng thái lotChip group bên trong tab Ví khuyến mãi 2 (parity với chip filter loại giao dịch tab Ví KM 1 hiện có)Đang hoạt động / Đã hết hạn / Đã dùng hết / Đã hoàn (single-select)

B2.9.6) Quy ước tương tác

ElementLoạiBehavior
Card home "Ví KM 2"Drill-downTap → mở wallet_screen.dart với initialTabIndex trỏ tab Ví KM 2 (giống pattern home card hiện có trỏ tab tương ứng)
Row lotDrill-downTap → mở drawer detail (slide-up)
Banner cảnh báoHiển thịTap banner → cuộn xuống lot sắp hết hạn
Tab switchTab switchĐổi giữa Ví thẻ trả trước / Ví khuyến mãi / Ví khuyến mãi 2 — preserve scroll position từng tab; balance card tổng phía trên đổi theo tab active (đồng bộ pattern _buildCard hiện có ở wallet_screen.dart:149-200)
ZNS deep link km2_lot_activatedExternalMở wallet_screen.dart với tab Ví khuyến mãi 2 active (theo handler deep link hiện có — thêm route mapping)

B2.9.7B) Form Interaction Deep

N/A — read-only Phase 1, không có form.

B2.9.7C) Concurrency

  • Realtime balance: refetch khi return to foreground; debounce 2s
  • Pull-to-refresh ở tab Ví khuyến mãi 2: fetch lại action get_customer_km2_balance + danh sách lot (chỉ refetch tab đang active để tiết kiệm network — pattern hiện có ở _onRefreshPromotionWallet)
  • KHÔNG có optimistic update (read-only)

B2.9.7D) Network resilience

ScenarioHành vi
Mất mạng khi load tabHiển thị cached balance gần nhất (nếu có) + banner "Đang ngoại tuyến — số dư có thể chưa cập nhật"
Action get_customer_km2_balance timeout (>5s)Fallback đọc wallet.amount cached + banner "Số dư có thể chưa realtime"
Fail load lot listEmpty state lỗi + nút "Tải lại"

B2.9.8) Ma trận phân quyền

RoleActionCho phép?
Khách đã đăng nhập appVào tab Ví khuyến mãi 2 trong wallet_screen.dart✅ (filter customer_id = auth.uid ở action get_customer_km2_balance/get_customer_km2_lots)
Khách chưa đăng nhậpVào wallet_screen.dart❌ (route guard auth — giống pattern hiện hữu cho 2 tab cũ)
Khách bị Admin disableVào wallet_screen.dart❌ (login fail)
Khách có balance KM 2 = 0Card "Ví KM 2" trên Home❌ (ẩn card — conditional balance > 0)
Khách có balance KM 2 = 0Vào tab Ví KM 2 qua deep link ZNS hoặc switch tab thủ công✅ (vẫn vào được, hiện empty state)

B2.9.8A) Ma trận Role × Variant

Chỉ 1 role "customer authenticated" — không có variant phân quyền sâu hơn.

B2.9.9) Ma trận trạng thái (ref B0.5)

StateBehavior
DefaultHiện balance + 2 bảng trong tab Ví khuyến mãi 2
LoadingSkeleton balance + skeleton 3 row mỗi bảng
Empty (never_purchased)"Bạn chưa có Gói Ví KM 2 nào. Đến spa để mua."
Error (fetch lot fail)"Không thể tải. Tải lại"
No permissionRoute guard chặn (chưa đăng nhập) → redirect login
PartialBalance load thành công, danh sách lot fail → hiện balance + empty list + nút retry
Feature disabled (admin tắt KM 2 globally)Tab Ví khuyến mãi 2 vẫn hiện nhưng empty state (đồng bộ với SCR-01); cách khác: ẩn tab — chốt theo PD-002 + admin config

B2.9.10) Phản hồi sau thao tác

ActionPhản hồi
Pull-to-refreshSpinner top + toast nhẹ "Đã cập nhật" sau khi xong
Tap row → drawerSlide-up 200ms
Đóng drawerSlide-down 200ms

B2.9.10A) Error Taxonomy

CodeKhi nàoHành vi UI
NETWORK_OFFLINEMất mạngBanner persistent + cached data
ACTION_TIMEOUTget_customer_km2_balance > 5sFallback cached + banner warning
UNAUTHORIZEDToken expiredRedirect login
INTERNAL_ERRORBE 500Toast + retry button

B2.9.11) Mapping UI theo lifecycle

Lifecycle eventUI mobile
Khách trả lần đầu cho Gói KM 2 tại POSLot mới xuất hiện trong "Đang hoạt động" (sau pull-to-refresh hoặc return foreground)
ZNS km2_lot_activated gửi (FR-011)App có thể tap ZNS deep link mở wallet_screen.dart với tab Ví khuyến mãi 2 active
NV/khách dùng KM 2 thanh toán đơn DVBalance giảm (qua pull-to-refresh), row lot cập nhật balance
Lot hết hạn (scheduler 00:05)Sau pull-to-refresh: lot chuyển từ "Đang hoạt động" → "Đã hết hạn"
NV tạo Yêu cầu Hoàn ví KM 2 (SCR-07)Lot chuyển status sau khi duyệt → "Đã hoàn"

B2.10) SCR-10: Staff app — Customer detail balance KM 2

B2.10.1) Ngữ cảnh nghiệp vụ

Staff (NV/Manager) khi mở chi tiết khách trên app staff thường cần đối chiếu số dư các ví của khách để tư vấn. App staff hiện đã fetch + hiển thị balance Ví KM 1 thông qua customer_repository.impl.dart:473-481 (getBalances(type: WalletType.promotion, userId: id)). Phase 1 KM 2 cần thêm parity tương tự để staff thấy balance Ví KM 2 (đồng bộ với DEC-013 — quyền customer_management:access + branch scope).

B2.10.1A) Nguyên tắc UX bắt buộc

Nguyên tắcLý do
Parity với pattern Ví KM 1 hiện cóNV không cần học UI mới; chỉ thêm 1 dòng
Conditional hiển thị (balance > 0)Tránh nhiễu UI khi khách chưa có lot KM 2
Permission đồng bộ DEC-013customer_management:access + branch scope (không tạo permission riêng)
Read-onlyStaff KHÔNG action gì trên balance từ screen này (Hoàn ví KM 2 vẫn qua SCR-07 web admin)

B2.10.2) Bố cục — Delta nhẹ

Customer Detail Page (staff app) — section "Ví của khách":
┌────────────────────────────────────────────────────────────┐
│  Ví của khách                                              │
│  ─────────────────────────────────────────                 │
│  Ví DIVA              :   2.500.000 đ                      │
│  Hoa hồng             :     250.000 đ                      │
│  Ví khuyến mãi        :     180.000 đ   ← Ví KM 1 (hiện có)│
│  Ví khuyến mãi 2 ★    :   4.700.000 đ   ← row mới (KM 2)   │
└────────────────────────────────────────────────────────────┘
★ Row mới — chỉ render khi balance KM 2 > 0

B2.10.3) Phân loại reuse + điểm update

PhầnLoạiFile / PatternDelta
Repository fetch balance🔧 Extendstaff/lib/data/data_source/remote/customer/customer_repository.impl.dart:473-481Thêm 1 call _walletRepo.getBalances(type: WalletType.promotion2, userId: id) (song song với call promotion hiện có)
Customer detail UI🔧 ExtendSection "Ví của khách" trong customer detail page hiện cóThêm 1 row "Ví khuyến mãi 2" conditional balance > 0, parity row Ví KM 1
WalletType enum(ref)core/lib/common/constants/server_constants.dart:300-346Phụ thuộc SCR-09-MOBILE-03 đã thêm WalletType.promotion2

B2.10.4) Quy ước field

FieldRenderConditional
Label "Ví khuyến mãi 2"Từ l10n key promotionWallet2Luôn nếu balance > 0
Valuebalance.toAppCurrencyString() (định dạng VND)Ẩn row nếu balance = 0 (đồng bộ pattern Ví KM 1)

B2.10.5) Filter

N/A — chỉ là 1 row hiển thị.

B2.10.6) Quy ước tương tác

ElementLoạiBehavior
Row Ví KM 2Read-only displayKHÔNG tap action (parity Ví KM 1 hiện có); Phase 2+ có thể thêm tap → mở popup chi tiết lot

B2.10.7C) Concurrency

  • Refetch khi pull-to-refresh customer detail page (cùng cycle với các ví khác)
  • KHÔNG có optimistic update

B2.10.7D) Network resilience

ScenarioHành vi
API getBalances(promotion2) failẨn row (KHÔNG hiện "Đang tải lỗi") — đồng bộ pattern row Ví KM 1 hiện có
TimeoutẨn row + log warning

B2.10.8) Ma trận phân quyền

RoleActionCho phép?
Staff/Manager có customer_management:access + đúng branch scopeXem row Ví KM 2✅ (đồng bộ DEC-013)
Staff/Manager không có quyền xem kháchVào customer detail❌ (chặn từ trước)
Khách balance KM 2 = 0Row hiển thị❌ (ẩn — conditional)

B2.10.8A) Ma trận Role × Variant

Đồng bộ với DEC-013 — chỉ 1 role gating duy nhất (customer_management:access).

B2.10.9) Ma trận trạng thái

StateBehavior
Default (balance > 0)Hiện row "Ví khuyến mãi 2: {balance} đ"
Empty (balance = 0)Ẩn row hoàn toàn
LoadingSkeleton 1 dòng cùng cycle các ví khác
ErrorẨn row (silent)

B2.10.10) Phản hồi sau thao tác

N/A — read-only display.

B2.10.11) Mapping UI theo lifecycle

Lifecycle eventUI staff app
Khách trả lần đầu cho Gói KM 2 tại POSRow hiện sau pull-to-refresh customer detail
Khách dùng KM 2 thanh toán đơn → balance về 0Row biến mất sau pull-to-refresh (conditional ẩn)
Lot hết hạn (scheduler 00:05)Row update balance hoặc biến mất (tuỳ kết quả get balance)
NV/Admin hoàn ví KM 2 (SCR-07)Row update balance sau pull-to-refresh

B3) Luồng người dùng

B3.1) Luồng thành công

Luồng 1 — Admin cấu hình Ví KM 2

Admin vào Settings → Ví khuyến mãi
  → Thấy banner "4 bước readiness" với 4 chip "Chưa đạt"
  → Bước 1: Bật toggle "Ví khuyến mãi 2" → ✓ Đạt
  → Bước 2: Click [Cấu hình →] mở /settings/prepaid-card?wallet_target=VND_PROMOTION_2
  → Tạo Gói Gold 500K → 5tr (6 tháng) → quay lại Settings
  → Bước 2 ✓ Đạt
  → Bước 3: Click [Cấu hình →] mở /ecommerce/service/:id, bật toggle KM 2 cho 1 dịch vụ
  → quay lại Settings → Bước 3 ✓ Đạt
  → Bước 4: Bán test 1 đơn KM 2 nội bộ ở POS
  → quay lại Settings → 4/4 ✓
  → Click [Bật Ví KM 2 cho POS]
  → Modal confirm "Bật cho toàn hệ thống?" → Xác nhận
  → Toast `Đã bật Ví KM 2 cho POS` → POS thấy chip Ví KM 2 ngay

Luồng 2 — Sale bán Gói Ví KM2 (mix với thẻ trả trước VND)

Sale POS → Nạp Tiền → Tạo Mới Nạp Tiền
  → Chọn khách Nguyễn Thị Lan
  → + Thêm thẻ nạp: Thẻ trả trước 10tr × 1 → CẦN TT 10tr, NẠP DIVA 10tr
  → + Thêm thẻ nạp: Gói Gold (6 tháng) × 2 → CẦN TT 1tr, NẠP KM 2 10tr (cột KM 2 hiện)
  → Sidebar: Tiền vào ví DIVA 10tr, Tiền vào ví KM 1.5tr, Tiền vào Ví KM 2 10tr, Số tiền cần TT 11tr
  → Chọn Tiền mặt; chip "Trả góp" disabled với tooltip
  → Khách trả 11tr tiền mặt
  → Bấm [Tạo Mới] → Backend tạo:
       • prepaid_order
       • Cộng 10tr Ví VND + 1.5tr Ví KM 1
       • Tạo 2 lot KM 2 (mỗi lot 5tr, expired_at = NOW + 6 tháng end_of_day)
       • ZNS gửi (nếu FR-011 bật): "Bạn đã kích hoạt 2 Gói Gold, tổng 10.000.000đ, HSD: 24/10/2026"
  → Redirect /ecommerce/prepaid-card-order/{id} + toast `Đã tạo đơn nạp tiền {mã}`

Luồng 3 — Sale thanh toán đơn DV bằng Ví KM 2

Sale POS mở đơn /ecommerce/order/DH-20260424-001
  → Đơn: Massage 500K (eligible) + Liệu trình 4.5tr (eligible) + Kem 300K (không eligible)
  → Tổng 5.3tr; Eligible 5tr
  → Click chip Ví KM 2
  → Block hiện: Số dư 4.6tr / Eligible 5tr / Tối đa (20%) = 1tr / Số tiền KM 2 = 1tr (auto, readonly)
  → Còn lại cần trả: 4.3tr
  → NV chọn Tiền mặt 4.3tr
  → Bấm [Thanh toán]
  → Backend deduct_km2_payment FIFO trừ lot cũ nhất trước
  → Toast `Đã thanh toán 5.300.000đ. KM 2 áp dụng: 1.000.000đ. Số dư còn: 3.600.000đ.`

Luồng 4 — Manager tạo Yêu cầu Hoàn ví KM2

Manager mở /user/customer/{id} của Nguyễn Thị Lan
  → Click card "Ví KM 2: 8.500.000đ"
  → Popup hiện 3 lot active; thấy Gói Gold #1 còn 2.5tr, hết hạn 15/04/2026
  → Click [Hoàn] ở dòng #1
  → Dialog SCR-07 mở với:
       Tỉ lệ hoàn 10%, Hoàn theo tỉ lệ 250K, Phí xử lý 100K
       KHÁCH NHẬN: 150.000đ
  → Chọn "Hoàn vào: Tiền mặt"
  → Ghi chú "Khách yêu cầu hoàn ví KM2 Gold"
  → Bấm [Tạo yêu cầu hoàn tiền]
  → Toast `Đã tạo Yêu cầu hoàn tiền KM2 #REQ-2026-0042. Vui lòng chờ duyệt.`
  → Đóng dialog; SCR-06 reload (chưa thấy đổi vì request mới chỉ ở pending)
  → Kế toán mở màn Yêu cầu hoàn tiền → thấy request, duyệt + thanh toán
  → Sau khi paid: lot #1 → status = refunded, balance = 0; toast `Đã hoàn 150.000đ cho khách. Lot Gold đã tất toán.`

B3.2) Luồng thay thế / lỗi

Luồng 5 — Toggle KM1+KM2 tắt, NV cố chọn cả 2

NV mở đơn → Click chip Ví KM 2 (selected)
  → Click chip Ví KM 1 → BE check config allow_combine_km1 = false
  → FE auto bỏ chọn Ví KM 2 + toast `Cấu hình hiện không cho phép dùng cả 2 ví trong 1 đơn`
  → Block KM 2 ẩn, block KM 1 hiện

Luồng 6 — Race condition khi thanh toán

Sale A và Sale B cùng mở đơn cho Nguyễn Thị Lan, mỗi người mở riêng order khác nhau
  → Cùng lúc: A bấm Thanh toán đơn 1, B bấm Thanh toán đơn 2
  → Backend SELECT FOR UPDATE lot
  → A thắng race: trừ lot, toast success cho A
  → B nhận 422 "insufficient balance"; toast `Số dư không đủ. Đã tải lại số dư mới.`
  → Block KM 2 của B refresh số dư → có thể tính lại hoặc bỏ chọn KM 2

Luồng 7 — Khách nhận 0đ khi hoàn ví KM2

Manager hoàn ví KM2 Gói Silver còn 100K (giá mua 200K, ví 1.5tr)
  → Tỉ lệ hoàn = 200K / 1.500K = 13,33%
  → Hoàn theo tỉ lệ = 100K × 13,33% = 13.333đ
  → Phí xử lý = 200K × 20% = 40.000đ
  → KHÁCH NHẬN = MAX(13.333 - 40.000, 0) = 0đ
  → Dialog hiện "0đ" intent danger + cảnh báo
  → Modal confirm "Phí lớn hơn số tiền hoàn. Khách KHÔNG nhận tiền nhưng số dư Gói Silver sẽ tất toán. Tiếp tục?"
  → Manager xác nhận → tạo request thành công

Luồng 8 — Lot vừa hết hạn khi đang thanh toán

Sale POS mở chip Ví KM 2 lúc 23:59
  → Block tính eligible × 20% = 1tr; số dư 4.6tr
  → Sale chọn cash + bấm Thanh toán lúc 00:01 (cron 00:05 chưa chạy)
  → BE deduct lot, kiểm tra `expired_at > NOW()` trong transaction
  → Lot cũ nhất hết hạn 23:59:59 ngày trước → BE skip, FIFO chuyển sang lot tiếp theo
  → Vẫn thanh toán thành công với lot khác → toast success

B4) Đặc tả thông báo

TriggerKênhMẫu nội dungVariablesChống trùng
km2_lot_activated (lần trả đầu)ZNS (Zalo)"Bạn đã kích hoạt {qty} {package_name}, tổng {total_amount}đ, hạn dùng đến {expired_at}. Hãy đến spa sử dụng nhé!"qty, package_name, total_amount, expired_at1 lần / prepaid_order_id
km2_lot_expiring_7d (cron daily)ZNS"Gói Ví KM 2 {package_name} của bạn còn {balance}đ sẽ hết hạn ngày {expired_at}. Đến spa dùng trước khi hết hạn nhé!"package_name, balance, expired_at1 lần / lot_id / 7-day window
km2_lot_refunded (sau thanh toán refund)ZNS (optional)"Đã hoàn {amount}đ cho Gói {package_name}. Vui lòng kiểm tra {refund_method}."amount, package_name, refund_method1 lần / transaction_request_id

Lưu ý:

  • ZNS template phải đăng ký với Zalo trước khi dùng (ASM-004).
  • FR-011 là Could; nếu PO chưa bật ZNS cho Phase 1 thì không block bán Gói Ví KM2/thanh toán.
  • Cảnh báo hết hạn trong profile = TEXT BANNER trong UI, KHÔNG phải push notification (DEC-006).

B5) Đặc tả xuất dữ liệu (Export)

B5.1) Excel — Báo cáo Ví KM 2 (SCR-08)

Sheet 1: Thống kê theo Gói

CộtHeaderFormatWidth
STTSTTNumberAuto
Tên GóiTên GóiText20
Số bánSố bánNumber12
Doanh thuDoanh thu (đ)Currency (number Excel)18
Tổng nạpTổng nạp (đ)Currency18
Đã dùngĐã dùng (đ)Currency18
Tỉ lệTỉ lệ dùng (%)Number 2 decimal14

Sheet 2: Gói sắp hết hạn

CộtHeaderFormat
STTSTTNumber
Khách hàngKhách hàngText
SĐTSố điện thoạiText
Tên GóiTên GóiText
Số dưSố dư (đ)Currency
Hết hạnHết hạnDate DD/MM/YYYY

Sheet 3: Top khách dùng

CộtHeaderFormat
STTSTTNumber
Khách hàngKhách hàngText
SĐTSố điện thoạiText
Tổng nạpTổng nạp (đ)Currency
Đã dùngĐã dùng (đ)Currency
Số lầnSố lầnNumber
Tỉ lệTỉ lệ dùng (%)Number 2 decimal
  • File name: bao-cao-vi-km2_{branch_code}_{from-date}_{to-date}_{timestamp}.xlsx
  • Row cap: 10,000 rows / sheet
  • Async threshold: > 5,000 rows tổng → background job + notification

B5A) Quy ước xuất dữ liệu chuyên sâu (Export Deep Contract)

Khía cạnhQuy ước
Mã hoá fileUTF-8 with BOM (Excel mở đúng tiếng Việt)
Header rowsHàng 1: "Báo cáo Ví KM 2" + tên CN + kỳ; Hàng 2: filter applied; Hàng 3: header cột
Footer rowsHàng cuối: tổng số dòng, người xuất, thời điểm, correlation_id
Số formatTiền: cell type Number (Currency), không kèm "đ" trong cell; ngày: cell type Date Excel
Masking theo quyềnManager branch_mode=branch chỉ thấy branch mình; Admin all-branch thấy tất cả; cell nếu không quyền
Multi-sheetSheet 1 "Thống kê theo Gói"; Sheet 2 "Sắp hết hạn"; Sheet 3 "Top khách"; Sheet 4 "Bộ lọc"; Sheet 5 "README" giải thích cột
Async threshold> 5,000 dòng → background job; toast "Đang xử lý. Bạn sẽ nhận thông báo khi xong"; ETA dự kiến 30-60s
File quá lớn> 50,000 dòng → chia _part1.xlsx, _part2.xlsx
Tên filebao-cao-vi-km2_{branch_code}_{from}_{to}_{timestamp}.xlsx; timezone Asia/Ho_Chi_Minh
TelemetryLog export_id, user_id, row_count, column_count
Re-downloadLưu URL trong notification 7 ngày
Encoding edgeTên KH có dấu (Đặng Thị Ánh Sương) → escape đúng

B6) Từ điển nội dung hiển thị (Copy Dictionary)

Mọi copy mới hoặc thay đổi: label, CTA, empty/loading/error/success, validation, confirm, permission, tooltip.

KeyTiếng ViệtEN (i18n nếu cần)Ngữ cảnh
wallet_km2_titleVí KM 2Promotion Wallet 2Card title trong wallet stats; sidebar settings
wallet_km2_settings_titleCài đặt Ví Khuyến Mãi 2Promotion Wallet 2 SettingsTiêu đề trang Settings KM 2
toggle_km2_labelVí khuyến mãi 2 (KM 2)Label toggle phương thức
toggle_km2_onBậtToggle ON state
toggle_km2_offTắtToggle OFF state
max_percent_per_order_labelTối đa % trên đơn hàngLabel input config
max_percent_per_order_hintMỗi đơn được trả bằng KM 2 tối đa X% phần eligibleHelp text dưới input
toggle_km1_km2_labelCho phép dùng KM 1 + KM 2 cùng đơnLabel toggle
toggle_km1_km2_hintBật = khách dùng cả 2 ví trong 1 đơn hàngHelp text
allow_refund_labelCho phép Hoàn ví KM 2Label toggle
refund_fee_labelPhí xử lý hoàn ví (% giá mua gói)Label input
refund_period_labelThời hạn hoàn (ngày)Label input
refund_extend_labelGia hạn khi refund DV (ngày)Label input
wallet_target_labelVí đíchLabel radio trong form thẻ
wallet_target_vndVí VND (mặc định)Option radio
wallet_target_km2Ví KM 2Option radio
expiry_months_labelHạn sử dụng (tháng)Label input
column_wallet_targetVí đíchHeader cột list thẻ
column_hsdHSDHeader cột hạn sử dụng
badge_km2KM 2Badge text
badge_vndVNDBadge text
payment_chip_km2Ví KM 2Label chip payment method
km2_balance_labelSố dư Ví KM 2Label trong block payment
km2_eligible_labelEligible totalLabel tổng item eligible
km2_max_percent_per_orderTối đa ({max}%)Label tính giá trị max
km2_auto_amountSố tiền KM 2 áp dụngLabel auto-fill amount
km2_auto_hintTự động tính = MIN(Eligible × max%, Số dư)Help text
km2_remainingCòn lại cần trảLabel amount remaining
column_km2_amountNẠP VÍ KM 2Cột bảng SCR-03
summary_km2_amountTiền vào Ví KM 2Dòng summary panel phải SCR-03
installment_blocked_tooltipTrả góp không áp dụng cho đơn có Gói Ví KM 2Tooltip chip "Trả góp" disabled
alert_expiring_30dGói {package_name} còn {balance}đ — hết hạn {date} (còn {days} ngày)Banner warning trong profile
alert_expiring_7dGói {package_name} còn {balance}đ — hết hạn {date} (còn {days} ngày)Banner danger trong profile
refund_dialog_titleHoàn ví KM 2KM2 Wallet RefundTitle dialog SCR-07
refund_ratio_labelTỉ lệ hoànRefund ratioLabel tỉ lệ
refund_calc_pre_feeHoàn theo tỉ lệPre-fee refundLabel tính tỉ lệ
refund_fee_calcPhí xử lý hoàn víProcessing feeLabel phí
refund_customer_receiveKHÁCH NHẬNCustomer receivesLabel tổng
refund_zero_warningPhí xử lý hoàn ví lớn hơn số tiền hoàn. Số dư Gói Ví KM 2 sẽ được tất toán, khách không nhận tiền.Warning text khi = 0
refund_into_labelHoàn vàoRefund intoLabel radio
refund_cashTiền mặtCashOption
refund_wallet_vndVí VND của kháchCustomer VND walletOption
refund_bankChuyển khoảnBank transferOption
btn_refund_km2_walletHoàn ví KM2Refund KM2 walletNút trong SCR-06
btn_create_refund_requestTạo yêu cầu hoàn tiềnCreate refund requestSubmit button SCR-07
btn_save_settingsLưu cài đặtSave settingsFooter SCR-01
btn_exportXuất ExcelExport ExcelReport SCR-08
btn_enable_for_posBật Ví KM 2 cho POSEnable for POSCTA cuối banner readiness SCR-01
toast_save_successĐã lưu cài đặt Ví KM 2Toast success SCR-01
toast_save_failKhông thể lưu cài đặt. Vui lòng thử lại.Toast danger SCR-01
toast_payment_successĐã thanh toán {tổng}đ. KM 2 áp dụng: {km2}đ. Số dư còn: {còn}đ.Toast SCR-04
toast_payment_insufficientSố dư Ví KM 2 không đủ. Vui lòng thử lại.Toast danger SCR-04
toast_payment_raceSố dư đã thay đổi từ thiết bị khác. Đã tải lại số dư mới.Toast 409
toast_refund_request_successĐã tạo Yêu cầu hoàn tiền KM 2 #{id}. Vui lòng chờ duyệt.Toast SCR-07
toast_refund_paid_successĐã hoàn {x}đ cho khách. Lot {tên} đã tất toán.Toast sau payment
toast_refund_failTạo yêu cầu thất bại. Vui lòng thử lại.Toast danger SCR-07
toast_order_create_successĐã tạo đơn nạp tiền {mã}. ZNS đã gửi cho khách (nếu bật).Toast SCR-03
toast_order_create_debtĐã tạo đơn nạp tiền {mã}. Đơn còn nợ {x}đ — Gói Ví KM 2 chưa kích hoạt cho đến khi thu đủ.Toast SCR-03 (đơn nợ)
toast_update_successĐã cập nhật cho phép Ví KM 2 cho "{tên item}"Toast SCR-05
empty_no_lotsKhách chưa có Gói Ví KM 2 nào trong Ví KM 2Empty SCR-06
empty_no_prepaidChưa có thẻ trả trước. Liên hệ Admin để tạo thẻ.Empty SCR-03
empty_no_dataChưa có dữ liệuEmpty SCR-08
empty_no_orders_in_rangeChưa có đơn KM 2 nào trong khoảng thời gian nàyEmpty SCR-08 với filter
report_titleBáo cáo Ví Khuyến Mãi 2Tiêu đề report
kpi_packages_soldĐã bánKPI label
kpi_revenueDoanh thuKPI label
kpi_redemption_rateTỉ lệ dùngKPI label
kpi_expiring_balanceSắp cháy (30 ngày)KPI label
table_by_packageThống kê theo GóiTable title
table_expiringGói sắp hết hạn (30 ngày tới)Table title
table_top_customersTop khách dùng nhiều nhấtTable title
status_activeĐang hoạt độngActiveBadge
status_exhaustedĐã dùng hếtExhaustedBadge
status_expiredĐã hết hạnExpiredBadge
status_refundedĐã hoànRefundedBadge
confirm_toggle_km2Bạn có chắc muốn cho phép thanh toán bằng Ví KM 2 cho "{tên item}"?Confirm SCR-05
confirm_disable_km2Hiện có {N} khách đang giữ tổng {X}đ trong Ví KM 2. Tắt sẽ ẩn phương thức cho POS, không xoá số dư. Tiếp tục?Confirm SCR-01
confirm_refund_zeroPhí xử lý lớn hơn số tiền hoàn. Khách KHÔNG nhận tiền nhưng số dư Gói {tên} sẽ tất toán. Tiếp tục?Confirm SCR-07
confirm_leave_dirtyThay đổi chưa được lưu. Bạn có muốn rời trang?Confirm rời trang dirty
validation_max_percent_per_order_rangeVui lòng nhập giá trị từ 1 đến 100Inline error SCR-01
validation_expiry_requiredVui lòng nhập hạn sử dụng (số tháng > 0)Inline error SCR-02
validation_value_geq_priceSố tiền nạp vào ví phải lớn hơn hoặc bằng mệnh giá thẻInline error SCR-02
validation_code_duplicateMã thẻ "{code}" đã tồn tại. Vui lòng dùng mã khác.Inline error SCR-02
permission_no_settingsBạn không có quyền cập nhật cấu hình nàyToast 403 SCR-01
permission_no_refundCần quyền tạo Yêu cầu hoàn tiềnTooltip nút Hoàn ví KM2
permission_no_paymentBạn không có quyền thanh toán đơn nàyToast 403 SCR-04
tooltip_lot_expired_lockedLot đã hết hạn — không thể hoànTooltip nút Hoàn disabled
tooltip_branch_a_used_branch_bKhách mua tại {branch_a}, đang dùng tạiTooltip drawer chi tiết lot

B6A) Ngân sách độ dài copy (Copy Length Budget)

SurfaceMax VIMax ENĐã kiểm
Button primary2418✅ "Tạo yêu cầu hoàn tiền" 22 ≤ 24; "Bật Ví KM 2 cho POS" 21 ≤ 24
Button secondary2822
Tab title1612✅ "Ví KM 2" 7 ≤ 16
Badge1410✅ "Đang hoạt động" 13 ≤ 14; "Đã hết hạn" 11 ≤ 14
Toast 1 dòng8060✅ "Đã thanh toán {tổng}đ. KM 2 áp dụng: {km2}đ. Số dư còn: {còn}đ." dynamic, vẫn dưới 80 cho range thực
Banner200150✅ "Hiện có {N} khách đang giữ tổng {X}đ trong Ví KM 2..." dưới 200
Tooltip12090
Empty primary6045✅ "Khách chưa có Gói Ví KM 2 nào trong Ví KM 2" 47 ≤ 60
Empty secondary10075
Error inline10075✅ "Mã thẻ "{code}" đã tồn tại. Vui lòng dùng mã khác."
Modal title5040✅ "Hoàn ví KM 2" 13 ≤ 50
Modal body400300
Confirm modal200150✅ confirm_disable_km2 ≤ 200 với {N}/{X} dynamic
Notification push80+12060+90✅ Title "Kích hoạt Gói Ví KM 2" + body 120
Form field label3022✅ "Phí xử lý hoàn ví (% giá mua gói)" 33 — VƯỢT, đề xuất rút "Phí xử lý hoàn ví (%)" 24
Form helper text8060
Table column header1814⚠ "NẠP VÍ KM 2" 11 OK; "Tiền vào Ví KM 2" 18 chính xác, không vượt

Action: rút label "Phí xử lý hoàn ví (% giá mua gói)" thành "Phí xử lý hoàn ví (%)" ở field label; giữ chú giải đầy đủ trong tooltip ⓘ.


B7) Sự kiện phân tích (Analytics)

Sự kiệnTriggerPropertiesKPI liên quan
km2_settings_savedAdmin lưu configmax_percent_per_order, allow_combine_km1, allow_refund, refund_fee_percent, refund_deadline_days, refund_extend_days
km2_enabled_for_posAdmin bấm "Bật Ví KM 2 cho POS" sau readinessreadiness_steps_passed, time_from_first_save_to_enableAdoption
km2_package_createdAdmin tạo Gói Ví KM2package_code, package_name, value, value_into_wallet, expiry_months, wallet_target
km2_package_updatedAdmin sửa Góipackage_code, fields_changed
km2_eligible_toggledAdmin bật/tắt allow_promo_wallet_2item_id, item_type (product/service), new_value
km2_lot_soldNV hoàn thành đơn bán Gói Ví KM2customer_id, package_code, qty, total_price, total_wallet_value, branch_idĐã bán, Doanh thu
km2_lot_activatedKhách trả lần đầu → BE tạo lot với status='active', activated_at=NOW()lot_id, customer_id, branch_id, wallet_value, expired_at, credited_amount, initial_amount
km2_payment_chip_shownChip Ví KM 2 render trong paymentorder_id, eligible_total, balance
km2_payment_selectedNV bấm chip Ví KM 2order_id, auto_amount, eligible_total, balance
km2_payment_completedThanh toán đơn có KM 2 thành côngorder_id, km2_amount, lots_deducted_count, remaining_balanceTỉ lệ dùng
km2_payment_failedThanh toán KM 2 failorder_id, error_type (insufficient_balance/race_condition/network/5xx)
km2_profile_card_clickedUser click card "Ví KM 2" trong profilecustomer_id, total_balance, active_lots_count
km2_profile_popup_viewedPopup chi tiết lot mởcustomer_id, expiring_lots_count
km2_refund_dialog_openedMở SCR-07 dialogcustomer_id, lot_id, suggested_refund
km2_refund_request_createdTạo refund_km2_wallet requestcustomer_id, lot_id, refund_amount, refund_method, request_id
km2_refund_request_paidSau khi kế toán thanh toánrequest_id, lot_id, amount, refund_method
km2_lot_expired_cronCron daily đánh dấu lot expiredlot_id, customer_id, lost_balanceSắp cháy
km2_zns_sentZNS template gửi thành côngtemplate, customer_id, lot_id
km2_zns_failedZNS gửi failtemplate, customer_id, error_code
km2_report_viewedUser mở report dashboarddate_range, branch_id, role
km2_report_exportedUser xuất Exceldate_range, branch_id, total_rows, is_async

B7A) Quy ước schema sự kiện

Khía cạnhQuy ước
Tên eventkm2_* snake_case theo domain.action
Bắt buộc propertiesuser_id, branch_id, portal, correlation_id, feature_flag_state, client_ts, server_ts
Property typestring/int/bool/timestamp/enum khai báo rõ; không gửi PII raw (số CMND, CCCD); customer_id UUID
Sampling100% với event critical (sold, activated, payment_completed, refund_paid); 100% với failed events; 10% với chip_shown
RetentionHot 90 ngày; warehouse 2 năm; sau aggregate
Correlationcorrelation_id cho chuỗi event của 1 hành động (chip_shown → selected → completed)
VersioningSchema thay đổi → bump event_version; backward compat 30 ngày
Pre-launch testPipe sang dev env trước launch; QA verify đủ properties

B8) Quy tắc responsive và khả năng truy cập

B8.1) Responsive

BreakpointHành vi layout
Desktop (≥ 1024px)Layout đầy đủ như wireframe; tables full cột; dialogs 50vw
Tablet (768-1023px)Tables responsive: ẩn cột "Ghi chú" / "Branch"; dialogs 70vw; KPI 2x2; popup chi tiết lot 90vw
Mobile (< 768px)Diva Admin desktop-first; POS chạy tablet ≥ 768px; KHÔNG hỗ trợ mobile < 768px

B8.2) Accessibility / keyboard

Khu vựcYêu cầu
Toggle KM 2 / KM 1+KM 2 / allow_refundLabel đọc được screen reader; focus bằng Tab; Space đổi state
Dialog SCR-02 / SCR-07Focus trap; Esc đóng nếu không dirty; nếu dirty → confirm bỏ thay đổi
Chip Ví KM 2Aria-label "Ví KM 2"; trạng thái disabled có tooltip lý do (config tắt / số dư = 0 / eligible = 0)
Bảng lot SCR-06Hàng sắp hết hạn có badge text + icon, không chỉ màu; CTA Hoàn có confirm modal
Banner cảnh báo SCR-06role="alert" cho danger, role="status" cho warning
Lỗi validationInline + toast; aria-live="assertive" cho lỗi quan trọng

B8A) Ngân sách hiệu năng UI (Performance Budget)

Khía cạnhQuy ước
Table virtualizationSCR-06 popup > 100 lot → virtual scroll; render visible + buffer
PaginationSCR-06 mặc định 50 dòng/page (lot/deduction); SCR-08 50/page; cho phép 25/50/100
"Load more" vs paginationPagination cho data cấu trúc; load more cho activity feed (lịch sử deduction trong drawer)
Lazy-load imagesAvatar khách loading="lazy"
Initial bundleModule wallet KM 2 < 200KB gzipped; lazy-import dialog SCR-07
Time to first paint< 1.5s với 3G yếu (POS tablet ở CN tỉnh)
API timeout30s cứng
PollingSCR-06 popup polling 60s; SCR-08 không polling, manual refresh
Large formForm Settings KM 2 chia card; không render hết cùng lúc nếu thêm phase sau
Dropdown lớn"Tên Gói" filter trong SCR-06 < 50 options thì render hết; > 50 → searchable

B8B) Quy ước về khả năng truy cập chuyên sâu (A11y Deep)

Khía cạnhQuy ước
Color contrastText vs background ≥ 4.5:1; large text ≥ 3:1
Không dựa vào màuTrạng thái lot dùng màu + label + icon; banner dùng màu + icon ⛔/⚠
Focus visibleOutline rõ ≥ 2px; không outline: none
Skip link"Bỏ qua điều hướng" cho keyboard user
Heading hierarchyh1 = tên màn; h2 = card/section; h3 = subsection; không nhảy bậc
Form labelsMọi input có <label> gắn for; placeholder KHÔNG thay label
Aria-livepolite cho toast info; assertive cho lỗi
Aria-describedbyField có help text/error → aria-describedby
Aria-expandedDrawer/dropdown → aria-expanded
Modal trapTab cuối quay về đầu; Esc đóng; trả focus về trigger
Image altAvatar khách alt="Ảnh đại diện {tên}"
Icon-only buttonNút × đóng popup aria-label="Đóng"
AnimationTôn trọng prefers-reduced-motion

B8C) Phản hồi cảm ứng / âm thanh / haptic (Tablet POS)

Tình huốngQuy ước
Submit thanh toán thành côngHaptic nhẹ; KHÔNG phát âm thanh nếu OS silent
Submit thất bạiHaptic mạnh + toast danger
Notification ZNS gửi (admin)Phát âm thanh nhẹ; tôn trọng setting
Pull-to-refresh trong popupHaptic khi đạt threshold
Long-press row trong bảng lotHaptic medium → mở context menu (Phase sau)
Tôn trọng settingOS / app-level toggle âm thanh + haptic

B9) Từ điển tooltip

MànField/IconNội dung tooltipĐiều kiện hiện
SCR-01Input "Tối đa %" ⓘMỗi đơn hàng được trả bằng KM 2 tối đa X% trên phần SP/DV bật Cho phép Ví KM 2Hover icon ⓘ
SCR-01Toggle "Cho phép KM 1 + KM 2" ⓘBật = khách dùng đồng thời Ví KM 1 và Ví KM 2 trong cùng 1 đơn. Tắt = chỉ chọn 1 trong 2Hover icon ⓘ
SCR-01Input "Phí xử lý hoàn ví" ⓘPhí thu khi khách hoàn ví KM 2; tính theo % giá mua gói gốc, không tính trên ví giá trịHover icon ⓘ
SCR-01Input "Gia hạn refund DV" ⓘKhi hoàn đơn dịch vụ và lot đã hết hạn, hệ thống gia hạn thêm số ngày này để khách tiếp tục dùngHover icon ⓘ
SCR-01CTA "Bật Ví KM 2 cho POS" disabledHoàn tất 4 bước readiness trước khi bật cho toàn hệ thốngHover khi disabled
SCR-02Radio "Ví đích" ⓘChọn "Ví KM 2" để biến thẻ này thành Gói Ví KM2 khuyến mãi. Toàn bộ giá trị nạp sẽ vào Ví KM 2 (có hạn sử dụng)Hover icon ⓘ
SCR-02Input "Hạn sử dụng" ⓘSố tháng kể từ ngày khách trả lần đầu. Sau thời hạn, số dư còn lại bị xoáHover icon ⓘ
SCR-024 field readonly khi đã có lotĐã có lot bán ra; chỉ sửa Tên / Mô tả / Trạng tháiHover field disabled
SCR-03Cột "NẠP VÍ KM 2" ⓘSố tiền sẽ vào Ví KM 2 cho khách (auto-tính theo Gói × SL)Hover header
SCR-03Chip "Trả góp" disabledTrả góp không áp dụng cho đơn có Gói Ví KM 2 (DEC-017)Hover khi disabled
SCR-04"Số tiền KM 2 áp dụng" ⓘTự động tính = MIN(Eligible × max%, Số dư). NV không thể chỉnh sửa thủ côngHover icon ⓘ
SCR-04"Eligible total" ⓘTổng giá trị các SP/DV bật Cho phép Ví KM 2 trong đơn nàyHover icon ⓘ
SCR-04Chip Ví KM 2 disabledHiện một trong: "Tính năng đang tắt" / "Số dư = 0" / "Đơn không có item eligible"Hover khi ẩn/disabled
SCR-06Card "Ví KM 2" ⓘTổng số dư các Gói đang hoạt động. Không tính lot hết hạn / đã dùng hết / đã hoànHover card
SCR-06Cột "Hết hạn" đỏLot sẽ hết hạn trong 7 ngày tới. Đề xuất khách đến sử dụng sớmLuôn hiện khi ≤ 7 ngày
SCR-06Cột "Hết hạn" vàngLot sẽ hết hạn trong 30 ngày tớiLuôn hiện khi ≤ 30 ngày
SCR-06Cột "Đã nạp" inline "X/Y"Khách đã trả X/Y (đơn còn nợ); lot vẫn active và dùng được với balance theo phần đã nạpHover cell
SCR-06Nút Hoàn ví KM 2 disabledLot đã hết hạn / đã hoàn / allow_refund=false / thiếu quyền refund requestHover khi disabled
SCR-07"Tỉ lệ hoàn" ⓘ= Giá mua gói / Giá trị ví. VD: 500K / 5.000K = 10%. Khách chỉ nhận lại theo tỉ lệ nàyLuôn hiện
SCR-07"Phí xử lý hoàn ví" ⓘ= Giá mua gói × % phí (config bởi Admin). VD: 500K × 20% = 100KLuôn hiện
SCR-07"KHÁCH NHẬN" ⓘ= MAX(Còn lại × Tỉ lệ - Phí, 0). Nếu phí > số hoàn → 0đ, lot vẫn tất toánLuôn hiện
SCR-07"Hoàn vào: Ví VND" ⓘHoàn vào Ví VND của khách trong Diva (không phải tài khoản ngân hàng)Hover option
SCR-08KPI "Tỉ lệ dùng" ⓘ= Tổng đã dùng / Tổng đã nạp × 100%. Chỉ tính lot active/exhausted/expired; không tính refundedHover icon ⓘ
SCR-08KPI "Sắp cháy" ⓘTổng số dư các lot sẽ hết hạn trong 30 ngày tới. = tiền sẽ mất nếu khách không đến dùngHover icon ⓘ

B-Microcopy) Quy ước vi-copy form

MẫuQuy ước áp dụng cho package này
Required marker* đỏ sau label (toàn bộ field bắt buộc); aria-required=true
Optional marker(Tuỳ chọn) chỉ ở field "Ghi chú" SCR-03 / SCR-07
Help textDưới field, font nhỏ; mô tả cách điền (VD: "Số tháng > 0")
TooltipHover icon ⓘ; chi tiết phụ; KHÔNG lặp label
Char countField "Ghi chú" SCR-07 (max 500): hiện {X}/500 khi > 400; đỏ khi > 90%
PlaceholderMô tả định dạng (VD: "VD: Khách yêu cầu hoàn ví KM 2 Gold"); KHÔNG thay label
Inline error"Vui lòng nhập {field} hợp lệ" + lý do cụ thể
Inline successKhông dùng cho package này (tránh spam)
Auto-format SĐTFormat 0XXX XXX XXX khi blur (chỉ ở wireframe khách)
Auto-format tiền1.250.000 khi blur; raw khi gõ (input mệnh giá / số tiền nạp / max% / phí)
Auto-format ngàyDD/MM/YYYY; mask khi gõ; cho dán format khác và auto-convert
Reset fieldNút × xoá nội dung; xác nhận nếu > 50 ký tự

B-Voice) Quy ước giọng thương hiệu

Tình huốngToneVí dụ ĐÚNGVí dụ SAI
Chào / hướng dẫn (admin tool)Trung tính, chuyên môn"Hoàn tất 4 bước để bật Ví KM 2 cho POS""Yo, làm 4 bước này nhé"
Thông báo thành côngKhẳng định, súc tích"Đã tạo đơn nạp tiền {mã}""Yay, đơn đã tạo!"
Lỗi user-inducedTrung tính, hướng dẫn"Vui lòng nhập giá trị từ 1 đến 100""Sai rồi, nhập lại đi"
Lỗi systemXin lỗi nhẹ + trace"Có sự cố từ hệ thống. Mã sự cố: {trace}. Vui lòng báo bộ phận hỗ trợ.""Server lỗi không xác định"
Cảnh báo (banner sắp hết hạn)Rõ ràng, hành động cụ thể"Gói Gold #1 còn 2.500.000đ sẽ hết hạn 15/04/2026 (còn 22 ngày)""Cẩn thận, sắp hết hạn"
Confirm destructiveNói rõ tác động"Tắt sẽ ẩn phương thức cho POS, không xoá số dư. Tiếp tục?""Bạn có chắc không?"
Hỏi khách (qua ZNS)Lễ phép, ấm áp"Bạn đã kích hoạt 2 Gói Gold... Hãy đến spa sử dụng nhé!""Anh/chị đến dùng đi"
Nội dung POSTrung tính, chuyên môn"Số dư Ví KM 2: 4.600.000đ""Còn xài được 4tr6"
CấmRõ ràng, có lý do"Trả góp không áp dụng cho đơn có Gói Ví KM 2 (theo cấu hình DEC-017)""Không cho trả góp"

Quy tắc chung áp dụng cho package này:

  • KHÔNG dùng emoji trong copy POS / admin
  • KHÔNG viết tắt "k", "đc", "trc" trong UI (cho phép trong ZNS nếu spa Việt thật quen dùng)
  • KHÔNG dùng English raw ("Submit", "Cancel", "OK", "Confirm", "Loading", "No data", "Error", "Success") làm text hiển thị
  • "Bạn" cho NV nội bộ (admin, sale); "Quý khách" / "Bạn" cho ZNS gửi khách (theo style ngành spa)
  • Câu chủ động > bị động; ngắn gọn

B-i18n) Quy ước quốc tế hoá / format

Khía cạnhQuy ước Diva (vi-VN) áp dụng
NgàyDD/MM/YYYY (KHÔNG MM/DD) — VD 24/03/2026
Ngày + giờDD/MM/YYYY HH:mm (24h) — VD 24/03/2026 14:30
GiờHH:mm (24h)
TimezoneAsia/Ho_Chi_Minh (UTC+7); expired_at là end_of_day
Tiền VND1.250.000đ (chấm ngàn, "đ" cuối, không khoảng cách) — VD 5.000.000đ
Số thập phân12,5 (dấu phẩy thập phân); 12.500,50 cho large + decimal
Phần trăm37,50% (2 thập phân) — KPI "Tỉ lệ dùng"
SĐT0909 123 456 (cách 4-3-3)
Tên KHCapitalize từng chữ; tôn trọng dấu (Đặng Thị Ánh Sương)
Plural"1 Gói Ví KM 2" / "5 Gói Ví KM 2" (VI không inflection)
TruncationCắt theo từ + — VD "Gói Premium Diamond V…"
Tên file exportSlug không dấu — bao-cao-vi-km2_*
Sort tiếng ViệtTheo bảng chữ cái VN; collation vi_VN cho tên Gói/khách

Package Day-1 KHÔNG cần EN i18n; VI là ngôn ngữ chính của Diva.


B-Versioning) Quy ước UI cho feature flag / staged rollout

Áp dụng vì package có wallet_km2_config.disabled mặc định bật, dùng như feature flag.

Tình huốngQuy ước UI
disabled=true (Day-1 sau migration)Toàn bộ UI KM 2 ẩn cho POS (chip không render); admin vẫn thấy Settings để cấu hình; ZNS template không trigger
disabled=falseUI KM 2 hiện đầy đủ cho POS theo permission
Staged rollout 0-100%Day-1 chỉ có on/off global; chưa có % rollout (deferred)
A/B testKhông áp dụng
Kill switchAdmin có toggle tắt qua SCR-01; FE polling 60s khi POS đang mở payment để check; tắt → ẩn chip ngay
Migration periodOld POS không có KM 2 + new POS có KM 2 cùng tồn tại trước migration; sau migration toàn bộ POS có code mới
RollbackDisable wallet_km2_config.disabled=true qua SCR-01 → user thấy UI cũ ngay sau refresh; số dư không mất; không có nháp để giữ

B-Help) Quy ước điểm hỗ trợ

Khía cạnhQuy ước áp dụng
Help link cuối trang Settings"Cần hỗ trợ?" → mở runbook URL ở docs.diva-group/vi-km-2
Tooltip ⓘToàn bộ field nghiệp vụ phức tạp có icon ⓘ — đã liệt kê ở B9
Onboarding tourLần đầu Admin vào SCR-01 → tour 4 bước readiness; có "Bỏ qua" + "Xem lại sau"
Empty state with hintSCR-06 empty: "Khách chưa có Gói Ví KM 2 nào trong Ví KM 2" + link "Hướng dẫn bán Gói Ví KM 2"
Error with support hintError 5xx kèm correlation_id + "Báo bộ phận hỗ trợ với mã: {id}"
Contact supportFloating button góc phải dưới (toàn admin) hoặc menu "Hỗ trợ" header
Feedback channel"Phản hồi tính năng" link cuối footer

B-FR012) Tích hợp KM 2 vào màn hình hiện có (chỉ delta)

Các màn dưới đây đã tồn tại — chỉ ghi thay đổi (delta), không vẽ wireframe mới.

Phase 1

Đơn mỹ phẩm — thanh toán (CosmeticOrderFormPayment)

  • Reuse: CosmeticOrderFormPayment.tsx + CosmeticOrderFormPaymentTable.tsx
  • Delta: Thêm chip "Ví KM 2" trong payment methods (giống pattern KM 1). Auto-fill max%, FIFO, validate eligible — logic Y HỆT SCR-04 đơn DV. CosmeticOrderPaymentFormMultiple.tsx thêm wallet_promotion_2_amount vào popup multi-payment.
  • Bằng chứng: CosmeticOrderFormPayment.tsx (reuse OrderPaymentMethodRadio)
  • Vị trí update: chip mới chèn cạnh chip Ví KM 1; block calc xếp dưới hàng chip
  • Vì sao đủ: dùng cùng pattern KM 1; không phá layout đơn

Đơn sản phẩm — flag eligible (ProductOrder)

  • Reuse: ProductOrderItems.tsx, ProductOrderCreate.tsx
  • Delta: Thêm check allow_promo_wallet_2 per item (giống allow_promo_wallet). Inject customerWalletValue["VND_PROMOTION_2"] vào props.
  • Vị trí update: check eligibility trong logic component, không thay đổi UI nếu không có item eligible

Hoá đơn in (InvoiceTemplate)

  • Reuse: InvoiceTemplatePopupPrint.tsx + InvoiceTemplatePreview.tsx
  • Delta: Payment method mapping thêm "wallet_promotion_2" → label "Ví KM 2" + biến payment_wallet_promotion_2_code. Hiển thị cùng cách KM 1.
  • Vị trí update: dòng "Ví KM 2: {amount}đ" chèn ngay dưới dòng "Ví khuyến mãi" trong block Phương thức thanh toán
  • Bằng chứng hiện trạng: 1768552468000_add_column_print_invocie_template/up.sql đã có biến KM 1
  • Không kỳ vọng đổi màn khác: chỉ template; logic invoice không đổi

Fund / Quỹ (FundTable, FundInvoicePopup)

  • Reuse: FundTable.tsx, FundInvoicePopup.tsx
  • Delta: Thêm cột "Vào Ví KM 2" conditional (chỉ hiện khi tổng wallet_promotion_2_amount > 0 trong range). Giống cột "Vào ví khuyến mãi" KM 1.
  • Vị trí update: cột chèn ngay sau cột "Vào ví khuyến mãi"
  • Vì sao đủ: không gộp KM 1 + KM 2; semantics tách biệt

Phase 2

Withdraw / Refund detail (WithdrawRequestDetail)

  • Reuse: WithdrawRequestDetail.tsx
  • Delta: Payment method label thêm "wallet_promotion_2" → "Ví KM 2"; behavior refund_km2_wallet hiển thị badge "Hoàn ví KM 2" thay vì "Hoàn đơn"
  • Vị trí update: label trong detail block; không đổi layout

Report DV/NV hiện có (ServiceReportTable, ServiceGroupReportTable, EmployeeRevenueReportDoughnutChart)

  • Reuse: các component report hiện hữu
  • Delta: Thêm cột/segment wallet_promotion_2_revenue (giống wallet_promotion_revenue). Doughnut chart thêm 1 segment "Ví KM 2"
  • Vị trí update: cột chèn cạnh KM 1; segment thứ N+1 trong chart
  • Vì sao đủ: KM 2 paid amount cũng là wallet-paid (không tính thực thu)

CRM Customer (CustomerEWalletInformation)

  • Reuse: CustomerEWalletInformation.tsx (CRM portal section)
  • Delta: Thêm dòng "Ví KM 2: {balance}đ" trong section E-wallet, conditional khi khách có wallet VND_PROMOTION_2
  • Vị trí update: dòng chèn ngay dưới dòng "Ví khuyến mãi"

Negative Invoices (NegativeInvoicesTable.tsx) — Phase 1

  • Reuse: NegativeInvoicesTable.tsx (bảng hoàn đơn âm hiện có; cột "Tiền nạp vào ví khuyến mãi" hiển thị wallet_promotion_amount âm)
  • Delta: Thêm cột "Vào Ví KM 2" conditional khi range có invoice negative dùng KM 2
  • Vị trí update: cột chèn ngay cạnh cột KM 1
  • Vì sao đủ: Khi hoàn đơn DV/SP thanh toán bằng KM 2, invoice negative có wallet_promotion_2_amount âm — cần hiển thị tách biệt với KM 1
  • Evidence: NegativeInvoicesTable.tsx line render wallet_promotion_amount (cột KM 1 hiện hữu)

SMS Template vnd_wallet_balance (notification_template table) — Phase 1

  • Reuse: Template hiện có placeholder {{.wallet_amount}}, {{.wallet_promotion_amount}} — gửi sau invoice complete bằng ví. Hiện text: "Vi chinh con lai: {{.wallet_amount}}, KM con lai: {{.wallet_promotion_amount}}. ND: {{.note}}"
  • Delta: Thêm placeholder {{.wallet_promotion_2_amount}} (conditional). Content mới: "Vi chinh: X, Vi KM1: Y, Vi KM2: Z. ND: ..." — dòng KM 2 chỉ render khi balance > 0
  • Vị trí update: template content field trong notification_template WHERE name = 'vnd_wallet_balance'
  • Vì sao đủ: Khách dùng KM 2 (hoặc mix KM 1 + KM 2 khi allow_combine_km1=true) → SMS hiện tại chỉ báo balance KM 1 → khách bối rối, NV phải nói miệng số dư. Update đảm bảo thông tin đầy đủ.
  • Migration: xem dev-spec C5 M-KM2-SMS-TEMPLATE (idempotent + pre-check anchor theo CLAUDE.md Pitfalls Map)
  • Đồng bộ: Cũng update template prepaid_card (gửi sau khi nạp Gói) — chèn placeholder tương tự

Affiliate Configuration (AffiliateConfiguration.tsx) — Phase 1

  • Reuse: UI matrix tại /s/internal-settings/affiliate → tab "Cấu Hình Chung" → block "Cấu hình hình thức nhận hoa hồng". Component auto-render mọi payment_method row có trong DB
  • Delta: Migration thêm payment_method row wallet_promotion_2 + seed affiliate_config 3 row mặc định FALSE:
    • ('service', 'wallet_promotion_2', FALSE)
    • ('cosmetic', 'wallet_promotion_2', FALSE)
    • ('prepaid', 'wallet_promotion_2', FALSE)
  • Vị trí update: không sửa code FE; matrix tự render cột "VÍ KHUYẾN MÃI 2" sau khi migration chạy
  • Vì sao FALSE mặc định: Đồng bộ với default production hiện hành — Diva đang setup walletwallet_promotionenable=FALSE cho cả 3 order_kind. KM 2 inherit nguyên trạng (DEC-030)
  • Hành vi runtime: Tuân theo affiliate_config matrix giống KM 1 — kết quả commission per invoice phụ thuộc ô (order_kind, payment_method_id) đang TRUE/FALSE tại thời điểm invoice complete. Spec KHÔNG prescribe hành vi cụ thể "ví có/không có commission" — đó là quyết định vận hành admin
  • Admin override: Admin tự bật/tắt từng ô qua UI nếu muốn promo cho CTV; spa chịu trách nhiệm policy

App khách Flutter — Tab Ví KM 2 + Home widget (SCR-09 — Phase 1)

  • Reuse: Pattern hiện hữu của tab "Ví KM 1" trong diva-flutter/customer/lib/presentation/modules/wallet/:
    • Repository: wallet_repository.impl.dart (getBalance(WalletType.promotion))
    • Bloc: wallet_bloc.dart (filter pattern)
    • Page: listing/views/pages/promotion_wallet_page.dart
    • Enum: core/lib/data/models/balance.dartWalletType.promotion → 'VND_PROMOTION'
    • Home: main/home_page/views/ui_parts/home_wallet.dart (4 ví hiện có)
  • Delta:
    • Thêm WalletType.promotion2 → 'VND_PROMOTION_2' vào enum balance model
    • Thêm tab "Ví KM 2" vào wallet_screen.dart (parity với KM 1 — list lot + balance + transaction history theo loại)
    • Thêm card "Ví KM 2" vào home_wallet.dart conditional khi balance > 0
    • Repository fetch balance qua action get_customer_km2_balance (DEC-029, realtime) hoặc query wallet theo wallet_type_id='VND_PROMOTION_2' (cached)
    • Transaction history dùng query wallet_km2_lot_deduction cho khách (filter theo customer_id từ auth context)
  • Vị trí update: thêm route + 2 component mới; home_wallet thêm 1 card
  • Vì sao đủ: Khách phải XEM được Ví KM 2 trên app để verify số dư (DEC-029 + ASM-001). Phase 1 read-only — khách không tự mua Gói KM 2 trên app (vẫn ASM-001), chỉ xem.
  • Out-of-scope Phase 1: khách tự mua Gói KM 2 in-app — backlog Phase 2+ (cần flow checkout, validation payment gateway mobile)

Order Commission Item (OrderCommissionItem.tsx) — Phase 1

  • Reuse: Component hiện hữu filter wallet_type_id === "VND_PROMOTION" để hiển thị row commission của khách trong đơn
  • Delta: Mở rộng filter để include cả "VND_PROMOTION_2":
    ts
    // Trước: status === "S" && wallet_type_id === "VND_PROMOTION"
    // Sau:   status === "S" && (wallet_type_id === "VND_PROMOTION" || wallet_type_id === "VND_PROMOTION_2")
  • Vì sao cần: Mặc định Migration 3 seed affiliate_config(*, wallet_promotion_2, FALSE) → không sinh row commission. Nhưng nếu admin bật ô qua UI matrix → BE sinh row commission với wallet_type_id='VND_PROMOTION_2' → UI hiện tại sẽ không hiển thị row này. Update filter để chuẩn bị sẵn cho khả năng admin enable
  • Vị trí update: Một dòng filter trong component; không đổi layout/UX
  • Cost: ~1 dòng code, 0 ảnh hưởng performance, 1 TC regression

KHÔNG thay đổi UI

  • OrderCard.tsx + CustomerOrderHistory.tsx — card không breakdown payment method
  • Dashboard (SalesCards, RevenuePercentageByPaymentMethod) — Phase 3
  • rank.graphql, profile.graphql — KM 2 áp dụng cùng logic rank/loyalty như KM 1 (ASM-005)
  • PolicyGroupForm.tsx, PolicyGroupTable.tsx, PolicyGroupLabelSettings.tsx — % hoa hồng promotion_wallet_percent dùng chung KM 1 + KM 2; gate qua affiliate_config (DEC-030); Phase 1 KHÔNG thêm cột promotion_wallet_2_percent
  • CustomerRevokedReferralForm.tsx — Hành vi truy thu commission tuân theo affiliate_config (DEC-030); 2 checkbox hiện có (Ví KM 1 + Ví CTV) đủ. Phase 1 KHÔNG thêm checkbox truy thu KM 2 — sẽ cân nhắc Phase 2 nếu admin bật (service|cosmetic, wallet_promotion_2)
  • RankCreate.tsx — KM 2 cùng logic rank như KM 1 (ASM-005); KHÔNG cần field discount_value_wallet_promotion_2
  • CustomerAffiliateCards.tsx (affiliate report) — Phase 1 báo cáo affiliate giữ chỉ KM 1; KM 2 nếu sinh commission sẽ phụ thuộc admin bật matrix — Phase 2 cân nhắc bổ sung

Bằng chứng thay đổi màn hình (cho mỗi delta)

MànBằng chứng hiện trạngThay đổi kỳ vọngVị trí updateVì sao đủKhông kỳ vọng đổi
CosmeticOrderFormPaymentĐã có chip KM 1, pattern OrderPaymentMethodRadioThêm chip Ví KM 2 + block calcSau chip KM 1Cùng pattern, không phá layoutLogic flat-rate KM 1, FIFO KM 2 độc lập
InvoiceTemplatePopupPrintBiến payment_wallet_promotion_codeThêm biến payment_wallet_promotion_2_codeDòng dưới KM 1Template hiện hữu hỗ trợLayout in không đổi
FundTableCột "Vào ví khuyến mãi" hiện hữuCột "Vào Ví KM 2" conditionalCạnh cột KM 1Conditional column patternCột thực thu/cash giữ nguyên
WithdrawRequestDetailHiển thị behavior refund_order / refund_order_cosmeticThêm behavior refund_km2_wallet label "Hoàn ví KM 2"Label trong detailBehavior mới đăng ký vào màn hiện cóKhông tạo route detail mới
ServiceReportTableCột wallet_promotion_revenueCột wallet_promotion_2_revenue conditionalCạnh cột KM 1Pattern conditionalTổng thực thu giữ
EmployeeRevenueReportDoughnutChartSegment KM 1Segment KM 2 conditionalSau KM 1Pattern segmentSegment khác giữ
CustomerEWalletInformation (CRM)Dòng "Ví khuyến mãi"Dòng "Ví KM 2" conditionalDưới KM 1Conditional rowDòng khác giữ

B-Tình huống cá biệt (12 nhóm chuẩn G1-G12)

Nhóm G1 — Quyền và phân quyền

#Trường hợpHành vi kỳ vọng
G1.1Admin được cấp internal_configuration:update giữa sessionRefetch sau ≤ 60s; menu Ví KM 2 trong Settings hiện ngay sau refresh permission
G1.2Sale POS bị thu hồi service_order:payment giữa sessionRequest kế tiếp 401/403; chip Ví KM 2 ẩn sau refetch; toast "Quyền của bạn đã thay đổi"
G1.3Sale chuyển CN giữa session (multi-branch)Cache invalidate; SCR-06 chỉ hiện khách trong CN mới
G1.4Admin gán quyền customer_management:view_all cho POS-only roleBackend hard-deny vì role is_pos_only=true; log warning
G1.5User multi-role (Manager + Kế toán)Effective permission = union (refund_request_management_submenu:* + customer_management:access)
G1.6Bị thu hồi refund_request_management_submenu:create lúc dialog SCR-07 đang mởSubmit nhận 403 → toast + đóng dialog; lot không bị tạo request

Nhóm G2 — Lifecycle và đồng thời

#Trường hợpHành vi kỳ vọng
G2.12 Sale POS cùng thanh toán cho 1 kháchBE SELECT FOR UPDATE lot; user 2 nhận 422; toast + reload số dư
G2.2Lot bị scheduler expire khi user đang xem SCR-06Polling 60s refresh; banner cập nhật từ "đang HĐ" sang "đã hết"; bảng row chuyển section
G2.3Lot bị refund_request paid khi user đang xem SCR-06Tương tự — polling cập nhật
G2.4User mở 2 tab cùng customer profileBroadcastChannel cảnh báo; không khoá; cả 2 tab cùng đọc
G2.5Mobile + desktop cùng kháchSync qua server; KHÔNG conflict (chỉ 1 device write)
G2.6Sale submit thanh toán đúng lúc cron 00:05 chuyển lot expiredBE check expired_at > NOW() trong tx; nếu race → skip lot, FIFO chuyển
G2.7Manager bấm Hoàn ví KM 2 đúng lúc lot vừa exhausted (do user khác thanh toán)BE 422; modal "Lot đã được dùng hết. Vui lòng tải lại."
G2.82 Manager cùng tạo refund request cho 1 lotĐầu tiên thắng; thứ 2 nhận 422 "Lot đã có yêu cầu hoàn đang xử lý"

Nhóm G3 — Mạng và độ tin cậy

#Trường hợpHành vi kỳ vọng
G3.1Mất mạng giữa lúc submit SCR-04Disable nút, hiện "Đang chờ kết nối…"; auto retry với cùng client_request_id; idempotent
G3.2Mạng yếu (response > 5s)Spinner + "Mạng yếu, đang xử lý..." + nút Huỷ sau 10s
G3.3Server 500/502 khi tạo đơn SCR-03Toast "Có sự cố từ hệ thống. Mã: {trace_id}"; user báo support
G3.4Rate limit 429 (tạo request liên tục)Banner "Bạn đã thao tác quá nhanh. Vui lòng thử lại sau {N}s" + đếm ngược
G3.5DNS / SSL failPage lỗi tĩnh + nút "Thử lại"
G3.6Conflict 409 sau retryModal 3 lựa chọn không áp dụng cho package này (config save đơn giản); chỉ "Tải lại"
G3.7Network mất khi đang multi-payment popupĐóng popup giữ form chính; hiện toast retry

Nhóm G4 — Dữ liệu biên / boundary

#Trường hợpHành vi kỳ vọng
G4.1Nhập max% = 0 hoặc 101Inline error "Vui lòng nhập giá trị từ 1 đến 100"
G4.2Số tiền KM 2 cực lớn (> 10 tỷ trong test)Format 10.000.000.000đ; KHÔNG scientific notation
G4.3Tên Gói có ký tự đặc biệt (Gói Đặng Thị Ánh Sương)Render đúng dấu; sort theo collation VN
G4.4Emoji trong field "Ghi chú" SCR-07Cho phép; không cảnh báo (admin tool)
G4.5RTL text (Ả-rập) trong tên kháchRender được; không phá layout (hiếm)
G4.6IME composing tiếng Việt khi gõ Ghi chúKHÔNG submit khi đang compose; FE bắt event
G4.7Decimal precision tỉ lệ dùng (KPI)2 chữ số sau phẩy: 37,50%
G4.8Date 29/02 cho expired_atValidate đúng (leap year); cron xử lý đúng
G4.9DST / múi giờ — Asia/Ho_Chi_Minh không có DSTConsistent
G4.10Year boundary (lot bán 31/12, hết hạn 30/06 năm sau)Sequence reset đúng; report cross-year cho phép
G4.11Mệnh giá thẻ = 0 hoặc âmInline error "Mệnh giá phải lớn hơn 0"
G4.12expiry_months = 1000 (24 năm)Cho phép; cảnh báo soft "Hạn sử dụng dài bất thường"
G4.13Số dư lot = 1đ (rất nhỏ)Hiển thị đúng; FIFO trừ nếu đủ
G4.14Khách có 100+ lotSCR-06 popup pagination 50/page; performance OK

Nhóm G5 — Hành vi user lệch chuẩn

#Trường hợpHành vi kỳ vọng
G5.1Admin đóng tab SCR-01 khi đang dirtyTrình duyệt warn "Thay đổi chưa được lưu"
G5.2Sale refresh giữa lúc đang submit SCR-04Idempotency key bảo vệ; sau refresh nhận lại result đã tạo
G5.3Sale back browser sau submit SCR-03 thành côngHiện màn list, không hiện form cũ
G5.4Sale tap nhiều lần CTA "Thanh toán"Idempotent + spinner ngay; backend dedupe theo client_request_id
G5.5Sale dán dữ liệu format lạ vào input sốAuto-strip non-digit; nếu không parse được → toast hướng dẫn
G5.6Manager bấm Hoàn ví KM 2 → đóng dialog không submitConfirm "Bỏ thay đổi?" nếu đã đổi method/note; không tạo request
G5.7Sale chọn Gói Ví KM 2 → chọn "Trả góp" → bấm Tạo MớiChip "Trả góp" disabled, không cho click; nếu BE bị bypass → 422

Nhóm G6 — In / Xuất / Tài liệu

#Trường hợpHành vi kỳ vọng
G6.1Máy in offline khi in hoá đơn có KM 2Toast "Không tìm thấy máy in" + cho tải PDF
G6.2In thiếu trang giữa chừngCho phép in lại; không có version stamp riêng cho KM 2
G6.3Sửa đơn sau khi đã in (đặc biệt hoàn KM 2)Tạo bản v2 invoice; bản cũ chuyển superseded
G6.4Export SCR-08 > 50.000 dòngChia file _part1.xlsx, _part2.xlsx; thông báo qua notification
G6.5Export khi user offlineQueue + thông báo khi xong
G6.6Tên file export có ký tự đặc biệtSlug không dấu đúng

Nhóm G7 — Migration / Legacy

#Trường hợpHành vi kỳ vọng
G7.1Khách có wallet KM 1 cũ + bắt đầu mua KM 2KM 1 và KM 2 hoàn toàn độc lập; UI hiện cả 2 dòng trong card stats
G7.2Đơn hàng cũ trước khi bật KM 2Không hồi cứu; chỉ đơn mới sau enable mới có chip KM 2
G7.3Format số / ngày cũ trong invoice template legacyAuto-convert; warn nếu không parse được
G7.4Soft-deleted Gói Ví KM 2 (Admin xoá) vẫn được lot referenceHiển thị "Đã xoá" badge ở SCR-02 list; lot vẫn dùng được (snapshot package_name)
G7.5Gói Ví KM 2 sửa giá / mệnh giá sau khi đã có lotKhông ảnh hưởng lot cũ (snapshot); lot mới dùng giá mới
G7.6Migration seed config sau deployDefault disabled=true; UI hiện banner "Đang tắt" + readiness 4 bước

Nhóm G8 — Thiết bị / nền tảng

#Trường hợpHành vi kỳ vọng
G8.1Admin zoom 200% trên desktopLayout không vỡ; field không bị ẩn
G8.2Dark mode OSDiva Admin có theme; tôn trọng setting nếu app hỗ trợ
G8.3Browser cũ (IE / Edge legacy)Banner "Trình duyệt không hỗ trợ. Vui lòng dùng Chrome/Edge mới"
G8.4Tablet POS portrait/landscapeResponsive; lock landscape nếu cần (POS thường dùng landscape)
G8.5Print preview với CSS brokenCSS print riêng test; in lại pass QA
G8.6Tablet POS connection chậm 3GGiao diện vẫn render được < 1.5s; spinner cho fetch

Nhóm G9 — Thời gian / Múi giờ

#Trường hợpHành vi kỳ vọng
G9.1Lot mua ở Asia/Ho_Chi_Minh, hiển thị cho user khác CNServer canonical UTC; hiển thị Asia/Ho_Chi_Minh cho user
G9.2Cron 00:05 chạy chậm vượt cutoffSkip với log; không double-run
G9.3Year-end report bridge 2 năm (KPI Doanh thu cross 2025-2026)Filter cho phép cross-year
G9.4expired_at end_of_day 23:59:59 → cron 00:05 next day chạyLot expired đúng; thanh toán sau 00:05 không trừ lot này
G9.5DST không áp dụng VNConsistent timezone

Nhóm G10 — Bảo mật / Audit

#Trường hợpHành vi kỳ vọng
G10.1User cố mở lot/deduction qua API GraphQL trực tiếpAction get_customer_km2_lots check branch_mode + customer scope; bypass FE 401/403
G10.2Bypass FE qua DevTools sửa amount KM 2 trước submitBackend luôn re-validate MIN(eligible × max%, balance); không tin FE
G10.3Brute force tạo request hoàn víRate limit 10 req/phút/user; banner đếm ngược
G10.4Export hàng loạt từ tool ngoài qua APIAPI có rate limit + audit log + cảnh báo nếu > 5 export/giờ
G10.5Idempotency key bị reuse có chủ đíchBE dedupe theo client_request_id; trả result đã tạo
G10.6Audit policy_snapshot trong requestwallet_km2_config lúc tạo lưu JSON; không thay đổi sau approve

Nhóm G11 — Onboarding / first-time

#Trường hợpHành vi kỳ vọng
G11.1Admin lần đầu vào SCR-01Tour 4 bước readiness + "Bỏ qua" / "Xem lại sau"
G11.2Sale lần đầu thấy chip Ví KM 2Tooltip auto hiện 1 lần "Chip mới — KM 2 áp dụng tối đa {max%}"
G11.3Master data Gói Ví KM 2 thiếu khi vào POSSCR-03 dropdown empty + cảnh báo "Cần cấu hình Gói Ví KM 2 trước" + deeplink
G11.4Khách lần đầu được kích hoạt KM 2ZNS gửi (nếu bật); SCR-06 hiện tooltip "Khách vừa kích hoạt {N} Gói"

Nhóm G12 — Pháp lý / Compliance

#Trường hợpHành vi kỳ vọng
G12.1Retention wallet_km2_lot hết hạn theo chính sáchMove sang archive sau 5 năm; không xoá hard
G12.2Audit query bị từ chối (kế toán cố access lot khách ngoài branch)Backend hard-deny + log warning; thông báo Admin
G12.3GDPR/PDPA right to forget cho 1 kháchProcess qua workflow riêng (không thuộc scope KM 2); không xoá thẳng wallet_km2_lot
G12.4Legal hold trên wallet_km2_lotKhoá xoá/sửa; banner "Đang giữ pháp lý" trong SCR-06 (nếu áp dụng)
G12.5Audit refund_request có chứng từpolicy_snapshot + refund_method + reviewer info lưu đầy đủ

B-Trường hợp cá biệt theo SCR (concrete spa-context cases)

Mục đích: bổ sung các tình huống biên cụ thể theo SCR, dữ liệu thật ngành spa, ngoài 12 nhóm chuẩn G1-G12. QA dùng làm seed test case trực tiếp.

Nhóm SCR-01 + SCR-02: Settings + tạo Gói Ví KM 2

#Tình huốngHành vi kỳ vọngQA TC
S01-01Admin nhập max_percent_per_order = 0Inline error Vui lòng nhập giá trị từ 1 đến 100; nút Lưu disabledTC-001-02
S01-02Admin nhập max_percent_per_order = 150Inline error tương tựTC-001-03
S01-03Admin nhập max_percent_per_order = 100 (boundary trên)Hợp lệ; toast Đã lưu cài đặt Ví KM 2TC-001-04
S01-04Admin nhập max_percent_per_order = 1 (boundary dưới)Hợp lệTC-001-05
S01-05Admin tắt Ví KM 2 trong khi 87 khách đang giữ tổng 45.250.000đModal warning với số liệu thật; chọn "Tiếp tục tắt" → payment chip ẩn cho POS, số dư khách giữ nguyên cho đến khi bật lạiTC-001-DISABLE-WITH-BALANCE
S01-06Admin nhập refund_fee_percent = 100Hợp lệ; mọi lot hoàn ví KM2 sẽ có khách nhận = 0đ (toàn bộ về phí)TC-009-FEE-100
S01-07Admin nhập refund_deadline_days = 0Inline error Vui lòng nhập số ngày lớn hơn 0TC-009-PERIOD-0
S02-01Admin tạo Gói "VE_GOLD" với value=500.000đ, value_into_wallet=300.000đInline error Số tiền nạp vào ví phải lớn hơn hoặc bằng mệnh giá thẻTC-002-INVALID-VALUE
S02-02Admin tạo Gói "VE_GOLD" trùng mã đã cóInline error Mã thẻ "VE_GOLD" đã tồn tại. Vui lòng dùng mã khác.TC-002-CODE-DUP
S02-03Admin sửa Gói "Gói Gold 500K" đang có 12 lot active của khách4 field (Mệnh giá / Số tiền nạp / Ví đích / Hạn sử dụng) readonly + tooltip Đã có lot bán ra; chỉ sửa Tên / Mô tả / Trạng tháiTC-002-LOCKED-FIELDS
S02-04Admin tạo Gói chọn Ví đích = Ví KM 2 nhưng để trống Hạn sử dụngInline error Vui lòng nhập hạn sử dụng (số tháng > 0)TC-002-EXPIRY-REQUIRED
S02-05Admin tạo Gói chọn Ví đích = VNDField Hạn sử dụng ẩn; pass validation theo behavior thẻ trả trước hiện hữuTC-002-VND
S02-06Admin đổi Ví đích từ KM 2 → VND giữa lúc đang điền formField Hạn sử dụng ẩn ngay (giữ value cũ trong state, BE bỏ qua khi submit VND)TC-002-TOGGLE-WALLET-TARGET
S02-072 Admin lưu config SCR-01 cùng lúcLast-write-wins + 409 cho người sau → modal Cấu hình đã thay đổi từ thiết bị khác. Vui lòng tải lại.TC-001-CONCURRENCY
S02-08Admin ngưng Gói Ví KM 2 đang có lot activeModal Ngưng "Gói Gold 500K" sẽ ẩn khỏi POS bán Gói Ví KM 2. Số dư khách hiện có không bị ảnh hưởng. Tiếp tục? → ngưng → POS dropdown ẩn Gói; lot active của khách giữ nguyênTC-002-DEACTIVATE

Nhóm SCR-03: Bán Gói Ví KM 2

#Tình huốngHành vi kỳ vọngQA TC
S03-01NV bán Gói Gold cho khách Nguyễn Thị Lan chưa có Ví KM 2Hệ thống tự tạo wallet VND_PROMOTION_2 cho khách (qua action walletBalances reuse); tạo lot ngayTC-003-FIRST-TIME
S03-02NV bán Gói Silver × qty = 10 cho 1 kháchTạo 10 bản ghi wallet_km2_lot riêng biệt, mỗi lot 1.5tr với expired_at riêng (cùng ngày nếu trả 1 lần); ZNS gửi 1 lần với qty=10TC-003-QTY-10
S03-03NV bán đơn 11tr nhưng khách trả lần 1 chỉ 5tr (45,45%)Tạo đơn prepaid_order.status='pending' còn nợ 6tr; tạo wallet_km2_lot với status='active', credited_amount = 45,45% × 10tr = 4.545.500đ, balance = 4.545.500đ, activated_at = NOW(), HSD tính từ lần trả đầu; ZNS gửi lần 1 với balance đã nạpTC-003-DEBT
S03-04NV bán đơn 11tr, khách trả 5tr lần 1 (45,45%), 4tr lần 2 (36,36%), 2tr lần 3 (18,19%)Lần 1: tạo lot status active, balance = 45,45% × 10tr = 4.545.500đ; ZNS gửi lần 1. Lần 2: balance += 36,36% × 10tr = 3.636.400đ = 8.181.900đ. Lần 3: balance = 10.000.000đ tròn (rounding cuối). Hạn sử dụng tính từ lần trả đầuTC-003-MULTI-PAYMENT
S03-05Mất mạng lúc NV bấm Tạo MớiDisable nút, hiện "Đang chờ kết nối…"; auto retry với cùng client_request_id (UUID gen client); BE idempotent → trả result đã tạo nếu duplicateTC-003-NETWORK
S03-06NV mix 1 dòng "Thẻ trả trước 10tr" + 2 dòng "Gói Gold KM 2" trong cùng 1 đơnCột "NẠP VÍ KM 2" hiện; dòng VND có cột KM 2 = 0; dòng KM 2 có cột DIVA + KM 1 = 0; sidebar hiển thị 3 dòng riêng biệt; chip "Trả góp" disabled vì có dòng KM 2TC-003-MIX
S03-07NV cố chọn "Trả góp" cho đơn có dòng Gói KM 2Chip disabled với tooltip Trả góp không áp dụng cho đơn có Gói Ví KM 2 (DEC-017); nếu user bypass FE qua DevTools, BE 422 installment_not_allowed_for_km2TC-003-INSTALLMENT-BLOCKED
S03-08NV double click Tạo MớiNút disabled sau click đầu (loading state); BE dedupe client_request_id → trả cùng kết quảTC-003-DOUBLE-CLICK
S03-09NV xoá dòng KM 2 cuối cùng trong đơn (chỉ còn dòng VND)Cột "NẠP VÍ KM 2" ẩn ngay; dòng summary "Tiền vào Ví KM 2" ẩn; chip "Trả góp" enabled trở lạiTC-003-REMOVE-LAST-KM2

Nhóm SCR-04: Thanh toán bằng Ví KM 2

#Tình huốngHành vi kỳ vọngQA TC
S04-01Đơn chỉ có Kem chống nắng XYZ (không bật allow_promo_wallet_2)Chip Ví KM 2 ẩn (eligible_total = 0)TC-004-NO-ELIGIBLE
S04-02Khách có balance 50.000đ, eligible 500.000đ, max 20% → max calc = 100.000đAuto-fill = MIN(100.000, 50.000) = 50.000đ (dùng hết số dư); còn lại = 450.000đ tiền mặtTC-004-INSUFFICIENT-BALANCE
S04-03Khách có 5 lot active (số dư lần lượt 100k, 200k, 300k, 400k, 500k = 1.5tr); thanh toán cần 600kFIFO trừ: lot 1 hết (100k), lot 2 hết (200k), lot 3 trừ 300k (hết); ghi 3 deduction records cho cùng order_id; lot 4-5 còn nguyênTC-004-FIFO-PARTIAL
S04-04Lot cũ nhất hết hạn 23:59:59 ngày trước; thanh toán lúc 00:01 (cron 00:05 chưa chạy)BE check expired_at > NOW() trong tx; lot cũ skip, FIFO chuyển sang lot tiếp theo; lot cũ đánh dấu expired sau cron 00:05TC-004-EXPIRY-RACE
S04-05Đơn có allow_combine_km1 = true; KM 2 eligible 500k, KM 1 eligible 800k (item trùng)KM 2 tính trước: 500k × 20% = 100k; KM 1 tính trên phần còn lại của item trùng: nếu cùng Massage 500k, KM 1 chỉ trừ trên 500k - 100k = 400k; nếu item riêng (KM 1 dùng cho item khác), KM 1 trừ riêng 800kTC-004-DUAL-WALLET
S04-06NV double click "Thanh toán"Nút disabled sau click đầu (loading); BE dedupe client_request_idTC-004-DOUBLE-CLICK
S04-072 Sale POS thanh toán cùng khách Nguyễn Thị Lan vào cùng giâyA: BE SELECT FOR UPDATE lot, trừ thành công; B: nhận 422 insufficient_balance; toast Số dư đã thay đổi từ thiết bị khác. Đã tải lại số dư mới.; block KM 2 của B refresh số dưTC-004-RACE-CONCURRENT
S04-08Đơn 1tr, eligible 700k, max 20% → max calc = 140k; balance 5trAuto-fill = MIN(140k, 5tr) = 140k; còn lại 860k tiền mặt; sau thanh toán balance = 5tr - 140k = 4.860.000đTC-004-NORMAL-CASE

Nhóm SCR-06 + SCR-07: Profile + Hoàn ví KM 2

#Tình huốngHành vi kỳ vọngQA TC
S06-01Khách có 23 lot activeBảng "Đang hoạt động" pagination 50/page (1 trang); virtual scroll nếu > 100TC-006-PAGINATION
S06-02Khách có toàn bộ 5 lot đều ở expired/exhausted/refundedSection "Đang hoạt động" ẩn; chỉ hiện section "Đã hết / hoàn"; card stats hiện "0đ"; banner cảnh báo ẩnTC-006-ALL-INACTIVE
S06-03Manager bấm Hoàn ví KM 2 ở Gói Gold #1 (còn 2.5tr, hết hạn 15/04/2026)Dialog SCR-07 mở: Tỉ lệ hoàn = 500k / 5tr = 10,00%; Hoàn theo tỉ lệ = 2.5tr × 10% = 250.000đ; Phí xử lý = 500k × 20% = 100.000đ; KHÁCH NHẬN = MAX(250k - 100k, 0) = 150.000đTC-009-NORMAL-REFUND
S06-04Lot Gói Silver còn 100k (giá mua 200k, ví 1.5tr)Tỉ lệ hoàn = 200k / 1.5tr = 13,33%; Hoàn theo tỉ lệ = 100k × 13,33% = 13.333đ; Phí = 200k × 20% = 40.000đ; KHÁCH NHẬN = MAX(13.333 - 40.000, 0) = 0đ; modal warning Phí xử lý lớn hơn số tiền hoàn. Khách KHÔNG nhận tiền nhưng số dư Gói Silver sẽ tất toán. Tiếp tục?; vẫn cho submitTC-009-ZERO-CASE
S06-05Manager bấm Hoàn ví KM 2 đúng lúc lot vừa hết hạn (cron 00:05 chạy giữa lúc dialog mở)BE 422 khi submit lot_already_expired; modal Lot vừa hết hạn. Vui lòng đóng và tải lại tab Ví KM 2.TC-009-EXPIRED-RACE
S06-06Lot Gói Gold đã bị refund đơn DV trước đó (balance hiện = 1.8tr thay vì 5tr ban đầu)Hoàn ví KM 2 tính trên balance hiện tại 1.8tr; KHÁCH NHẬN = MAX(1.8tr × 10% - 100k, 0) = 80.000đTC-009-AFTER-REFUND-BACK
S06-07Lot đã hết hạn; sau đó user refund đơn DV đã dùng lot nàyLot status expiredactive; expired_at = NOW() + refund_extend_days (30); balance += amount refund; hiển thị lại trong section "Đang hoạt động" với badge Đã gia hạn (tooltip lý do)TC-008-EXTEND
S06-08Lot đã refunded toàn bộ → user refund đơn DV đã dùng lot nàyLot cũ giữ status refunded; tạo lot mới với package_name = "{tên cũ} (gia hạn từ refund DV)", balance = amount refund, expiry = NOW + extendTC-008-NEW-LOT-AFTER-REFUND
S06-092 Manager cùng bấm Hoàn ví KM 2 cho cùng lotĐầu tiên thắng; thứ 2 nhận 422 Lot đã có yêu cầu hoàn đang xử lý + reloadTC-009-CONCURRENT-REFUND
S07-01Manager chọn "Hoàn vào: Tiền mặt" → submitTạo transaction_request với refund_method='cash', behavior='refund_km2_wallet', snapshot policy; toast Đã tạo Yêu cầu hoàn tiền KM 2 #{id}. Vui lòng chờ duyệt.TC-009-METHOD-CASH
S07-02Manager chọn "Hoàn vào: Ví VND của khách" → reviewer duyệt → kế toán bấm Thanh toánLot tất toán (status=refunded); transfer balance VND wallet của khách +150k; toast Đã hoàn 150.000đ vào Ví VND của khách. Lot Gold đã tất toán.TC-009-METHOD-WALLET
S07-03Reviewer từ chối yêu cầu hoànLot KHÔNG bị tất toán (giữ status active); Manager có thể tạo request mớiTC-009-REJECT

Nhóm SCR-08: Report dashboard

#Tình huốngHành vi kỳ vọngQA TC
S08-01Manager xem report nhưng branch không có dữ liệu KM 2KPI hiện "0"/"—"; bảng hiện Chưa có dữ liệuTC-010-EMPTY-BRANCH
S08-02Export > 5.000 dòng tổngAsync: toast Đang xử lý, bạn sẽ nhận file qua thông báo; nút Xuất Excel disable cho đến khi xong; sau khi xong gửi notification với link tảiTC-010-ASYNC-EXPORT
S08-03KPI "Tỉ lệ dùng" có denominator (Tổng nạp) = 0Hiển thị "—" (không hiện 0% hay NaN)TC-010-NAN
S08-04Khách Nguyễn Thị Lan mua Gói Gold ở chi nhánh A, dùng tại chi nhánh BReport chi nhánh A: tính vào "Đã bán" + "Doanh thu" (theo branch_id lúc bán); Report chi nhánh B: tính vào "Đã dùng" (theo branch_id lúc deduction); Report "Tất cả": tổng đúngTC-010-CROSS-BRANCH
S08-05Manager branch_mode=branch mở reportBranchSelect ẩn dropdown all-branch; chỉ hiện branch của Manager; backend enforcement filter theo role.branch_modeTC-010-MANAGER-SCOPE
S08-06Admin branch_mode=all mở reportBranchSelect cho phép chọn "Tất cả" hoặc 1 branch cụ thể; hiển thị toàn hệ thốngTC-010-ADMIN-SCOPE

Nhóm B-FR012: Tích hợp KM 2 vào màn hình hiện có

#Tình huốngHành vi kỳ vọngQA TC
F12-01Đơn mỹ phẩm — chọn KM 2 thanh toánChip Ví KM 2 hiện trong CosmeticOrderFormPayment (giống đơn DV); auto-fill max%, FIFO; CosmeticOrderFormPaymentTable hiện cột wallet_promotion_2_amount conditionalTC-FR012-COSMETIC
F12-02Đơn sản phẩm — item không có allow_promo_wallet_2 = trueChip Ví KM 2 ẩn (eligible_total = 0); giống pattern KM 1TC-FR012-PRODUCT
F12-03Hoá đơn in cho đơn dùng KM 2Dòng Ví KM 2: 140.000đ hiện trong block PTTT (ngay dưới dòng Ví khuyến mãi: ... của KM 1); biến template payment_wallet_promotion_2_code được renderTC-FR012-INVOICE
F12-04Fund / Quỹ — đơn nạp tiền có Gói KM 2FundTable hiện cột "Vào Ví KM 2" conditional khi range có wallet_promotion_2_amount > 0TC-FR012-FUND
F12-05Withdraw / Refund detail — request refund_km2_walletWithdrawRequestDetail hiện badge Hoàn ví KM 2 cho behavior; payment method label "Ví KM 2" hiển thị đúng nếu refund_method=wallet; không thay đổi layoutTC-FR012-WITHDRAW
F12-06Report DV/NV — nhân viên bán Gói KM 2 + thanh toán đơn DVServiceReportTable cột wallet_promotion_2_revenue conditional cạnh KM 1; doughnut chart thêm segment "Ví KM 2"; KM 2 paid amount tính như wallet-paid (không cộng thực thu)TC-FR012-EMPLOYEE-REPORT
F12-07CRM — khách có wallet KM 2Block ví khách hiện dòng Ví KM 2: 4.600.000đ ngay dưới dòng Ví khuyến mãi: ...; conditional khi balance > 0TC-FR012-CRM-WALLET

Note QA: Các case S03-04 (multi-payment), S04-03 (FIFO 5 lots), S06-07 (gia hạn lot expired qua refund DV), S06-08 (tạo lot mới từ refund DV của lot đã refund) là 4 case quan trọng nhất phản ánh nghiệp vụ KM 2 cốt lõi — phải có TC dedicated với assertion rõ trên DB state.


B-POST) Verification (BẮT BUỘC trước DELIVER)

B-POST.1) Checkpoint completeness

  • [x] As-Is đầy đủ: B0.1 inventory bao phủ 100% UI hiện hữu các màn bị đụng (32 dòng UI ID, kèm Evidence file:line)
  • [x] Delta status đầy đủ: mọi UI ID có Delta Status (KEEP/UPDATE/NEW/HIDE); không có REMOVE trong scope
  • [x] Field × Surface ma trận: B0.4 có 28 field mới/sửa với 12 cột đầy đủ
  • [x] State × Screen ma trận: B0.5 có 13 màn/khối với 6 state (default/loading/empty/error/no-permission/partial)
  • [x] Reuse classification: mọi SCR có ✅ Reuse / 🔧 Extend / 🆕 Build mới + file path + delta (B-PRE.3, B2.x.3)
  • [x] Wireframe context: Reuse/Extend đều vẽ vùng KEEP xung quanh update (SCR-01 banner readiness + card hiện hữu; SCR-02 form 2 cột; SCR-03 layout 8/4; SCR-04 chip + block; SCR-06 sidebar wallet stats; SCR-FR012 dòng dưới KM 1)
  • [x] UX principles: mỗi SCR (SCR-01..SCR-08) có B2.x.1A 7 nguyên tắc UX
  • [x] Variant coverage: B2.x.2.0 có Ma trận Biến thể với default/loading/error/no-permission/partial/lifecycle/permission cho mọi SCR có UI phức tạp
  • [x] Role × Variant: B2.x.8A khớp PRD Decision Log + permission seed + QA TC; field không quyền không render (Variant C/H/I/K)
  • [x] CTA hierarchy: mỗi SCR có primary CTA + điều kiện enable + secondary CTA
  • [x] Validation đầy đủ: mọi input có rule (số liệu, range, format) trong B2.x.4 + B0.4
  • [x] Permission matrix: mọi action nhạy cảm có dòng phân quyền per portal × role (B2.x.8 + B1.6)
  • [x] Confirm modal: mọi destructive action có confirm copy (tắt KM 2, tạo refund khi khách nhận = 0, ngưng Gói)
  • [x] Empty với CTA: SCR-03 empty "Chưa thêm thẻ" + nút + Thêm thẻ nạp; SCR-06 empty + lý do; SCR-08 empty với filter context
  • [x] Format VN: tiền 1.250.000đ, ngày DD/MM/YYYY, giờ HH:mm (B-i18n)
  • [x] Design-token intent: không hard-code màu/font; dùng success/warning/danger/muted/primary emphasis/negative-value

B-POST.2) Cross-spec consistency check

  • [x] Mọi field trong B0.4 đều xuất hiện trong PRD A5 FR (FR-001..FR-012)
  • [x] Mọi field trong B0.4 đều xuất hiện trong Dev Spec C4 (data model wallet_km2_config, wallet_km2_lot, wallet_km2_lot_deduction, prepaid_card.*, product/service.allow_promo_wallet_2, transaction_request.*)
  • [x] Mọi action mới đều có TC tương ứng trong QA Test Plan (TC-SCR01..TC-SCR08, TC-FR012, TC-PERM, TC-IMPACT)
  • [x] Mọi state trong B0.5 đều có TC trong QA
  • [x] Mọi variant trong B2.x.2.0 đều có PRD AC (FR-001 đến FR-012) hoặc UI-only state
  • [x] Mọi variant trong B2.x.2.0 đều có QA TC ref (TC-SCR01-A-01, TC-PERM-SCR06-01, ...)
  • [x] Role/permission trong B2.x.8 / B2.x.8A khớp PRD Decision Log (DEC-013 đến DEC-026), Permission seed (module_permission_action) và QA TC-PERM
  • [x] Đã ripple sang PRD/QA/_consistency-matrix.md sau khi update UI Spec
  • [x] Đã điền _consistency-matrix.md

B-POST.3) Lint nhanh kiểm tra nhất quán

bash
SLUG=vi-km-2

# 1. Style hard-code
rg -n "cam đỏ|xám đỏ|màu đỏ|font-size|font-weight" docs/features/$SLUG/ui-spec.md
# Kết quả: rỗng ✅

# 2. English UI raw
rg -n '\b(Submit|Confirm|Cancel|OK|Loading|No data|Forbidden|Error|Success|Save|Delete|Edit|View)\b' docs/features/$SLUG/ui-spec.md | grep -v "Reuse\|MOVE\|NEW\|REMOVE\|HIDE\|KEEP\|UPDATE\|EXTEND\|primary emphasis\|aria-\|HSD\|FOR UPDATE\|VND\|wallet_target\|wallet_value"
# Kết quả: chỉ còn match trong code/literal/Evidence path ✅

# 3. Placeholder
rg -n "TBD|TODO|Lorem|\{placeholder\}|FIXME" docs/features/$SLUG/ui-spec.md
# Kết quả: rỗng ✅

# 4. Enum bị dịch (search-replace lỗi)
rg -n "đang hoạt động \(đang hoạt động\)|đã hoàn thành \(đã hoàn thành\)" docs/features/$SLUG/ui-spec.md
# Kết quả: rỗng ✅

# 5. Click verb chưa Việt hoá
rg -n '"Click"|Click [A-Za-z]' docs/features/$SLUG/ui-spec.md
# Kết quả: chỉ trong narrative VI "Click vào..." ✅

# 6. Khẩu hiệu mơ hồ
rg -n "tối ưu trải nghiệm|nâng cao hiệu quả|hỗ trợ quản lý|nói chung|cụ thể là|rất linh hoạt" docs/features/$SLUG/ui-spec.md
# Kết quả: rỗng ✅

# 7. Format VN sai
rg -n '\$[0-9]|MM/DD/YYYY|hh:mm A|[0-9],[0-9]{3}đ' docs/features/$SLUG/ui-spec.md
# Kết quả: rỗng ✅

# 8. Calque nguy hiểm
rg -n "Quy ước (field|tích hợp|phản hồi|hành vi)" docs/features/$SLUG/ui-spec.md
# Kết quả: tồn tại có chủ ý theo template skill ("Quy ước tương tác form") — pass

# 9. Schema NOT NULL field UI cho null
rg -n "_id=NULL|=NULL OK" docs/features/$SLUG/ui-spec.md
# Kết quả: rỗng ✅

# 10. Cấu trúc Mermaid label English
rg -n "stateDiagram.*Active|stateDiagram.*Pending|flowchart.*Pass|flowchart.*Fail" docs/features/$SLUG/ui-spec.md
# Kết quả: chỉ flowchart label tiếng Việt + code raw `active`/`expired`/`exhausted`/`refunded` trong table ✅

B-POST.4) Cross-spec diff bot

bash
SLUG=vi-km-2

# Diff PRD FR list vs UI Spec SCR list
PRD_FR=$(rg -o '^### FR-[0-9]+' docs/features/$SLUG/prd.md | sort -u)
UI_SCR=$(rg -o 'SCR-[0-9a-z]+' docs/features/$SLUG/ui-spec.md | sort -u)
echo "PRD FR coverage:"
echo "$PRD_FR"
# FR-001..FR-012 (12 FRs)
echo "UI SCR coverage:"
echo "$UI_SCR"
# SCR-01..SCR-08 + SCR-FR012-* (cover all FR)

# DEC trong SOURCE_OF_TRUTH phải xuất hiện trong ui-spec
DECs=$(rg -o 'DEC-[0-9]+' docs/features/$SLUG/SOURCE_OF_TRUTH.md | sort -u)
for dec in $DECs; do
  rg -q "$dec" docs/features/$SLUG/ui-spec.md && echo "OK $dec" || echo "MISSING $dec in ui-spec"
done
# Kết quả: DEC-001..DEC-026 — DEC liên quan UI đã được ref qua context (DEC-005, DEC-008, DEC-014, DEC-017, DEC-022, DEC-023, DEC-026 đều có); DEC kỹ thuật pure (DEC-007, DEC-018, DEC-022) không cần ref UI

# Action permission UI vs Dev Spec C8
UI_ACTIONS=$(rg -oE "[a-z_]+:[a-z_]+" docs/features/$SLUG/ui-spec.md | sort -u)
echo "UI actions:"
echo "$UI_ACTIONS"
# internal_configuration:update, prepaid_order:create, prepaid_order:payment, service_order:payment,
# product_order:payment, customer_management:access, customer_management:view_all,
# refund_request_management_submenu:access/create/update/approve/payment, report:access

Mọi mismatch ở B-POST.4 đều đã được giải thích hoặc fix trước DELIVER. Đặc biệt: 7 DEC kỹ thuật pure (DEC-007/018/022/...) không cần trace UI; 19 DEC còn lại đều có UI implementation.


B-QUALITY) Rà soát rủi ro thiếu sót (45 rủi ro)

Đối chiếu từng rủi ro với spec → ghi [Đã cover ở X] hoặc [Còn gap: ...].

Rủi ro QA thường gặp

#Rủi roTrạng thái cho package này
1Thiếu test cho field mớiĐã cover ở B0.4 + QA D2 với ≥3 TC mỗi field (happy/boundary/negative) — VD max_percent_per_order: TC max=1, max=100, max=0, max=101
2Thiếu state empty/loading/errorĐã cover ở B0.5 — 13 màn × 6 state
3Thiếu permission denied testĐã cover ở B2.x.8A + TC-PERM (Variant C/F/H/I/K) cho mọi SCR
4Thiếu confirm modalĐã cover: tắt KM 2 (SCR-01 confirm_disable_km2), refund khách nhận 0 (SCR-07 confirm_refund_zero), ngưng Gói (SCR-02), rời trang dirty (confirm_leave_dirty), toggle Cho phép Ví KM 2 (SCR-05 confirm_toggle_km2)
5Thiếu format VN trong testTC ghi rõ expected 1.250.000đ, DD/MM/YYYY, HH:mm, 37,50% (B-i18n + QA D2)
6Boundary value không cụ thểTC boundary có dòng cho min/max/min-1/max+1 cho max_percent_per_order, expiry_months, refund_fee_percent (B0.4)

Rủi ro UI/UX thường gặp

#Rủi roTrạng thái
7Thêm field không nói nằm đâuB0.1 có cột "Section" + "Thứ tự"; B0.4 có cột "Hiển thị ở đâu"
8Wireframe không vẽ vùng KEEPMọi wireframe SCR-01..SCR-07 đều vẽ vùng KEEP (banner readiness chèn vào shell, dòng KM 2 chèn dưới dòng KM 1, chip Ví KM 2 chèn cạnh chip KM 1)
9Field không có defaultB0.4 có cột Default cho mọi field — disabled=true, max_percent_per_order=20, allow_combine_km1=false, allow_refund=true, refund_fee_percent=20, refund_deadline_days=30, refund_extend_days=30, wallet_target=VND, expiry_months=6, allow_promo_wallet_2=false
10Empty state không có CTASCR-03 empty + nút + Thêm thẻ nạp; SCR-06 empty + lý do; SCR-08 empty với context filter
11Modal không nói triggerB2.7 dialog mở từ SCR-06 row click; B2.x.10 cột Hành động ghi trigger
12Filter không nói defaultSCR-02 filter "Trạng thái: Tất cả", "Ví đích: Tất cả"; SCR-06 filter Trạng thái + Tên Gói default Tất cả; SCR-08 default tháng hiện tại + branch theo role
13Chỉ có happy-path wireframeB2.x.2.0 Ma trận Biến thể có 6-11 variant per SCR (default/loading/empty/error/permission/lifecycle/race/partial)
14Role/permission flow lệch nhauB2.x.8A Role × Variant + B-POST.2 ripple PRD/QA
15UI spec hard-code styleĐã pass lint B-POST.3 — không có "cam đỏ", "font-size", "font-weight"

Rủi ro PO/BA thường gặp

#Rủi roTrạng thái
16Spec mới ghi đè behavior cũ mà không khai báoB0.1 có Delta Status cho mọi UI hiện hữu — KEEP rõ ràng cho card hiện có ở SCR-01, form 2 cột SCR-02, layout 8/4 SCR-03, chip KM 1 SCR-04, toggle KM 1 SCR-05, sidebar wallet stats SCR-06
17Out-of-scope không rõPRD A2 + B-FR012 "KHÔNG thay đổi UI" liệt kê rõ (OrderCard, dashboard Phase 3, rank.graphql)
18Decision không có ≥2 phương ánZ) Decision Log trong PRD đã có cột "Lý do" với ≥2 alternatives cho mọi DEC

Rủi ro chuyên sâu

#Rủi roTrạng thái
19Wireframe ASCII vỡ alignmentB0.6 — đã verify số cách đều
20Search-replace lỗi enumB0.7 Bilingual Pairing + lint B-POST.3 — pass
21UI cho phép null field NOT NULLB0.8 Schema Cross-Check — đã verify expiry_months (conditional NOT NULL khi KM 2), branch_id NOT NULL (auto-fill), transaction_request.lot_id (NOT NULL với behavior refund_km2_wallet)
22Stepper số bước inconsistencySetup readiness 4 bước nhất quán B-PRE / B0.5 / B1.5 / SCR-01 wireframe
23Form thiếu autosave/paste/IME contractB2.x.7B Form Interaction Deep cho SCR-01/02/03/07
24Concurrency lờ điB2.x.7C cho SCR-01 (config 409), SCR-02 (Gói 409), SCR-03 (idempotency), SCR-04 (lot SELECT FOR UPDATE), SCR-06 (polling 60s), SCR-07 (lot race)
25Mạng yếu / offline không ghiB2.x.7D Network Resilience cho SCR-01/02/03/04/06/07
26Lỗi gộp chung "Có lỗi xảy ra"B2.x.10A Error Taxonomy phân loại 7 loại lỗi cho mọi SCR (validation client/server, 401/403/409/422/429/5xx, network, partial 207)
27Print/PDF thiếu page break / version stampB-FR012 đoạn InvoiceTemplate + B5A Export Deep; package này không có form in pháp lý mới
28Token UI thiếu countdown / revokeKhông áp dụng (không có token portal customer self-serve Day-1)
29File upload thiếu chunk/retryKhông áp dụng (không có upload trong scope)
30Search debounce ad-hocB2.2 search Gói Ví KM 2 debounce 300ms; SCR-08 không search; SCR-06 filter không search
31Bulk action thiếu undo / partialKhông áp dụng Day-1 (deferred); B2.x.10A có "Partial success" cho multi-payment 207
32Export thiếu masking theo quyềnB5A: Manager branch_mode=branch chỉ thấy branch mình; cell nếu không quyền
33Copy length vỡ layoutB6A — đã kiểm; rút gọn label "Phí xử lý hoàn ví (% giá mua gói)" → "Phí xử lý hoàn ví (%)"
34Format VN sai ($50, MM/DD)B-i18n + lint B-POST.3 — pass
35Brand voice không nhất quánB-Voice — chuẩn hoá tone admin/POS (trung tính, chuyên môn) vs ZNS khách (lễ phép, ấm áp)
36Microcopy form lộn xộnB-Microcopy — chuẩn hoá * đỏ, char counter, format ngàn
37A11y bỏ sótB8B A11y Deep — focus visible, aria-label, heading hierarchy, focus trap dialog
38Performance không nóiB8A Performance — virtual scroll SCR-06 popup > 100 lot; pagination 50/page; bundle < 200KB; FCP < 1.5s
39Edge case không phân nhómB-Tình huống cá biệt 12 nhóm G1-G12 chuẩn (60+ case)
40Feature flag / staged rollout không có UIB-Versioning — disabled=true mặc định = kill switch; FE polling 60s; rollback by toggle
41Help touchpoint thiếuB-Help — link runbook, tooltip ⓘ, onboarding tour 4 bước, error correlation_id
42Lifecycle ≥4 trạng thái không có EXT-3Lot có 4 trạng thái (active/exhausted/expired/refunded) — chốt KHÔNG có pending theo DEC-027 (lot chỉ được tạo khi khách trả ≥ 1 đồng); B2.6.11 + PRD A4 lifecycle diagram cover; chưa load EXT-3 vì không phải lifecycle với approval phức tạp (request lifecycle reuse transaction_request hiện hữu)
43RBAC field-level không có EXT-4B2.x.8A Role × Variant + B1.6 Dynamic Permission v2 cover; package không có field-level masking phức tạp (số tiền chỉ ẩn cả khối, không partial)
44Audit/compliance UI thiếuB2.7 policy_snapshot + audit wallet_km2_lot_deduction + B-FR012 WithdrawRequestDetail; G12 cover compliance
45Real-time update không nóiB2.x.7C polling 60s cho SCR-06 popup; SCR-01 không real-time (config save explicit)

Cách dùng B-QUALITY

  1. ✅ Đã rà 45 rủi ro
  2. ✅ Mọi rủi ro đã có [Đã cover] hoặc đã được tự suy diễn không áp dụng (token portal customer self-serve / file upload / form pháp lý — không trong scope KM 2)
  3. ✅ Không còn gap → DELIVER OK