271 行
12 KiB
JavaScript
271 行
12 KiB
JavaScript
"use strict";
|
|
|
|
const { createHash } = require("node:crypto");
|
|
const path = require("node:path");
|
|
|
|
const TARGET_NAME_SHA256 = "d6ad24a313331e19381654bab85b3a53de2ce2d518d7fe6a819124cca81c9cc6";
|
|
const MANAGER_NAME_SHA256 = "5da0f6dcab6e9b1e0aa52e06e18a7cc9e181ae51c4a10b64fb3411cfd607424a";
|
|
const REIMBURSEMENT_STATUSES = Object.freeze(["draft", "reviewing", "approved", "paid", "rejected", "cancelled"]);
|
|
const INVOICE_STATUSES = Object.freeze(["pending_ocr", "ocr_failed", "available", "bound", "archived"]);
|
|
const WORKFLOW_INSTANCE_STATUSES = Object.freeze(["pending", "approved", "rejected", "cancelled", "terminated"]);
|
|
const WORKFLOW_TASK_STATUSES = Object.freeze(["pending", "completed", "rejected", "cancelled", "skipped"]);
|
|
const PAYMENT_BATCH_STATUSES = Object.freeze(["draft", "reviewed", "executed", "failed", "cancelled"]);
|
|
const RECEIPT_GOVERNANCE_STATUSES = Object.freeze([
|
|
"draft", "under_approval", "pending_review", "approved", "archived", "returned", "rejected", "quarantined",
|
|
]);
|
|
|
|
function sha256(value) {
|
|
return createHash("sha256").update(String(value), "utf8").digest("hex");
|
|
}
|
|
|
|
function zeroFilled(rows, allowed) {
|
|
const result = Object.fromEntries(allowed.map((status) => [status, 0]));
|
|
for (const row of rows) {
|
|
const status = String(row.status ?? "");
|
|
if (!allowed.includes(status)) throw new Error("unexpected_status");
|
|
result[status] = Number(row.count ?? 0);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function rolesGranting(permissions, permission) {
|
|
return Object.entries(permissions)
|
|
.filter(([, values]) => values.includes("*") || values.includes(permission))
|
|
.map(([role]) => role)
|
|
.sort();
|
|
}
|
|
|
|
async function grouped(client, sql, allowed) {
|
|
const result = await client.query(sql);
|
|
return zeroFilled(result.rows, allowed);
|
|
}
|
|
|
|
async function run() {
|
|
const appRoot = path.resolve(process.env.REIMBURSEMENT_READINESS_APP_ROOT || process.cwd());
|
|
const { Client } = require(path.join(appRoot, "node_modules/pg"));
|
|
const { getConfig } = require(path.join(appRoot, "src/config.js"));
|
|
const { ROLE_PERMISSIONS } = require(path.join(appRoot, "src/constants/permissions.js"));
|
|
const config = getConfig();
|
|
const permissionRoles = {
|
|
expenseSubmit: rolesGranting(ROLE_PERMISSIONS, "expense.submit"),
|
|
invoiceUpload: rolesGranting(ROLE_PERMISSIONS, "invoice.upload"),
|
|
expenseApprove: rolesGranting(ROLE_PERMISSIONS, "expense.approve"),
|
|
};
|
|
if (Object.values(permissionRoles).some((roles) => roles.length === 0)) {
|
|
throw new Error("permission_role_contract_empty");
|
|
}
|
|
|
|
const client = new Client({ connectionString: process.env.DATABASE_URL });
|
|
let inTransaction = false;
|
|
try {
|
|
await client.connect();
|
|
await client.query("BEGIN TRANSACTION READ ONLY");
|
|
inTransaction = true;
|
|
await client.query("SET LOCAL statement_timeout = '30s'");
|
|
await client.query("SET LOCAL lock_timeout = '2s'");
|
|
const readOnly = await client.query("SELECT current_setting('transaction_read_only') AS value");
|
|
if (readOnly.rows[0]?.value !== "on") throw new Error("transaction_not_read_only");
|
|
|
|
const employees = await client.query(`
|
|
SELECT
|
|
e.id,
|
|
e.name,
|
|
e.status::text AS status,
|
|
e.archived_at,
|
|
e.email,
|
|
e.phone,
|
|
e.password_hash,
|
|
e.must_change_password,
|
|
e.login_locked_until,
|
|
e.direct_manager_id,
|
|
EXISTS (SELECT 1 FROM wecom_identity_bindings w WHERE w.employee_id = e.id) AS has_wecom_binding,
|
|
COALESCE(array_agg(er.role_code::text ORDER BY er.role_code::text)
|
|
FILTER (WHERE er.role_code IS NOT NULL), ARRAY[]::text[]) AS role_codes
|
|
FROM employees e
|
|
LEFT JOIN employee_roles er ON er.employee_id = e.id
|
|
GROUP BY e.id
|
|
ORDER BY e.id
|
|
`);
|
|
if (!Array.isArray(employees.rows) || employees.rows.length === 0) throw new Error("employees_empty");
|
|
const active = employees.rows.filter((item) => item.status === "active" && item.archived_at == null);
|
|
const permissionCount = (permission) => active.filter((item) => (
|
|
item.role_codes.some((role) => permissionRoles[permission].includes(role))
|
|
)).length;
|
|
const byId = new Map(employees.rows.map((item) => [item.id, item]));
|
|
const targetMatches = employees.rows.filter((item) => sha256(item.name) === TARGET_NAME_SHA256);
|
|
const managerMatches = employees.rows.filter((item) => sha256(item.name) === MANAGER_NAME_SHA256);
|
|
const target = targetMatches.length === 1 ? targetMatches[0] : null;
|
|
const manager = managerMatches.length === 1 ? managerMatches[0] : null;
|
|
|
|
const employeeSummary = {
|
|
total: employees.rows.length,
|
|
active: active.length,
|
|
activeWithLoginIdentifier: active.filter((item) => (
|
|
String(item.email ?? "").trim() || String(item.phone ?? "").trim()
|
|
)).length,
|
|
activeWithPasswordCredential: active.filter((item) => String(item.password_hash ?? "").trim()).length,
|
|
activeCurrentlyUnlocked: active.filter((item) => !item.login_locked_until || new Date(item.login_locked_until) <= new Date()).length,
|
|
activeWithoutInitialPasswordChange: active.filter((item) => item.must_change_password === false).length,
|
|
activeWithWecomBinding: active.filter((item) => item.has_wecom_binding === true).length,
|
|
activeWithDirectManager: active.filter((item) => item.direct_manager_id != null).length,
|
|
activeWithValidActiveDistinctManager: active.filter((item) => {
|
|
const linked = byId.get(item.direct_manager_id);
|
|
return linked && linked.id !== item.id && linked.status === "active" && linked.archived_at == null;
|
|
}).length,
|
|
permissions: {
|
|
expenseSubmit: permissionCount("expenseSubmit"),
|
|
invoiceUpload: permissionCount("invoiceUpload"),
|
|
expenseApprove: permissionCount("expenseApprove"),
|
|
},
|
|
targetManagerContract: {
|
|
targetUnique: targetMatches.length === 1,
|
|
managerUnique: managerMatches.length === 1,
|
|
distinct: Boolean(target && manager && target.id !== manager.id),
|
|
targetActive: Boolean(target && target.status === "active" && target.archived_at == null),
|
|
managerActive: Boolean(manager && manager.status === "active" && manager.archived_at == null),
|
|
managerCanApprove: Boolean(manager && manager.role_codes.some((role) => permissionRoles.expenseApprove.includes(role))),
|
|
relationshipMatches: Boolean(target && manager && target.direct_manager_id === manager.id),
|
|
},
|
|
};
|
|
|
|
const expenseTypes = await client.query("SELECT count(*)::int AS count FROM expense_types WHERE enabled = true");
|
|
const workflows = await client.query(`
|
|
SELECT count(*)::int AS count
|
|
FROM workflow_definitions
|
|
WHERE entity_type = 'reimbursement' AND is_active = true AND archived_at IS NULL
|
|
`);
|
|
const legalEntities = await client.query("SELECT count(*)::int AS count FROM legal_entities WHERE enabled = true AND archived_at IS NULL");
|
|
const paymentFlag = await client.query(`
|
|
SELECT override_mode, version
|
|
FROM feature_flags
|
|
WHERE flag_key = 'payment_batch_v1'
|
|
`);
|
|
if (paymentFlag.rows.length !== 1) throw new Error("payment_flag_not_unique");
|
|
|
|
const reimbursements = await grouped(
|
|
client,
|
|
"SELECT status::text AS status, count(*)::int AS count FROM reimbursement_forms GROUP BY status ORDER BY status",
|
|
REIMBURSEMENT_STATUSES,
|
|
);
|
|
const invoices = await grouped(
|
|
client,
|
|
"SELECT status::text AS status, count(*)::int AS count FROM invoice_pool GROUP BY status ORDER BY status",
|
|
INVOICE_STATUSES,
|
|
);
|
|
const workflowInstances = await grouped(
|
|
client,
|
|
"SELECT current_status::text AS status, count(*)::int AS count FROM workflow_instances WHERE entity_type = 'reimbursement' GROUP BY current_status ORDER BY current_status",
|
|
WORKFLOW_INSTANCE_STATUSES,
|
|
);
|
|
const workflowTasks = await grouped(
|
|
client,
|
|
`SELECT wt.status::text AS status, count(*)::int AS count
|
|
FROM workflow_tasks wt
|
|
JOIN workflow_instances wi ON wi.id = wt.instance_id
|
|
WHERE wi.entity_type = 'reimbursement'
|
|
GROUP BY wt.status ORDER BY wt.status`,
|
|
WORKFLOW_TASK_STATUSES,
|
|
);
|
|
const paymentBatches = await grouped(
|
|
client,
|
|
"SELECT status::text AS status, count(*)::int AS count FROM payment_batches WHERE entity_type = 'reimbursement' GROUP BY status ORDER BY status",
|
|
PAYMENT_BATCH_STATUSES,
|
|
);
|
|
const receiptGovernance = await grouped(
|
|
client,
|
|
`SELECT pra.governance_status AS status, count(*)::int AS count
|
|
FROM payment_batch_receipt_attachments pra
|
|
JOIN payment_batches pb ON pb.id = pra.payment_batch_id
|
|
WHERE pb.entity_type = 'reimbursement'
|
|
GROUP BY pra.governance_status ORDER BY pra.governance_status`,
|
|
RECEIPT_GOVERNANCE_STATUSES,
|
|
);
|
|
const receiptCounts = await client.query(`
|
|
SELECT
|
|
count(*)::int AS total,
|
|
count(DISTINCT pra.payment_batch_id)::int AS batches_with_receipt,
|
|
count(DISTINCT pra.payment_batch_id) FILTER (WHERE pb.status = 'executed')::int AS executed_batches_with_receipt
|
|
FROM payment_batch_receipt_attachments pra
|
|
JOIN payment_batches pb ON pb.id = pra.payment_batch_id
|
|
WHERE pb.entity_type = 'reimbursement'
|
|
`);
|
|
const closedLoop = await client.query(`
|
|
SELECT count(DISTINCT rf.id)::int AS count
|
|
FROM reimbursement_forms rf
|
|
JOIN workflow_instances wi
|
|
ON wi.reimbursement_id = rf.id
|
|
AND wi.entity_type = 'reimbursement'
|
|
AND wi.current_status = 'approved'
|
|
JOIN payment_batch_items pbi ON pbi.entity_id = rf.id
|
|
JOIN payment_batches pb
|
|
ON pb.id = pbi.payment_batch_id
|
|
AND pb.entity_type = 'reimbursement'
|
|
AND pb.status = 'executed'
|
|
WHERE rf.status = 'paid'
|
|
AND EXISTS (
|
|
SELECT 1 FROM payment_batch_receipt_attachments pra
|
|
WHERE pra.payment_batch_id = pb.id
|
|
AND pra.governance_status = 'approved'
|
|
)
|
|
`);
|
|
|
|
const ocrProvider = String(process.env.OCR_PROVIDER ?? "off").trim().toLowerCase();
|
|
if (!["off", "mock", "tencent"].includes(ocrProvider)) throw new Error("ocr_provider_unexpected");
|
|
const result = {
|
|
status: "PASS",
|
|
transactionReadOnly: true,
|
|
sourceBuildSha: config.buildSha,
|
|
employees: employeeSummary,
|
|
configuration: {
|
|
activeExpenseTypes: Number(expenseTypes.rows[0]?.count ?? -1),
|
|
activeReimbursementWorkflows: Number(workflows.rows[0]?.count ?? -1),
|
|
activeLegalEntitiesObservationOnly: Number(legalEntities.rows[0]?.count ?? -1),
|
|
invoiceUploadConfigured: Number(config.maxUploadSizeBytes ?? 0) > 0,
|
|
ocrProvider,
|
|
invoiceVerificationEnabled: config.invoiceVerificationEnabled === true,
|
|
paymentBatchFlag: {
|
|
overrideMode: String(paymentFlag.rows[0].override_mode),
|
|
version: Number(paymentFlag.rows[0].version),
|
|
},
|
|
},
|
|
lifecycle: {
|
|
reimbursements,
|
|
invoices,
|
|
workflow: { instances: workflowInstances, tasks: workflowTasks },
|
|
paymentBatches,
|
|
receipts: {
|
|
total: Number(receiptCounts.rows[0]?.total ?? 0),
|
|
batchesWithReceipt: Number(receiptCounts.rows[0]?.batches_with_receipt ?? 0),
|
|
executedBatchesWithReceipt: Number(receiptCounts.rows[0]?.executed_batches_with_receipt ?? 0),
|
|
byGovernanceStatus: receiptGovernance,
|
|
},
|
|
completeSubmitApprovePayReceiptLoops: Number(closedLoop.rows[0]?.count ?? 0),
|
|
},
|
|
};
|
|
if (result.configuration.activeExpenseTypes < 0 || result.configuration.activeReimbursementWorkflows < 0) {
|
|
throw new Error("configuration_count_invalid");
|
|
}
|
|
if (!result.employees.targetManagerContract.targetUnique || !result.employees.targetManagerContract.managerUnique) {
|
|
throw new Error("target_manager_not_unique");
|
|
}
|
|
|
|
await client.query("COMMIT");
|
|
inTransaction = false;
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
} catch {
|
|
if (inTransaction) {
|
|
try { await client.query("ROLLBACK"); } catch { /* fail closed */ }
|
|
}
|
|
process.stderr.write("[reimbursement-readiness-pre][FAILED] code=QUERY_FAILED\n");
|
|
process.exitCode = 1;
|
|
} finally {
|
|
await client.end().catch(() => {});
|
|
}
|
|
}
|
|
|
|
if (require.main === module || process.argv[1] === "-") {
|
|
run();
|
|
}
|
|
|
|
module.exports = { zeroFilled, rolesGranting };
|