Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0870771
fix(recon): Pha 1 độ chính xác — cascade phí thống nhất + tie-out XLS…
minhnb9897 Jul 9, 2026
1ada543
feat(recon): Pha 2-A — bảng đối soát XLSX khớp 100% mẫu T6
minhnb9897 Jul 9, 2026
27ecba9
feat(recon): Pha 2-A — biên bản DOCX khớp 100% mẫu T6
minhnb9897 Jul 9, 2026
6273cff
feat(recon): Pha 2-A — export Danh sách VĐV (43 cột khớp mẫu T6)
minhnb9897 Jul 9, 2026
0c5303d
feat(recon-admin): nút tải "Danh sách VĐV" ở trang chi tiết đối soát
minhnb9897 Jul 9, 2026
0475ead
fix(recon): sửa biên kỳ ICT — processed_on lưu ICT wall-clock, bỏ trừ…
minhnb9897 Jul 9, 2026
49a4c04
feat(recon): Pha 3 config email đầu mối + Pha 4 gửi email đối soát (b…
minhnb9897 Jul 10, 2026
d5f277e
feat(recon-admin): Pha 4 UI — gửi email đối soát (per-item + batch) +…
minhnb9897 Jul 10, 2026
b412bd5
fix(recon): preflight nhận biết event override — bỏ ERROR "chưa thiết…
minhnb9897 Jul 10, 2026
d8ca54b
fix(recon): QC fixes — biên bản DOCX khớp payout thật (blocker) + sta…
minhnb9897 Jul 10, 2026
5006926
docs(recon): 04-qc-report — QC gatekeeper verdict + fix status (block…
minhnb9897 Jul 10, 2026
ce84033
fix(recon): audit giấy tờ — số bằng chữ chuẩn kế toán + chức vụ/VAT/p…
minhnb9897 Jul 10, 2026
252fe9a
docs(recon): 05-docs-audit — audit giấy tờ (19 confirmed) + trước/sau…
minhnb9897 Jul 10, 2026
bdb0db0
fix(recon): nhãn cột "Đơn giá phí/vé"+"Thành tiền" + diễn giải mốc (3…
minhnb9897 Jul 10, 2026
c639f11
docs(recon): 05-docs-audit — cập nhật 2 quyết định Danny đã áp
minhnb9897 Jul 10, 2026
346887e
fix(recon): số bằng chữ dùng "linh" (house-style 5BIB) + báo cáo rà s…
minhnb9897 Jul 11, 2026
64d5835
feat(recon): Pha 5-A kỳ đối soát theo GIAI ĐOẠN bán vé + Bên B đổi MB…
minhnb9897 Jul 11, 2026
41c669b
feat(recon): cột "Giảm giá" điều kiện trong bảng doanh thu DOCX (audi…
minhnb9897 Jul 11, 2026
7784111
docs(recon): 06 — cập nhật 4 quyết định Danny 2026-07-11 (MBBANK/giai…
minhnb9897 Jul 11, 2026
79ffd58
fix(recon-admin): list hiển thị kỳ giai đoạn dạng dd/mm/yyyy – dd/mm/…
minhnb9897 Jul 11, 2026
6b476cf
feat(recon): email đối soát theo 4 chốt Danny 2026-07-11
minhnb9897 Jul 11, 2026
28c9ae6
docs: CLAUDE.md env RECON_* (email đối soát from/CC/notification kế t…
minhnb9897 Jul 11, 2026
2f8d530
feat(recon): raw data cross-check + role "Đối soát" + finance access …
minhnb9897 Jul 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ RESULT_PUBLIC_URL=https://result.5bib.com # Used to embed QR code + share links
BIB_PASS_SEND_ENABLED=false # Kill-switch — false = cron/batch chỉ dry-run, KHÔNG gửi email thật (dev/staging default)
BIB_PASS_BATCH_LIMIT=200 # Trần số email gửi mỗi lần quét/cron (throttle)
BIB_PASS_SCAN_CRON=0 */2 * * * # Lịch cron quét VĐV mới xác nhận BIB (mặc định mỗi 2 giờ, TZ Asia/Ho_Chi_Minh)

# Recon automation — email đối soát gửi BTC (Danny chốt 2026-07-11; Joi default sẵn, KHÔNG cần set env)
RECON_FROM_EMAIL=ketoan@5bib.com # From của email đối soát + notification kế toán
RECON_FROM_NAME=Đối soát 5BIB # Tên người gửi hiển thị
RECON_ALWAYS_CC=danny@5bib.com # LUÔN CC trên mọi email đối soát (dedupe nếu trùng To/CC)
RECON_ACCOUNTING_EMAIL=ketoan@5bib.com # Nhận notification nhắc xuất hóa đơn (ngay sau gửi + cron 08:30 ICT quá-5-ngày)
```

### Frontend / Admin (runtime)
Expand Down
35 changes: 34 additions & 1 deletion admin/src/app/(dashboard)/merchants/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ interface Merchant {
bank_name: string | null;
bank_branch: string | null;
admin_note: string | null;
reconciliation_emails: string[] | null;
reconciliation_cc_emails: string[] | null;
created_on: string;
}

Expand Down Expand Up @@ -134,6 +136,8 @@ export default function MerchantDetailPage() {
bank_name: "",
bank_branch: "",
admin_note: "",
recon_to: "",
recon_cc: "",
});

// Fee update modal
Expand Down Expand Up @@ -232,6 +236,8 @@ export default function MerchantDetailPage() {
bank_name: merchant.bank_name ?? "",
bank_branch: merchant.bank_branch ?? "",
admin_note: merchant.admin_note ?? "",
recon_to: (merchant.reconciliation_emails ?? []).join(", "),
recon_cc: (merchant.reconciliation_cc_emails ?? []).join(", "),
});
setCompanyEditing(true);
}
Expand All @@ -240,10 +246,19 @@ export default function MerchantDetailPage() {
if (!token) return;
setCompanyLoading(true);
try {
// recon_to/recon_cc là chuỗi email ngăn cách dấu phẩy → tách thành mảng.
const { recon_to, recon_cc, ...rest } = companyForm;
const parseEmails = (s: string) =>
s.split(",").map((e) => e.trim()).filter(Boolean);
const payload = {
...rest,
reconciliation_emails: parseEmails(recon_to),
reconciliation_cc_emails: parseEmails(recon_cc),
};
const res = await fetch(`/api/merchants/${id}/company`, {
method: "PATCH",
headers: { ...authHeaders(token).headers, "Content-Type": "application/json" },
body: JSON.stringify(companyForm),
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error();
toast.success("Đã cập nhật thông tin công ty");
Expand Down Expand Up @@ -433,6 +448,15 @@ export default function MerchantDetailPage() {
<Label>Ghi chú admin</Label>
<Input value={companyForm.admin_note} onChange={e => setCompanyForm(p => ({ ...p, admin_note: e.target.value }))} placeholder="Ghi chú nội bộ..." />
</div>
<Separator className="sm:col-span-2" />
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label>Email đầu mối đối soát (To)</Label>
<Input value={companyForm.recon_to} onChange={e => setCompanyForm(p => ({ ...p, recon_to: e.target.value }))} placeholder="Nhiều email cách nhau dấu phẩy — VD: daumoi@btc.vn, sales@btc.vn" />
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label>Email CC (kế toán 5BIB)</Label>
<Input value={companyForm.recon_cc} onChange={e => setCompanyForm(p => ({ ...p, recon_cc: e.target.value }))} placeholder="VD: ketoan@5bib.com" />
</div>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2">
Expand Down Expand Up @@ -467,6 +491,15 @@ export default function MerchantDetailPage() {
<span className="italic">{merchant.admin_note}</span>
</div>
)}
<div className="mt-2 border-t pt-2">
<div className="text-muted-foreground mb-1 font-semibold">Email đối soát</div>
<div className="grid grid-cols-[50px_1fr] gap-1">
<span className="text-muted-foreground">To</span>
<span className="font-medium break-all">{(merchant.reconciliation_emails ?? []).join(", ") || "— (chưa cấu hình)"}</span>
<span className="text-muted-foreground">CC</span>
<span className="break-all">{(merchant.reconciliation_cc_emails ?? []).join(", ") || "—"}</span>
</div>
</div>
</div>
</div>
)}
Expand Down
139 changes: 137 additions & 2 deletions admin/src/app/(dashboard)/reconciliations/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
RefreshCw,
ArrowRight,
Loader2,
Mail,
AlertTriangle,
Calendar,
Building2,
Expand Down Expand Up @@ -132,6 +133,12 @@ interface ReconciliationDetail {
/** Same as `xlsx_url` — internal use only. Use backend download endpoint for UI. */
docx_url: string | null;
signed_at: string | null;
email_sent_at?: string | null;
email_sent_by?: string | null;
email_recipients?: string[];
email_confirmed_at?: string | null;
paper_sent_at?: string | null;
invoice_issued_at?: string | null;
line_items: LineItem[];
manual_orders: ManualOrderRow[];
createdAt: string;
Expand Down Expand Up @@ -231,7 +238,7 @@ function StatusStepper({ currentStatus }: { currentStatus: string }) {

export default function ReconciliationDetailPage() {
const { id } = useParams<{ id: string }>();
const { token, isStaff, isLoading: authLoading } = useAuth();
const { token, isStaff, isFinance, isReconciliation, isLoading: authLoading } = useAuth();
const router = useRouter();

const [data, setData] = useState<ReconciliationDetail | null>(null);
Expand All @@ -241,6 +248,9 @@ export default function ReconciliationDetailPage() {
const [signedAt, setSignedAt] = useState(new Date().toISOString().slice(0, 10));
const [regenLoading, setRegenLoading] = useState(false);
const [approveLoading, setApproveLoading] = useState(false);
const [sendOpen, setSendOpen] = useState(false);
const [sendLoading, setSendLoading] = useState(false);
const [milestoneLoading, setMilestoneLoading] = useState(false);

const fetchDetail = useCallback(async () => {
if (!token || !id) return;
Expand Down Expand Up @@ -321,9 +331,52 @@ export default function ReconciliationDetailPage() {
}
}

async function handleSendEmail() {
if (!token || !data) return;
setSendLoading(true);
try {
const res = await fetch(`/api/reconciliations/${id}/send-email`, {
method: "POST",
headers: { ...authHeaders(token).headers, "Content-Type": "application/json" },
});
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.message || "Gửi email thất bại");
if (json?.sent) {
toast.success(`Đã gửi email tới ${(json.to || []).join(", ")}`);
} else {
toast(json?.reason || "Chưa gửi được — Mailchimp chưa cấu hình (dev)");
}
setSendOpen(false);
await fetchDetail();
} catch (e) {
toast.error((e as Error).message || "Gửi email thất bại");
} finally {
setSendLoading(false);
}
}

async function handleMilestone(milestone: string, label: string) {
if (!token) return;
setMilestoneLoading(true);
try {
const res = await fetch(`/api/reconciliations/${id}/milestone`, {
method: "PATCH",
headers: { ...authHeaders(token).headers, "Content-Type": "application/json" },
body: JSON.stringify({ milestone }),
});
if (!res.ok) throw new Error();
toast.success(`Đã đánh dấu: ${label}`);
await fetchDetail();
} catch {
toast.error("Không thể cập nhật mốc");
} finally {
setMilestoneLoading(false);
}
}

// F-029 BR-HD-30 — page-level RBAC gate.
if (authLoading) return null;
if (!isStaff) return <RestrictedAccess />;
if (!isStaff && !isFinance && !isReconciliation) return <RestrictedAccess />;

if (loading) {
return (
Expand Down Expand Up @@ -557,6 +610,61 @@ export default function ReconciliationDetailPage() {
DOCX
</button>
</div>
<button
onClick={() => downloadWithAuth(
`/api/reconciliations/${data._id}/download/athletes`,
`Danh sách VĐV - ${buildRecFilename(data, "xlsx")}`, token!
).catch(() => toast.error("Tải Danh sách VĐV thất bại"))}
className="inline-flex items-center justify-center gap-1.5 rounded-md bg-violet-600 px-3 py-2 text-sm font-medium text-white hover:bg-violet-700 transition-colors"
>
<FileSpreadsheet className="size-4" />
Danh sách VĐV
</button>
<button
onClick={() => downloadWithAuth(
`/api/reconciliations/raw-data/download?mysql_race_id=${data.mysql_race_id}&period_start=${data.period_start}&period_end=${data.period_end}&race_title=${encodeURIComponent(data.race_title)}`,
`Raw data - ${buildRecFilename(data, "xlsx")}`, token!
).catch(() => toast.error("Tải raw data thất bại"))}
className="inline-flex items-center justify-center gap-1.5 rounded-md border border-input bg-background px-3 py-2 text-sm font-medium hover:bg-accent transition-colors"
title="Dump thô toàn bộ đơn COMPLETE trong kỳ (25 cột) để cross-check — đúng câu query đối soát tay"
>
<FileSpreadsheet className="size-4" />
Raw data (cross-check)
</button>
</div>

{/* Gửi email đối soát (Pha 4) */}
<Separator />
<div className="flex flex-col gap-2">
<p className="text-xs text-muted-foreground font-medium">Gửi email đối soát</p>
{data.email_sent_at ? (
<div className="text-xs text-muted-foreground">
✓ Đã gửi {new Date(data.email_sent_at).toLocaleString("vi-VN")}
{data.email_recipients?.length ? (
<div className="break-all">Tới: {data.email_recipients.join(", ")}</div>
) : null}
</div>
) : null}
<Button className="w-full" variant={data.email_sent_at ? "outline" : "default"} onClick={() => setSendOpen(true)}>
<Mail className="mr-2 size-4" />
{data.email_sent_at ? "Gửi lại email" : "Gửi email cho BTC"}
</Button>
{data.email_sent_at && (
<div className="flex flex-wrap gap-1.5 mt-1">
<Button variant={data.email_confirmed_at ? "secondary" : "outline"} size="sm" disabled={milestoneLoading} className="text-xs" onClick={() => handleMilestone("email_confirmed", "BTC xác nhận email")}>
{data.email_confirmed_at ? "✓ " : ""}BTC xác nhận
</Button>
<Button variant={data.paper_sent_at ? "secondary" : "outline"} size="sm" disabled={milestoneLoading} className="text-xs" onClick={() => handleMilestone("paper_sent", "Đã gửi bản in")}>
{data.paper_sent_at ? "✓ " : ""}Gửi bản in
</Button>
<Button variant={data.signed_at ? "secondary" : "outline"} size="sm" disabled={milestoneLoading} className="text-xs" onClick={() => handleMilestone("signed", "Đã nhận bản ký")}>
{data.signed_at ? "✓ " : ""}Nhận bản ký
</Button>
<Button variant={data.invoice_issued_at ? "secondary" : "outline"} size="sm" disabled={milestoneLoading} className="text-xs" onClick={() => handleMilestone("invoice_issued", "Kế toán xuất HĐ")}>
{data.invoice_issued_at ? "✓ " : ""}Xuất HĐ
</Button>
</div>
)}
</div>

{/* Transition */}
Expand Down Expand Up @@ -736,6 +844,33 @@ export default function ReconciliationDetailPage() {
</DialogFooter>
</DialogContent>
</Dialog>

<Dialog open={sendOpen} onOpenChange={setSendOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Gửi email đối soát cho BTC</DialogTitle>
<DialogDescription>
Kèm 3 file: Biên bản đối soát (DOCX), Bảng đối soát (XLSX), Danh sách VĐV (XLSX).
</DialogDescription>
</DialogHeader>
<div className="text-sm flex flex-col gap-1.5">
<div>
<span className="text-muted-foreground">Tiêu đề: </span>
<span className="font-medium">[5BIB] Đối soát bán vé sự kiện {data.race_title}</span>
</div>
<div className="text-xs text-muted-foreground">
Người nhận (To/CC) lấy từ Merchant &gt; Công ty đối tác. Chưa cấu hình email đầu mối → hệ thống sẽ báo lỗi.
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSendOpen(false)}>Hủy</Button>
<Button onClick={handleSendEmail} disabled={sendLoading}>
{sendLoading ? <Loader2 className="mr-2 size-4 animate-spin" /> : <Mail className="mr-2 size-4" />}
Gửi ngay
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
4 changes: 2 additions & 2 deletions admin/src/app/(dashboard)/reconciliations/audit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ type AuditResponse = {
};

export default function ReconciliationAuditPage() {
const { token, isStaff, isLoading: authLoading } = useAuth();
const { token, isStaff, isFinance, isReconciliation, isLoading: authLoading } = useAuth();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<AuditResponse | null>(null);
const [hasRun, setHasRun] = useState(false);
Expand All @@ -68,7 +68,7 @@ export default function ReconciliationAuditPage() {

// F-029 BR-HD-30 — page-level RBAC gate.
if (authLoading) return null;
if (!isStaff) return <RestrictedAccess />;
if (!isStaff && !isFinance && !isReconciliation) return <RestrictedAccess />;

return (
<div className="flex flex-col gap-6 p-6">
Expand Down
Loading
Loading