<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cảnh Báo Bill Lệch Giữa Activity & Gate — The Operator's Journal</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Noto+Serif:ital,wght@0,400;0,600;0,700;1,400;1,600&family=Noto+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--ink: #0a0a0a;
--paper: #fafafa;
--muted: #6b7280;
--rule: #e5e7eb;
--accent: #c2410c;
--accent-soft: #fff7ed;
--code-bg: #0f172a;
}
html { scroll-behavior: smooth; }
body {
font-family: 'Noto Serif', 'Inter', Georgia, serif;
background: var(--paper);
color: var(--ink);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.font-display { font-family: 'Noto Serif', Georgia, serif; }
.font-sans-ui { font-family: 'Inter', 'Noto Sans', system-ui, sans-serif; }
.font-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
.prose-article {
font-size: 1.125rem;
line-height: 1.75;
color: #1f2937;
max-width: 65ch;
margin-inline: auto;
}
.prose-article p { margin-bottom: 1.5rem; }
.prose-article p.lede {
font-size: 1.375rem;
line-height: 1.6;
color: #111827;
font-weight: 400;
}
.prose-article p.lede::first-letter {
font-family: 'Noto Serif', serif;
font-size: 4.5rem;
line-height: 0.85;
float: left;
margin: 0.5rem 0.75rem 0 0;
color: var(--accent);
font-weight: 700;
}
.prose-article h2 {
font-family: 'Noto Serif', serif;
font-size: 2rem;
font-weight: 700;
margin-top: 4rem;
margin-bottom: 1.25rem;
letter-spacing: -0.01em;
color: #0a0a0a;
}
.prose-article h3 {
font-family: 'Inter', sans-serif;
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--accent);
font-weight: 600;
margin-top: 2.5rem;
margin-bottom: 0.75rem;
}
.prose-article a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
.prose-article strong { color: #0a0a0a; font-weight: 600; }
.prose-article em { font-style: italic; }
.prose-article ul, .prose-article ol { margin-bottom: 1.5rem; padding-left: 1.5rem; }
.prose-article ul li { list-style: disc; margin-bottom: 0.5rem; }
.prose-article ol li { list-style: decimal; margin-bottom: 0.5rem; }
.pull-quote {
font-family: 'Noto Serif', serif;
font-style: italic;
font-size: 1.875rem;
line-height: 1.4;
color: #0a0a0a;
border-left: 4px solid var(--accent);
padding: 0.5rem 0 0.5rem 1.5rem;
margin: 3rem 0;
font-weight: 500;
}
.pull-quote cite {
display: block;
font-style: normal;
font-family: 'Inter', sans-serif;
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--muted);
margin-top: 1rem;
font-weight: 500;
}
figure { margin: 3rem 0; }
figcaption {
font-family: 'Inter', sans-serif;
font-style: italic;
font-size: 0.875rem;
color: var(--muted);
margin-top: 0.75rem;
text-align: center;
line-height: 1.5;
}
.code-block {
background: var(--code-bg);
border-radius: 12px;
overflow: hidden;
margin: 2rem 0;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.625rem 1.25rem;
background: #1e293b;
color: #94a3b8;
font-family: 'Inter', sans-serif;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
border-bottom: 1px solid #334155;
}
.code-header .lang-tag {
background: var(--accent);
color: white;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-weight: 600;
font-size: 0.6875rem;
letter-spacing: 0.08em;
}
.code-body {
padding: 1.25rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
line-height: 1.7;
color: #e2e8f0;
overflow-x: auto;
}
.code-body .tok-key { color: #fbbf24; }
.code-body .tok-str { color: #86efac; }
.code-body .tok-num { color: #f472b6; }
.code-body .tok-com { color: #64748b; font-style: italic; }
.code-body .tok-op { color: #f97316; }
.inline-code {
font-family: 'JetBrains Mono', monospace;
background: #fef3c7;
color: #78350f;
padding: 0.125rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
}
.rule-thin { border-color: var(--rule); }
.deco-rule {
height: 1px;
background: linear-gradient(to right, transparent, var(--rule) 20%, var(--rule) 80%, transparent);
margin: 2.5rem auto;
max-width: 65ch;
}
.small-caps {
font-family: 'Inter', sans-serif;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.2em;
font-weight: 600;
}
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* Field anatomy figure */
.anatomy-token {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
position: relative;
}
.anatomy-token .label-top {
font-family: 'Inter', sans-serif;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--accent);
font-weight: 600;
}
.anatomy-token .glyph {
font-family: 'JetBrains Mono', monospace;
font-size: 1.5rem;
font-weight: 600;
color: #0a0a0a;
background: #fff;
padding: 0.5rem 0.75rem;
border: 1px solid var(--rule);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
}
.anatomy-token .label-bot {
font-family: 'Inter', sans-serif;
font-size: 0.75rem;
color: var(--muted);
text-align: center;
line-height: 1.3;
max-width: 80px;
}
.anatomy-sep {
font-family: 'JetBrains Mono', monospace;
font-size: 1.5rem;
color: #cbd5e1;
align-self: center;
padding-top: 0.5rem;
}
/* Status pill */
.status-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border-radius: 999px;
font-family: 'Inter', sans-serif;
font-size: 0.8125rem;
font-weight: 500;
}
/* Flow diagram */
.flow-step {
background: white;
border: 1px solid var(--rule);
border-radius: 12px;
padding: 1rem 1.25rem;
position: relative;
}
.flow-step .step-num {
position: absolute;
top: -0.75rem;
left: 1.25rem;
background: var(--accent);
color: white;
width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Inter', sans-serif;
font-size: 0.75rem;
font-weight: 700;
}
/* Table */
.field-table {
width: 100%;
border-collapse: collapse;
font-family: 'Inter', sans-serif;
}
.field-table th {
text-align: left;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--muted);
font-weight: 600;
padding: 0.75rem 1rem;
border-bottom: 1.5px solid #0a0a0a;
}
.field-table td {
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--rule);
font-size: 0.9375rem;
vertical-align: top;
}
.field-table tr:hover { background: #fafafa; }
.field-table .col-field { font-family: 'JetBrains Mono', monospace; font-weight: 600; color: var(--accent); }
.field-table .col-rule { color: var(--muted); }
/* Card */
.related-card {
background: white;
border: 1px solid var(--rule);
border-radius: 16px;
padding: 1.5rem;
transition: all 0.2s ease;
cursor: pointer;
}
.related-card:hover {
border-color: #0a0a0a;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.06);
}
/* Error stripe */
.error-stripe {
background: repeating-linear-gradient(
45deg,
#fef2f2,
#fef2f2 10px,
#fee2e2 10px,
#fee2e2 20px
);
}
/* Margin note */
.margin-note {
font-family: 'Inter', sans-serif;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--muted);
padding-left: 1rem;
border-left: 2px solid var(--rule);
}
.focus\:ring-accent:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
</style>
</head>
<body class="bg-[#fafafa]">
<!-- ============= MASTHEAD ============= -->
<header class="border-b-2 border-[#0a0a0a]">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<svg width="36" height="36" viewBox="0 0 36 36" fill="none">
<rect x="2" y="2" width="32" height="32" rx="6" fill="#0a0a0a"/>
<path d="M11 18 L17 24 L25 12" stroke="#fafafa" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div>
<div class="font-display font-bold text-xl tracking-tight leading-none">The Operator's Journal</div>
<div class="font-sans-ui text-xs text-gray-500 mt-0.5 tracking-wide">Product specs, ops playbooks & internal tooling notes</div>
</div>
</div>
<nav class="hidden md:flex items-center gap-6 font-sans-ui text-sm text-gray-700">
<a href="#" class="hover:text-[#c2410c]">Specs</a>
<a href="#" class="hover:text-[#c2410c]">Playbooks</a>
<a href="#" class="hover:text-[#c2410c]">Postmortems</a>
<a href="#" class="hover:text-[#c2410c]">Archive</a>
</nav>
<div class="font-sans-ui text-xs text-gray-500 tracking-wider uppercase">Vol. 04 · Issue 18</div>
</div>
</header>
<!-- ============= HERO ============= -->
<section class="border-b border-gray-200 fade-in">
<div class="max-w-4xl mx-auto px-6 py-20 md:py-28">
<div class="flex items-center gap-3 mb-8">
<span class="small-caps text-[#c2410c]">Product Spec</span>
<span class="text-gray-300">·</span>
<span class="small-caps text-gray-500">Dashboard thuê TKQC · billing/risk</span>
<span class="text-gray-300">·</span>
<span class="small-caps text-gray-500">NolimitHub · Ghost bill</span>
</div>
<h1 class="font-display font-bold text-5xl md:text-7xl leading-[1.05] tracking-tight text-[#0a0a0a]">
Cảnh Báo Bill Lệch Giữa Activity & Gate: Bổ Sung ID, Đánh Dấu, Chống Lặp Trên NolimitHub
</h1>
<p class="font-display text-xl md:text-2xl text-gray-600 mt-8 italic leading-relaxed max-w-3xl">
Đặc tả cải thiện luồng cảnh báo "activity có bill ID nhưng gate không tìm thấy" — nhét ID bill vào cảnh báo, đánh dấu bill không còn tồn tại trên FB, và chống bắn lại cảnh báo cho cùng một bill ghost ở các chu kỳ quét sau.
</p>
<div class="mt-12 flex flex-wrap items-center gap-x-8 gap-y-4 pt-6 border-t border-gray-200">
<div class="flex items-center gap-3">
<div class="w-11 h-11 rounded-full bg-[#0a0a0a] text-[#fafafa] flex items-center justify-center font-display font-bold text-lg">D</div>
<div>
<div class="font-sans-ui font-semibold text-sm text-[#0a0a0a]">Doanh Nguyễn</div>
<div class="font-sans-ui text-xs text-gray-500">Editor, The Operator's Journal</div>
</div>
</div>
<div class="font-sans-ui text-sm text-gray-500">
<span class="small-caps text-gray-400">Published</span>
<span class="ml-2">04 Jun 2026</span>
</div>
<div class="font-sans-ui text-sm text-gray-500">
<span class="small-caps text-gray-400">Read time</span>
<span class="ml-2">~ 16 min</span>
</div>
<div class="font-sans-ui text-sm text-gray-500">
<span class="small-caps text-gray-400">Priority</span>
<span class="ml-2">P2 — Medium</span>
</div>
</div>
</div>
</section>
<!-- ============= TABLE OF CONTENTS ============= -->
<section class="border-b border-gray-200 bg-white">
<div class="max-w-4xl mx-auto px-6 py-10">
<div class="small-caps text-gray-400 mb-4">In this spec</div>
<ol class="grid md:grid-cols-2 gap-x-12 gap-y-2 font-sans-ui text-sm">
<li class="flex gap-3"><span class="text-gray-400 font-mono">01</span><a href="#info" class="hover:text-[#c2410c] text-gray-800">Thông tin chung</a></li>
<li class="flex gap-3"><span class="text-gray-400 font-mono">02</span><a href="#context" class="hover:text-[#c2410c] text-gray-800">Bối cảnh</a></li>
<li class="flex gap-3"><span class="text-gray-400 font-mono">03</span><a href="#objectives" class="hover:text-[#c2410c] text-gray-800">Mục tiêu & Chỉ số</a></li>
<li class="flex gap-3"><span class="text-gray-400 font-mono">04</span><a href="#personas" class="hover:text-[#c2410c] text-gray-800">Personas</a></li>
<li class="flex gap-3"><span class="text-gray-400 font-mono">05</span><a href="#flow" class="hover:text-[#c2410c] text-gray-800">Luồng nghiệp vụ cốt lõi</a></li>
<li class="flex gap-3"><span class="text-gray-400 font-mono">06</span><a href="#edgecases" class="hover:text-[#c2410c] text-gray-800">Edge cases</a></li>
<li class="flex gap-3"><span class="text-gray-400 font-mono">07</span><a href="#stories" class="hover:text-[#c2410c] text-gray-800">User stories</a></li>
<li class="flex gap-3"><span class="text-gray-400 font-mono">08</span><a href="#rules" class="hover:text-[#c2410c] text-gray-800">Business rules</a></li>
<li class="flex gap-3"><span class="text-gray-400 font-mono">09</span><a href="#acceptance" class="hover:text-[#c2410c] text-gray-800">Acceptance & Test cases</a></li>
<li class="flex gap-3"><span class="text-gray-400 font-mono">10</span><a href="#handover" class="hover:text-[#c2410c] text-gray-800">Quy trình bàn giao</a></li>
</ol>
</div>
</section>
<!-- ============= ARTICLE BODY ============= -->
<article class="py-20">
<div class="prose-article">
<!-- INTRO -->
<p class="lede">
Cảnh báo trên NolimitHub đang <em>lặp đi lặp lại, thiếu ID, và làm nhiễu kênh</em>: cùng một bill mà activity của TKQC vẫn liệt kê (nhưng gate không tìm thấy) tạo ra cảnh báo mới mỗi chu kỳ quét — che mất cảnh báo thật và bào mòn độ tin. Bài này đặc tả ba thay đổi mỏng: <strong>nhét ID bill vào cảnh báo</strong>, <strong>đánh dấu bill đã xác nhận không còn trên FB</strong>, và <strong>chống bắn lại</strong> cảnh báo cho bill đã đánh dấu — mà <em>không</em> phải xây lại worker quét bill.
</p>
<p>
Tài liệu được tổ chức theo trình tự PRD chuẩn: thông tin chung trước, sau đó là bối cảnh và mục tiêu, rồi mới đi vào personas, luồng nghiệp vụ, business rules, và cuối cùng là acceptance criteria + quy trình bàn giao. Phần 6 (Bàn giao) liệt kê những điểm CTO cần chốt <strong>trước khi code</strong> — đặc biệt là cơ chế đánh dấu cần thống nhất với các doc liên quan, không đẻ khái niệm "ghost bill" song song.
</p>
<!-- ============= SECTION 1: INFO ============= -->
<h2 id="info">01 · Thông tin chung</h2>
<h3>Bốn trường meta cần đọc đầu tiên</h3>
<p>
Trước khi đọc bất kỳ phần nào khác, hãy đối chiếu bốn ô dưới đây để chắc rằng bạn đang đọc đúng phiên bản tài liệu và đúng feature. <strong>Feature ID</strong> là khoá nối tới ticket, <strong>Status</strong> cho biết tài liệu đã sẵn sàng để code hay chưa.
</p>
</div>
<!-- FIGURE: Info grid -->
<figure class="max-w-3xl mx-auto px-6">
<div class="border border-gray-200 rounded-2xl bg-white p-8 shadow-sm">
<div class="grid md:grid-cols-2 gap-6">
<div class="flex gap-4">
<div class="flex-shrink-0 w-9 h-9 rounded-lg bg-orange-50 text-[#c2410c] flex items-center justify-center font-mono font-bold">ID</div>
<div>
<div class="font-sans-ui font-semibold text-[#0a0a0a] mb-1">Feature ID</div>
<div class="font-sans-ui text-sm text-gray-600 leading-relaxed"><span class="inline-code">FT-BILL-GHOST-ALERT-01</span> — khoá nối tới ticket & branch.</div>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0 w-9 h-9 rounded-lg bg-orange-50 text-[#c2410c] flex items-center justify-center font-mono font-bold">PO</div>
<div>
<div class="font-sans-ui font-semibold text-[#0a0a0a] mb-1">Owner / PO</div>
<div class="font-sans-ui text-sm text-gray-600 leading-relaxed"><strong>CTO</strong> — cũng là người Approve cuối cùng trước khi deploy Production.</div>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0 w-9 h-9 rounded-lg bg-orange-50 text-[#c2410c] flex items-center justify-center font-mono font-bold">P2</div>
<div>
<div class="font-sans-ui font-semibold text-[#0a0a0a] mb-1">Priority</div>
<div class="font-sans-ui text-sm text-gray-600 leading-relaxed"><strong>P2 — Medium</strong>: không mất tiền trực tiếp, nhưng cảnh báo lặp vô nghĩa làm nhiễu NolimitHub, che mất cảnh báo thật. Có thể nâng nếu lượng nhiễu lớn.</div>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0 w-9 h-9 rounded-lg bg-orange-50 text-[#c2410c] flex items-center justify-center font-mono font-bold">DR</div>
<div>
<div class="font-sans-ui font-semibold text-[#0a0a0a] mb-1">Status</div>
<div class="font-sans-ui text-sm text-gray-600 leading-relaxed"><strong>Draft</strong> — tạo 04/06/2026, cập nhật cuối 04/06/2026.</div>
</div>
</div>
</div>
</div>
<figcaption>Hình 1.1 — Bốn trường meta của feature. ID / PO / Priority / Status — đối chiếu trước khi đọc tiếp.</figcaption>
</figure>
<div class="prose-article">
<p class="margin-note">
<strong>Hệ thống:</strong> Dashboard thuê TKQC (module <em>billing</em> / <em>risk</em>) + luồng quét bill từ activity của TKQC rồi đối chiếu sang gate (worker check bill, liên quan <em>Service_Check_RRS_V3</em>). Bảng dữ liệu chính: <span class="inline-code">ad_account_bills</span>. Kênh cảnh báo: <strong>NolimitHub</strong> (nội bộ kỹ thuật) + module Audit & Log có sẵn.
</p>
<p class="margin-note">
<strong>Quy mô:</strong> Cải thiện luồng cảnh báo cho trường hợp <em>activity báo có bill ID nhưng gate không tìm thấy</em>: (1) nhét ID bill vào nội dung cảnh báo để actionable; (2) thêm cơ chế đánh dấu bill đã xác nhận không còn trên FB; (3) chống gửi lại cảnh báo cho bill đã đánh dấu. <strong>KHÔNG</strong> xây lại worker quét bill, <strong>KHÔNG</strong> đổi luồng cào bill bình thường. Cơ chế đánh dấu phải <strong>thống nhất</strong> với nhãn terminal (<span class="inline-code">Removed</span>/<span class="inline-code">Expired</span>) ở <a href="#">cao_lai_trang_thai_bill_pending.md</a> và bộ canonical ở <a href="#">dong_bo_dung_trang_thai_funded_bill.md</a> — không đẻ hai khái niệm "ghost bill" song song.
</p>
<!-- ============= SECTION 2: CONTEXT ============= -->
<h2 id="context">02 · Bối cảnh</h2>
<h3>Hai nỗi đau hiện tại — và đâu là nguyên nhân gốc</h3>
<p>
Hệ thống có một luồng kiểm tra bill làm việc theo <strong>hai nguồn dữ liệu của Facebook</strong>: <strong>activity</strong> (nhật ký hoạt động/giao dịch của TKQC — hệ thống đọc và phát hiện có một bill với ID nào đó phát sinh) và <strong>gate</strong> (cổng hóa đơn — nơi fetch chi tiết bill để lấy số tiền, trạng thái, phương thức thanh toán…). Sau khi biết ID từ activity, hệ thống sang gate để lấy chi tiết bill đó.
</p>
<p>
Vấn đề: có những bill <strong>activity báo là CÓ (có ID), nhưng khi sang gate fetch lại KHÔNG tìm thấy</strong> — bill đó không còn trên gate của TKQC nữa. Phần lớn các trường hợp này là bill đã không còn tồn tại trên FB. Khi gặp lệch này, hệ thống <strong>bắn một cảnh báo về NolimitHub</strong>. Hệ quả là hai nỗi đau:
</p>
<ul>
<li><strong>Cảnh báo không actionable — thiếu ID bill:</strong> nội dung cảnh báo trên NolimitHub hiện <em>không kèm ID của bill</em> cần kiểm tra. Người trực nhận cảnh báo nhưng <em>không biết phải mở/fetch bill nào</em> để xác minh → phải tự dò, mất thời gian, dễ bỏ qua.</li>
<li><strong>Cảnh báo lặp vô tận — không có cơ chế đánh dấu:</strong> vì bill này vẫn <em>còn được activity liệt kê</em>, mỗi lần worker quét lại là lại phát hiện "có bill ID này nhưng gate không thấy" → lại bắn đúng cảnh báo đó. Cùng một bill "ghost" tạo ra cảnh báo lặp đi lặp lại, làm nhiễu kênh, <em>che mất các cảnh báo thật sự cần xử lý</em> và khiến người trực dần phớt lờ.</li>
</ul>
<p>
Tính năng cần bổ sung: (1) <strong>bổ sung ID bill (kèm ID TKQC/workspace) vào nội dung cảnh báo</strong> để actionable; (2) một <strong>cơ chế đánh dấu</strong> bill đã xác nhận "activity có ID nhưng gate không tìm thấy → không còn tồn tại trên FB"; (3) <strong>chống lặp</strong>: bill đã đánh dấu thì không bắn lại cảnh báo về NolimitHub ở các lần quét sau, dù activity vẫn còn liệt kê ID đó.
</p>
<div class="code-block">
<div class="code-header">
<span>ghost-bill.shape</span>
<span class="lang-tag">SCHEMA</span>
</div>
<div class="code-body">
<span class="tok-com">// Một bill ghost = activity báo có, gate không tìm thấy (thật sự)</span>
<span class="tok-key">GhostBill</span> <span class="tok-op">=</span> {
<span class="tok-key">bill_id</span><span class="tok-op">:</span> <span class="tok-str">"transaction/tracking id từ activity"</span><span class="tok-op">,</span>
<span class="tok-key">ad_account_id</span><span class="tok-op">:</span> <span class="tok-str">"ID TKQC"</span><span class="tok-op">,</span> <span class="tok-com">// scope đánh dấu</span>
<span class="tok-key">workspace_id</span><span class="tok-op">:</span> <span class="tok-str">"ID workspace"</span><span class="tok-op">,</span>
<span class="tok-key">terminal_status</span><span class="tok-op">:</span> <span class="tok-str">"Removed | Expired"</span><span class="tok-op">,</span> <span class="tok-com">// thống nhất canonical</span>
<span class="tok-key">alerted_once</span><span class="tok-op">:</span> <span class="tok-key">true</span> <span class="tok-com">// chống lặp</span>
}
</div>
</div>
<div class="pull-quote">
Nguyên nhân gốc: hệ thống chưa phân biệt được "bill đã xác nhận không còn trên FB" với "bất thường mới cần báo" — nên coi mọi lần lệch là sự kiện cảnh báo mới, lặp lại mãi.
<cite>— Vì sao đây là vấn đề thiết kế, không phải vấn đề kênh</cite>
</div>
<!-- ============= SECTION 3: OBJECTIVES ============= -->
<h2 id="objectives">03 · Mục tiêu & Chỉ số</h2>
<h3>Bốn chỉ số đo lường thành công</h3>
<p>
<strong>Phạm vi & cách làm — chốt rõ để dev không làm quá tay:</strong> chỉ cải thiện <strong>nội dung cảnh báo</strong> + thêm <strong>đánh dấu/chống lặp</strong> cho đúng trường hợp bill lệch activity↔gate. <strong>KHÔNG</strong> xây lại worker quét bill, <strong>KHÔNG</strong> đổi luồng cào bill bình thường, <strong>KHÔNG</strong> xoá row bill. Cơ chế đánh dấu phải <em>thống nhất</em> với nhãn terminal "biến mất trên FB" đã định ở <a href="#">cao_lai_trang_thai_bill_pending.md</a>.
</p>
<p>
Sau khi làm xong: mỗi cảnh báo bill lệch activity↔gate trên NolimitHub <strong>kèm đủ ID bill + TKQC</strong> để người trực mở ra kiểm tra ngay; một bill "ghost" (đã xác nhận không còn trên FB) <strong>chỉ cảnh báo một lần rồi thôi</strong>, không spam lại mỗi chu kỳ quét. Cảnh báo còn lại trên kênh đều là case <em>cần xử lý thật</em>. Release đầu: cảnh báo có ID; có cơ chế đánh dấu (tự động khi xác nhận gate không tìm thấy, <em>không phải lỗi tạm thời</em>); bill đã đánh dấu bị bỏ qua, không bắn lại cảnh báo.
</p>
</div>
<figure class="max-w-3xl mx-auto px-6">
<div class="grid md:grid-cols-2 gap-4">
<div class="bg-[#fff7ed] border border-orange-200 rounded-xl p-5">
<div class="small-caps text-[#c2410c] mb-2">Metric · 01</div>
<div class="font-sans-ui font-semibold text-[#0a0a0a]">Hết lặp</div>
<div class="font-sans-ui text-sm text-gray-600 mt-2 leading-relaxed">Số cảnh báo lặp về <em>cùng một bill</em> trên NolimitHub giảm về ~0 — mỗi bill ghost tối đa 1 cảnh báo.</div>
</div>
<div class="bg-[#fff7ed] border border-orange-200 rounded-xl p-5">
<div class="small-caps text-[#c2410c] mb-2">Metric · 02</div>
<div class="font-sans-ui font-semibold text-[#0a0a0a]">Actionable</div>
<div class="font-sans-ui text-sm text-gray-600 mt-2 leading-relaxed">~100% cảnh báo bill kèm <em>ID bill + ID TKQC</em> — mở ra fetch/đối chiếu được ngay.</div>
</div>
<div class="bg-[#fff7ed] border border-orange-200 rounded-xl p-5">
<div class="small-caps text-[#c2410c] mb-2">Metric · 03</div>
<div class="font-sans-ui font-semibold text-[#0a0a0a]">Bớt nhiễu</div>
<div class="font-sans-ui text-sm text-gray-600 mt-2 leading-relaxed">Tổng lượng cảnh báo bill "nhiễu" trên NolimitHub giảm rõ rệt; người trực không còn phớt lờ kênh.</div>
</div>
<div class="bg-[#fff7ed] border border-orange-200 rounded-xl p-5">
<div class="small-caps text-[#c2410c] mb-2">Metric · 04</div>
<div class="font-sans-ui font-semibold text-[#0a0a0a]">Không che mất sự cố thật</div>
<div class="font-sans-ui text-sm text-gray-600 mt-2 leading-relaxed">Khi gate lỗi diện rộng (không phải ghost lẻ) thì <em>vẫn cảnh báo</em> — chống lặp KHÔNG được làm im sự cố kỹ thuật thật.</div>
</div>
</div>
<figcaption>Hình 3.1 — Bốn chỉ số đo lường thành công. Metric 04 là ràng buộc an toàn — chống lặp không được biến thành "im lặng diện rộng".</figcaption>
</figure>
<div class="prose-article">
<!-- ============= SECTION 4: PERSONAS ============= -->
<h2 id="personas">04 · Personas</h2>
<h3>Ai làm gì với feature này</h3>
<p>
Feature có ba persona <em>active</em> + ba persona <em>hỗ trợ</em>. Lưu ý: kênh NolimitHub là <strong>nội bộ kỹ thuật</strong> — người nhận cảnh báo là dev/kỹ thuật, không phải end-user; cảnh báo cần đủ thông tin để mở ra fetch/đối chiếu, không cần "thân thiện".
</p>
</div>
<!-- FIGURE: Personas -->
<figure class="max-w-4xl mx-auto px-6">
<div class="grid md:grid-cols-3 gap-4">
<div class="bg-white border border-gray-200 rounded-2xl p-6">
<div class="font-display font-bold text-2xl text-[#c2410c] leading-tight">Worker check bill</div>
<div class="font-sans-ui font-semibold text-sm text-[#0a0a0a] mt-3">Đã có — luồng activity↔gate</div>
<div class="font-sans-ui text-sm text-gray-600 mt-2 leading-relaxed">Đọc activity, phát hiện ID bill, sang gate fetch, đối chiếu. <strong>Tái sử dụng, không xây lại</strong> — chỉ bổ sung ID vào cảnh báo + đọc/ghi trạng thái đánh dấu.</div>
</div>
<div class="bg-white border border-gray-200 rounded-2xl p-6">
<div class="font-display font-bold text-2xl text-[#c2410c] leading-tight">Logic đánh dấu</div>
<div class="font-sans-ui font-semibold text-sm text-[#0a0a0a] mt-3">MỚI — cần xây</div>
<div class="font-sans-ui text-sm text-gray-600 mt-2 leading-relaxed">Sau khi xác nhận "không tìm thấy" (không phải lỗi tạm thời), <strong>đánh dấu</strong> bill; chu kỳ sau thấy đã đánh dấu thì <em>bỏ qua, không bắn lại</em>.</div>
</div>
<div class="bg-white border border-gray-200 rounded-2xl p-6">
<div class="font-display font-bold text-2xl text-[#c2410c] leading-tight">Người trực NolimitHub</div>
<div class="font-sans-ui font-semibold text-sm text-[#0a0a0a] mt-3">Dev / kỹ thuật</div>
<div class="font-sans-ui text-sm text-gray-600 mt-2 leading-relaxed">Nhận cảnh báo. Cần cảnh báo <strong>kèm ID bill + TKQC</strong> để mở ra xác minh/fetch. Kênh <em>nội bộ kỹ thuật</em>.</div>
</div>
</div>
<figcaption>Hình 4.1 — Ba persona active. Persona hỗ trợ: lớp dữ liệu <em>ad_account_bills</em> (lưu cờ đánh dấu), Admin/CTO (chốt cơ chế & ngưỡng), hệ thống NolimitHub (nhận cảnh báo bill + cảnh báo lỗi kỹ thuật).</figcaption>
</figure>
<div class="prose-article">
<!-- ============= SECTION 5: FLOW ============= -->
<h2 id="flow">05 · Luồng nghiệp vụ cốt lõi</h2>
<h3>Sáu bước, theo thứ tự</h3>
<p>
Luồng nghiệp vụ phân nhánh ở Bước 3: nếu fetch được thì xử lý bình thường; nếu không thì phải <em>phân biệt nguyên nhân</em> (Bước 4) trước khi quyết định bắn cảnh báo và đánh dấu. Lưu ý Bước 5 — chỉ bắn cảnh báo khi bill <em>chưa được đánh dấu</em>; nếu đã đánh dấu thì bỏ qua hoàn toàn.
</p>
</div>
<!-- FIGURE: Flow -->
<figure class="max-w-5xl mx-auto px-6">
<div class="grid md:grid-cols-6 gap-4">
<div class="flow-step">
<div class="step-num">1</div>
<div class="mt-2 font-sans-ui font-semibold text-sm text-[#0a0a0a]">Đọc activity</div>
<div class="font-sans-ui text-xs text-gray-500 mt-1.5 leading-relaxed">Worker phát hiện bill ID = X trên TKQC.</div>
</div>
<div class="flow-step">
<div class="step-num">2</div>
<div class="mt-2 font-sans-ui font-semibold text-sm text-[#0a0a0a]">Sang gate fetch</div>
<div class="font-sans-ui text-xs text-gray-500 mt-1.5 leading-relaxed">Lấy chi tiết bill X từ gate.</div>
</div>
<div class="flow-step">
<div class="step-num">3</div>
<div class="mt-2 font-sans-ui font-semibold text-sm text-[#0a0a0a]">Fetch được?</div>
<div class="font-sans-ui text-xs text-gray-500 mt-1.5 leading-relaxed">Có → cập nhật bình thường, kết thúc.</div>
</div>
<div class="flow-step">
<div class="step-num">4</div>
<div class="mt-2 font-sans-ui font-semibold text-sm text-[#0a0a0a]">Phân biệt nguyên nhân</div>
<div class="font-sans-ui text-xs text-gray-500 mt-1.5 leading-relaxed">Lỗi tạm thời (retry) vs thật sự 404.</div>
</div>
<div class="flow-step">
<div class="step-num">5</div>
<div class="mt-2 font-sans-ui font-semibold text-sm text-[#0a0a0a]">Đã đánh dấu?</div>
<div class="font-sans-ui text-xs text-gray-500 mt-1.5 leading-relaxed">Có → bỏ qua. Chưa → bắn cảnh báo + đánh dấu.</div>
</div>
<div class="flow-step">
<div class="step-num">6</div>
<div class="mt-2 font-sans-ui font-semibold text-sm text-[#0a0a0a]">Ghi log</div>
<div class="font-sans-ui text-xs text-gray-500 mt-1.5 leading-relaxed">Cảnh báo / đánh dấu / suppress → Audit & Log.</div>
</div>
</div>
<figcaption>Hình 5.1 — Sáu bước. Bước 3 nhánh "có" → kết thúc; nhánh "không" → đi tiếp Bước 4. Bước 5 chống lặp: chỉ bắn cảnh báo lần đầu, lần sau bỏ qua.</figcaption>
</figure>
<div class="prose-article">
<p>
Nếu Bước 4 kết luận "lỗi tạm thời / diện rộng" — vd rate limit, timeout, token-cookie chết, gate đang lỗi nhiều bill — thì <strong>không</strong> đánh dấu, <strong>không</strong> kết luận ghost; chỉ retry chu kỳ sau. Nếu là lỗi kỹ thuật thật (gate down, credential chết kéo dài) → cảnh báo NolimitHub <em>theo kênh lỗi kỹ thuật</em>, không trộn với cảnh báo ghost.
</p>
<!-- ============= SECTION 6: EDGE CASES ============= -->
<h2 id="edgecases">06 · Edge cases</h2>
<h3>Năm tình huống dev bắt buộc xử lý</h3>
<p>
FB Agency lỗi rất nhiều — bên cạnh luồng chính ở Mục 05, đây là năm tình huống biên mà <em>cách xử lý phải được code rõ ràng</em>, không để mặc định. Nhầm chỗ này có thể vừa đánh dấu sai, vừa che mất bill thật.
</p>
</div>
<!-- FIGURE: Edge cases -->
<figure class="max-w-4xl mx-auto px-6">
<div class="space-y-3">
<div class="rounded-xl p-5 flex items-start gap-4 error-stripe border border-red-200">
<span class="status-pill bg-red-100 text-red-700 flex-shrink-0"><span class="w-2 h-2 rounded-full bg-red-500"></span>Quan trọng nhất</span>
<div class="flex-1">
<div class="font-mono text-sm text-[#0a0a0a] font-semibold">Lỗi tạm thời ≠ ghost</div>
<div class="font-sans-ui text-sm text-gray-500 mt-1">Rate limit / timeout / transient OAuth / token-cookie chết khiến gate trả lỗi <em>không</em> được kết luận "không tồn tại". <strong>Tuyệt đối không</strong> đánh dấu và không bắn cảnh báo "ghost" — chỉ retry.</div>
</div>
</div>
<div class="bg-white border border-red-200 rounded-xl p-5 flex items-start gap-4">
<span class="status-pill bg-red-50 text-red-700 flex-shrink-0"><span class="w-2 h-2 rounded-full bg-red-500"></span>Diện rộng</span>
<div class="flex-1">
<div class="font-mono text-sm text-[#0a0a0a] font-semibold">Gate lỗi diện rộng vs ghost lẻ</div>
<div class="font-sans-ui text-sm text-gray-500 mt-1">Nhiều bill cùng không fetch được / auth lỗi / gate down → <strong>cảnh báo kỹ thuật</strong> NolimitHub, KHÔNG mass-mark. Chỉ đánh dấu một bill là ghost khi gate <em>vẫn fetch được các bill khác bình thường</em> mà riêng bill này 404.</div>
</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 flex items-start gap-4">
<span class="status-pill bg-amber-50 text-amber-700 flex-shrink-0"><span class="w-2 h-2 rounded-full bg-amber-500"></span>Credential</span>
<div class="flex-1">
<div class="font-mono text-sm text-[#0a0a0a] font-semibold">Token / cookie chết khi check gate</div>
<div class="font-sans-ui text-sm text-gray-500 mt-1">Cần credential VIA còn quyền trên TKQC; chết → xoay credential phù hợp, không phụ thuộc token của chính TKQC đã chết. Lỗi kéo dài → cảnh báo NolimitHub; <em>không</em> kết luận ghost, không đánh dấu.</div>
</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 flex items-start gap-4">
<span class="status-pill bg-blue-50 text-blue-700 flex-shrink-0"><span class="w-2 h-2 rounded-full bg-blue-500"></span>Bill quay lại</span>
<div class="flex-1">
<div class="font-mono text-sm text-[#0a0a0a] font-semibold">Bill xuất hiện lại trên gate sau khi đánh dấu</div>
<div class="font-sans-ui text-sm text-gray-500 mt-1">FB đôi khi hiện lại giao dịch. Khi gate fetch được bill đã bị đánh dấu trước đó → phải <strong>bỏ đánh dấu / xử lý như bill bình thường</strong> (cập nhật trạng thái thực), không kẹt vĩnh viễn ở nhãn "biến mất".</div>
</div>
</div>
<div class="bg-white border border-gray-200 rounded-xl p-5 flex items-start gap-4">
<span class="status-pill bg-gray-100 text-gray-700 flex-shrink-0"><span class="w-2 h-2 rounded-full bg-gray-500"></span>Đồng bộ</span>
<div class="flex-1">
<div class="font-mono text-sm text-[#0a0a0a] font-semibold">Thống nhất với re-crawl Pending</div>
<div class="font-sans-ui text-sm text-gray-500 mt-1">Hai luồng (re-crawl Pending và check activity↔gate) phải <strong>đánh dấu vào cùng một chỗ / cùng một nhãn terminal</strong>, tránh mỗi worker đánh dấu một kiểu gây mâu thuẫn trạng thái.</div>
</div>
</div>
</div>
<figcaption>Hình 6.1 — Năm tình huống biên + hành vi kỳ vọng. Các edge case phụ (không xoá row, lọc đúng loại giao dịch, scope TKQC+ID, idempotent, TKQC bị xoá, đánh dấu tay) liệt kê trong văn bản dưới.</figcaption>
</figure>
<div class="prose-article">
<p>
Sáu edge case phụ cần xử lý gọn: (1) <strong>Không xoá row</strong> — đánh dấu chỉ cập nhật trạng thái/cờ, không DELETE khỏi <span class="inline-code">ad_account_bills</span> (giữ lịch sử & debug). (2) <strong>Phân biệt đúng loại giao dịch trong activity</strong> — activity có nhiều loại mục, lọc đúng loại để không cảnh báo nhầm cho mục vốn dĩ không có trên gate. (3) <strong>Scope theo (TKQC + ID bill)</strong> — đánh dấu phải gắn đúng cặp; không để đánh dấu một bill làm im cảnh báo của bill khác trùng ID ở TKQC khác. (4) <strong>Idempotent</strong> — nhiều worker chạy song song chỉ một cảnh báo, một lần đánh dấu. (5) <strong>TKQC bị xoá/disabled/rời workspace</strong> — dừng quét, không cảnh báo, không văng lỗi. (6) <strong>Đánh dấu tay</strong> — cân nhắc cho người trực đánh dấu tay một bill để dừng cảnh báo ngay (chốt ở Bước 0).
</p>
<div class="pull-quote">
Chống lặp tuyệt đối không được biến một sự cố diện rộng thành "im lặng". Khi nhiều bill cùng fail, đó là cảnh báo kỹ thuật — không phải ghost hàng loạt.
<cite>— Quy tắc ranh giới ghost lẻ vs sự cố diện rộng</cite>
</div>
<!-- ============= SECTION 7: USER STORIES ============= -->
<h2 id="stories">07 · User stories</h2>
<h3>Bảng tham chiếu nhanh</h3>
<p>
Sáu user story định nghĩa <em>scope chức năng</em> của release đầu. <strong>P0</strong> là phải có để release; <strong>P1/P2</strong> có thể chậm hơn nhưng vẫn nằm trong cùng feature, không tách ticket.
</p>
</div>
<!-- FIGURE: User stories table -->
<figure class="max-w-5xl mx-auto px-6">
<div class="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
<table class="field-table">
<thead>
<tr>
<th>ID</th>
<th>Vai trò</th>
<th>Muốn / Để</th>
<th>Ưu tiên</th>
</tr>
</thead>
<tbody>
<tr>
<td class="col-field">US-01</td>
<td class="col-rule">Người trực NolimitHub</td>
<td>Cảnh báo bill kèm <strong>ID bill + ID TKQC</strong> (và workspace, lý do) — <em>mở ra fetch/đối chiếu đúng bill ngay, không phải tự dò.</em></td>
<td class="col-rule"><span class="inline-code">P0</span></td>
</tr>
<tr>
<td class="col-field">US-02</td>
<td class="col-rule">Hệ thống</td>
<td><strong>Đánh dấu</strong> bill đã xác nhận gate không tìm thấy (không còn trên FB) — phân biệt "đã xử lý" với "bất thường mới", làm cơ sở chống lặp.</td>
<td class="col-rule"><span class="inline-code">P0</span></td>
</tr>
<tr>
<td class="col-field">US-03</td>
<td class="col-rule">Hệ thống</td>
<td><strong>Không bắn lại</strong> cảnh báo NolimitHub cho bill đã đánh dấu — tránh spam dù activity vẫn liệt kê ID.</td>
<td class="col-rule"><span class="inline-code">P0</span></td>
</tr>
<tr>
<td class="col-field">US-04</td>
<td class="col-rule">Dev / CTO</td>
<td>Khi gate lỗi <strong>diện rộng</strong> (auth/gate down) <em>vẫn được cảnh báo</em> kỹ thuật — không bị che mất sự cố thật vì tưởng là ghost bill.</td>
<td class="col-rule"><span class="inline-code">P1</span></td>
</tr>
<tr>
<td class="col-field">US-05</td>
<td class="col-rule">Hệ thống</td>
<td><strong>Bỏ đánh dấu / xử lý lại</strong> khi bill xuất hiện lại trên gate — dữ liệu phản ánh đúng khi FB hiện lại bill.</td>
<td class="col-rule"><span class="inline-code">P2</span></td>
</tr>
<tr>
<td class="col-field">US-06</td>
<td class="col-rule">Người trực</td>
<td>(Tuỳ chọn) <strong>Đánh dấu tay</strong> một bill là ghost để dừng cảnh báo — tự xác minh xong thì tắt nhiễu ngay, không chờ chu kỳ.</td>
<td class="col-rule"><span class="inline-code">P2</span></td>
</tr>
</tbody>
</table>
</div>
<figcaption>Hình 7.1 — Sáu user story. Ba P0 (US-01/02/03) phải có cho release đầu; P1/P2 có thể chậm hơn nhưng cùng ticket.</figcaption>
</figure>
<div class="prose-article">
<!-- ============= SECTION 8: RULES ============= -->
<h2 id="rules">08 · Business rules</h2>
<h3>Cấu trúc luồng quyết định cảnh báo</h3>
<p>
Trước khi đọc danh sách rule, nhìn vào sơ đồ dưới: cảnh báo không phải là "mỗi lần lệch là một event" — mà là kết quả của một <em>chuỗi quyết định</em> bắt đầu từ "đây có thật là ghost không" và kết thúc ở "đã đánh dấu chưa". Bảy thành phần trong sơ đồ tương ứng với cách <strong>quyết định bắn/suppress cảnh báo</strong> được tính tại thời điểm gate trả 404.
</p>
</div>
<!-- FIGURE: Apply formula anatomy -->
<figure class="max-w-4xl mx-auto px-6">
<div class="bg-white border border-gray-200 rounded-2xl p-10 shadow-sm">
<div class="flex flex-wrap items-start justify-center gap-x-1 gap-y-6">
<div class="anatomy-token">
<span class="label-top">ACTIVITY</span>
<span class="glyph">bill X</span>
<span class="label-bot">có ID</span>
</div>
<span class="anatomy-sep">→</span>
<div class="anatomy-token">
<span class="label-top">GATE FETCH</span>
<span class="glyph">404</span>
<span class="label-bot">không tìm thấy</span>
</div>
<span class="anatomy-sep">?</span>
<div class="anatomy-token">
<span class="label-top">PHÂN LOẠI</span>
<span class="glyph">tạm thời</span>
<span class="label-bot">retry<br>không đánh dấu</span>
</div>
<span class="anatomy-sep">|</span>
<div class="anatomy-token">
<span class="label-top">DIỆN RỘNG</span>
<span class="glyph">auth</span>
<span class="label-bot">cảnh báo kỹ thuật<br>không mass-mark</span>
</div>
<span class="anatomy-sep">|</span>
<div class="anatomy-token">
<span class="label-top">GHOST LẺ</span>
<span class="glyph">404 thật</span>
<span class="label-bot">khác bill<br>fetch bình thường</span>
</div>
<span class="anatomy-sep">?</span>
<div class="anatomy-token">
<span class="label-top">ĐÃ DẤU?</span>
<span class="glyph">cờ</span>
<span class="label-bot">có → suppress<br>chưa → bắn</span>
</div>
<span class="anatomy-sep">→</span>
<div class="anatomy-token">
<span class="label-top">CẢNH BÁO</span>
<span class="glyph">+ ID</span>
<span class="label-bot">đánh dấu<br>+ ghi log</span>
</div>
</div>
<div class="mt-10 pt-6 border-t border-gray-100 text-center">
<div class="small-caps text-gray-400 mb-2">Luồng quyết định cảnh báo</div>
<div class="font-mono text-base md:text-lg text-[#0a0a0a] font-semibold">activity(X) → gate(404) → classify → if(ghost lẻ ∧ ¬marked) ⇒ alert(+ID) + mark</div>
</div>
</div>
<figcaption>Hình 8.1 — Cách quyết định bắn/suppress cảnh báo được tính khi gate trả 404. Ba nhánh phân loại (tạm thời / diện rộng / ghost lẻ), sau đó kiểm cờ đánh dấu trước khi bắn.</figcaption>
</figure>
<div class="prose-article">
<p>Chín rule dưới đây là <em>chuẩn mực</em> để dev đối chiếu khi viết logic và QA đối chiếu khi viết test:</p>
<ol>
<li><strong>Cảnh báo phải actionable:</strong> mọi cảnh báo bill lệch activity↔gate phải kèm tối thiểu <em>ID bill</em> (transaction/tracking id), <em>ID TKQC</em>, <em>workspace</em>, <em>thời điểm</em>, <em>lý do</em> (gate không tìm thấy). Có link mở thẳng bill/TKQC càng tốt.</li>
<li><strong>Chỉ đánh dấu khi xác nhận "không tìm thấy" thật:</strong> chỉ khi gate trả 404 / không có bill (không phải lỗi tạm thời). Lỗi tạm thời → retry, KHÔNG đánh dấu, KHÔNG kết luận ghost.</li>
<li><strong>Phân biệt ghost lẻ vs sự cố diện rộng (P0 an toàn):</strong> nếu gate fail hàng loạt / lỗi xác thực → cảnh báo kỹ thuật NolimitHub, KHÔNG mass-mark. Chỉ đánh dấu khi gate vẫn fetch được các bill khác bình thường.</li>
<li><strong>Đánh dấu trên chính bill, không xoá:</strong> đánh dấu = cập nhật trạng thái/cờ trên row bill, scope theo (TKQC + ID bill); không DELETE. Nhãn terminal "không còn trên FB" <em>thống nhất</em> với <a href="#">cao_lai_trang_thai_bill_pending.md</a> (<span class="inline-code">Removed</span>/<span class="inline-code">Expired</span>) và nằm trong bộ canonical ở <a href="#">dong_bo_dung_trang_thai_funded_bill.md</a> — không đẻ biến thể mới.</li>
<li><strong>Chống lặp:</strong> bill đã đánh dấu → các lần quét sau bỏ qua, KHÔNG bắn lại cảnh báo NolimitHub, <em>kể cả khi</em> activity vẫn liệt kê ID đó. Dedupe theo bill.</li>
<li><strong>Bill quay lại:</strong> nếu bill đã đánh dấu mà sau đó gate fetch được → bỏ đánh dấu và cập nhật trạng thái thực; không kẹt vĩnh viễn.</li>
<li><strong>Idempotent:</strong> nhiều chu kỳ/nhiều worker → tối đa một cảnh báo + một lần đánh dấu cho cùng bill; không trùng, không mâu thuẫn.</li>
<li><strong>Ghi log đầy đủ:</strong> việc bắn cảnh báo, việc đánh dấu (cái gì/ai đánh dấu, khi nào, lý do) và việc suppress cảnh báo đều ghi vào nhật ký — tận dụng module <em>Audit & Log</em> có sẵn để truy vết.</li>
<li><strong>Một nguồn sự thật cho "bill biến mất":</strong> re-crawl Pending và check activity↔gate dùng chung cơ chế/nhãn đánh dấu, không tạo hai khái niệm ghost song song.</li>
</ol>
<div class="code-block">
<div class="code-header">
<span>alert-decision.pseudo</span>
<span class="lang-tag">LOGIC</span>
</div>
<div class="code-body">
<span class="tok-com">// Quyết định cảnh báo khi gate trả 404 cho bill X của TKQC</span>
<span class="tok-key">if</span> (gate_error_is_transient_or_widespread) {
<span class="tok-key">retry</span>() <span class="tok-com">// KHÔNG đánh dấu</span>
<span class="tok-key">if</span> (auth_or_gate_down_persists)
alert_tech_channel(<span class="tok-str">"gate down"</span>) <span class="tok-com">// kênh lỗi kỹ thuật</span>
<span class="tok-key">return</span>
}
<span class="tok-com">// Ghost lẻ thật: 404 cho X, các bill khác vẫn fetch được</span>
<span class="tok-key">if</span> (<span class="tok-key">already_marked</span>(ad_account_id<span class="tok-op">,</span> bill_id)) {
audit<span class="tok-op">.</span><span class="tok-key">write</span>({ <span class="tok-key">action</span><span class="tok-op">:</span> <span class="tok-str">"suppress"</span><span class="tok-op">,</span> bill_id })
<span class="tok-key">return</span> <span class="tok-com">// chống lặp</span>
}
alert_nolimithub({ bill_id<span class="tok-op">,</span> ad_account_id<span class="tok-op">,</span> workspace_id<span class="tok-op">,</span> reason })
<span class="tok-key">mark_terminal</span>(ad_account_id<span class="tok-op">,</span> bill_id<span class="tok-op">,</span> <span class="tok-str">"Removed"</span>)
audit<span class="tok-op">.</span><span class="tok-key">write</span>({ <span class="tok-key">action</span><span class="tok-op">:</span> <span class="tok-str">"alert+mark"</span><span class="tok-op">,</span> bill_id })
</div>
</div>
<!-- ============= SECTION 9: ACCEPTANCE ============= -->
<h2 id="acceptance">09 · Acceptance & Test cases</h2>
<h3>Chín tiêu chí nghiệm thu</h3>
<p>
Để feature được coi là "xong", <em>cả chín</em> tiêu chí dưới phải pass trên staging. Đối chiếu trực tiếp khi viết test:
</p>
<ul>
<li>Cảnh báo bill lệch activity↔gate trên NolimitHub <strong>kèm ID bill + ID TKQC</strong> (+ workspace, lý do); người trực mở ra fetch đúng bill.</li>
<li>Bill xác nhận gate không tìm thấy (404, không phải lỗi tạm thời) được <strong>đánh dấu</strong>, không bị xoá row.</li>
<li>Bill đã đánh dấu → các chu kỳ quét sau <strong>không bắn lại</strong> cảnh báo NolimitHub (dù activity vẫn còn ID đó).</li>
<li><strong>Lỗi tạm thời</strong> (rate limit/timeout/token-cookie chết) → không đánh dấu, không cảnh báo "ghost"; có retry.</li>
<li><strong>Gate lỗi diện rộng / auth</strong> → vẫn cảnh báo kỹ thuật NolimitHub và KHÔNG mass-mark.</li>
<li>Bill đã đánh dấu nhưng <strong>xuất hiện lại</strong> trên gate → bị bỏ đánh dấu và cập nhật trạng thái thực.</li>
<li>Đánh dấu <strong>scope đúng (TKQC + ID bill)</strong>; không làm im cảnh báo của bill khác.</li>
<li>Nhãn terminal & xử lý <strong>thống nhất</strong> với doc cao_lai/funded; không phát sinh biến thể nhãn mới.</li>
<li>Mọi cảnh báo/đánh dấu/suppress được <strong>ghi log</strong> đầy đủ.</li>
</ul>
<h3>Ma trận test case</h3>
<p>
Test case chia ba nhóm: <em>luồng chính</em>, <em>edge case</em>, và <em>TC bổ sung do dev tự dựng</em>. Mỗi cột dưới là một nhóm:
</p>
</div>
<figure class="max-w-4xl mx-auto px-6">
<div class="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
<div class="grid md:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-gray-200">
<div class="p-6">
<div class="font-mono text-xs text-gray-400 mb-2">GROUP · 01</div>
<div class="font-display text-xl font-bold text-[#0a0a0a]">Luồng chính</div>
<div class="font-sans-ui text-sm text-gray-600 mt-3 leading-relaxed">TC-01 → TC-03 — phải pass 100%.</div>
<hr class="my-4 border-gray-200">
<ul class="font-sans-ui text-sm text-gray-700 space-y-1.5">
<li>→ <strong>TC-01</strong> bill X 404 → 1 cảnh báo kèm ID + đánh dấu</li>
<li>→ <strong>TC-02</strong> quét lại nhiều chu kỳ → không cảnh báo mới</li>
<li>→ <strong>TC-03</strong> bill Y fetch được → xử lý bình thường, không cảnh báo</li>
</ul>
</div>
<div class="p-6">
<div class="font-mono text-xs text-gray-400 mb-2">GROUP · 02</div>
<div class="font-display text-xl font-bold text-[#0a0a0a]">Edge case</div>
<div class="font-sans-ui text-sm text-gray-600 mt-3 leading-relaxed">TC-04 → TC-09 — bảo vệ các invariant.</div>
<hr class="my-4 border-gray-200">
<ul class="font-sans-ui text-sm text-gray-700 space-y-1.5">
<li>→ <strong>TC-04</strong> 429/timeout → không đánh dấu, không "ghost"</li>
<li>→ <strong>TC-05</strong> gate down diện rộng → cảnh báo kỹ thuật, không mass-mark</li>
<li>→ <strong>TC-06</strong> bill quay lại → bỏ đánh dấu, cập nhật thực</li>
<li>→ <strong>TC-07</strong> scope: A bị mark, B trùng ID ở TKQC khác vẫn cảnh báo</li>
<li>→ <strong>TC-08</strong> hai worker → một cảnh báo, một lần đánh dấu</li>
<li>→ <strong>TC-09</strong> token/cookie chết → xoay hoặc bỏ qua an toàn</li>
</ul>
</div>
<div class="p-6">
<div class="font-mono text-xs text-gray-400 mb-2">GROUP · 03</div>
<div class="font-display text-xl font-bold text-[#0a0a0a]">Dev tự dựng</div>
<div class="font-sans-ui text-sm text-gray-600 mt-3 leading-relaxed">Bám theo quyết định ở Bước 0.</div>
<hr class="my-4 border-gray-200">
<ul class="font-sans-ui text-sm text-gray-700 space-y-1.5">
<li>→ TKQC bị xoá/disabled/rời workspace khi đang chờ check</li>
<li>→ Lọc nhầm loại mục activity (mục không phải bill)</li>
<li>→ Ngưỡng số lần thử/khoảng thời gian trước khi kết luận ghost</li>
<li>→ (Nếu làm) đánh dấu tay của người trực</li>
</ul>
</div>
</div>
</div>
<figcaption>Hình 9.1 — Ma trận test case ba nhóm. Cột 3 yêu cầu dev chủ động dựng thêm — không có sẵn ID.</figcaption>
</figure>
<div class="prose-article">
<!-- ============= SECTION 10: HANDOVER ============= -->
<h2 id="handover">10 · Quy trình bàn giao</h2>
<h3>Bước 0 — chốt sáu điểm trước khi code</h3>
<p>
Trước khi viết dòng code đầu tiên, sáu câu hỏi dưới phải có câu trả lời từ CTO. Đừng đoán — đoán sai ở Bước 0 thì refactor cả luồng cảnh báo, và còn dễ tạo nhãn terminal mâu thuẫn với các doc liên quan.
</p>
</div>
<figure class="max-w-3xl mx-auto px-6">
<ol class="space-y-3">
<li class="bg-white border border-gray-200 rounded-xl p-5 flex items-start gap-4">
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-[#0a0a0a] text-white font-mono font-bold flex items-center justify-center text-sm">1</span>
<div class="flex-1">
<div class="font-sans-ui font-semibold text-[#0a0a0a]">Cơ chế đánh dấu</div>
<div class="font-sans-ui text-sm text-gray-500 mt-1">Dùng <em>trạng thái terminal</em> thống nhất với cao_lai (<span class="inline-code">Removed</span>/<span class="inline-code">Expired</span>) hay thêm <em>cờ riêng</em> "đã-cảnh-báo/suppressed" (hoặc cả hai)? Đặt ở <span class="inline-code">ad_account_bills</span> thế nào để hai luồng dùng chung.</div>
</div>
</li>
<li class="bg-white border border-gray-200 rounded-xl p-5 flex items-start gap-4">
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-[#0a0a0a] text-white font-mono font-bold flex items-center justify-center text-sm">2</span>
<div class="flex-1">
<div class="font-sans-ui font-semibold text-[#0a0a0a]">Ngưỡng kết luận ghost</div>
<div class="font-sans-ui text-sm text-gray-500 mt-1">Thử <strong>N lần</strong> trong <strong>M giờ</strong> rồi mới kết luận "không tồn tại" và đánh dấu — tránh đánh dấu non khi lỗi tạm thời.</div>
</div>
</li>
<li class="bg-white border border-gray-200 rounded-xl p-5 flex items-start gap-4">
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-[#0a0a0a] text-white font-mono font-bold flex items-center justify-center text-sm">3</span>
<div class="flex-1">
<div class="font-sans-ui font-semibold text-[#0a0a0a]">Tiêu chí phân biệt "gate down diện rộng" vs "ghost lẻ"</div>
<div class="font-sans-ui text-sm text-gray-500 mt-1">Dựa vào tỉ lệ not-found trong một batch / loại lỗi (auth vs 404) — chốt mức cụ thể.</div>
</div>
</li>
<li class="bg-white border border-gray-200 rounded-xl p-5 flex items-start gap-4">
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-[#0a0a0a] text-white font-mono font-bold flex items-center justify-center text-sm">4</span>
<div class="flex-1">
<div class="font-sans-ui font-semibold text-[#0a0a0a]">Nội dung cảnh báo</div>
<div class="font-sans-ui text-sm text-gray-500 mt-1">Ngoài ID bill + TKQC + workspace + lý do, có cần thêm <em>link mở thẳng</em> / mức độ nghiêm trọng không.</div>
</div>
</li>
<li class="bg-white border border-gray-200 rounded-xl p-5 flex items-start gap-4">
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-[#0a0a0a] text-white font-mono font-bold flex items-center justify-center text-sm">5</span>
<div class="flex-1">
<div class="font-sans-ui font-semibold text-[#0a0a0a]">Bill quay lại — auto bỏ đánh dấu</div>
<div class="font-sans-ui text-sm text-gray-500 mt-1">Tự bỏ đánh dấu khi gate fetch được lại — xác nhận hành vi mong muốn.</div>
</div>
</li>
<li class="bg-white border border-gray-200 rounded-xl p-5 flex items-start gap-4">
<span class="flex-shrink-0 w-8 h-8 rounded-full bg-[#0a0a0a] text-white font-mono font-bold flex items-center justify-center text-sm">6</span>
<div class="flex-1">
<div class="font-sans-ui font-semibold text-[#0a0a0a]">Đánh dấu tay (US-06)</div>
<div class="font-sans-ui text-sm text-gray-500 mt-1">Có làm kênh cho người trực đánh dấu tay 1 bill để tắt nhiễu ngay không?</div>
</div>
</li>
</ol>
<figcaption>Hình 10.1 — Sáu câu hỏi Bước 0. Có câu trả lời thì code; chưa có thì hỏi, đừng đoán.</figcaption>
</figure>
<div class="prose-article">
<h3>Phụ thuộc bắt buộc & deploy gate</h3>
<p>
Thống nhất cơ chế/nhãn "bill biến mất trên FB" với <a href="#">cao_lai_trang_thai_bill_pending.md</a> và bộ canonical ở <a href="#">dong_bo_dung_trang_thai_funded_bill.md</a> — nên làm/đồng bộ cùng đợt. Sau khi xong + UAT trên staging, dev chuẩn bị <strong>Test Report</strong> kèm bằng chứng thực tế: (1) ảnh/log một cảnh báo NolimitHub <em>có ID bill + TKQC</em> mở ra fetch đúng; (2) demo chạy lại worker nhiều chu kỳ ⇒ bill ghost <em>không</em> cảnh báo lại; (3) demo gate lỗi diện rộng <em>vẫn</em> cảnh báo kỹ thuật (không bị chống-lặp che mất); (4) demo bill quay lại được bỏ đánh dấu. Họp demo/review trực tiếp với CTO duyệt nội dung cảnh báo, cơ chế đánh dấu/chống lặp, ranh giới ghost-lẻ vs sự cố-diện-rộng và độ ổn định.
</p>
<div class="my-10 border-l-4 border-red-500 bg-red-50 px-6 py-4 rounded-r-lg">
<div class="small-caps text-red-700 mb-2">Deploy gate — bất di bất dịch</div>
<p class="font-sans-ui text-base text-[#0a0a0a] !mb-0 leading-relaxed">
<strong>CHỈ KHI CTO APPROVE mới deploy Production.</strong> Không có shortcut, không có ngoại lệ. Luồng cảnh báo đụng tới kênh tin cậy duy nhất của đội kỹ thuật — sai chống-lặp một bước là che mất sự cố thật hàng loạt.
</p>
</div>
<hr class="deco-rule">
<p>
Đó là toàn bộ đặc tả của <em>FT-BILL-GHOST-ALERT-01</em>. Tài liệu được sắp xếp theo trình tự PRD chuẩn — nếu sau khi đọc xong bạn vẫn lăn tăn ở một mục nào, hãy <em>quay lại đúng mục đó</em> thay vì dò lại từ đầu. Mười mục, mười câu hỏi: bạn đang vướng câu nào?
</p>
<p class="text-gray-500 italic !mb-0">
— Last reviewed: 04/06/2026. Phản hồi gửi về <a href="mailto:owaf.fakku@gmail.com">owaf.fakku@gmail.com</a>.
</p>
</div>
</article>
<!-- ============= AUTHOR BIO ============= -->
<section class="bg-white border-t border-b border-gray-200 py-16">
<div class="max-w-4xl mx-auto px-6">
<div class="flex flex-col md:flex-row gap-8 items-start">
<div class="flex-shrink-0">
<div class="w-24 h-24 rounded-2xl bg-gradient-to-br from-[#0a0a0a] to-[#374151] text-[#fafafa] flex items-center justify-center font-display font-bold text-4xl shadow-lg">D</div>
</div>
<div class="flex-1">
<div class="small-caps text-gray-400 mb-2">About the author</div>
<h3 class="font-display font-bold text-2xl text-[#0a0a0a]">Doanh Nguyễn</h3>
<p class="font-sans-ui text-gray-600 mt-3 leading-relaxed max-w-2xl">
Editor của <em>The Operator's Journal</em>, viết về product spec cho công cụ nội bộ, hệ thống cảnh báo, và những lớp "chống nhiễu" giúp kênh nội bộ kỹ thuật không bị bào mòn độ tin. Trước đây xây dựng dashboard thuê TKQC cho các tổ chức ở Đông Nam Á — nơi câu hỏi "bill này có thật không, hay FB lại nuốt mất" lặp lại mỗi ngày.
</p>
<div class="mt-4 flex gap-4 font-sans-ui text-sm">
<a href="#" class="text-[#c2410c] hover:underline">More articles →</a>
<a href="#" class="text-gray-600 hover:text-[#0a0a0a]">Subscribe</a>
<a href="#" class="text-gray-600 hover:text-[#0a0a0a]">@doanh</a>
</div>
</div>
</div>
</div>
</section>
<!-- ============= RELATED POSTS ============= -->
<section class="py-20">
<div class="max-w-6xl mx-auto px-6">
<div class="flex items-end justify-between mb-10 border-b border-gray-200 pb-6">
<h3 class="font-display font-bold text-3xl text-[#0a0a0a]">Tiếp tục đọc</h3>
<a href="#" class="font-sans-ui text-sm text-[#c2410c] hover:underline">Xem toàn bộ archive →</a>
</div>
<div class="grid md:grid-cols-3 gap-6">
<article class="related-card">
<div class="small-caps text-[#c2410c] mb-3">Spec</div>
<h4 class="font-display font-bold text-xl text-[#0a0a0a] leading-snug">Re-crawl bill Pending: khi nào FB "nuốt" giao dịch — và ta đánh dấu thế nào</h4>
<p class="font-sans-ui text-sm text-gray-600 mt-3 leading-relaxed">Doc anh em của bài này — cùng hiện tượng bill 404/biến mất, nhưng xuất phát từ luồng re-crawl Pending. Cần đọc cùng để thống nhất nhãn terminal.</p>
<div class="mt-5 flex items-center gap-3 font-sans-ui text-xs text-gray-500">
<span>Doanh Nguyễn</span>
<span class="text-gray-300">·</span>
<span>11 min read</span>
</div>
</article>
<article class="related-card">
<div class="small-caps text-[#c2410c] mb-3">Spec</div>
<h4 class="font-display font-bold text-xl text-[#0a0a0a] leading-snug">Bộ trạng thái canonical cho funded bill: một nguồn sự thật, nhiều worker</h4>
<p class="font-sans-ui text-sm text-gray-600 mt-3 leading-relaxed">Định nghĩa canonical trạng thái dùng chung trên <span class="inline-code">ad_account_bills</span>. Mọi nhãn terminal (kể cả "biến mất") phải nằm trong bộ này — không tạo biến thể.</p>
<div class="mt-5 flex items-center gap-3 font-sans-ui text-xs text-gray-500">
<span>Doanh Nguyễn</span>
<span class="text-gray-300">·</span>
<span>13 min read</span>
</div>
</article>
<article class="related-card">
<div class="small-caps text-[#c2410c] mb-3">Pattern</div>
<h4 class="font-display font-bold text-xl text-[#0a0a0a] leading-snug">Khi nào chống-lặp cảnh báo trở thành "im lặng diện rộng"</h4>
<p class="font-sans-ui text-sm text-gray-600 mt-3 leading-relaxed">Dedupe alert là tốt — đến khi nó che mất sự cố thật. Bài này phân tích ranh giới giữa "ghost lẻ" và "sự cố diện rộng" trong hệ thống cảnh báo nội bộ.</p>
<div class="mt-5 flex items-center gap-3 font-sans-ui text-xs text-gray-500">
<span>Doanh Nguyễn</span>
<span class="text-gray-300">·</span>
<span>9 min read</span>
</div>
</article>
</div>
</div>
</section>
<!-- ============= FOOTER ============= -->
<footer class="bg-[#0a0a0a] text-[#fafafa] py-12 mt-10">
<div class="max-w-6xl mx-auto px-6 flex flex-col md:flex-row justify-between gap-6">
<div>
<div class="font-display font-bold text-xl">The Operator's Journal</div>
<div class="font-sans-ui text-sm text-gray-400 mt-2">Product specs, ops playbooks & internal tooling notes.</div>
</div>
<div class="flex gap-8 font-sans-ui text-sm text-gray-400">
<div>
<div class="small-caps text-gray-500 mb-2">Sections</div>
<div class="space-y-1">
<div>Specs</div>
<div>Playbooks</div>
<div>Postmortems</div>
</div>
</div>
<div>
<div class="small-caps text-gray-500 mb-2">About</div>
<div class="space-y-1">
<div>Editor</div>
<div>Subscribe</div>
<div>RSS</div>
</div>
</div>
</div>
</div>
<div class="max-w-6xl mx-auto px-6 mt-10 pt-6 border-t border-gray-800 font-sans-ui text-xs text-gray-500 flex justify-between">
<span>© 2026 The Operator's Journal · Vol. 04 Issue 18</span>
<span>Published 04 Jun 2026 · Hà Nội / Sài Gòn</span>
</div>
</footer>
</body>
</html>
Số dòng: 1131