HEL-157: 审核中心接入三类待办完整队列
将待复核账户、待审手工单、匹配异常统一渲染到审核中心列表, 处置走既有 decision API,并补三类种子一致性与处置减一集成测试。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
7fb97119ba
commit
e04f47b9d2
@@ -313,6 +313,167 @@ class DashboardApiTests(unittest.TestCase):
|
|||||||
after["total"], after["high"] + after["medium"] + after["low"]
|
after["total"], after["high"] + after["medium"] + after["low"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_three_queue_audit_parity_and_dispose_sync(self) -> None:
|
||||||
|
"""Seed account+manual+exception: list sizes == dashboard.audit; dispose syncs -1."""
|
||||||
|
from bank_importer.db import connect as db_connect
|
||||||
|
from bank_importer import auth, manual_records, master_data as md
|
||||||
|
|
||||||
|
status, _, raw = self.admin.get("/api/admin/companies")
|
||||||
|
company_id = as_json(raw)["companies"][0]["id"]
|
||||||
|
|
||||||
|
# Second company for manual counterparty.
|
||||||
|
status, _, raw = self.admin.post_json(
|
||||||
|
"/api/admin/companies", {"name": "乙公司", "username": "cashier-b-hel157"}
|
||||||
|
)
|
||||||
|
self.assertEqual(200, status, raw)
|
||||||
|
company_b = as_json(raw).get("company_id") or as_json(raw).get("id")
|
||||||
|
self.assertIsNotNone(company_b)
|
||||||
|
|
||||||
|
connection = db_connect(self.db_path)
|
||||||
|
try:
|
||||||
|
account = md.submit_bank_account(
|
||||||
|
connection,
|
||||||
|
company_id=company_id,
|
||||||
|
bank_name="工行",
|
||||||
|
account_type="一般户",
|
||||||
|
account_number="6222020000000157",
|
||||||
|
start_date="2026-01-01",
|
||||||
|
actor=None,
|
||||||
|
)
|
||||||
|
account_id = account["id"]
|
||||||
|
|
||||||
|
cashier = connection.execute(
|
||||||
|
"SELECT * FROM users WHERE username = 'cashier-a'"
|
||||||
|
).fetchone()
|
||||||
|
if cashier is None:
|
||||||
|
auth.create_user(
|
||||||
|
connection,
|
||||||
|
"cashier-a",
|
||||||
|
"CashierPass123",
|
||||||
|
"company",
|
||||||
|
company_id=company_id,
|
||||||
|
)
|
||||||
|
cashier = connection.execute(
|
||||||
|
"SELECT * FROM users WHERE username = 'cashier-a'"
|
||||||
|
).fetchone()
|
||||||
|
manual = manual_records.submit(
|
||||||
|
connection,
|
||||||
|
company_id=company_id,
|
||||||
|
counterparty_company_id=int(company_b),
|
||||||
|
occurred_at="2026-02-01T09:00:00",
|
||||||
|
direction="incoming",
|
||||||
|
amount="100.00",
|
||||||
|
currency="CNY",
|
||||||
|
funding_source="other",
|
||||||
|
requested_subject="receivable",
|
||||||
|
request_key="hel157-queue-manual",
|
||||||
|
actor=cashier,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = md.utc_now()
|
||||||
|
event_id = connection.execute(
|
||||||
|
"INSERT INTO canonical_transfer_events (lifecycle, created_at) VALUES ('active', ?)",
|
||||||
|
(now,),
|
||||||
|
).lastrowid
|
||||||
|
decision_id = connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO transfer_match_decisions (
|
||||||
|
event_id, revision, effective_at, amount, currency, classification,
|
||||||
|
pairing, locked, mode, rule_version, created_at
|
||||||
|
) VALUES (?, 1, '2026-07-05T10:00:00', '100000.00', 'CNY', 'unresolved',
|
||||||
|
'single', 0, 'manual', 'test', ?)
|
||||||
|
""",
|
||||||
|
(event_id, now),
|
||||||
|
).lastrowid
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO current_transfer_decisions (event_id, decision_id) VALUES (?, ?)",
|
||||||
|
(event_id, decision_id),
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
manual_id = manual["id"]
|
||||||
|
manual_decision_id = manual["decision_id"]
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def queue_sizes():
|
||||||
|
st, _, body = self.admin.get("/api/admin/accounts?status=pending")
|
||||||
|
self.assertEqual(200, st, body)
|
||||||
|
accounts_n = len(as_json(body)["accounts"])
|
||||||
|
st, _, body = self.admin.get("/api/admin/manual-records?state=pending")
|
||||||
|
self.assertEqual(200, st, body)
|
||||||
|
manuals_n = len(as_json(body)["records"])
|
||||||
|
st, _, body = self.admin.get("/api/admin/match-exceptions")
|
||||||
|
self.assertEqual(200, st, body)
|
||||||
|
exceptions_n = len(as_json(body)["exceptions"])
|
||||||
|
return accounts_n, manuals_n, exceptions_n, accounts_n + manuals_n + exceptions_n
|
||||||
|
|
||||||
|
def assert_parity(expected_total: int) -> dict:
|
||||||
|
st, _, body = self.admin.get("/api/admin/dashboard")
|
||||||
|
self.assertEqual(200, st, body)
|
||||||
|
audit = as_json(body)["audit"]
|
||||||
|
accounts_n, manuals_n, exceptions_n, list_total = queue_sizes()
|
||||||
|
self.assertEqual(expected_total, audit["total"])
|
||||||
|
self.assertEqual(expected_total, list_total)
|
||||||
|
self.assertEqual(
|
||||||
|
audit["total"], audit["high"] + audit["medium"] + audit["low"]
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(accounts_n, 1 if expected_total >= 3 else 0)
|
||||||
|
return {
|
||||||
|
"audit": audit,
|
||||||
|
"accounts": accounts_n,
|
||||||
|
"manuals": manuals_n,
|
||||||
|
"exceptions": exceptions_n,
|
||||||
|
}
|
||||||
|
|
||||||
|
before = assert_parity(3)
|
||||||
|
self.assertEqual(1, before["accounts"])
|
||||||
|
self.assertEqual(1, before["manuals"])
|
||||||
|
self.assertEqual(1, before["exceptions"])
|
||||||
|
self.assertEqual(1, before["audit"]["high"])
|
||||||
|
self.assertEqual(2, before["audit"]["medium"])
|
||||||
|
|
||||||
|
# Dispose account → total 2
|
||||||
|
st, _, body = self.admin.post_json(
|
||||||
|
f"/api/admin/accounts/{account_id}/review",
|
||||||
|
{"decision": "approve", "reason": "HEL-157 three-queue dispose account"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, st, body)
|
||||||
|
after_account = assert_parity(2)
|
||||||
|
|
||||||
|
# Dispose manual → total 1
|
||||||
|
st, _, body = self.admin.post_json(
|
||||||
|
f"/api/admin/manual-records/{manual_id}/decisions",
|
||||||
|
{
|
||||||
|
"action": "approve_new",
|
||||||
|
"reason": "HEL-157 three-queue dispose manual",
|
||||||
|
"expected_decision_id": manual_decision_id,
|
||||||
|
"request_key": "hel157-dispose-manual",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, st, body)
|
||||||
|
after_manual = assert_parity(1)
|
||||||
|
self.assertEqual(0, after_manual["manuals"])
|
||||||
|
|
||||||
|
# Dispose match exception via reverse → total 0
|
||||||
|
st, _, body = self.admin.get("/api/admin/match-exceptions")
|
||||||
|
exceptions = as_json(body)["exceptions"]
|
||||||
|
self.assertEqual(1, len(exceptions))
|
||||||
|
target = exceptions[0]
|
||||||
|
st, _, body = self.admin.post_json(
|
||||||
|
f"/api/admin/transfer-events/{target['event_id']}/decisions",
|
||||||
|
{
|
||||||
|
"action": "reverse",
|
||||||
|
"reason": "HEL-157 three-queue dispose match",
|
||||||
|
"expected_revision": target["revision"],
|
||||||
|
"request_key": "hel157-dispose-match",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, st, body)
|
||||||
|
after_match = assert_parity(0)
|
||||||
|
self.assertEqual(0, after_match["exceptions"])
|
||||||
|
self.assertEqual(after_account["audit"]["total"] - 1, after_manual["audit"]["total"])
|
||||||
|
self.assertEqual(after_manual["audit"]["total"] - 1, after_match["audit"]["total"])
|
||||||
|
|
||||||
def test_company_detail_missing(self) -> None:
|
def test_company_detail_missing(self) -> None:
|
||||||
status, _, raw = self.admin.get(
|
status, _, raw = self.admin.get(
|
||||||
"/api/admin/dashboard/companies/999999?from=2026-01-01&cutoff=2026-08-20"
|
"/api/admin/dashboard/companies/999999?from=2026-01-01&cutoff=2026-08-20"
|
||||||
|
|||||||
+218
-57
@@ -793,23 +793,48 @@ async function loadCompanyAccounts() {
|
|||||||
else showTableError(tbody, 6);
|
else showTableError(tbody, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const manualSubjectLabels = {
|
||||||
|
receivable: "应收",
|
||||||
|
payable: "应付",
|
||||||
|
other_receivable: "其他应收",
|
||||||
|
other_payable: "其他应付",
|
||||||
|
};
|
||||||
|
|
||||||
|
const manualDirectionLabels = {
|
||||||
|
incoming: "收入",
|
||||||
|
outgoing: "支出",
|
||||||
|
};
|
||||||
|
|
||||||
function appendAdminReviewRow(record, kind) {
|
function appendAdminReviewRow(record, kind) {
|
||||||
const tbody = $("#auditRows");
|
const tbody = $("#auditRows");
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
const isAccount = kind === "account";
|
const isAccount = kind === "account";
|
||||||
const statusLabel = isAccount ? accountStatusLabel(record.status) : record.status;
|
const isManual = kind === "manual";
|
||||||
|
const statusLabel = isAccount
|
||||||
|
? accountStatusLabel(record.status)
|
||||||
|
: (record.state === "pending" ? "待管理复核" : (record.state || "待复核"));
|
||||||
const row = document.createElement("tr");
|
const row = document.createElement("tr");
|
||||||
row.dataset.storedReview = record.id;
|
row.dataset.storedReview = String(record.id);
|
||||||
row.dataset.recordId = record.id;
|
row.dataset.recordId = String(record.id);
|
||||||
row.dataset.recordKind = kind;
|
row.dataset.recordKind = kind;
|
||||||
if (isAccount) row.dataset.accountId = record.id;
|
if (isAccount) row.dataset.accountId = String(record.id);
|
||||||
|
if (isManual) {
|
||||||
|
row.dataset.decisionId = String(record.decision_id || "");
|
||||||
|
row.dataset.requestedSubject = record.requested_subject || "";
|
||||||
|
}
|
||||||
row.dataset.auditType = isAccount ? "账户" : "手工";
|
row.dataset.auditType = isAccount ? "账户" : "手工";
|
||||||
row.dataset.company = isAccount ? record.company_name : record.company;
|
row.dataset.company = isAccount
|
||||||
|
? (record.company_name || "")
|
||||||
|
: (record.company_name || record.company || "");
|
||||||
row.dataset.evidence = isAccount
|
row.dataset.evidence = isAccount
|
||||||
? "公司提交资料、开户行、账号、账户类型与启用日期"
|
? "公司提交资料、开户行、账号、账户类型与启用日期"
|
||||||
: "公司手工记录、关联银行流水号、证明附件与提交说明";
|
: "公司手工记录、关联银行流水号、证明附件与提交说明";
|
||||||
if (statusLabel !== "待复核" && statusLabel !== "待管理复核") row.dataset.resolved = "true";
|
if (isAccount) {
|
||||||
if (isAccount) row.dataset.accountStatus = record.status;
|
if (record.status !== "pending") row.dataset.resolved = "true";
|
||||||
|
row.dataset.accountStatus = record.status;
|
||||||
|
} else if (record.state && record.state !== "pending") {
|
||||||
|
row.dataset.resolved = "true";
|
||||||
|
}
|
||||||
|
|
||||||
const riskCell = document.createElement("td");
|
const riskCell = document.createElement("td");
|
||||||
riskCell.innerHTML = '<span class="pill pill-warn">中</span>';
|
riskCell.innerHTML = '<span class="pill pill-warn">中</span>';
|
||||||
@@ -821,8 +846,11 @@ function appendAdminReviewRow(record, kind) {
|
|||||||
identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`;
|
identity.textContent = `${record.company_name} · ${record.bank_name} ${String(record.account_number).slice(-4)}`;
|
||||||
detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`;
|
detail.textContent = `${record.account_type} · 完整账号 ${record.account_number}`;
|
||||||
} else {
|
} else {
|
||||||
identity.textContent = `${record.company} · ${record.id}`;
|
const subject = manualSubjectLabels[record.requested_subject] || record.requested_subject || "—";
|
||||||
detail.textContent = `${record.direction} ${formatCurrency(record.amount)} 元 · ${record.counterparty} · ${record.subject}`;
|
const direction = manualDirectionLabels[record.direction] || record.direction || "";
|
||||||
|
const counterparty = record.counterparty_company_name || record.counterparty || "—";
|
||||||
|
identity.textContent = `${row.dataset.company} · 手工单 #${record.id}`;
|
||||||
|
detail.textContent = `${direction} ${formatCurrency(record.amount)} 元 · ${counterparty} · ${subject}`;
|
||||||
}
|
}
|
||||||
identityCell.append(identity, detail);
|
identityCell.append(identity, detail);
|
||||||
|
|
||||||
@@ -830,11 +858,18 @@ function appendAdminReviewRow(record, kind) {
|
|||||||
typeCell.textContent = isAccount ? "账户登记" : "手工记录";
|
typeCell.textContent = isAccount ? "账户登记" : "手工记录";
|
||||||
|
|
||||||
const periodCell = document.createElement("td");
|
const periodCell = document.createElement("td");
|
||||||
periodCell.textContent = isAccount ? (record.effective_from || "待审核确定") : record.transactionDate;
|
if (isAccount) {
|
||||||
|
periodCell.textContent = record.effective_from || "待审核确定";
|
||||||
|
} else {
|
||||||
|
periodCell.className = "num";
|
||||||
|
periodCell.textContent = String(record.occurred_at || record.transactionDate || "").slice(0, 10) || "—";
|
||||||
|
}
|
||||||
|
|
||||||
const impactCell = document.createElement("td");
|
const impactCell = document.createElement("td");
|
||||||
impactCell.className = "wrap";
|
impactCell.className = "wrap";
|
||||||
impactCell.textContent = isAccount ? "账户识别与流水上传" : `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`;
|
impactCell.textContent = isAccount
|
||||||
|
? "账户识别与流水上传"
|
||||||
|
: `待确认往来 ${(Number(record.amount) / 10000).toFixed(2)} 万元`;
|
||||||
|
|
||||||
const statusCell = document.createElement("td");
|
const statusCell = document.createElement("td");
|
||||||
const status = recordStatus(statusLabel);
|
const status = recordStatus(statusLabel);
|
||||||
@@ -848,7 +883,7 @@ function appendAdminReviewRow(record, kind) {
|
|||||||
} else {
|
} else {
|
||||||
const resolvedLabel = isAccount
|
const resolvedLabel = isAccount
|
||||||
? ({ returned: "已退回", disabled: "已停用" })[record.status] || "已通过"
|
? ({ returned: "已退回", disabled: "已停用" })[record.status] || "已通过"
|
||||||
: (statusLabel === "已确认" ? "已通过" : "已驳回");
|
: (statusLabel === "已确认" || record.state === "approved" ? "已通过" : "已驳回");
|
||||||
actionCell.innerHTML = `<span class="meta">${resolvedLabel} · 系统管理员</span>`;
|
actionCell.innerHTML = `<span class="meta">${resolvedLabel} · 系统管理员</span>`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -859,41 +894,108 @@ function appendAdminReviewRow(record, kind) {
|
|||||||
tbody.append(row);
|
tbody.append(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderAdminAccountReviews() {
|
function appendMatchExceptionRow(item) {
|
||||||
const tbody = $("#auditRows");
|
const tbody = $("#auditRows");
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
$$('[data-stored-review][data-record-kind="account"]', tbody).forEach((row) => row.remove());
|
const payer = item.payer_company_name || "—";
|
||||||
const response = await fetch("/api/admin/accounts").catch(() => null);
|
const payee = item.payee_company_name || "—";
|
||||||
if (!response?.ok) {
|
const companyLabel = payer !== "—" ? payer : payee;
|
||||||
showToast("审核数据加载失败", "请稍后重试", "danger");
|
const row = document.createElement("tr");
|
||||||
|
row.dataset.storedReview = `match-${item.event_id}`;
|
||||||
|
row.dataset.recordId = String(item.event_id);
|
||||||
|
row.dataset.recordKind = "match";
|
||||||
|
row.dataset.eventId = String(item.event_id);
|
||||||
|
row.dataset.revision = String(item.revision ?? "");
|
||||||
|
row.dataset.auditType = "单边";
|
||||||
|
row.dataset.company = companyLabel;
|
||||||
|
row.dataset.evidence = "匹配异常事件、观察流水、参与方与历史决定";
|
||||||
|
row.dataset.classification = item.classification || "";
|
||||||
|
|
||||||
|
const riskCell = document.createElement("td");
|
||||||
|
riskCell.innerHTML = '<span class="pill pill-danger">高</span>';
|
||||||
|
|
||||||
|
const identityCell = document.createElement("td");
|
||||||
|
const identity = document.createElement("span"); identity.className = "cell-main";
|
||||||
|
const detail = document.createElement("span"); detail.className = "cell-sub";
|
||||||
|
identity.textContent = `${payer} ↔ ${payee} · 事件 #${item.event_id}`;
|
||||||
|
detail.textContent = `${item.classification || item.status || "unresolved"} · ${formatCurrency(item.amount)} ${item.currency || "CNY"} · 证据 ${item.evidence_count ?? 0} 条`;
|
||||||
|
identityCell.append(identity, detail);
|
||||||
|
|
||||||
|
const typeCell = document.createElement("td");
|
||||||
|
typeCell.textContent = "单边匹配";
|
||||||
|
|
||||||
|
const periodCell = document.createElement("td");
|
||||||
|
periodCell.className = "num";
|
||||||
|
periodCell.textContent = String(item.effective_at || "").slice(0, 10) || "—";
|
||||||
|
|
||||||
|
const impactCell = document.createElement("td");
|
||||||
|
impactCell.className = "wrap";
|
||||||
|
impactCell.textContent = `匹配异常 ${(Number(item.amount) / 10000).toFixed(2)} 万元 · 阻断往来归集`;
|
||||||
|
|
||||||
|
const statusCell = document.createElement("td");
|
||||||
|
statusCell.innerHTML = '<span class="pill pill-danger">待处理</span>';
|
||||||
|
|
||||||
|
const actionCell = document.createElement("td");
|
||||||
|
actionCell.innerHTML = '<div class="row" style="gap:6px;"><button type="button" class="btn btn-sm btn-primary" data-audit-action="approve">关闭异常</button><button type="button" class="btn btn-sm btn-danger" data-audit-action="reject">退回重匹配</button></div>';
|
||||||
|
|
||||||
|
row.append(riskCell, identityCell, typeCell, periodCell, impactCell, statusCell, actionCell);
|
||||||
|
tbody.append(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminAuditQueue() {
|
||||||
|
const tbody = $("#auditRows");
|
||||||
|
if (!tbody) return;
|
||||||
|
$$('[data-stored-review]', tbody).forEach((row) => row.remove());
|
||||||
|
showTableLoading(tbody, 7);
|
||||||
|
|
||||||
|
const [accountsRes, manualsRes, exceptionsRes] = await Promise.all([
|
||||||
|
fetch("/api/admin/accounts?status=pending").catch(() => null),
|
||||||
|
fetch("/api/admin/manual-records?state=pending").catch(() => null),
|
||||||
|
fetch("/api/admin/match-exceptions").catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ([accountsRes, manualsRes, exceptionsRes].some((res) => res?.status === 401)) {
|
||||||
|
window.location.href = "index.html";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await response.json().catch(() => null);
|
if (![accountsRes, manualsRes, exceptionsRes].every((res) => res?.ok)) {
|
||||||
(result?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account"));
|
showTableError(tbody, 7);
|
||||||
|
showToast("审核队列加载失败", "请稍后重试", "danger");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [accountsPayload, manualsPayload, exceptionsPayload] = await Promise.all([
|
||||||
|
accountsRes.json().catch(() => null),
|
||||||
|
manualsRes.json().catch(() => null),
|
||||||
|
exceptionsRes.json().catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
tbody.replaceChildren();
|
||||||
|
(accountsPayload?.accounts || []).forEach((account) => appendAdminReviewRow(account, "account"));
|
||||||
|
(manualsPayload?.records || []).forEach((record) => appendAdminReviewRow(record, "manual"));
|
||||||
|
(exceptionsPayload?.exceptions || []).forEach((item) => appendMatchExceptionRow(item));
|
||||||
|
|
||||||
updateAuditCounts();
|
updateAuditCounts();
|
||||||
updatePendingAccountNotice();
|
updatePendingAccountNotice();
|
||||||
await refreshAuditCountsFromApi();
|
await refreshAuditCountsFromApi();
|
||||||
|
// 列表条数必须与后端口径一致:不一致时以真实列表为准覆盖标题/角标,避免再出现「数字 3 / 列表 1」。
|
||||||
|
const unresolved = $$("#auditRows tr").filter((row) => row.dataset.resolved !== "true");
|
||||||
|
if (!state.dashAudit || Number(state.dashAudit.total) !== unresolved.length) {
|
||||||
|
const high = unresolved.filter((row) => row.querySelector(".pill-danger")).length;
|
||||||
|
const medium = unresolved.filter((row) => row.querySelector(".pill-warn")).length;
|
||||||
|
const low = Math.max(0, unresolved.length - high - medium);
|
||||||
|
applyAuditCounts({ total: unresolved.length, high, medium, low });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStoredAdminReviews() {
|
function renderStoredAdminReviews() {
|
||||||
if (!$("#auditRows")) return;
|
if (!$("#auditRows")) return;
|
||||||
$$('[data-stored-review]', $("#auditRows")).forEach((row) => row.remove());
|
// 完整队列:待复核账户 + 待审手工单 + 匹配异常,三处口径同源。
|
||||||
// 管理端待审列表只展示后端权威队列(当前为待复核银行账户等),
|
loadAdminAuditQueue();
|
||||||
// 不再混入 localStorage 演示手工单,避免角标/首页/列表三处口径分裂。
|
|
||||||
renderAdminAccountReviews();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStoredReview(kind, id, status, decision, reviewReason, reviewedAt) {
|
|
||||||
if (kind !== "manual" || !id) return;
|
|
||||||
const records = readStoredRecords(storageKeys.manual);
|
|
||||||
const record = records.find((item) => item.id === id);
|
|
||||||
if (!record) return;
|
|
||||||
Object.assign(record, { status, decision, reviewReason, reviewedAt });
|
|
||||||
writeStoredRecords(storageKeys.manual, records);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAuditCounts() {
|
function updateAuditCounts() {
|
||||||
const rows = $$("#auditRows tr");
|
const rows = $$("#auditRows tr").filter((row) => !row.classList.contains("loading-row"));
|
||||||
const unresolved = rows.filter((row) => row.dataset.resolved !== "true");
|
const unresolved = rows.filter((row) => row.dataset.resolved !== "true");
|
||||||
$$('[data-audit-filter]').forEach((button) => {
|
$$('[data-audit-filter]').forEach((button) => {
|
||||||
const type = button.dataset.auditFilter;
|
const type = button.dataset.auditFilter;
|
||||||
@@ -902,14 +1004,16 @@ function updateAuditCounts() {
|
|||||||
if (badge) badge.textContent = count;
|
if (badge) badge.textContent = count;
|
||||||
});
|
});
|
||||||
const foot = $("#auditFoot");
|
const foot = $("#auditFoot");
|
||||||
if (foot) foot.textContent = `共 ${rows.length} 项 · 待审核 ${unresolved.length} 项`;
|
if (foot) foot.textContent = `共 ${unresolved.length} 项 · 待审核 ${unresolved.length} 项`;
|
||||||
// 角标 / 首页待审核卡 / 审核中心标题数统一走后端口径(见 applyAuditCounts)。
|
// 角标 / 首页待审核卡 / 审核中心标题数:优先后端口径;列表加载完成后由 loadAdminAuditQueue 再对齐。
|
||||||
// 此处仅在尚无 API 结果时,用真实列表行数作短暂回退,避免演示写死数字。
|
|
||||||
if (!state.dashAuditFromApi) {
|
if (!state.dashAuditFromApi) {
|
||||||
const high = unresolved.filter((row) => row.querySelector(".pill-danger")).length;
|
const high = unresolved.filter((row) => row.querySelector(".pill-danger")).length;
|
||||||
const medium = unresolved.filter((row) => row.querySelector(".pill-warn")).length;
|
const medium = unresolved.filter((row) => row.querySelector(".pill-warn")).length;
|
||||||
const low = Math.max(0, unresolved.length - high - medium);
|
const low = Math.max(0, unresolved.length - high - medium);
|
||||||
applyAuditCounts({ total: unresolved.length, high, medium, low });
|
applyAuditCounts({ total: unresolved.length, high, medium, low });
|
||||||
|
} else {
|
||||||
|
const pending = $("#pending-count");
|
||||||
|
if (pending) pending.textContent = String(unresolved.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1283,12 +1387,13 @@ function initAdmin() {
|
|||||||
async function submitAuditResult(row) {
|
async function submitAuditResult(row) {
|
||||||
const decision = state.auditDecision;
|
const decision = state.auditDecision;
|
||||||
const reason = state.auditReason || "";
|
const reason = state.auditReason || "";
|
||||||
const approved = decision.includes("通过") || decision.includes("确认并纳入") || decision.includes("启用");
|
const approved = decision.includes("通过") || decision.includes("确认并纳入") || decision.includes("启用") || decision.includes("关闭异常");
|
||||||
const returned = decision.includes("退回");
|
const returned = decision.includes("退回");
|
||||||
|
const kind = row.dataset.recordKind;
|
||||||
let storedStatus;
|
let storedStatus;
|
||||||
let reviewedAccount = null;
|
let reviewedAccount = null;
|
||||||
if (row.dataset.recordKind === "account" && row.dataset.accountId) {
|
|
||||||
// Server-side review: the account only becomes usable after this succeeds.
|
if (kind === "account" && row.dataset.accountId) {
|
||||||
const apiDecision = approved ? "approve" : returned ? "return" : "disable";
|
const apiDecision = approved ? "approve" : returned ? "return" : "disable";
|
||||||
const response = await fetch(`/api/admin/accounts/${row.dataset.accountId}/review`, {
|
const response = await fetch(`/api/admin/accounts/${row.dataset.accountId}/review`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1306,26 +1411,72 @@ function initAdmin() {
|
|||||||
}
|
}
|
||||||
reviewedAccount = result.account;
|
reviewedAccount = result.account;
|
||||||
storedStatus = accountStatusLabel(result.account?.status);
|
storedStatus = accountStatusLabel(result.account?.status);
|
||||||
|
} else if (kind === "manual" && row.dataset.recordId) {
|
||||||
|
const action = approved ? "approve_new" : "return";
|
||||||
|
const response = await fetch(`/api/admin/manual-records/${row.dataset.recordId}/decisions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action,
|
||||||
|
reason: reason || decision,
|
||||||
|
expected_decision_id: row.dataset.decisionId ? Number(row.dataset.decisionId) : null,
|
||||||
|
request_key: `audit-manual-${row.dataset.recordId}-${Date.now()}`,
|
||||||
|
subject_code: row.dataset.requestedSubject || undefined,
|
||||||
|
}),
|
||||||
|
}).catch(() => null);
|
||||||
|
if (response?.status === 401) {
|
||||||
|
window.location.href = "index.html";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await response?.json().catch(() => ({}));
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
showToast("手工单审核失败", result?.message || "请稍后重试", "danger");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storedStatus = approved ? "已确认" : "已退回";
|
||||||
|
} else if (kind === "match" && row.dataset.eventId) {
|
||||||
|
// 既有 decision:撤销当前匹配决定,使异常退出待办队列(与 reverse 语义一致)。
|
||||||
|
const response = await fetch(`/api/admin/transfer-events/${row.dataset.eventId}/decisions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: "reverse",
|
||||||
|
reason: reason || decision || "审核中心关闭匹配异常",
|
||||||
|
expected_revision: row.dataset.revision ? Number(row.dataset.revision) : null,
|
||||||
|
request_key: `audit-match-${row.dataset.eventId}-${Date.now()}`,
|
||||||
|
}),
|
||||||
|
}).catch(() => null);
|
||||||
|
if (response?.status === 401) {
|
||||||
|
window.location.href = "index.html";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await response?.json().catch(() => ({}));
|
||||||
|
if (!response || !response.ok) {
|
||||||
|
showToast("匹配异常处置失败", result?.message || "请稍后重试", "danger");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storedStatus = approved ? "已确认" : "已退回";
|
||||||
} else {
|
} else {
|
||||||
storedStatus = approved
|
showToast("审核结果提交失败", "未知待办类型", "danger");
|
||||||
? (row.dataset.recordKind === "account" ? "已启用" : "已确认")
|
return;
|
||||||
: (returned ? "已退回" : "异常待处理");
|
|
||||||
updateStoredReview(row.dataset.recordKind, row.dataset.recordId, storedStatus, decision, reason, new Date().toLocaleString("zh-CN", { hour12: false }));
|
|
||||||
}
|
|
||||||
const status = recordStatus(storedStatus);
|
|
||||||
const statusCell = row.children[5];
|
|
||||||
statusCell.innerHTML = `<span class="pill ${pillClass(status.className)}">${status.label}</span>`;
|
|
||||||
row.dataset.resolved = "true";
|
|
||||||
if (reviewedAccount) row.dataset.accountStatus = reviewedAccount.status;
|
|
||||||
const actionCell = row.children[6];
|
|
||||||
if (reviewedAccount && reviewedAccount.status === "active") {
|
|
||||||
actionCell.innerHTML = '<button type="button" class="btn btn-sm btn-danger" data-audit-action="disable">停用</button>';
|
|
||||||
} else {
|
|
||||||
actionCell.innerHTML = `<span class="meta">${approved ? "已通过" : "已驳回"} · 系统管理员</span>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处置后从列表移除并重拉三队列,保证页脚/过滤/标题与角标同步减一。
|
||||||
|
row.remove();
|
||||||
updateAuditCounts();
|
updateAuditCounts();
|
||||||
await refreshAuditCountsFromApi();
|
await refreshAuditCountsFromApi();
|
||||||
showToast("审核结果已记录", approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算", "success");
|
const unresolved = $$("#auditRows tr").filter((r) => r.dataset.resolved !== "true");
|
||||||
|
const high = unresolved.filter((r) => r.querySelector(".pill-danger")).length;
|
||||||
|
const medium = unresolved.filter((r) => r.querySelector(".pill-warn")).length;
|
||||||
|
const low = Math.max(0, unresolved.length - high - medium);
|
||||||
|
applyAuditCounts({ total: unresolved.length, high, medium, low }, { fromApi: true });
|
||||||
|
updatePendingAccountNotice();
|
||||||
|
filterAuditRows();
|
||||||
|
showToast(
|
||||||
|
"审核结果已记录",
|
||||||
|
approved ? "已通过的数据取得相应使用或核算资格" : "当前记录不参与自动核算",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#auditRows")?.addEventListener("click", (event) => {
|
$("#auditRows")?.addEventListener("click", (event) => {
|
||||||
@@ -1359,7 +1510,12 @@ function initAdmin() {
|
|||||||
$("#approve-confirm")?.addEventListener("click", () => {
|
$("#approve-confirm")?.addEventListener("click", () => {
|
||||||
if (!state.auditRow) return;
|
if (!state.auditRow) return;
|
||||||
closeModal("modal-approve");
|
closeModal("modal-approve");
|
||||||
state.auditDecision = state.auditRow.dataset.recordKind === "account" ? "复核通过并启用账户" : "确认并纳入计算";
|
const kind = state.auditRow.dataset.recordKind;
|
||||||
|
state.auditDecision = kind === "account"
|
||||||
|
? "复核通过并启用账户"
|
||||||
|
: kind === "match"
|
||||||
|
? "关闭异常"
|
||||||
|
: "确认并纳入计算";
|
||||||
state.auditReason = state.auditDecision;
|
state.auditReason = state.auditDecision;
|
||||||
submitAuditResult(state.auditRow);
|
submitAuditResult(state.auditRow);
|
||||||
});
|
});
|
||||||
@@ -1369,7 +1525,12 @@ function initAdmin() {
|
|||||||
const reason = $("#reject-reason").value.trim();
|
const reason = $("#reject-reason").value.trim();
|
||||||
if (reason.length < 5) { $("#reject-hint").style.display = ""; return; }
|
if (reason.length < 5) { $("#reject-hint").style.display = ""; return; }
|
||||||
closeModal("modal-reject");
|
closeModal("modal-reject");
|
||||||
state.auditDecision = state.auditRow.dataset.recordKind === "account" ? "退回公司修改" : "退回公司补充材料";
|
const kind = state.auditRow.dataset.recordKind;
|
||||||
|
state.auditDecision = kind === "account"
|
||||||
|
? "退回公司修改"
|
||||||
|
: kind === "match"
|
||||||
|
? "退回重匹配"
|
||||||
|
: "退回公司补充材料";
|
||||||
state.auditReason = reason;
|
state.auditReason = reason;
|
||||||
submitAuditResult(state.auditRow);
|
submitAuditResult(state.auditRow);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user