Skip to content

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ưỡngFormat 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

  1. NV click "In hóa đơn" → action printInvoice tạo/update print_invoice với is_printed=false, gộp orders của ngày
  2. Scheduler print_invoice_daily chạy 17:15 mỗi ngày → finalize tất cả receipts is_printed=false của hôm qua → recompute actual_revenue → set is_printed=true
  3. → 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ệnChi tiết
Thời gianprint_invoice.sales_date nằm trong khoảng sự kiện (config qua app_setting)
Activeprint_invoice.disabled = false
Ngưỡngprint_invoice.actual_revenue10.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_revenuewallet, wallet_promotion

⚠️ "Thực thu" theo nghĩa Diva = giá trị HĐ trừ ví KM, BAO GỒM công nợ

Logic actual_revenueaction/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)

ReceiptGộp ordersThanh toánactual_revenueSố mã
R11 order spa9.5tr cash9.500.0000
R22 orders10tr bank10.000.0001
R33 orders (DV + SP)63tr cash + bank63.000.0006
R41 order 50tr30tr cash + 20tr wallet_promotion30.000.0003 (loại ví KM)
R51 order 100tr100% wallet_promotion00
R6 ⚠️1 order 50tr30tr cash + 20tr công nợ50.000.0005 (gồm cả nợ)
R7 ⚠️1 order 80tr60tr cash + 20tr ví KM + 0 nợ60.000.0006
R8 ⚠️1 order 100tr50tr cash + 20tr nợ + 30tr ví KM70.000.0007 (gồm nợ, loại ví)

Edge case

Tình huốngXử 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 receiptScheduler update actual_revenue → trigger fire → sinh mã (idempotent)
In lại hóa đơnprint_invoice_preview.go query lại mã hiện có theo print_invoice_id → render trên template
Receipt bị disableUPDATE 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-M3W4N

4) 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

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

  1. Hasura có thể retry trigger → logic delta = needed - existing đảm bảo không sinh trùng
  2. 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_template chứa nhiều name:
    • invoice_template gốc — 1753263047000_print_invoice_migration/up.sql:113
    • daily_report_template1758075040000_insert_template/up.sql:1
  • Trong invoice_template: A5 set is_default + A80 thêm vào — đều ở migration 1768552468000_add_column_print_invocie_template/up.sql (A5 :13, A80 :30)
  • Runtime: chỉ WHERE name = 'invoice_template' AND disabled = false rồi templates[0] (print_invoice_preview.go:229, :492) — KHÔNG filter paper_size

Migration phải:

  1. Filter name = 'invoice_template' để KHÔNG động vào daily_report_template
  2. Filter disabled = false để cover hết paper size active (cả A5 + A80)
  3. Idempotent: skip rows đã có placeholder, guard affected_rows == target_missing, PRE-CHECK anchor

4 nơi cần đụng

#FileViệc cần làm
1controller/migrations/ecommerce/<NEW_TS>_update_invoice_template_lottery/up.sqlMigration 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
2ecommerce-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
3controller/migrations/ecommerce/<NEW_TS>_seed_lottery_template_variable/up.sqlINSERT vào print_invoice_variable — register 1 biến duy nhất lottery_section
4diva-admin/src/modules/ecommerce/components/fund-management/FundInvoicePopup.tsxKHÔ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_missing KHÔ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 }}"] = lotterySection

Hà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> &nbsp;|&nbsp; ")
    }
    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 URL
  • ecommerce-api KHÔNG có FileClient trong 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ải full_name)
  • ecommerce_user.phone_number (KHÔNG phải phone)
  • Bảng branch (KHÔNG phải business_branch) — xem public_branch.yaml:1
  • print_invoice.customer_id là TEXT — xem print_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.000SUM(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.

STTMã KHTên KHSĐTChi nhánhSố đơn hàngTổng thực thuSố mãDanh sách mã
1KH001Nguyễn Thị Hiếu0375400277Tam Kỳ3180.000.00018QT-K2M9F; QT-A3F7T; QT-X9B2C; ...
2KH002Trần Văn A0901234567Đà Nẵng125.000.0002QT-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ỏiFindings
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
2Cách receipt gộp nhiều order?✅ Field print_invoice.related_orders TEXT[] — array chứa các order codes (vd: ["DV01058579", "DV01045796"])
3Field 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
4Mã 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
5Khi 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
6Hasura 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
7customer_id trong print_invoice?⚠️ TEXT (không phải UUID), FK đến ecommerce_user. Lottery_code phải dùng TEXT cho customer_id
8Render 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
9FE component dialog?FundInvoicePopup.tsx (React/TSX). Chỉ nhận invoice_html rồi render → KHÔNG cần sửa
10Helper app_setting?✅ Có sẵn ở pkg/store/app_setting.go
11Crockford base32 helper?⚠️ Verify pkg/util/. Nếu chưa có → viết mới (~10 dòng)
12Export 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ảng lottery_code + 4 indexes
  • [ ] Migration controller/migrations/ecommerce/<TS>_seed_lottery_config/up.sql — insert 5 rows vào app_setting: start_at, end_at, threshold, name, draw_date
  • [ ] Migration controller/migrations/ecommerce/<TS>_update_invoice_template_lottery/up.sql — UPDATE print_invoice_template vớ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_code vào public_print_invoice.yaml

BE — ecommerce-api

  • [ ] FIX BUG TRƯỚC: print_invoice_daily.go:281 đang silent-swallow lỗi Exec() — fix log/return error trước khi trigger lottery dựa vào scheduler
  • [ ] Helper pkg/util/crockford32.go mới — random Crockford base32 dùng crypto/rand (~10 dòng)
  • [ ] Repo function loadLotteryConfig() đọc từ app_setting với cache TTL 5 phút
  • [ ] Repo function generateUniqueLotteryCode() với retry 5 lần (UNIQUE constraint trên lottery_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.go map — key đúng tên trigger: "print_invoice_generate_lottery_code"
  • [ ] Update action/print_invoice_preview.go — trong SetPrintInvoiceVariable() (~line 530): query lottery_code + load config + build HTML qua buildLotterySectionHTML() + set DUY NHẤT 1 key replaceMap["{{ .lottery_section }}"] = htmlString
  • [ ] Helper buildLotterySectionHTML(codes []string, eventName, drawDate string) string — dùng strings.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.yaml với handler: '{{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_codeprint_invoiceecommerce_userbranch (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.tsx render đúng section mã (BE đã inject HTML)

DevOps

  • [ ] Chạy migrate.sh pipeline: 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_setting rỗ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 $to phả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.