Appearance
Mã quay thưởng theo hóa đơn
Sự kiện quay thưởng — sinh mã tự động khi printed receipt (gộp nhiều order trong ngày) có thực thu ≥ 10.000.000 VND (đã loại trừ
wallet+wallet_promotion).
Effort ước tính
3–5 ngày (BE handler + Excel export via export-api). FE chỉ thêm 1 trang Export.
1) Tóm tắt
Spa đang chạy sự kiện quay thưởng. Mỗi "hóa đơn in" (printed receipt) gộp nhiều order trong ngày — nếu thực thu ≥ 10.000.000 VND (đã loại ví khuyến mãi) → sinh mã quay thưởng. Receipt 63tr → 6 mã. Mã không trùng, in trực tiếp trên hóa đơn. Admin xuất Excel theo ngày để tổng hợp danh sách khách có mã.
| Ngưỡng | Format mã | Phạm vi |
|---|---|---|
| 10tr / 1 mã | QT-XXXXX (8 ký tự, 5 random Crockford base32) | 1 bảng DB mới + reuse print_invoice |
Mapping nghiệp vụ ↔ DB Diva (đã confirm với PO)
"Hóa đơn" trong yêu cầu PO = bảng print_invoice (printed receipt). Mỗi record gộp nhiều order qua field related_orders TEXT[]. Field actual_revenue đã được Diva tính sẵn (loại trừ wallet + wallet_promotion) — KHÔNG cần tự SUM filter.
Timeline tạo receipt trong Diva
- NV click "In hóa đơn" → action
printInvoicetạo/updateprint_invoicevớiis_printed=false, gộp orders của ngày - Scheduler
print_invoice_dailychạy 17:15 mỗi ngày → finalize tất cả receiptsis_printed=falsecủa hôm qua → recomputeactual_revenue→ setis_printed=true - → Mã quay thưởng nên fire khi
actual_revenueđược set/update (insert + update)
2) Quy tắc nghiệp vụ
Điều kiện sinh mã
| Điều kiện | Chi tiết |
|---|---|
| Thời gian | print_invoice.sales_date nằm trong khoảng sự kiện (config qua app_setting) |
| Active | print_invoice.disabled = false |
| Ngưỡng | print_invoice.actual_revenue ≥ 10.000.000 VND |
| Tính (gross HĐ) | Đã có trong actual_revenue — gồm cash, bank, card, installment, VÀ phần công nợ chưa trả |
| KHÔNG tính (ví KM) | Đã bị loại trong actual_revenue — wallet, wallet_promotion |
⚠️ "Thực thu" theo nghĩa Diva = giá trị HĐ trừ ví KM, BAO GỒM công nợ
Logic actual_revenue ở action/print_invoice_preview.go:413-416 KHÔNG filter paid_at IS NOT NULL — tức là gộp luôn cả invoice chưa trả (công nợ) vào tổng.
go
for _, invoice := range invoices { // ← KHÔNG check paid_at
totalPaid += invoice.ReferenceAmount
if invoice.PaymentMethodID != "wallet" &&
invoice.PaymentMethodID != "wallet_promotion" {
actualRevenue += invoice.ReferenceAmount // ← Cộng cả phần nợ
}
}Hệ quả:
- HĐ 50tr = 30tr cash + 20tr nợ →
actual_revenue= 50tr → 5 mã (kể cả phần nợ) - HĐ 50tr = 30tr cash + 20tr ví KM →
actual_revenue= 30tr → 3 mã (loại ví đúng)
Đây là chủ đích nghiệp vụ (đã confirm với PO 13/05/2026): khuyến khích KH chốt HĐ lớn dù còn nợ. Rủi ro KH hủy đơn / quỵt nợ → mitigate bằng nút revoke mã thủ công cho Admin (xem Edge cases).
Công thức (đơn giản hóa)
số_mã_cần = FLOOR(print_invoice.actual_revenue / 10.000.000)
Điều kiện sinh:
NOT disabled
AND sales_date IN [event_start, event_end]⚠️ KHÔNG dùng order.paid_amount trực tiếp
Field này là rollup từ TẤT CẢ invoice đã paid_at (bao gồm wallet/wallet_promotion). Phải dùng print_invoice.actual_revenue để có "thực thu theo nghĩa Diva" (gross HĐ loại ví KM, bao gồm công nợ).
Ví dụ (1 print_invoice / receipt gộp nhiều order)
| Receipt | Gộp orders | Thanh toán | actual_revenue | Số mã |
|---|---|---|---|---|
| R1 | 1 order spa | 9.5tr cash | 9.500.000 | 0 |
| R2 | 2 orders | 10tr bank | 10.000.000 | 1 |
| R3 | 3 orders (DV + SP) | 63tr cash + bank | 63.000.000 | 6 |
| R4 | 1 order 50tr | 30tr cash + 20tr wallet_promotion | 30.000.000 | 3 (loại ví KM) |
| R5 | 1 order 100tr | 100% wallet_promotion | 0 | 0 |
| R6 ⚠️ | 1 order 50tr | 30tr cash + 20tr công nợ | 50.000.000 | 5 (gồm cả nợ) |
| R7 ⚠️ | 1 order 80tr | 60tr cash + 20tr ví KM + 0 nợ | 60.000.000 | 6 |
| R8 ⚠️ | 1 order 100tr | 50tr cash + 20tr nợ + 30tr ví KM | 70.000.000 | 7 (gồm nợ, loại ví) |
Edge case
| Tình huống | Xử lý |
|---|---|
| Receipt được update (merge thêm orders trong ngày) | Incremental: Hasura trigger fire khi actual_revenue update → recompute → sinh delta mã |
| Scheduler 17:15 finalize receipt | Scheduler update actual_revenue → trigger fire → sinh mã (idempotent) |
| In lại hóa đơn | print_invoice_preview.go query lại mã hiện có theo print_invoice_id → render trên template |
| Receipt bị disable | UPDATE lottery_code SET is_cancelled = true WHERE print_invoice_id = X |
Hoàn 1 phần (giảm actual_revenue) | Giữ nguyên mã đã sinh (giấy đã giao khách). Admin có thể revoke thủ công nếu phát hiện gian lận (phase sau) |
| KH có công nợ (R6, R8) | Được mã ngay theo giá trị HĐ (gồm phần nợ). Đây là chủ đích nghiệp vụ — khuyến khích KH chốt HĐ lớn. NV bàn quay thưởng nên kiểm tra công nợ trước khi cho quay nếu KH đến quá hạn nợ |
| KH hủy đơn sau khi nhận mã | Admin vào màn tra cứu mã → revoke (is_cancelled = true). Quy trình: yêu cầu trả lại HĐ giấy hoặc lấy lý do trong hệ thống ticket |
3) Format mã quay thưởng
QT-K2M9F
│ │
│ └─ 5 ký tự Crockford Base32 (random)
└──── Prefix cố định "Quay Thưởng"- Crockford base32: 32 ký tự (0-9, A-Z bỏ I, L, O, U) → tránh nhầm lẫn khi đọc/in
- Tổ hợp: 32⁵ = 33.554.432 mã ≈ 33,5 triệu → đủ cho vài thập kỷ sự kiện
- Collision rate thực tế:
- 10.000 mã đã gen → 0.030% va chạm khi gen tiếp
- 60.000 mã đã gen → 0.18% va chạm
- → Retry hầu như KHÔNG bao giờ trigger trong scope sự kiện thực tế
- Đảm bảo unique: UNIQUE constraint trên column
code+ retry 5 lần phòng hờ va chạm - Không có QR code — khách đọc mã cho NV nhập tay khi đến quay thưởng (5 ký tự = 1 cụm dễ đọc qua điện thoại: "K-2-M-9-F")
Ví dụ mã hợp lệ
QT-K2M9F QT-A3F7T QT-X9B2C QT-7M3KZ
QT-C5R9P QT-T1Y6V QT-D2K8H QT-M3W4N4) Config qua app_setting
Không build màn admin để config sự kiện. BE update trực tiếp qua DB hoặc API admin có sẵn.
sql
-- Khi bắt đầu sự kiện (5 keys)
INSERT INTO app_setting (key, value, description) VALUES
('lottery.event.start_at', '2026-05-01T00:00:00+07:00', 'Bắt đầu sự kiện quay thưởng'),
('lottery.event.end_at', '2026-05-31T23:59:59+07:00', 'Kết thúc sự kiện quay thưởng'),
('lottery.event.threshold', '10000000', 'Ngưỡng VND / 1 mã'),
('lottery.event.name', 'Tri ân khách hàng T5/2026', 'Tên sự kiện in trên HĐ'),
('lottery.event.draw_date', '15/06/2026', 'Hạn quay thưởng in trên HĐ');
-- Khi cần dừng sự kiện sớm
UPDATE app_setting SET value = '2026-05-15T23:59:59+07:00'
WHERE key = 'lottery.event.end_at';
-- Khi cần tắt hoàn toàn
DELETE FROM app_setting WHERE key LIKE 'lottery.event.%';⚠️ Fallback nếu thiếu key
Handler PHẢI có fallback: nếu lottery.event.name hoặc lottery.event.draw_date chưa set → buildLotterySectionHTML() trả empty string (section không in). Tránh in dòng "🎁 MÃ QUAY THƯỞNG — " (trống) gây trải nghiệm xấu.
Caching
Handler đọc config có TTL 5 phút. Khi update key, đợi tối đa 5 phút hoặc gọi API flush cache.
5) Database schema
sql
CREATE TABLE lottery_code (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) UNIQUE NOT NULL, -- 'QT-K2M9F'
print_invoice_id UUID NOT NULL REFERENCES print_invoice(id),
customer_id TEXT NOT NULL, -- ecommerce_user (TEXT, không phải UUID)
branch_id UUID NOT NULL,
index_in_invoice SMALLINT NOT NULL, -- 1, 2, 3...
is_cancelled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID
);
CREATE INDEX idx_lottery_print_invoice ON lottery_code(print_invoice_id);
CREATE INDEX idx_lottery_customer ON lottery_code(customer_id);
CREATE INDEX idx_lottery_created ON lottery_code(created_at)
WHERE is_cancelled = false;
CREATE INDEX idx_lottery_branch_date
ON lottery_code(branch_id, created_at)
WHERE is_cancelled = false;FK đến print_invoice (printed receipt) — không phải order
Theo confirm của PO: "hóa đơn" = printed receipt gộp nhiều order trong ngày. Mã quay thưởng tính trên thực thu của RECEIPT (đã có sẵn ở print_invoice.actual_revenue), không phải order đơn lẻ.
6) Event handler logic
File handler mới: diva-backend/services/ecommerce-api/event/print_invoice_generate_lottery_code.go
Trigger nguồn: Thêm mới Hasura event trigger trên bảng print_invoice — chưa có trigger nào trên bảng này.
Wire trigger mới vào Hasura metadata
yaml
# File: diva-backend/services/controller/metadata/databases/ecommerce/tables/public_print_invoice.yaml
# Thêm vào event_triggers list (mục mới, vì bảng này chưa có trigger nào)
event_triggers:
- name: print_invoice_generate_lottery_code
definition:
enable_manual: false
insert:
columns: '*'
update:
columns: [actual_revenue, disabled] # Fire khi revenue update hoặc receipt disable
retry_conf:
interval_sec: 10
num_retries: 0
timeout_sec: 60
webhook: '{{ECOMMERCE_BASE_URL}}/events'Register handler trong dispatcher
go
// File: diva-backend/services/ecommerce-api/event/event.go
// Thêm vào map handlers — key = đúng tên trigger trong YAML:
"print_invoice_generate_lottery_code": wrapHandler(config, printInvoiceGenerateLotteryCode),Handler implementation (theo pattern Diva)
go
// File: diva-backend/services/ecommerce-api/event/print_invoice_generate_lottery_code.go
package event
import (
"encoding/json"
"fmt"
"time"
"github.com/hgiasac/hasura-router/go/event"
)
const eventPrintInvoiceGenerateLotteryCode = "print_invoice_generate_lottery_code"
// Local payload struct — match đúng type của store.PrintInvoice:
// ID & CustomerID & BranchID = string; SalesDate = string ("2026-05-13")
// Xem: pkg/store/print_invoice.go:15
type printInvoicePayload struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
BranchID string `json:"branch_id"`
SalesDate string `json:"sales_date"` // ISO date "YYYY-MM-DD"
ActualRevenue int64 `json:"actual_revenue"`
Disabled bool `json:"disabled"`
}
func printInvoiceGenerateLotteryCode(
ctx eventContext,
payload event.EventTriggerPayload,
) (interface{}, error) {
var newPI printInvoicePayload
if err := json.Unmarshal(payload.Event.Data.New, &newPI); err != nil {
return nil, err
}
// 1. Receipt bị disable → cancel tất cả mã đã sinh
if newPI.Disabled {
return ok(), cancelLotteryCodesByPrintInvoice(ctx.AdminClient, newPI.ID)
}
// 2. Load config từ app_setting (cache TTL 5 phút)
cfg, err := loadLotteryConfig(ctx.AdminClient)
if err != nil || cfg == nil { return ok(), nil }
// 3. Parse sales_date (string → time.Time) rồi check khoảng sự kiện
salesDate, err := time.Parse("2006-01-02", newPI.SalesDate)
if err != nil { return ok(), nil } // sales_date không hợp lệ → skip
if salesDate.Before(cfg.StartDate) || salesDate.After(cfg.EndDate) {
return ok(), nil
}
// 4. Tính số mã cần (actual_revenue đã loại wallet sẵn)
needed := int(newPI.ActualRevenue / cfg.Threshold)
if needed <= 0 { return ok(), nil }
// 5. Đếm mã đang có cho receipt này
existing := countActiveCodesForPrintInvoice(ctx.AdminClient, newPI.ID)
delta := needed - existing
if delta <= 0 { return ok(), nil } // Idempotent
// 6. Sinh delta mã (retry 5 lần nếu va chạm)
for i := 0; i < delta; i++ {
code, err := generateUniqueLotteryCode(ctx.AdminClient, 5)
if err != nil { return nil, err }
insertLotteryCode(ctx.AdminClient, LotteryCode{
Code: code,
PrintInvoiceID: newPI.ID, // string (UUID dạng text)
CustomerID: newPI.CustomerID, // string (TEXT)
BranchID: newPI.BranchID, // string (UUID dạng text)
IndexInInvoice: existing + i + 1,
})
}
return ok(), nil
}
func generateUniqueLotteryCode(client AdminClient, maxRetry int) (string, error) {
for i := 0; i < maxRetry; i++ {
code := fmt.Sprintf("QT-%s", randomCrockford(5))
if !codeExists(client, code) { return code, nil }
}
return "", ErrCodeCollisionTooMany
}
// piID là string (UUID dạng text) — match store.PrintInvoice.ID
func cancelLotteryCodesByPrintInvoice(client AdminClient, piID string) error {
return client.Exec(`
UPDATE lottery_code SET is_cancelled = true
WHERE print_invoice_id = $1
`, piID)
}
func ok() interface{} { return map[string]string{"message": "success"} }✓ Logic đơn giản nhờ reuse actual_revenue
Vì print_invoice.actual_revenue đã được Diva tính sẵn (loại wallet) → handler KHÔNG cần tự SUM filter. Chỉ cần đọc field + check ngưỡng + sinh delta mã.
Idempotency 2 lớp
- Hasura có thể retry trigger → logic
delta = needed - existingđảm bảo không sinh trùng - Scheduler 17:15 update
actual_revenue→ trigger fire → recompute → sinh delta (nếu revenue tăng) hoặc skip (nếu không đổi)
Template tham khảo
Copy từ add_event_points_on_invoice_update.go hoặc bất kỳ handler nào trong event/invoice_*.go để giữ đúng pattern (eventContext, wrapHandler, ok() helper).
7) Hiển thị mã trên hóa đơn in
⚠️ Kiến trúc backend string-template (KHÔNG phải Go text/template)
Hóa đơn in của Diva được render PHÍA BACKEND bằng backend string-template (placeholder replace với strings.ReplaceAll, KHÔNG phải Go text/template). FE chỉ là dialog hiển thị HTML string trả về từ BE. Phải sửa cả backend template + struct + handler, KHÔNG sửa Vue/React component.
Vị trí trong layout hóa đơn Diva
Layout hóa đơn hiện tại có thứ tự (từ trên xuống):
┌────────────────────────────────────────────────────────────┐
│ [Logo DIVA] HÓA ĐƠN BÁN HÀNG [Công ty + Chi nhánh] │
├────────────────────────────────────────────────────────────┤
│ Ngày 08 tháng 04 năm 2026 MTR08-04/26-000133 │
│ Khách hàng: NGUYỄN THỊ HIỂU Ngày sinh: 11/02/1991 │
│ SĐT: ****400277 Nghề nghiệp: Tự Do │
│ Địa chỉ: ., Đà Nẵng │
├────────────────────────────────────────────────────────────┤
│ ┌────────────────────────────────────────────────────────┐ │
│ │ STT | TÊN DV-SP | SL | ĐƠN GIÁ | THÀNH TIỀN | ĐTT |...│ │
│ │ 1 | DV - ... | 1 | 0 | 0 | 0 |...│ │
│ │ ... │ │
│ │ Tổng tiền: 3.325.000 (Ba triệu ba trăm hai mươi lăm... │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ╔═══════════════════════════════════════════════════════╗ │ ← ⭐ CHÈN Ở ĐÂY
│ ║ 🎁 MÃ QUAY THƯỞNG — TRI ÂN KHÁCH HÀNG T5/2026 ║ │
│ ║ ║ │
│ ║ QT-K2M9F QT-A3F7T QT-X9B2C ║ │
│ ║ QT-7M3KZ QT-C5R9P QT-T1Y6V ║ │
│ ║ ║ │
│ ║ Hạn quay thưởng: 15/06/2026 ║ │
│ ║ Vui lòng giữ hóa đơn để đối chiếu mã ║ │
│ ╚═══════════════════════════════════════════════════════╝ │
│ │
│ Ghi chú: ___________________________ │
│ │
│ Người mua hàng Ngày... Người bán hàng │
└────────────────────────────────────────────────────────────┘⚠️ Diva có NHIỀU paper size + NHIỀU template name — filter migration phải chặt
- Bảng
print_invoice_templatechứa nhiềuname:invoice_templategốc —1753263047000_print_invoice_migration/up.sql:113daily_report_template—1758075040000_insert_template/up.sql:1
- Trong
invoice_template: A5 setis_default+ A80 thêm vào — đều ở migration1768552468000_add_column_print_invocie_template/up.sql(A5:13, A80:30) - Runtime: chỉ
WHERE name = 'invoice_template' AND disabled = falserồitemplates[0](print_invoice_preview.go:229, :492) — KHÔNG filterpaper_size
Migration phải:
- Filter
name = 'invoice_template'để KHÔNG động vàodaily_report_template - Filter
disabled = falseđể cover hết paper size active (cả A5 + A80) - Idempotent: skip rows đã có placeholder, guard
affected_rows == target_missing, PRE-CHECK anchor
4 nơi cần đụng
| # | File | Việc cần làm |
|---|---|---|
| 1 | controller/migrations/ecommerce/<NEW_TS>_update_invoice_template_lottery/up.sql | Migration UPDATE print_invoice_template chèn {{ .lottery_section }} tại anchor cố định. Filter 3 lớp + PL/pgSQL guard anchor-aware. → Xem code mẫu bên dưới |
| 2 | ecommerce-api/action/print_invoice_preview.go (hàm SetPrintInvoiceVariable() ~line 530) | Query SELECT code FROM lottery_code WHERE print_invoice_id = $1 AND NOT is_cancelled ORDER BY index_in_invoice. Load config name/draw_date từ app_setting. Build HTML qua buildLotterySectionHTML(codes, name, drawDate) — empty string nếu không có mã. Set DUY NHẤT 1 key: replaceMap["{{ .lottery_section }}"] = htmlString |
| 3 | controller/migrations/ecommerce/<NEW_TS>_seed_lottery_template_variable/up.sql | INSERT vào print_invoice_variable — register 1 biến duy nhất lottery_section |
| 4 | diva-admin/src/modules/ecommerce/components/fund-management/FundInvoicePopup.tsx | KHÔNG cần sửa — popup nhận invoice_html đã có sẵn callout box từ BE. Test render OK |
Migration mẫu cho row #1 (PL/pgSQL idempotent + anchor-aware)
sql
-- File: controller/migrations/ecommerce/<NEW_TS>_update_invoice_template_lottery/up.sql
DO $$
DECLARE
target_missing INT; -- rows cần update (chưa có placeholder)
missing_anchor INT; -- rows cần update NHƯNG thiếu anchor → bug
affected INT;
BEGIN
-- 1. Đếm rows cần update (filter 3 lớp: name + disabled + chưa có placeholder)
SELECT COUNT(*) INTO target_missing
FROM print_invoice_template
WHERE name = 'invoice_template'
AND disabled = false
AND content NOT LIKE '%{{ .lottery_section }}%';
-- 2. Idempotent: chạy lần 2 thì target_missing = 0 → skip clean
IF target_missing = 0 THEN
RAISE NOTICE 'Migration đã apply, skip (idempotent)';
RETURN;
END IF;
-- 3. PRE-CHECK anchor — fail SỚM nếu có row cần update nhưng thiếu anchor
-- (REPLACE() không lỗi khi anchor missing → silent no-op, KHÔNG dựa vào ROW_COUNT để phát hiện)
SELECT COUNT(*) INTO missing_anchor
FROM print_invoice_template
WHERE name = 'invoice_template'
AND disabled = false
AND content NOT LIKE '%{{ .lottery_section }}%'
AND content NOT LIKE '%<anchor-thật>%';
IF missing_anchor > 0 THEN
RAISE EXCEPTION
'Migration FAILED: % invoice_template rows missing anchor <anchor-thật>. Migration KHÔNG chạy.',
missing_anchor;
END IF;
-- 4. UPDATE — thêm điều kiện anchor LIKE để chỉ touch row có anchor
UPDATE print_invoice_template
SET content = REPLACE(
content,
'<anchor-thật>', -- vd: </table> đóng bảng line items
'{{ .lottery_section }}<anchor-thật>'
)
WHERE name = 'invoice_template'
AND disabled = false
AND content NOT LIKE '%{{ .lottery_section }}%'
AND content LIKE '%<anchor-thật>%';
GET DIAGNOSTICS affected = ROW_COUNT;
-- 5. Defense-in-depth: affected phải == target_missing
IF affected <> target_missing THEN
RAISE EXCEPTION
'Migration FAILED unexpectedly: expected %, got %', target_missing, affected;
END IF;
RAISE NOTICE 'Applied successfully: % rows updated', affected;
END $$;Vì sao cần PRE-CHECK anchor (không chỉ dựa vào ROW_COUNT)?
- PostgreSQL
UPDATE ... SET col = REPLACE(col, X, Y): nếu X không có trong col → REPLACE trả col y nguyên, KHÔNG lỗi - Nhưng UPDATE vẫn count row đó là "affected" (vì SET đã thực hiện)
- →
ROW_COUNT == target_missingKHÔNG đảm bảo placeholder đã được chèn thật - Pre-check
content NOT LIKE '%anchor%'trước UPDATE: fail sớm nếu có row thiếu anchor - UPDATE thêm
AND content LIKE '%anchor%': chỉ touch row có anchor
BE pre-build HTML helper
go
// File: ecommerce-api/action/print_invoice_preview.go (~line 530)
// 1. Query mã theo print_invoice_id
codes, err := store.QueryActiveLotteryCodes(ctx, printInvoiceID)
if err != nil { return err }
// 2. Load config (có fallback nếu chưa set)
eventName := getSettingOrDefault(ctx, "lottery.event.name", "")
drawDate := getSettingOrDefault(ctx, "lottery.event.draw_date","")
// 3. Pre-build HTML: nếu không có mã → empty string (section biến mất)
lotterySection := ""
if len(codes) > 0 && eventName != "" {
lotterySection = buildLotterySectionHTML(codes, eventName, drawDate)
}
// 4. Set 1 placeholder duy nhất
replaceMap["{{ .lottery_section }}"] = lotterySectionHàm buildLotterySectionHTML() — build chuỗi HTML responsive (A5 + A80)
go
func buildLotterySectionHTML(codes []string, eventName, drawDate string) string {
var sb strings.Builder
sb.WriteString(`<div id="lottery-codes-box" style="`)
sb.WriteString(`margin:16px 0; padding:16px 20px;`)
sb.WriteString(`border:2px solid #C9A961; border-radius:8px;`)
sb.WriteString(`background:#FBFAF5; page-break-inside:avoid;">`)
// Tiêu đề
sb.WriteString(`<div style="text-align:center; font-weight:bold;`)
sb.WriteString(`color:#8B6F2B; font-size:14px; margin-bottom:10px;`)
sb.WriteString(`letter-spacing:0.5px;">`)
sb.WriteString("🎁 MÃ QUAY THƯỞNG — ")
sb.WriteString(html.EscapeString(eventName))
sb.WriteString("</div>")
// Grid mã — auto-fill responsive: A5 = 2-3 cột, A80 (giấy nhiệt) = 1 cột tự động
// Mã 8 ký tự (QT-K2M9F) ngắn → minmax 110px đủ cho 1 mã
sb.WriteString(`<div style="display:grid;`)
sb.WriteString(`grid-template-columns:repeat(auto-fill, minmax(110px, 1fr));`)
sb.WriteString(`gap:6px 16px; font-family:'Courier New',monospace;`)
sb.WriteString(`font-size:14px; font-weight:bold; color:#1F1F1F;`)
sb.WriteString(`text-align:center; padding:8px 0;`)
sb.WriteString(`border-top:1px dashed #C9A961;`)
sb.WriteString(`border-bottom:1px dashed #C9A961;">`)
for _, c := range codes {
sb.WriteString("<span>")
sb.WriteString(html.EscapeString(c))
sb.WriteString("</span>")
}
sb.WriteString("</div>")
// Footer note
sb.WriteString(`<div style="text-align:center; font-size:11px;`)
sb.WriteString(`color:#6B6B6B; margin-top:10px; line-height:1.5;">`)
if drawDate != "" {
sb.WriteString("Hạn quay thưởng: <strong>")
sb.WriteString(html.EscapeString(drawDate))
sb.WriteString("</strong> | ")
}
sb.WriteString("Vui lòng giữ hóa đơn để đối chiếu mã<br>")
sb.WriteString("📞 Liên hệ <strong>1900 2222</strong> để biết lịch quay thưởng")
sb.WriteString("</div>")
sb.WriteString("</div>")
return sb.String()
}✓ Responsive grid cho cả A5 và A80
Dùng grid-template-columns:repeat(auto-fill, minmax(110px, 1fr)) — mã ngắn 8 ký tự nên A5 (≈148mm) tự render 2-3 cột, A80 (≈80mm giấy nhiệt) tự xuống 1 cột. Không cần helper riêng cho từng paper size.
8) Frontend — Trang Export Excel
Đường dẫn menu: Báo cáo → Mã quay thưởng
Quyền truy cập: Admin (Manager xem theo chi nhánh — phase sau)
┌────────────────────────────────────┐
│ XUẤT BÁO CÁO MÃ QUAY THƯỞNG │
├────────────────────────────────────┤
│ Từ ngày: [01/05/2026] 📅 │
│ Đến ngày: [31/05/2026] 📅 │
│ │
│ [ 📥 Tải Excel ] │
└────────────────────────────────────┘API endpoint — Hasura Action
graphql
# Hasura action (KHÔNG dùng REST endpoint tùy ý —
# Diva chỉ expose /actions, /events, /schedulers, /healthz)
# Action name: lotteryExport
# Handler URL: {{EXPORT_BASE_URL}}/actions
# Service: export-api (có sẵn FileClient/storage)
# GraphQL signature
mutation LotteryExport($input: LotteryExportInput!) {
lotteryExport(input: $input) {
url
expires_at
}
}
# Input type
input LotteryExportInput {
from: date! # 2026-05-01
to: date! # 2026-05-31
}
# Output type
type LotteryExportOutput {
url: String! # https://.../files/lottery-xxx.xlsx
expires_at: timestamptz
}📌 Vì sao dùng export-api thay vì ecommerce-api?
export-apiđã cóFileClient+ storage pattern (config.go:15) — reuse upload Excel + return signed URLecommerce-apiKHÔNG cóFileClienttrong config (config.go:16) — phải thêm dependency mới nếu tự sinh- Pattern có sẵn:
export_report_erp.go:560(export Excel multi-sheet + upload)
Logic backend (đơn giản nhờ reuse actual_revenue)
sql
-- Tách CTE để tránh SUM(DISTINCT) undercount khi 2 receipts cùng số tiền
WITH receipt_per_customer AS (
SELECT
lc.customer_id,
lc.branch_id,
pi.id AS print_invoice_id,
pi.actual_revenue,
COUNT(lc.id) AS code_count,
STRING_AGG(lc.code, '; ' ORDER BY lc.index_in_invoice) AS codes_str
FROM lottery_code lc
JOIN print_invoice pi ON pi.id = lc.print_invoice_id
WHERE lc.is_cancelled = false
-- ⚠️ Input là date! → KHÔNG dùng BETWEEN (sẽ exclude tất cả mã từ 00:00:01 trở đi của ngày $to)
AND lc.created_at >= $from::date
AND lc.created_at < ($to::date + interval '1 day')
GROUP BY lc.customer_id, lc.branch_id, pi.id, pi.actual_revenue
)
SELECT
u.id AS ma_kh,
u.display_name AS ten_kh,
u.phone_number AS sdt,
b.name AS chi_nhanh,
COUNT(rpc.print_invoice_id) AS so_hoa_don,
SUM(rpc.actual_revenue) AS tong_thuc_thu,
SUM(rpc.code_count) AS so_ma,
STRING_AGG(rpc.codes_str, '; ') AS danh_sach_ma
FROM receipt_per_customer rpc
JOIN ecommerce_user u ON u.id = rpc.customer_id
JOIN branch b ON b.id = rpc.branch_id
GROUP BY u.id, b.id
ORDER BY so_ma DESC;📌 Schema đúng theo codebase (đã verify)
ecommerce_user.display_name(KHÔNG phảifull_name)ecommerce_user.phone_number(KHÔNG phảiphone)- Bảng
branch(KHÔNG phảibusiness_branch) — xempublic_branch.yaml:1 print_invoice.customer_idlà TEXT — xemprint_invoice.go:41
Vì sao tách CTE thay vì SUM(DISTINCT pi.actual_revenue)?
Nếu 2 receipts khác nhau cùng actual_revenue = 10.000.000 → SUM(DISTINCT) chỉ tính 1 lần → undercount. CTE group theo print_invoice.id trước rồi SUM ở outer query → đảm bảo mỗi receipt được tính đúng 1 lần.
⚠️ Vì sao KHÔNG dùng BETWEEN $from AND $to khi input là date!?
Khi Postgres cast '2026-05-31'::date để so sánh với timestamptz, nó dùng 2026-05-31 00:00:00+00. BETWEEN sẽ exclude tất cả mã phát sinh trong ngày 31/05 (vì created_at của chúng > midnight). Đổi sang half-open range >= $from AND < ($to + 1 day) để include đúng toàn bộ ngày $to.
9) Format file Excel
1 sheet duy nhất — đơn giản nhất cho PO/Marketing.
| STT | Mã KH | Tên KH | SĐT | Chi nhánh | Số đơn hàng | Tổng thực thu | Số mã | Danh sách mã |
|---|---|---|---|---|---|---|---|---|
| 1 | KH001 | Nguyễn Thị Hiếu | 0375400277 | Tam Kỳ | 3 | 180.000.000 | 18 | QT-K2M9F; QT-A3F7T; QT-X9B2C; ... |
| 2 | KH002 | Trần Văn A | 0901234567 | Đà Nẵng | 1 | 25.000.000 | 2 | QT-G9P3Q; QT-H5T8W |
Note
Cột "Danh sách mã" phân cách bằng ; — Excel auto-wrap. Một cell chứa được 32K ký tự → đủ kể cả KH có hàng nghìn mã.
10) Codebase findings (đã verify)
✓ Đã tra cứu codebase + confirm với PO (13/05/2026)
Toàn bộ kiến trúc đã được verify. Mapping "hóa đơn" = print_invoice đã chốt.
| # | Câu hỏi | Findings |
|---|---|---|
| 1 | "Hóa đơn" trong yêu cầu là gì? | ✅ Bảng print_invoice — printed receipt gộp nhiều order trong ngày. PO đã confirm |
| 2 | Cách receipt gộp nhiều order? | ✅ Field print_invoice.related_orders TEXT[] — array chứa các order codes (vd: ["DV01058579", "DV01045796"]) |
| 3 | Field thực thu của receipt? | ✅ print_invoice.actual_revenue đã có sẵn — Diva đã tính loại trừ wallet + wallet_promotion (file print_invoice_preview.go:413-416). KHÔNG cần tự SUM |
| 4 | Mã hóa đơn sinh thế nào? | ✅ Format {BRANCH}-{MM/YY}-{6 digit seq}. PostgreSQL trigger trigger_set_print_invoice_code sinh tự động. File: controller/migrations/ecommerce/1754897635000_update_print_invoice_migration/up.sql |
| 5 | Khi nào receipt finalize? | ✅ Scheduler print_invoice_daily chạy 17:15 mỗi ngày — finalize receipts is_printed=false của hôm qua. File: scheduler/print_invoice_daily.go |
| 6 | Hasura event trigger trên print_invoice? | ❌ Chưa có. Cần thêm mới vào public_print_invoice.yaml. Pattern có sẵn ở các bảng khác |
| 7 | customer_id trong print_invoice? | ⚠️ TEXT (không phải UUID), FK đến ecommerce_user. Lottery_code phải dùng TEXT cho customer_id |
| 8 | Render hóa đơn in? | ✅ Backend string-template (placeholder replace qua strings.ReplaceAll, KHÔNG phải Go text/template). Template HTML lưu DB bảng print_invoice_template. Có cả paper size A5 + A80 active — phải update cả 2 |
| 9 | FE component dialog? | ✅ FundInvoicePopup.tsx (React/TSX). Chỉ nhận invoice_html rồi render → KHÔNG cần sửa |
| 10 | Helper app_setting? | ✅ Có sẵn ở pkg/store/app_setting.go |
| 11 | Crockford base32 helper? | ⚠️ Verify pkg/util/. Nếu chưa có → viết mới (~10 dòng) |
| 12 | Export Excel pattern? | ✅ Dùng export-api (có FileClient + pattern export_report_erp.go:560) |
11) Checklist công việc
DB Migration
- [ ] Migration
controller/migrations/ecommerce/<TS>_create_lottery_code/up.sql— tạo bảnglottery_code+ 4 indexes - [ ] Migration
controller/migrations/ecommerce/<TS>_seed_lottery_config/up.sql— insert 5 rows vàoapp_setting:start_at,end_at,threshold,name,draw_date - [ ] Migration
controller/migrations/ecommerce/<TS>_update_invoice_template_lottery/up.sql— UPDATEprint_invoice_templatevới filter chặt + PL/pgSQL guard anchor-aware (xem code mẫu Section 7) - [ ] Hasura metadata: track table
lottery_code+ permission cho role Admin/Manager - [ ] Hasura metadata: thêm event trigger
print_invoice_generate_lottery_codevàopublic_print_invoice.yaml
BE — ecommerce-api
- [ ] FIX BUG TRƯỚC:
print_invoice_daily.go:281đang silent-swallow lỗiExec()— fix log/return error trước khi trigger lottery dựa vào scheduler - [ ] Helper
pkg/util/crockford32.gomới — random Crockford base32 dùngcrypto/rand(~10 dòng) - [ ] Repo function
loadLotteryConfig()đọc từapp_settingvới cache TTL 5 phút - [ ] Repo function
generateUniqueLotteryCode()với retry 5 lần (UNIQUE constraint trênlottery_code.code) - [ ] Repo function
countActiveCodesForPrintInvoice(piID) - [ ] Repo function
cancelLotteryCodesByPrintInvoice(piID) - [ ] Handler
event/print_invoice_generate_lottery_code.go— copy pattern từadd_event_points_on_invoice_update.go - [ ] Register handler trong
event/event.gomap — key đúng tên trigger:"print_invoice_generate_lottery_code" - [ ] Update
action/print_invoice_preview.go— trongSetPrintInvoiceVariable()(~line 530): query lottery_code + load config + build HTML quabuildLotterySectionHTML()+ set DUY NHẤT 1 keyreplaceMap["{{ .lottery_section }}"] = htmlString - [ ] Helper
buildLotterySectionHTML(codes []string, eventName, drawDate string) string— dùngstrings.Builder+html.EscapeString()cho XSS-safe - [ ] Unit test: công thức (5 ví dụ), collision retry, idempotency (fire 2 lần với cùng actual_revenue), batch scheduler 100/120 receipts
BE — export-api (Excel export)
- [ ] Handler
export-api/action/lottery_export.go— copy pattern từexport_report_erp.go:560(Excel + upload via FileClient) - [ ] Register handler trong
export-api/action/action.go— thêm vào map:"lotteryExport": wrapHandler(config, lotteryExport) - [ ] Thêm custom types + action definition vào
controller/metadata/actions.yamlvớihandler: '{{EXPORT_BASE_URL}}/actions' - [ ] Thêm GraphQL signature vào
controller/metadata/actions.graphql:type Mutation { lotteryExport(input: LotteryExportInput!): LotteryExportOutput }+ input/output types - [ ] Query data: JOIN
lottery_code↔print_invoice↔ecommerce_user↔branch(xem SQL ở Section 8 — đã dùng CTE) - [ ] Upload Excel qua
FileClient(đã có ởconfig.go:15) → trả signed URL - [ ] Reload Hasura metadata sau khi commit actions.yaml/graphql
FE — diva-admin
- [ ] Tạo route
/reports/lottery - [ ] Component
LotteryExportPage— date range + nút Tải Excel - [ ] GraphQL mutation wrap action
lotteryExport(input) - [ ] Thêm menu item "Mã quay thưởng" dưới "Báo cáo" (chỉ hiện cho Admin)
- [ ] Toast thành công/lỗi khi export, mở URL Excel trong tab mới
- [ ] Test
FundInvoicePopup.tsxrender đúng section mã (BE đã inject HTML)
DevOps
- [ ] Chạy
migrate.shpipeline: metadata reload → migrate apply → metadata reload → metadata apply - [ ] Deploy/restart
ecommerce-api(handler Go mới) - [ ] Deploy/restart
export-api(action Excel mới) - [ ] KHÔNG cần restart Hasura nếu chạy đúng pipeline
- [ ] Verify K8s deployment cho cả 2 service đã update
QA
- [ ] Test 8 ví dụ ở section quy tắc nghiệp vụ (R1-R8)
- [ ] Test công nợ (R6): HĐ 50tr trả 30tr cash + 20tr nợ →
actual_revenue= 50tr → sinh 5 mã (KHÔNG phải 3 mã) - [ ] Test công nợ + ví KM (R8): HĐ 100tr = 50tr cash + 20tr nợ + 30tr ví KM →
actual_revenue= 70tr → sinh 7 mã - [ ] Test receipt được merge thêm orders (actual_revenue tăng) → delta mã sinh thêm
- [ ] Test scheduler 17:15 finalize → mã được sinh đúng (idempotent)
- [ ] Test mix payment method trong 1 receipt (cash + wallet) → chỉ tính tiền thực
- [ ] Test 100% wallet_promotion → 0 mã (kể cả receipt 100tr)
- [ ] Test disable receipt → mã → is_cancelled
- [ ] Test in lại hóa đơn — section mã render đúng từ DB
- [ ] Test config
app_settingrỗng/expired → skip không lỗi - [ ] Test export Excel 0/1/100/1000 records
- [ ] Test trigger fire 2 lần với cùng data → không sinh trùng
- [ ] Test in trên cả 2 paper size: A5 (in giấy thường) → 2 cột mã; A80 (in giấy nhiệt POS) → 1 cột mã; không bị tràn lề
- [ ] Test migration trên DB có cả 2 template active: assert
affected_rows = 2 - [ ] Test export ngày cuối kỳ: HĐ tạo lúc 23:59:00 ngày
$tophải xuất hiện trong Excel
12) Phạm vi MVP & Phase sau
✅ Trong MVP
- Sinh mã tự động theo
print_invoice.actual_revenue - Config sự kiện qua
app_setting - Reuse
actual_revenue(đã loại wallet) - In mã trên hóa đơn (BE template, không QR)
- Xuất Excel theo ngày
- Disable receipt → cancel mã
⏸ Phase sau (nếu cần)
- Màn admin cấu hình campaign (UI)
- QR code trên hóa đơn
- Tool tra cứu mã + mark used khi quay
- Admin revoke thủ công + audit log
- Nhiều campaign chạy song song
- Dashboard tổng quan (charts)
- Scope theo chi nhánh / loại receipt
Liên hệ & Status
Liên hệ PO: Nguyễn Sơn Thọ — sontho22@gmail.com
Status: ✅ Codebase verified — Ready to ship
History:
- Tech Lead review #1 (6 issues): 3 P1 + 3 P2 đã fix
- Tech Lead review #2 (3 issues): 2 P1 + 1 P2 đã fix
- Tech Lead review #3 (2 issues): 1 P1 + 1 P2 đã fix (multi-paper template + wording)
- Tech Lead review #4 (1 P1): template filter chặt + idempotent guard
- Tech Lead review #5 (2 P3): citation accuracy
- Tech Lead review #6 (1 P1): anchor-aware guard cho REPLACE silent no-op
Tổng 15 review issues đã giải quyết.
Đây là phiên bản onepage spec gộp PRD + Dev Spec + QA + Handoff để tiện tracking. Phiên bản HTML visualization có thể xem ở handoff.html.