完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)

汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。
统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。
纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。

关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。
预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。
完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
这个提交包含在:
2026-09-19 13:11:04 +08:00
父节点 729d90576f
当前提交 1f8d5b658a
共修改 367 个文件,包含 21079 行新增和 12287 行删除
+20 -1
查看文件
@@ -66,11 +66,28 @@ export async function observeGuestEntry(context, baseURL, timeout = 30000) {
return observed;
}
async function observeNativeEngine(engine, name, owner) {
const server = await engine.launchServer({ headless: true, host: "127.0.0.1" });
let browser;
try {
const browserNamespace = namespaceFingerprint(server.process().pid);
if (browserNamespace !== owner.ownedNamespace) throw new Error("environment_browser_namespace_mismatch");
browser = await engine.connect(server.wsEndpoint());
const context = await browser.newContext({ serviceWorkers: "block" });
const page = await context.newPage();
const online = await page.evaluate(() => navigator.onLine);
return { name, version: browser.version(), valid: online === true, online, browserNamespace, browserMatchesOwnedChild: true, noNavigatorOverride: true };
} finally {
await browser?.close().catch(() => {});
await server.close();
}
}
async function main(mode, baseURL) {
if (!["native", "guest"].includes(mode)) throw new Error("environment_probe_mode_invalid");
const { context: owner } = verifyManagedEnvironment();
const require = createRequire(path.join(root, "client/package.json"));
const { chromium } = require("@playwright/test");
const { chromium, webkit } = require("@playwright/test");
const server = await chromium.launchServer({ headless: true, host: "127.0.0.1" });
let browser;
let context;
@@ -89,6 +106,8 @@ async function main(mode, baseURL) {
const page = await context.newPage();
const online = await page.evaluate(() => navigator.onLine);
result = { valid: online === true, scope: "blank-page-native-probe", online, errorKind: online === true ? null : online === false ? "native_online_observed_false" : "native_online_observation_missing", browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true };
result.engines = [{ name: "chromium", version: browser.version(), valid: online === true, online, browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true }, await observeNativeEngine(webkit, "webkit", owner)];
result.valid = result.engines.every(engine => engine.valid);
} else result = { ...await observeGuestEntry(context, baseURL), browserNamespace, browserMatchesOwnedChild, noNavigatorOverride: true };
verifyManagedEnvironment();
} catch (error) {
+151
查看文件
@@ -0,0 +1,151 @@
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
const { createRequire } = require('node:module');
const path = require('node:path');
const { setTimeout: delay } = require('node:timers/promises');
const req = createRequire(path.resolve(__dirname, '../../server/package.json'));
const { PrismaClient } = req('@prisma/client');
const { PrismaPg } = req('@prisma/adapter-pg');
const { reserveBudgetForEntity, releaseReservedBudgetIfAny } = req('./src/services/purchaseBudget');
const { authorizeBudgetForEntity, reverseBudgetForEntity } = req('./src/services/travelPayments');
const { lockBudgetPoolsForUpdate, resolveApplicableBudgetPoolByContext } = req('./src/services/budgetPool');
module.exports = async function runBudgetFixture(pool) {
const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { disposeExternalPool: false }) });
const passed = []; const failures = [];
const tx = fn => prisma.$transaction(fn);
const actorId = randomUUID();
const createPool = (used = '0.00') => prisma.budgetPool.create({ data: {
code: `SYN-${randomUUID()}`, name: '合成预算池', period_start: new Date('2026-01-01'),
period_end: new Date('2026-12-31'), allocated_amount: '1000.00', used_amount: used,
} });
const input = (id, amount = '60.00') => ({ budgetPoolId: id, entityType: 'purchase_request',
entityId: randomUUID(), amount, actorId, note: 'synthetic concurrent budget check' });
async function check(name, fn) {
try { await fn(); passed.push(name); console.log(JSON.stringify({ check: name, status: 'PASS' })); }
catch (error) { failures.push(name); console.log(JSON.stringify({ check: name, status: 'FAIL', code: error.code ?? error.name,
fixtureLine: /budget-pool-db-fixture.cjs:(\d+)/.exec(error.stack ?? '')?.[1],
reason: /transaction client/.test(error.message) ? 'transaction_client_required' : undefined,
causeName: error.cause?.name, causeCode: error.cause?.code })); }
}
// Both calls must reach a real database lock wait before release. The old
// implementation waits at UPDATE after reading stale usage; the fixed one
// waits before its authoritative read. No simulated locks or timing guess.
async function contend(id, calls, standalone = false, beforeUnlock) {
const blocker = await pool.connect(); let results;
try {
await blocker.query('BEGIN');
await blocker.query('SELECT id FROM budget_pools WHERE id=$1 FOR NO KEY UPDATE', [id]);
const jobs = calls.map(fn => standalone ? fn() : tx(fn));
let completed;
results = Promise.allSettled(jobs).then(value => { completed = value; return value; });
const deadline = Date.now() + 2500;
let waiting = 0;
while (Date.now() < deadline) {
const r = await pool.query("SELECT count(*)::int n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%budget_pools%'");
waiting = r.rows[0].n;
if (completed?.some(r => r.status === 'rejected')) throw completed.find(r => r.status === 'rejected').reason;
if (waiting >= calls.length) break;
await delay(10);
}
assert.equal(waiting, calls.length, 'all contenders reached database lock wait');
if (beforeUnlock) await beforeUnlock(blocker);
await blocker.query('COMMIT');
const settled = await results;
for (const r of settled) if (r.status === 'rejected') throw r.reason;
return settled.map(r => r.value);
} finally {
await blocker.query('ROLLBACK'); blocker.release();
if (results) await results;
}
}
const used = async id => (await prisma.budgetPool.findUniqueOrThrow({ where: { id } })).used_amount.toFixed(2);
try {
await check('reserve_same_pool_accumulates_both_commits', async () => {
const p = await createPool();
await contend(p.id, [t => reserveBudgetForEntity(t, input(p.id)), t => reserveBudgetForEntity(t, input(p.id))]);
assert.equal(await used(p.id), '120.00');
assert.equal(await prisma.budgetLedgerEntry.count({ where: { budget_pool_id: p.id } }), 2);
});
await check('purchase_and_card_authorization_share_pool', async () => {
const p = await createPool();
await contend(p.id, [t => reserveBudgetForEntity(t, input(p.id)), t => authorizeBudgetForEntity(t, input(p.id))]);
assert.equal(await used(p.id), '120.00');
});
await check('release_and_reserve_preserve_both_deltas', async () => {
const p = await createPool('100.00'); const reserved = input(p.id, '40.00');
await prisma.budgetLedgerEntry.create({ data: { budget_pool_id: p.id, entity_type: reserved.entityType,
entity_id: reserved.entityId, kind: 'reserve', amount: reserved.amount } });
await contend(p.id, [t => releaseReservedBudgetIfAny(t, reserved), t => reserveBudgetForEntity(t, input(p.id, '20.00'))]);
assert.equal(await used(p.id), '80.00');
});
await check('card_reverse_and_reserve_preserve_both_deltas', async () => {
const p = await createPool('100.00');
await contend(p.id, [t => reverseBudgetForEntity(t, input(p.id, '40.00')), t => reserveBudgetForEntity(t, input(p.id, '20.00'))]);
assert.equal(await used(p.id), '80.00');
});
await check('ledger_and_usage_rollback_together', async () => {
const p = await createPool();
await assert.rejects(tx(async t => { await reserveBudgetForEntity(t, input(p.id)); throw new Error('synthetic rollback'); }), /synthetic rollback/);
assert.equal(await used(p.id), '0.00');
assert.equal(await prisma.budgetLedgerEntry.count({ where: { budget_pool_id: p.id } }), 0);
});
await check('concurrent_duplicate_reserve_rechecks_ledger_after_wait', async () => {
const p = await createPool(); const args = input(p.id);
const call = async t => { try { return await reserveBudgetForEntity(t, args); }
catch (error) { assert.equal(error.details?.reasonCode, 'purchase_budget.reserve_already_exists'); return 'duplicate'; } };
const results = await contend(p.id, [call, call]);
assert.equal(results.filter(r => r === 'duplicate').length, 1);
assert.equal(await used(p.id), '60.00');
assert.equal(await prisma.budgetLedgerEntry.count({ where: { budget_pool_id: p.id } }), 1);
});
await check('release_multi_pool_reverse_input_order_has_no_deadlock', async () => {
const a = await createPool('80.00'); const b = await createPool('80.00');
const left = input(a.id, '40.00'); const right = input(b.id, '40.00');
for (const [entity, pools] of [[left, [a, b]], [right, [b, a]]]) {
for (const p of pools) await prisma.budgetLedgerEntry.create({ data: {
budget_pool_id: p.id, entity_type: entity.entityType, entity_id: entity.entityId, kind: 'reserve', amount: '40.00',
} });
}
await contend([a.id, b.id].sort()[0], [t => releaseReservedBudgetIfAny(t, left), t => releaseReservedBudgetIfAny(t, right)]);
assert.equal(await used(a.id), '0.00'); assert.equal(await used(b.id), '0.00');
const second = await tx(t => releaseReservedBudgetIfAny(t, left)); assert.equal(second.applied, false);
});
await check('release_retains_nonnegative_floor', async () => {
const p = await createPool('20.00'); const args = input(p.id, '30.00');
await prisma.budgetLedgerEntry.create({ data: { budget_pool_id: p.id, entity_type: args.entityType,
entity_id: args.entityId, kind: 'reserve', amount: args.amount } });
await tx(t => releaseReservedBudgetIfAny(t, args)); assert.equal(await used(p.id), '0.00');
});
await check('different_pools_do_not_share_a_global_lock', async () => {
const a = await createPool(); const b = await createPool();
await tx(async t => {
await lockBudgetPoolsForUpdate(t, [a.id]);
await tx(other => reserveBudgetForEntity(other, input(b.id)));
});
assert.equal(await used(b.id), '60.00');
});
await check('candidate_archived_while_waiting_is_not_silently_replaced', async () => {
const department = await prisma.department.create({ data: { name: '合成候选变更' } });
const p = await createPool(); await prisma.budgetPool.update({ where: { id: p.id }, data: { department_id: department.id } });
const blocker = await pool.connect(); let pending;
try {
await blocker.query('BEGIN'); await blocker.query('SELECT id FROM budget_pools WHERE id=$1 FOR NO KEY UPDATE', [p.id]);
pending = tx(t => resolveApplicableBudgetPoolByContext(t, { departmentId: department.id, effectiveDate: new Date('2026-09-19') }, { forUpdate: true }))
.then(value => ({ value }), error => ({ error }));
const deadline = Date.now() + 2000; let waiting = 0;
while (Date.now() < deadline) {
waiting = (await pool.query("SELECT count(*)::int n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")).rows[0].n;
if (waiting) break; await delay(10);
}
assert.equal(waiting, 1);
await blocker.query("UPDATE budget_pools SET status='archived',archived_at=now() WHERE id=$1", [p.id]);
await blocker.query('COMMIT');
assert.equal((await pending).error?.details?.reasonCode, 'budget.pool_changed');
} finally { await blocker.query('ROLLBACK'); blocker.release(); if (pending) await pending; }
});
await require('./budget-pool-http-fixture.cjs')({ prisma, pool, check, contend, used });
console.log(JSON.stringify({ scope: 'real-services-real-isolated-postgresql', passed, failures }));
assert.equal(failures.length, 0, 'budget concurrency checks failed');
} finally { await prisma.$disconnect(); }
};
+201
查看文件
@@ -0,0 +1,201 @@
const assert = require('node:assert/strict');
const { randomUUID } = require('node:crypto');
const { createRequire } = require('node:module');
const path = require('node:path');
const { setTimeout: delay } = require('node:timers/promises');
const req = createRequire(path.resolve(__dirname, '../../server/package.json'));
// Real Fastify routes and Prisma transactions; synthetic authorized identity.
// Authentication and browser behavior remain covered by the ordinary full gate.
module.exports = async ({ prisma, pool, check, contend, used }) => {
const app = req('fastify')({ logger: false });
const department = await prisma.department.create({ data: { name: '合成预算并发部门' } });
const employee = async role => prisma.employee.create({ data: {
email: `${randomUUID()}@example.invalid`, name: '合成预算用户', department_id: department.id,
title: '合成岗位', hire_date: new Date('2025-01-01'), bank_name: 'synthetic',
bank_account_encrypted: 'synthetic-unused', password_hash: 'synthetic-unused',
roles: { create: { role_code: role } },
} });
const owner = await employee('employee'); const manager = await employee('manager'); await employee('finance');
const company = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: '合成公司' } });
const costCenter = await prisma.costCenter.create({ data: { code: 'SYN', name: '合成成本中心', legal_entity_id: company.id } });
const supplier = await prisma.supplier.create({ data: { code: `SYN-${randomUUID()}`, name: '合成供应商', legal_entity_id: company.id } });
const category = await prisma.documentCategory.create({ data: { code: `SYN-${randomUUID()}`, name: '合成采购类别',
entity_type: 'purchase_request', sequence_prefix: 'SYN', reset_period: 'yearly' } });
const expense = await prisma.expenseType.create({ data: { code: `SYN-${randomUUID()}`, name: '合成办公费',
category: 'office', sort_order: 999, requires_invoice: false } });
await prisma.approvalRule.create({ data: { name: '合成财务审批', min_amount: '0', level1_role: 'finance', priority: 1 } });
for (const entity_type of ['purchase_request', 'reimbursement']) {
await prisma.workflowDefinition.create({ data: { entity_type, name: '合成预算流程', version: 1,
definition_json: { steps: [{ id: 'approve', name: '合成审批', type: 'approval', assignee_rule: { type: 'role', value: 'manager' } }] } } });
}
app.decorate('prisma', prisma);
const config = { appTimezone: 'Asia/Shanghai', budgetPrecheckEnabled: false, budgetControlMode: 'block',
emailEnabled: false, imNotifierEnabled: false, cutoverConsoleEnabled: false, reimbursementInvoiceGuardEnabled: false };
app.decorate('config', config);
const actor = { ...owner, roleCodes: ['employee'], permissionCodes: ['*'] };
app.decorate('hasPermission', () => true);
app.decorate('requirePermission', () => async request => { request.employee = actor; });
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({
code: error.code ?? 'SYS_001', reason: error.details?.reasonCode ?? null,
precheckCodes: error.details?.precheck?.map?.(item => item.code) ?? [],
}));
await app.register(req('./src/routes/purchaseRequests'), { prefix: '/api' });
await app.register(req('./src/routes/reimbursements'), { prefix: '/api' });
await app.register(req('./src/routes/travelTransactions'), { prefix: '/api' });
await app.ready();
let selected;
const today = new Date();
async function setup({ enabled, mode = 'block' }) {
config.budgetPrecheckEnabled = enabled; config.budgetControlMode = mode;
await prisma.featureFlag.upsert({ where: { flag_key: 'budget_precheck_v1' }, update: { override_mode: enabled ? 'force_on' : 'force_off' },
create: { flag_key: 'budget_precheck_v1', override_mode: enabled ? 'force_on' : 'force_off', rollout_type: 'global' } });
await prisma.budgetPool.updateMany({ data: { status: 'archived', archived_at: today } });
selected = await prisma.budgetPool.create({ data: { code: `SYN-${randomUUID()}`, name: '合成共享预算',
period_start: new Date(today.getFullYear(), 0, 1), period_end: new Date(today.getFullYear(), 11, 31),
allocated_amount: '100.00', used_amount: '0.00', warning_threshold_percent: 100 } });
}
async function purchase(explicit = false, amount = '60.00') {
const row = await prisma.purchaseRequest.create({ data: { applicant_id: owner.id, department_id: department.id,
title: '合成并发采购', legal_entity_id: company.id, cost_center_id: costCenter.id, supplier_id: supplier.id,
document_category_id: category.id, request_no: `SYN-${randomUUID()}`, total_amount: amount,
budget_pool_id: explicit ? selected.id : null,
items: { create: { description: '合成用品', quantity: '1.00', unit_price: amount, line_amount: amount } } } });
return { id: row.id, type: 'purchase_request', url: `/api/purchase-requests/${row.id}/submit` };
}
async function reimbursement(amount = '60.00') {
const row = await prisma.reimbursementForm.create({ data: { owner_id: owner.id, submitter_id: owner.id,
department_id: department.id, reimbursement_date: today, form_number: `SYN-${randomUUID()}`, total_amount: amount,
items: { create: { expense_type_id: expense.id, expense_date: today, amount, description: '合成并发费用' } } }, include: { items: true } });
await prisma.invoicePool.create({ data: { owner_employee_id: owner.id, uploaded_by_employee_id: owner.id,
storage_path: 'synthetic/not-a-real-file', original_name: 'synthetic.txt', mime_type: 'text/plain', file_size: 1,
file_sha256: randomUUID(), status: 'bound', linked_reimbursement_id: row.id, linked_item_id: row.items[0].id,
total_amount: amount, ocr_completed_at: today } });
return { id: row.id, type: 'reimbursement', url: `/api/reimbursements/${row.id}/submit` };
}
const submit = entity => () => app.inject({ method: 'POST', url: entity.url });
async function assertRace(entities, expectedStatuses, expectedUsage) {
const responses = await contend(selected.id, entities.map(submit), true);
// Only safe codes are logged for unexpected route responses.
if (responses.some(r => !expectedStatuses.includes(r.statusCode))) {
console.log(JSON.stringify({ routeDiagnostics: responses.map(r => ({ status: r.statusCode, ...(r.statusCode >= 400 ? r.json() : {}) })) }));
}
assert.deepEqual(responses.map(r => r.statusCode).sort(), expectedStatuses.slice().sort());
assert.equal(await used(selected.id), expectedUsage);
const winners = responses.filter(r => r.statusCode === 200).length;
assert.equal(await prisma.budgetLedgerEntry.count({ where: { budget_pool_id: selected.id } }), winners);
assert.equal(await prisma.workflowInstance.count({ where: { entity_id: { in: entities.map(e => e.id) } } }), winners);
}
try {
for (const enabled of [false, true]) {
for (const explicit of [false, true]) {
await check(`purchase_block_concurrency_gate_${enabled}_explicit_${explicit}`, async () => {
await setup({ enabled });
await assertRace([await purchase(explicit), await purchase(explicit)], [200, 409], '60.00');
});
}
await check(`reimbursement_block_concurrency_gate_${enabled}`, async () => {
await setup({ enabled });
await assertRace([await reimbursement(), await reimbursement()], [200, 409], '60.00');
});
await check(`mixed_purchase_reimbursement_block_gate_${enabled}`, async () => {
await setup({ enabled });
await assertRace([await purchase(), await reimbursement()], [200, 409], '60.00');
});
await check(`mixed_warn_keeps_both_writes_gate_${enabled}`, async () => {
await setup({ enabled, mode: 'warn' });
await assertRace([await purchase(), await reimbursement()], [200, 200], '120.00');
});
}
await check('exact_capacity_and_duplicate_submit', async () => {
await setup({ enabled: true });
const a = await purchase(false, '40.00'); const b = await reimbursement();
// Precheck block also blocks warning threshold hits under its existing rule.
await prisma.budgetPool.update({ where: { id: selected.id }, data: { warning_threshold_percent: 101 } });
await assertRace([a, b], [200, 200], '100.00');
assert.equal((await submit(a)()).statusCode, 409);
assert.equal(await used(selected.id), '100.00');
});
await check('submit_ledger_failure_rolls_back_usage_and_workflow', async () => {
await setup({ enabled: false }); const form = await reimbursement();
await pool.query(`CREATE FUNCTION synthetic_budget_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic failure'; END $$;
CREATE TRIGGER synthetic_budget_failure BEFORE INSERT ON budget_ledger_entries FOR EACH ROW EXECUTE FUNCTION synthetic_budget_failure()`);
try {
assert.equal((await submit(form)()).statusCode, 500);
assert.equal(await used(selected.id), '0.00');
assert.equal(await prisma.workflowInstance.count({ where: { entity_id: form.id } }), 0);
assert.equal((await prisma.reimbursementForm.findUniqueOrThrow({ where: { id: form.id } })).status, 'draft');
} finally { await pool.query('DROP TRIGGER synthetic_budget_failure ON budget_ledger_entries; DROP FUNCTION synthetic_budget_failure()'); }
});
for (const kind of ['purchase', 'reimbursement']) {
await check(`legacy_block_exact_decimal_capacity_${kind}`, async () => {
await setup({ enabled: false });
await prisma.budgetPool.update({ where: { id: selected.id }, data: { allocated_amount: '0.30', used_amount: '0.10' } });
const entity = kind === 'purchase' ? await purchase(false, '0.20') : await reimbursement('0.20');
assert.equal((await submit(entity)()).statusCode, 200);
assert.equal(await used(selected.id), '0.30');
});
}
await check('allocation_reduced_during_wait_is_used_by_both_submitters', async () => {
await setup({ enabled: true });
const entities = [await purchase(), await reimbursement()];
const responses = await contend(selected.id, entities.map(submit), true,
blocker => blocker.query('UPDATE budget_pools SET allocated_amount=50 WHERE id=$1', [selected.id]));
assert.deepEqual(responses.map(r => r.statusCode), [409, 409]);
assert.equal(await used(selected.id), '0.00');
assert.equal(await prisma.budgetLedgerEntry.count({ where: { budget_pool_id: selected.id } }), 0);
});
for (const table of ['workflow_history', 'audit_logs']) {
await check(`submit_${table}_failure_rolls_back_all_writes`, async () => {
await setup({ enabled: false }); const form = await reimbursement();
if (table === 'audit_logs') await prisma.reimbursementForm.update({ where: { id: form.id }, data: { submitter_id: manager.id } });
// Table is from the fixed two-element allowlist above, not user input.
await pool.query(`CREATE FUNCTION synthetic_budget_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'synthetic failure'; END $$;
CREATE TRIGGER synthetic_budget_failure BEFORE INSERT ON ${table} FOR EACH ROW EXECUTE FUNCTION synthetic_budget_failure()`);
try {
assert.equal((await submit(form)()).statusCode, 500);
assert.equal(await used(selected.id), '0.00');
assert.equal(await prisma.budgetLedgerEntry.count({ where: { budget_pool_id: selected.id } }), 0);
assert.equal(await prisma.workflowInstance.count({ where: { entity_id: form.id } }), 0);
assert.equal((await prisma.reimbursementForm.findUniqueOrThrow({ where: { id: form.id } })).status, 'draft');
} finally { await pool.query(`DROP TRIGGER synthetic_budget_failure ON ${table}; DROP FUNCTION synthetic_budget_failure()`); }
});
}
await check('authorization_and_refund_share_entity_before_pool_lock_order', async () => {
await setup({ enabled: false });
// Exercise the frozen import handler only in this synthetic Fastify app;
// no deployment flag, external integration or browser scenario is enabled.
config.travelSyncEnabled = true;
const travel = await prisma.travelRequest.create({ data: { applicant_employee_id: owner.id, department_id: department.id,
legal_entity_id: company.id, cost_center_id: costCenter.id, budget_pool_id: selected.id,
purpose: '合成锁序验证', trip_type: 'domestic', start_date: today, end_date: today, estimated_amount: '60.00' } });
const authorization = await prisma.cardAuthorization.create({ data: { source: 'synthetic', external_auth_no: randomUUID(),
employee_id: owner.id, department_id: department.id, travel_request_id: travel.id, budget_pool_id: selected.id,
legal_entity_id: company.id, cost_center_id: costCenter.id, authorized_amount: '60.00' } });
let refund;
try {
await prisma.$transaction(async t => {
await t.$queryRaw`SELECT id FROM card_authorizations WHERE id=${authorization.id}::uuid FOR NO KEY UPDATE`;
refund = app.inject({ method: 'POST', url: '/api/travel/transactions/import', payload: { transactions: [{
source: 'synthetic', externalTxnId: randomUUID(), employeeId: owner.id, travelRequestId: travel.id,
cardAuthorizationId: authorization.id, amount: '20.00', currency: 'CNY', txnType: 'refund', status: 'settled',
}] } });
// Start the injection and observe its wait on the authorization.
refund = Promise.resolve(refund);
const deadline = Date.now() + 2000; let waiting = 0;
while (Date.now() < deadline) {
waiting = (await pool.query("SELECT count(*)::int n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")).rows[0].n;
if (waiting) break; await delay(10);
}
assert.equal(waiting, 1);
await req('./src/services/travelPayments').authorizeBudgetForEntity(t, { budgetPoolId: selected.id,
entityId: authorization.id, amount: '60.00', actorId: owner.id, note: 'synthetic authorization' });
});
const response = await refund;
assert.equal(response.statusCode, 200);
assert.equal(response.json().data.reversedCount, 1);
assert.equal(await used(selected.id), '40.00');
} finally { if (refund) await refund; config.travelSyncEnabled = false; }
});
} finally { await app.close(); }
};
@@ -0,0 +1,209 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { writeFileSync } = require('node:fs');
const { spawnSync } = require('node:child_process');
const { createRequire } = require('node:module');
const { setTimeout: delay } = require('node:timers/promises');
const req = createRequire(path.resolve(__dirname, '../../server/package.json'));
const { PrismaClient } = req('@prisma/client');
const { PrismaPg } = req('@prisma/adapter-pg');
const { resolveNumberingContext, issueBusinessNumber, reserveProjectNumber, setNumberingPolicy } = req('./src/services/businessNumbering');
const { issueDocumentNumber, resolveDocumentSequenceConfig } = req('./src/services/documentSequence');
module.exports = async function runNumberingFixture(pool, scratch) {
const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { disposeExternalPool: false }) });
const checks = [];
const instant = new Date('2026-10-01T00:00:00Z');
try {
const department = await prisma.department.create({ data: { name: '合成编号部门' } });
const actor = await prisma.employee.create({ data: {
email: `${randomUUID()}@example.invalid`, name: '合成编号用户', department_id: department.id,
title: '合成岗位', hire_date: new Date('2025-01-01'), bank_name: 'synthetic',
bank_account_encrypted: 'synthetic-unused', password_hash: 'synthetic-unused',
} });
const baseDraft = { applicant_id: actor.id, department_id: department.id, title: '合成采购' };
const oldDraft = await prisma.purchaseRequest.create({ data: baseDraft });
assert.equal(oldDraft.numbering_version, 1);
assert.equal(await prisma.businessNumberPolicy.count(), 0);
const oldContext = await prisma.$transaction(tx => resolveNumberingContext(tx, { now: instant }));
assert.equal(oldContext.version, 1);
const oldForm = await prisma.reimbursementForm.create({ data: { form_number: 'BX-20260918-0500',
submitter_id: actor.id, owner_id: actor.id, department_id: department.id, reimbursement_date: instant } });
const oldBatch = await prisma.paymentBatch.create({ data: { batch_no: 'PB-20260917-001', name: '合成旧批次',
entity_type: 'reimbursement', created_by: actor.id,
items: { create: { entity_id: oldForm.id, entity_code: oldForm.form_number, entity_title: '合成报销', amount: '1.00', snapshot_json: {} } } }, include: { items: true } });
checks.push('migration_inactive_existing_draft_legacy');
const effectiveAt = new Date('2026-09-01T00:00:00Z');
const change = (action, more = {}) => prisma.$transaction(tx => setNumberingPolicy(tx, {
action, actorId: actor.id, reference: 'synthetic-numbering', writersCompatible: true,
now: new Date('2026-08-31T00:00:00Z'), ...more,
}));
await prisma.documentSequence.create({ data: { document_type: 'reimbursement', prefix: 'BX', date_format: 'YYYYMM',
separator: '-', number_length: 4, reset_period: 'none', current_number: 12 } });
await assert.rejects(change('activate', { effectiveAt }), /compatibility review/);
assert.equal(await prisma.businessNumberPolicy.count(), 0);
assert.equal((await prisma.documentSequence.findUnique({ where: { document_type: 'reimbursement' } })).current_number, 12);
// Synthetic legacy-rule setup only; the activation operation itself never edits it.
await prisma.documentSequence.update({ where: { document_type: 'reimbursement' }, data: { date_format: 'YYYYMMDD' } });
checks.push('overlapping_active_legacy_rule_blocks_activation_without_rewriting_it');
await change('activate', { effectiveAt });
const activeConfig = await resolveDocumentSequenceConfig(prisma, { documentType: 'reimbursement' });
assert.equal(activeConfig.dateFormat, 'YYYYMM'); assert.equal(activeConfig.resetPeriod, 'monthly');
const newDraft = await prisma.$transaction(async tx => {
const ctx = await resolveNumberingContext(tx, { now: instant });
return tx.purchaseRequest.create({ data: { ...baseDraft, numbering_version: ctx.version } });
});
assert.equal(newDraft.numbering_version, 2);
async function submitDraft(draft) {
return prisma.$transaction(async tx => {
const row = await tx.purchaseRequest.findUniqueOrThrow({ where: { id: draft.id } });
const issued = await issueDocumentNumber(tx, { documentType: 'purchase_request', numberingVersion: row.numbering_version, now: instant });
return tx.purchaseRequest.update({ where: { id: row.id }, data: { request_no: issued.documentNumber } });
});
}
assert.match((await submitDraft(oldDraft)).request_no, /^CG-\d{8}-0001$/);
await change('pause');
assert.equal((await submitDraft(newDraft)).request_no, 'CG-2026-0001');
assert.equal((await prisma.$transaction(tx => resolveNumberingContext(tx, { now: instant }))).version, 1);
await change('resume');
assert.equal((await prisma.businessNumberPolicy.findUnique({ where: { version: 2 } })).effective_at.getTime(), effectiveAt.getTime());
await assert.rejects(pool.query('UPDATE purchase_requests SET numbering_version=2 WHERE id=$1', [oldDraft.id]), error => error.code === '23514');
await assert.rejects(pool.query("UPDATE business_number_policies SET effective_at='2020-01-01' WHERE version=2"), error => error.code === '23514');
checks.push('stored_draft_version_cutover_pause_resume_config');
const concurrent = await Promise.all(Array.from({ length: 12 }, () => prisma.$transaction(async tx => {
const context = await resolveNumberingContext(tx, { now: instant });
const issued = await issueBusinessNumber(tx, { family: 'reimbursement', ...context });
return tx.reimbursementForm.create({ data: { form_number: issued.documentNumber,
submitter_id: actor.id, owner_id: actor.id, department_id: department.id, reimbursement_date: instant } });
})));
assert.equal(new Set(concurrent.map(row => row.form_number)).size, 12);
assert.deepEqual(concurrent.map(row => row.form_number).sort(), Array.from({ length: 12 }, (_, i) => `BX-202610-${String(i + 1).padStart(4, '0')}`));
const deleted = concurrent.find(row => row.form_number.endsWith('0012'));
await prisma.reimbursementForm.delete({ where: { id: deleted.id } });
const next = await prisma.$transaction(tx => issueBusinessNumber(tx, { family: 'reimbursement', now: instant }));
assert.equal(next.documentNumber, 'BX-202610-0013');
const key = 'v2:reimbursement:BX:202610';
await assert.rejects(prisma.$transaction(async tx => {
await issueBusinessNumber(tx, { family: 'reimbursement', now: instant });
// Actual unique violation aborts the complete transaction including its counter.
await tx.reimbursementForm.create({ data: { form_number: oldForm.form_number,
submitter_id: actor.id, owner_id: actor.id, department_id: department.id, reimbursement_date: instant } });
}), error => error.code === 'P2002');
assert.equal((await prisma.businessNumberCounter.findUnique({ where: { key } })).current_number, 13n);
await assert.rejects(pool.query('UPDATE business_number_counters SET current_number=0 WHERE key=$1', [key]), error => error.code === '23514');
await assert.rejects(pool.query('DELETE FROM business_number_counters WHERE key=$1', [key]), error => error.code === '23514');
checks.push('twelve_concurrent_commits_unique_deletion_no_reuse_real_rollback');
// Existing same-format occupancy is scanned under the family lock; permanent
// high-water survives both imported-object deletion and process/client restart.
const projectData = { project_no: 'PRJ-2026-9999', name: '合成项目', client_name: '合成客户', owner_employee_id: actor.id };
await prisma.$transaction(async tx => { await reserveProjectNumber(tx, projectData.project_no); await tx.project.create({ data: projectData }); });
const projectNo = await prisma.$transaction(tx => issueBusinessNumber(tx, { family: 'project', now: instant }));
assert.equal(projectNo.documentNumber, 'PRJ-2026-10000');
await prisma.project.delete({ where: { project_no: projectData.project_no } });
const otherClient = new PrismaClient({ adapter: new PrismaPg(pool, { disposeExternalPool: false }) });
try {
assert.equal((await otherClient.$transaction(tx => issueBusinessNumber(tx, { family: 'project', now: instant }))).documentNumber, 'PRJ-2026-10001');
} finally { await otherClient.$disconnect(); }
const imported = await prisma.project.create({ data: { ...projectData, project_no: 'PRJ-2027-0042' } });
assert.equal((await prisma.$transaction(tx => issueBusinessNumber(tx, { family: 'project', now: new Date('2026-12-31T16:00:00Z') }))).documentNumber, 'PRJ-2027-0043');
await prisma.project.delete({ where: { id: imported.id } });
checks.push('occupied_format_expansion_year_boundary_manual_reservation_restart');
const csvPath = path.join(scratch, 'synthetic-project.csv');
writeFileSync(csvPath, `project_no,name,client_name,owner_employee_no
PRJ-2030-0042,synthetic,synthetic,${actor.employee_no}
`);
const dbUrl = `postgresql://synthetic@localhost/numbering_synthetic?host=${encodeURIComponent(pool.options.host)}`;
const cliEnv = { PATH: process.env.PATH, LANG: 'C.UTF-8', DATABASE_URL: dbUrl };
const runCli = (file, args) => {
const result = spawnSync(process.execPath, [path.resolve(__dirname, '../../server/src/scripts', file), ...args], {
cwd: path.resolve(__dirname, '../..'), env: cliEnv, encoding: 'utf8', timeout: 30000,
});
assert.equal(result.status, 0, 'synthetic CLI must succeed');
return JSON.parse(result.stdout);
};
for (const apply of [false, true, true]) {
const result = runCli('import-project-ledgers.js', ['--type', 'projects', '--file', csvPath, '--json', ...(apply ? ['--apply'] : [])]);
assert.equal(result.errorCount, 0);
if (!apply) assert.equal(await prisma.project.count({ where: { project_no: 'PRJ-2030-0042' } }), 0);
}
assert.equal((await prisma.project.findUnique({ where: { project_no: 'PRJ-2030-0042' } })).name, 'synthetic');
await prisma.projectMember.deleteMany({ where: { project: { project_no: 'PRJ-2030-0042' } } });
await prisma.project.delete({ where: { project_no: 'PRJ-2030-0042' } });
assert.equal((await prisma.$transaction(tx => issueBusinessNumber(tx, { family: 'project', now: new Date('2030-07-01T00:00:00Z') }))).documentNumber, 'PRJ-2030-0043');
assert.equal(runCli('manage-business-numbering.js', ['status']).enabled, true);
assert.equal(runCli('manage-business-numbering.js', ['pause', actor.id, 'synthetic-cli-pause', '--writers-compatible']).enabled, false);
assert.equal(runCli('manage-business-numbering.js', ['resume', actor.id, 'synthetic-cli-resume', '--writers-compatible']).enabled, true);
checks.push('real_project_import_dry_run_apply_update_reservation_and_policy_cli');
const assetCompany = await prisma.legalEntity.create({ data: { code: 'SYNTHETIC-NUMBERING', name: '合成资产公司' } });
const assetCenter = await prisma.costCenter.create({ data: { code: 'SYNTHETIC-NUMBERING', name: '合成资产成本', legal_entity_id: assetCompany.id } });
const assetCategory = await prisma.assetCategory.create({ data: { code: 'SYNTHETIC-NUMBERING', name: '合成资产分类' } });
for (const prefix of ['EA.DOC+', '电子🧾']) {
await prisma.assetCard.create({ data: { asset_no: `${prefix}-4321`, name: '合成已有资产',
category_id: assetCategory.id, legal_entity_id: assetCompany.id, cost_center_id: assetCenter.id } });
const issued = await prisma.$transaction(tx => issueBusinessNumber(tx, { family: 'asset_card', prefix, now: instant }));
assert.equal(issued.documentNumber, `${prefix}-4322`);
}
checks.push('existing_custom_asset_prefix_literal_regex_and_unicode_offset');
// A real held shared lock must block the admin's exclusive transition.
let release, acquired;
const held = new Promise(resolve => { release = resolve; });
const ready = new Promise(resolve => { acquired = resolve; });
const holder = prisma.$transaction(async tx => { await resolveNumberingContext(tx, { now: instant }); acquired(); await held; });
await ready;
const pausing = change('pause');
let blocked = false;
try {
for (let i = 0; i < 30; i++) {
const locks = await pool.query("SELECT count(*)::int AS count FROM pg_locks WHERE locktype='advisory' AND classid=1003 AND objid=0 AND NOT granted");
if (locks.rows[0].count > 0) { blocked = true; break; }
await delay(10);
}
assert.ok(blocked, 'policy pause must wait for an in-flight writer');
} finally { release(); await holder; await pausing; }
await change('resume');
checks.push('real_policy_shared_exclusive_lock_boundary');
const mixed = await prisma.$transaction(async tx => {
const ctx = await resolveNumberingContext(tx, { now: instant });
const issued = await issueBusinessNumber(tx, { family: 'payment_batch', ...ctx });
return tx.paymentBatch.create({ data: { batch_no: issued.documentNumber, name: '合成新旧混合批次',
entity_type: 'reimbursement', created_by: actor.id, items: { create: [oldForm, concurrent[0]].map(form => ({
entity_id: form.id, entity_code: form.form_number, entity_title: '合成来源', amount: '1.00', snapshot_json: {},
})) } }, include: { items: true } });
});
assert.equal(mixed.batch_no, 'PB-2026-0001');
assert.deepEqual(mixed.items.map(i => i.entity_code).sort(), [oldForm.form_number, concurrent[0].form_number].sort());
assert.deepEqual(await prisma.paymentBatch.findUnique({ where: { id: oldBatch.id }, include: { items: true } }), oldBatch);
assert.deepEqual(await prisma.reimbursementForm.findUnique({ where: { id: oldForm.id } }), oldForm);
const freshPB = await prisma.$transaction(tx => issueBusinessNumber(tx, { family: 'payment_batch', now: new Date('2026-12-31T16:00:00Z') }));
assert.equal(freshPB.documentNumber, 'PB-2027-0001');
checks.push('old_identifiers_and_item_snapshots_unchanged_mixed_sources');
await require('./company-loan-http-fixture.cjs')(pool, scratch);
const loans = await prisma.companyLoan.findMany({ select: { loan_no: true } });
const repayments = await prisma.loanRepayment.findMany({ select: { repayment_no: true } });
const batches = await prisma.paymentBatch.findMany({ where: { entity_type: 'loan_repayment' }, select: { batch_no: true } });
assert.ok(loans.length > 0 && loans.every(row => /^JK-\d{4}-\d{4,}$/.test(row.loan_no)));
assert.ok(repayments.length > 0 && repayments.every(row => /^HK-\d{4}-\d{4,}$/.test(row.repayment_no)));
assert.ok(batches.length > 0 && batches.every(row => /^PB-\d{4}-\d{4,}$/.test(row.batch_no)));
for (const [rows, column, family, prefix] of [[loans, 'loan_no', 'company_loan', 'JK'], [repayments, 'repayment_no', 'loan_repayment', 'HK']]) {
const groups = new Map();
for (const row of rows) {
const [, year, serial] = row[column].split('-');
if (!groups.has(year)) groups.set(year, []);
groups.get(year).push(Number(serial));
}
for (const [year, serials] of groups) {
assert.deepEqual(serials.sort((a, b) => a - b), Array.from({ length: serials.length }, (_, i) => i + 1));
assert.equal((await prisma.businessNumberCounter.findUnique({ where: { key: `v2:${family}:${prefix}:${year}` } })).current_number, BigInt(serials.length));
}
}
checks.push('active_numbering_real_authenticated_loan_repayment_payment_idempotency');
console.log(JSON.stringify({ status: 'PASS', numberingChecks: checks }));
} finally { await prisma.$disconnect(); }
};
@@ -0,0 +1,91 @@
#!/usr/bin/env node
// K0 only: synthetic, in-memory inputs. No env/config/backup loader or K2 entry.
const { randomBytes } = require("node:crypto");
const {
resolveFieldEncryptionKey,
encryptStringOrThrow,
decryptStringOrThrow,
} = require("../../server/src/services/crypto");
function result(status, reason) {
return { status, reason };
}
// The expected value belongs to a synthetic fixture, never a real-data probe.
// Return fixed categories only, including when crypto throws or returns null.
function checkSyntheticSample(config, encrypted, expectedPlaintext) {
try {
const key = resolveFieldEncryptionKey(config);
if (!key) return result("STOP", "KEY_UNAVAILABLE_OR_INVALID");
key.fill(0);
if (encrypted === null || encrypted === undefined || encrypted === "") {
return result("NOT_TESTED", "EMPTY_FIELD");
}
if (typeof encrypted !== "string") return result("STOP", "INVALID_PAYLOAD");
if (typeof expectedPlaintext !== "string" || !expectedPlaintext) {
return result("STOP", "SYNTHETIC_EXPECTATION_REQUIRED");
}
const plaintext = decryptStringOrThrow(config, encrypted);
if (plaintext === null) return result("STOP", "INVALID_PAYLOAD");
if (plaintext !== expectedPlaintext) return result("STOP", "PLAINTEXT_MISMATCH");
return result("PASS", "SYNTHETIC_MATCH");
} catch {
// Never call decryptStringSafe: its warning can contain a raw Error object.
return result("STOP", "DECRYPTION_FAILED");
}
}
function runSyntheticContract() {
const key = randomBytes(32);
const otherKey = Buffer.from(key);
otherKey[0] ^= 1;
try {
const config = { fieldEncryptionKey: key.toString("base64") };
const wrongConfig = { fieldEncryptionKey: otherKey.toString("base64") };
const plaintext = `synthetic-only-${randomBytes(16).toString("hex")}`;
const encrypted = encryptStringOrThrow(config, plaintext);
const parts = encrypted.split(":");
const tag = Buffer.from(parts[2], "base64url");
tag[0] ^= 1;
parts[2] = tag.toString("base64url");
const cases = [
["correct", config, encrypted, "PASS", "SYNTHETIC_MATCH"],
["missing", {}, encrypted, "STOP", "KEY_UNAVAILABLE_OR_INVALID"],
["invalid_key", { fieldEncryptionKey: "invalid" }, encrypted, "STOP", "KEY_UNAVAILABLE_OR_INVALID"],
["wrong", wrongConfig, encrypted, "STOP", "DECRYPTION_FAILED"],
["tampered", config, parts.join(":"), "STOP", "DECRYPTION_FAILED"],
["empty", config, "", "NOT_TESTED", "EMPTY_FIELD"],
["malformed", config, "v1:invalid", "STOP", "INVALID_PAYLOAD"],
];
const checks = cases.map(([category, inputConfig, payload, status, reason]) => {
const actual = checkSyntheticSample(inputConfig, payload, plaintext);
return { category, ...actual, matched: actual.status === status && actual.reason === reason };
});
return {
scope: "K0_SYNTHETIC_ONLY",
status: checks.every((check) => check.matched) ? "PASS" : "FAIL",
checks,
originalKeySource: "UNKNOWN",
custody: "NOT_TESTED",
realDataDecryption: "NOT_TESTED",
};
} finally {
key.fill(0);
otherKey.fill(0);
}
}
if (require.main === module) {
let report;
try {
report = process.argv.length === 2
? runSyntheticContract()
: { scope: "K0_SYNTHETIC_ONLY", status: "FAIL", reason: "ARGUMENTS_NOT_ALLOWED" };
} catch {
report = { scope: "K0_SYNTHETIC_ONLY", status: "FAIL", reason: "SYNTHETIC_CHECK_FAILED" };
}
process.stdout.write(`${JSON.stringify(report)}\n`);
process.exitCode = report.status === "PASS" ? 0 : 1;
}
module.exports = { checkSyntheticSample, runSyntheticContract };
+102
查看文件
@@ -0,0 +1,102 @@
const assert = require('node:assert/strict');
const { mkdtempSync } = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const root = path.resolve(__dirname, '../..');
const req = createRequire(path.join(root, 'client/package.json'));
const { chromium, expect } = req('@playwright/test');
module.exports = async function runBrowser({ app, finance, ordinary, password, company, bank, prisma }) {
const output = mkdtempSync(path.join(root, 'artifacts/qa-layout/payment-feedback-loan-slim-20260917/browser-'));
const origin = await app.listen({ host: '127.0.0.1', port: 0 });
const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
const checks = [];
try {
for (const mobile of [false, true]) {
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
const mode = mobile ? 'mobile' : 'desktop';
const target = mobile ? '/mobile/company-loans' : '/company-loans';
await page.goto(`${origin}/login?redirect=${encodeURIComponent(target)}`);
await page.getByTestId('login-identifier').fill(finance.email);
await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click();
await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
await page.getByRole('button', { name: '登记出借人', exact: true }).click();
let dialog = page.getByRole('dialog');
await dialog.getByLabel('公司主体').selectOption(company.id);
const lenderName = `合成浏览器出借人-${mode}`;
await dialog.getByLabel('出借人姓名').fill(lenderName);
await dialog.getByLabel('收款银行').selectOption(bank.id);
await dialog.getByLabel('开户行名称').fill('合成建行');
await dialog.getByLabel('账户名', { exact: true }).fill(lenderName);
await dialog.getByLabel('收款账号', { exact: true }).fill('6'.repeat(16));
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible();
await page.getByRole('tab', { name: '出借人', exact: true }).click();
const lenderCard = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${company.name}`, exact: true }) });
await lenderCard.getByRole('button', { name: '核实身份关联' }).click();
dialog = page.getByRole('dialog');
await dialog.getByLabel('出借人对应身份').selectOption('external');
await dialog.getByLabel('说明', { exact: true }).fill('合成浏览器非员工身份核实');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(lenderCard).toContainText('身份已核实');
await page.getByRole('button', { name: '登记借入', exact: true }).click();
dialog = page.getByRole('dialog');
const lender = await prisma.loanLender.findFirstOrThrow({ where: { name: lenderName } });
await dialog.getByLabel('出借人', { exact: true }).selectOption(lender.id);
const reference = `合成浏览器原借入-${mode}`;
await dialog.getByLabel('原借入凭据唯一引用').fill(reference);
await dialog.getByLabel('原始本金').fill('300.05');
await dialog.getByLabel('核对日未还本金').fill('300.05');
await dialog.getByLabel('实际到账日期').fill('2025-01-01');
await dialog.getByLabel('余额核对截止日').fill('2026-01-01');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible();
await page.getByRole('tab', { name: '借款台账', exact: true }).click();
const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference } });
const card = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${loan.loan_no}`, exact: true }) });
await card.locator('summary').click();
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) {
const received = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST');
await page.locator(`[id="${loan.id}-${kind}"]`).setInputFiles({ name: 'synthetic.pdf', mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n') });
assert.equal((await received).status(), 200);
}
await card.getByRole('button', { name: '确认期初余额' }).click();
dialog = page.getByRole('dialog');
await dialog.getByLabel('代账余额确认引用').fill('合成代账核对');
await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(card).toContainText('余额已确认');
await page.getByRole('button', { name: '申请还本', exact: true }).click();
dialog = page.getByRole('dialog');
await dialog.getByLabel('出借人', { exact: true }).selectOption(lender.id);
await dialog.getByLabel(`分配 ${reference}`, { exact: true }).fill('100.05');
await dialog.getByLabel('说明', { exact: true }).fill('合成浏览器部分还本');
await dialog.getByRole('button', { name: '提交还款申请', exact: true }).click();
await expect(page.getByText('原借款分配与会计交接', { exact: true })).toBeVisible();
await expect(page.getByText('可复核:', { exact: false }).first()).toBeVisible();
await expect(page.getByText('暂无企微个人消息发送成功记录', { exact: false }).first()).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1);
assert.equal(overflow, false, `${mode} horizontal overflow`);
await page.screenshot({ path: path.join(output, `${mode}-repayment.png`), fullPage: true });
checks.push(`${mode}_real_login_register_materials_confirm_submit_and_named_progress`);
await context.close();
}
const context = await browser.newContext({ serviceWorkers: 'block' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
await page.goto(`${origin}/login?redirect=%2Fcompany-loans`);
await page.getByTestId('login-identifier').fill(ordinary.email);
await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click();
await expect(page.getByRole('heading', { name: '公司借款', exact: true })).not.toBeVisible();
await page.waitForURL(/\/403$/);
checks.push('ordinary_employee_cannot_open_loan_ledger');
await context.close();
console.log(JSON.stringify({ stage: 'loan_browser', status: 'PASS', checks, output: path.relative(root, output) }));
} finally { await browser.close(); }
};
+331
查看文件
@@ -0,0 +1,331 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const { randomBytes, randomUUID } = require('node:crypto');
const { createRequire } = require('node:module');
const root = path.resolve(__dirname, '../..');
const req = createRequire(path.join(root, 'server/package.json'));
const { PrismaClient } = req('@prisma/client');
const { PrismaPg } = req('@prisma/adapter-pg');
const { hashPassword } = req('./src/utils/crypto');
module.exports = async function runLoanHttpFixture(pool, scratch) {
const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { disposeExternalPool: false }) });
const app = req('fastify')({ logger: false });
const config = {
fieldEncryptionKey: randomBytes(32).toString('base64'), jwtSecret: randomBytes(48).toString('hex'),
accessTokenTtl: '30m', refreshTtlSeconds: 3600, refreshCookieName: 'synthetic_refresh', cookieSecure: false,
rateLimitEnabled: false, emailEnabled: false, appOrigin: 'http://127.0.0.1',
storageDir: path.join(scratch, 'materials'), storageDriver: 'local', appTimezone: 'Asia/Shanghai',
paymentBatchEnabled: true,
};
app.decorate('config', config); app.decorate('prisma', prisma);
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({
code: error.code ?? 'SYS_001', details: error.details ?? {},
}));
await app.register(req('@fastify/cookie'));
await app.register(req('@fastify/multipart'));
await app.register(req('./src/plugins/auth'));
await app.register(req('./src/routes/auth'), { prefix: '/api/auth' });
await app.register(req('./src/routes/companyLoans'), { prefix: '/api' });
await app.register(req('./src/routes/paymentBatches'), { prefix: '/api' });
for (const route of ['me', 'systemWorkspace', 'notifications', 'masterData', 'paymentAccounts']) {
await app.register(req(`./src/routes/${route}`), { prefix: '/api' });
}
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') {
await app.register(req('@fastify/static'), { root: path.join(root, 'client/dist') });
app.setNotFoundHandler((request, reply) => request.url.startsWith('/api/') ? reply.code(404).send({ code: 'SYS_404' }) : reply.sendFile('index.html'));
}
await app.ready();
const checks = [];
try {
const company = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: '合成公司' } });
const bank = await prisma.bankInstitution.upsert({ where: { code: 'CCB' }, update: {}, create: { code: 'CCB', name: '合成建行' } });
const department = await prisma.department.create({ data: { name: '合成财务部' } });
await prisma.featureFlag.create({ data: { flag_key: 'payment_batch_v1', override_mode: 'force_on', rollout_type: 'global' } });
const password = `SyntH!${randomBytes(16).toString('hex')}`;
const passwordHash = await hashPassword(password);
async function actor(roleCodes) {
const id = randomUUID();
const employee = await prisma.employee.create({ data: {
id, email: `${id}@example.invalid`, name: '合成用户', department_id: department.id,
legal_entity_id: company.id, title: '合成岗位', hire_date: new Date('2025-01-01'),
bank_name: '合成', bank_account_encrypted: req('./src/services/crypto').encryptStringOrThrow(config, '7'.repeat(16)), password_hash: passwordHash,
roles: { create: roleCodes.map(role_code => ({ role_code })) },
} });
const login = await app.inject({ method: 'POST', url: '/api/auth/login',
headers: { 'x-device-fingerprint': randomUUID() }, payload: { identifier: employee.email, password } });
assert.equal(login.statusCode, 200, 'real synthetic password login');
return { id, email: employee.email, headers: { authorization: `Bearer ${login.json().data.accessToken}` } };
}
const finance = await actor(['finance']);
const manager = await actor(['manager']);
const system = await actor(['system_admin']);
const admin = await actor(['admin']);
const ordinary = await actor(['employee']);
async function call(who, method, url, payload, status = 200, reason) {
const response = await app.inject({ method, url: `/api${url}`, headers: who?.headers ?? {}, payload });
assert.equal(response.statusCode, status, `${method} ${url} => ${response.statusCode} ${response.json().details?.reasonCode ?? ''}`);
if (reason) assert.equal(response.json().details?.reasonCode, reason);
return response.json().data;
}
for (const who of [manager, system, admin, ordinary]) {
await call(who, 'GET', '/company-loans', undefined, 403);
await call(who, 'POST', '/loan-lenders', {}, 403);
}
await call(null, 'GET', '/company-loans', undefined, 401);
checks.push('real_login_and_explicit_finance_permissions');
const bankAccount = '9'.repeat(16); // synthetic account, never log its value
const lenderInput = { requestKey: randomUUID(), legalEntityId: company.id, name: '合成出借人',
bankInstitutionId: bank.id, bankName: '合成银行', bankAccount, accountName: '合成出借人' };
const lender = await call(finance, 'POST', '/loan-lenders', lenderInput);
assert.equal((await call(finance, 'POST', '/loan-lenders', lenderInput)).id, lender.id);
await call(finance, 'POST', '/loan-lenders', { ...lenderInput, name: 'different' }, 409, 'loan.duplicate_registration');
assert.equal(lender.identityStatus, 'unverified');
assert.ok(!JSON.stringify(lender).includes(bankAccount));
assert.ok(!JSON.stringify(lender).includes('v1:'));
await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
expectedVersion: 1, employeeId: null, externalPersonConfirmed: false, reason: '合成核实',
}, 400, 'loan.invalid_input');
const verified = await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
expectedVersion: 1, employeeId: null, externalPersonConfirmed: true, reason: '合成非员工核实',
});
assert.equal(verified.identityStatus, 'verified');
checks.push('lender_encryption_identity_confirmation_and_idempotency');
const input = { requestKey: randomUUID(), lenderId: lender.id, originReference: '合成原借入凭据一',
originalPrincipal: '1000.10', openingOutstandingPrincipal: '800.05',
receivedDate: '2025-01-01', balanceAsOf: '2026-01-01', historicalEntry: true };
const loan = await call(finance, 'POST', '/company-loans', input);
assert.equal(loan.available, '0.00'); assert.equal(loan.outstanding, '800.05');
assert.equal((await call(finance, 'POST', '/company-loans', input)).id, loan.id);
await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID() }, 409, 'loan.duplicate_registration');
await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), receivedDate: '2025-02-30' }, 400);
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required');
const material = Buffer.from('%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n');
async function upload(kind, data = material, filename = 'synthetic.pdf', type = 'application/pdf', status = 200, loanId = loan.id) {
const boundary = `synthetic-${randomUUID()}`;
const payload = Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${type}\r\n\r\n`), data, Buffer.from(`\r\n--${boundary}--\r\n`)]);
const response = await app.inject({ method: 'POST', url: `/api/company-loans/${loanId}/evidences/${kind}`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, payload });
assert.equal(response.statusCode, status, `upload ${kind}`);
return response.json().data;
}
await upload('agreement', Buffer.from('<script>synthetic</script>'), 'bad.pdf', 'application/pdf', 400);
const evidence = await upload('agreement'); await upload('incoming'); await upload('accounting_confirmation');
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' }, 409, 'loan.evidence_required');
await upload('adjustment');
const confirmed = await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对' });
assert.equal(confirmed.available, '800.05'); assert.equal(confirmed.version, 2);
await call(finance, 'POST', `/company-loans/${loan.id}/confirm`, { expectedVersion: 2, accountingConfirmationRef: '改写' }, 409, 'loan.confirmed_immutable');
checks.push('historical_balance_evidence_and_outside_repayment_required');
await call(ordinary, 'POST', `/company-loans/${loan.id}/evidences/${evidence.id}/download`, { reason: '合成读取' }, 403);
await call(finance, 'POST', `/company-loans/${loan.id}/evidences/${evidence.id}/download`, {}, 400);
const downloaded = await app.inject({ method: 'POST', url: `/api/company-loans/${loan.id}/evidences/${evidence.id}/download`,
headers: finance.headers, payload: { reason: '合成核对读取' } });
assert.equal(downloaded.statusCode, 200);
assert.ok(downloaded.headers['content-disposition'].startsWith('attachment;'));
assert.equal(downloaded.headers['x-content-type-options'], 'nosniff');
assert.ok(downloaded.headers['cache-control'].includes('no-store'));
checks.push('scoped_evidence_download_and_headers');
// Real database audit failure must roll back the source write and not merely log it.
await pool.query(`CREATE FUNCTION synthetic_fail_loan_audit() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.action='COMPANY_LOAN_REGISTERED' THEN RAISE EXCEPTION 'synthetic audit unavailable'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_loan_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_loan_audit()');
const count = await prisma.companyLoan.count();
try {
await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), originReference: '合成失败' }, 500);
assert.equal(await prisma.companyLoan.count(), count);
} finally {
await pool.query('DROP TRIGGER synthetic_loan_audit ON audit_logs');
await pool.query('DROP FUNCTION synthetic_fail_loan_audit()');
}
checks.push('real_audit_failure_atomic_rollback');
const { encryptStringOrThrow } = req('./src/services/crypto');
const payerAccount = '8'.repeat(16);
await prisma.paymentAccount.create({ data: { legal_entity_id: company.id, bank_institution_id: bank.id,
account_name: '合成付款公司', account_number_encrypted: encryptStringOrThrow(config, payerAccount) } });
const second = await call(finance, 'POST', '/company-loans', { ...input, requestKey: randomUUID(), originReference: '合成原借入凭据二',
originalPrincipal: '300.05', openingOutstandingPrincipal: '300.05' });
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) await upload(kind, material, 'synthetic.pdf', 'application/pdf', 200, second.id);
await call(finance, 'POST', `/company-loans/${second.id}/confirm`, { expectedVersion: 1, accountingConfirmationRef: '合成核对二' });
const repaymentInput = { requestKey: randomUUID(), lenderId: lender.id, reason: '合成两笔借入部分还本',
allocations: [{ loanId: loan.id, amount: '100.10' }, { loanId: second.id, amount: '200.05' }] };
const { item: batch } = await call(finance, 'POST', '/loan-repayments', repaymentInput);
assert.equal(batch.items.length, 1); assert.equal(String(batch.totalAmount), '300.15');
assert.equal((await call(finance, 'POST', '/loan-repayments', repaymentInput)).item.id, batch.id);
await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, reason: 'changed' }, 409, 'loan.duplicate_registration');
const balance = await call(finance, 'GET', `/company-loans/${loan.id}`);
assert.equal(balance.outstanding, '800.05'); assert.equal(balance.reserved, '100.10'); assert.equal(balance.available, '699.95');
await call(finance, 'POST', `/payment-batches/${batch.id}/review`, {}, 403);
await call(admin, 'POST', `/payment-batches/${batch.id}/execute`, {}, 403);
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {});
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}, 409, 'payment_batch.bank_receipt_required');
await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '提前交接' }, 409, 'loan.handoff_not_ready');
checks.push('two_loans_one_payment_partial_allocation_idempotency_and_review');
const { runReceiptOcrOnce } = req('./src/jobs/paymentReceiptOcrJob');
async function receipt(batchId, amount, reference, overrides = {}) {
const boundary = `synthetic-${randomUUID()}`;
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/receipt-attachments`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="files"; filename="receipt.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), material, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
assert.equal(response.statusCode, 200, 'receipt upload');
const recognition = { status: 'recognized', pagesExpected: 1, pagesProcessed: 1,
pages: [{ pageNumber: 1, amount, currency: 'CNY', payerAccount, payeeAccount: bankAccount,
transactionStatus: 'success', reference, issues: [], ...overrides }] };
// Only the cloud recognizer is replaced. Queue claiming, local storage,
// normalization, verification, transaction claims and HTTP writes are real.
const result = await runReceiptOcrOnce({ prisma, config, recognize: async () => recognition });
return { attachment: response.json().data.items[0], result };
}
const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} });
assert.equal(exportResponse.statusCode, 200, 'loan bank-order export');
assert.equal(exportResponse.rawPayload.subarray(0, 8).toString('hex'), 'd0cf11e0a1b11ae1');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '制单后银行结果待查' }, 409, 'loan.bank_state_unsafe');
checks.push('actual_biff8_export_keeps_principal_and_prevents_unsafe_release');
const receiptResult = await receipt(batch.id, '30015', 'SYNTHETIC-BANK-1');
assert.equal(receiptResult.result.status, 'matched');
await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '未知银行结果不能释放' }, 409, 'loan.bank_state_unsafe');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
await pool.query(`CREATE FUNCTION synthetic_fail_settlement_audit() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.action='LOAN_PRINCIPAL_SETTLED' THEN RAISE EXCEPTION 'synthetic settlement audit unavailable'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_settlement_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_settlement_audit()');
try {
await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {}, 500);
assert.equal(await prisma.paymentReceiptClaim.count(), 0);
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
} finally {
await pool.query('DROP TRIGGER synthetic_settlement_audit ON audit_logs');
await pool.query('DROP FUNCTION synthetic_fail_settlement_audit()');
}
const paid = await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {});
assert.equal(paid.item.status, 'executed');
const after = await call(finance, 'GET', `/company-loans/${loan.id}`);
assert.equal(after.outstanding, '699.95'); assert.equal(after.reserved, '0.00');
assert.equal(await prisma.paymentReceiptClaim.count(), 1);
await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {});
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '699.95');
await call(finance, 'POST', `/loan-repayments/${batch.loanRepaymentId}/accounting-handoff`, { reference: '合成会计凭证' });
checks.push('real_receipt_worker_atomic_claim_settlement_rollback_retry_and_handoff');
const nextInput = { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '300.15' }] };
const { item: duplicate } = await call(finance, 'POST', '/loan-repayments', nextInput);
await call(manager, 'POST', `/payment-batches/${duplicate.id}/review`, {});
await receipt(duplicate.id, '30015', 'SYNTHETIC-BANK-1');
await call(finance, 'POST', `/payment-batches/${duplicate.id}/execute`, {}, 409, 'payment_batch.receipt_transaction_reused');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '699.95');
const concurrentInput = { ...repaymentInput, allocations: [{ loanId: loan.id, amount: '300.00' }] };
const concurrent = await Promise.all([1, 2].map(() => app.inject({ method: 'POST', url: '/api/loan-repayments',
headers: finance.headers, payload: { ...concurrentInput, requestKey: randomUUID() } })));
assert.deepEqual(concurrent.map(r => r.statusCode).sort(), [200, 409]);
const winner = concurrent.find(r => r.statusCode === 200).json().data.item;
await call(finance, 'POST', `/payment-batches/${winner.id}/cancel`, { reason: '合成未发送取消' });
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80');
checks.push('duplicate_transaction_refusal_real_http_concurrency_and_safe_cancel');
const { item: failedBank } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '10.00' }] });
await call(manager, 'POST', `/payment-batches/${failedBank.id}/review`, {});
const failureReceipt = await receipt(failedBank.id, '1000', 'SYNTHETIC-FAILED', { transactionStatus: 'failed', issues: ['transaction_failed'] });
await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409);
await call(finance, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id }, 403);
await call(manager, 'POST', `/payment-batches/${failedBank.id}/bank-failure-review`, { attachmentId: failureReceipt.attachment.id });
await call(finance, 'POST', `/payment-batches/${failedBank.id}/execute`, {}, 409);
await call(finance, 'POST', `/payment-batches/${failedBank.id}/cancel`, { reason: '合成银行明确失败' });
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).available, '399.80');
checks.push('confirmed_bank_failure_requires_independent_matching_evidence');
const { item: manual } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '20.00' }] });
await call(manager, 'POST', `/payment-batches/${manual.id}/review`, {});
const unclear = await receipt(manual.id, null, null, { payerAccount: null, payeeAccount: null, transactionStatus: null, issues: [] });
assert.equal(unclear.result.status, 'needs_review');
const currentAttachment = await prisma.paymentBatchReceiptAttachment.findUnique({ where: { id: unclear.attachment.id } });
const manualBase = `/payment-batches/${manual.id}/receipt-attachments/${unclear.attachment.id}/manual-verification`;
const proposal = await call(finance, 'POST', manualBase, { expectedVersion: currentAttachment.receipt_ocr_version,
reason: '合成原件独立人工核对', pages: [{ pageNumber: 1, itemId: manual.items[0].id, fields: Object.entries({
小写金额: '20.00', 币别: 'CNY', 付款人账号: payerAccount, 收款人账号: bankAccount, 凭证号: 'SYNTHETIC-MANUAL', 单据状态: '交易成功',
}).map(([Name, Value]) => ({ Name, Value })) }] });
const saved = proposal.item ?? proposal;
await call(finance, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成不允许自审' }, 403);
await call(manager, 'POST', `${manualBase}/${saved.id}/review`, { expectedVersion: saved.version, decision: 'approve', reason: '合成独立复核' });
await call(finance, 'POST', `/payment-batches/${manual.id}/execute`, {});
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '679.95');
checks.push('external_lender_two_person_manual_receipt_and_real_settlement');
// Cross-source claim protection: seed only the prior reimbursement facts,
// then use actual receipt upload/worker/execute HTTP. No reimbursement paid
// result is seeded or asserted by this setup.
const source = await prisma.reimbursementForm.create({ data: {
form_number: `SYN-${randomUUID()}`, submitter_id: ordinary.id, owner_id: ordinary.id,
department_id: department.id, reimbursement_date: new Date('2026-01-01'), total_amount: '300.15', status: 'approved',
} });
const savedBatch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id }, include: { items: true } });
const crossBatch = await prisma.paymentBatch.create({ data: {
batch_no: `SYN-${randomUUID()}`, name: '合成跨业务去重', entity_type: 'reimbursement', status: 'reviewed',
created_by: finance.id, reviewed_by: manager.id, reviewed_at: new Date(), legal_entity_id: company.id,
payment_account_id: savedBatch.payment_account_id, payment_account_snapshot_json: savedBatch.payment_account_snapshot_json,
default_payment_channel: 'bank', items: { create: { entity_id: source.id, entity_code: source.form_number, entity_title: '合成报销来源',
amount: '300.15', snapshot_json: savedBatch.items[0].snapshot_json } },
} });
await receipt(crossBatch.id, '30015', 'SYNTHETIC-BANK-1');
await call(finance, 'POST', `/payment-batches/${crossBatch.id}/execute`, {}, 409, 'payment_batch.receipt_transaction_reused');
assert.equal((await prisma.reimbursementForm.findUniqueOrThrow({ where: { id: source.id } })).status, 'approved');
checks.push('same_bank_transaction_rejected_across_loan_and_reimbursement_http');
for (const [label, overrides] of [
['amount', { amount: '101' }], ['currency', { currency: 'USD' }],
['payer', { payerAccount: '1'.repeat(16) }], ['payee', { payeeAccount: '2'.repeat(16) }],
['pending', { transactionStatus: 'pending', issues: ['transaction_pending'] }],
]) {
const { item: rejected } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput,
requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] });
await call(manager, 'POST', `/payment-batches/${rejected.id}/review`, {});
const mismatch = await receipt(rejected.id, '100', `SYNTHETIC-NEGATIVE-${label}`, overrides);
assert.equal(mismatch.result.status, 'needs_review');
await call(finance, 'POST', `/payment-batches/${rejected.id}/execute`, {}, 409);
await call(manager, 'POST', `/payment-batches/${rejected.id}/bank-failure-review`, { attachmentId: mismatch.attachment.id }, 409);
await call(finance, 'POST', `/payment-batches/${rejected.id}/cancel`, { reason: '不明或不符不得释放' }, 409, 'loan.bank_state_unsafe');
}
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '679.95');
checks.push('wrong_amount_currency_both_accounts_and_pending_never_settle_or_release');
const noReviewerInput = { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] };
await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } });
try { await call(finance, 'POST', '/loan-repayments', noReviewerInput, 409, 'loan.reviewer_required'); }
finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); }
const sameKey = await Promise.all([1, 2].map(() => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: noReviewerInput })));
assert.deepEqual(sameKey.map(r => r.statusCode), [200, 200]);
assert.equal(sameKey[0].json().data.item.id, sameKey[1].json().data.item.id);
await call(finance, 'POST', `/payment-batches/${sameKey[0].json().data.item.id}/cancel`, { reason: '合成并发验证结束' });
checks.push('no_eligible_reviewer_blocks_and_concurrent_same_key_is_idempotent');
const evidencePath = `/company-loans/${loan.id}/evidences/${evidence.id}/download`;
const reviewerDownload = await app.inject({ method: 'POST', url: `/api${evidencePath}`, headers: manager.headers,
payload: { reason: '合成还款审批材料', paymentBatchId: manual.id } });
assert.equal(reviewerDownload.statusCode, 200);
await call(manager, 'POST', evidencePath, { reason: '缺少审批范围' }, 403);
const { item: stale } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '5.00' }] });
await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
expectedVersion: verified.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成收款人关联经理',
});
await call(manager, 'POST', `/payment-batches/${stale.id}/review`, {}, 403);
const independent = await actor(['ceo']);
await call(independent, 'POST', `/payment-batches/${stale.id}/review`, {}, 409, 'loan.version_changed');
await call(finance, 'POST', `/payment-batches/${stale.id}/cancel`, { reason: '合成未发起身份更正' });
const { item: ownPayee } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '5.00' }] });
await call(manager, 'POST', `/payment-batches/${ownPayee.id}/review`, {}, 403);
await call(independent, 'POST', `/payment-batches/${ownPayee.id}/review`, {});
await call(finance, 'POST', `/payment-batches/${ownPayee.id}/cancel`, { reason: '合成验证结束' });
checks.push('scoped_reviewer_materials_payee_self_review_and_identity_version_guards');
const { item: oldAccount } = await call(finance, 'POST', '/loan-repayments', { ...repaymentInput, requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] });
const changedAccount = await call(finance, 'PATCH', `/loan-lenders/${lender.id}/account`, {
expectedVersion: lender.accountVersion, bankInstitutionId: bank.id, bankName: '合成变更开户行',
bankAccount: '3'.repeat(16), accountName: '合成出借人',
});
assert.equal(changedAccount.identityStatus, 'unverified');
await call(independent, 'POST', `/payment-batches/${oldAccount.id}/review`, {}, 409, 'loan.identity_required');
await call(finance, 'POST', `/loan-lenders/${lender.id}/identity-confirmation`, {
expectedVersion: changedAccount.identityVersion, employeeId: manager.id, externalPersonConfirmed: false, reason: '合成账户变更后重新核实',
});
await call(independent, 'POST', `/payment-batches/${oldAccount.id}/review`, {}, 409, 'loan.version_changed');
await call(finance, 'POST', `/payment-batches/${oldAccount.id}/cancel`, { reason: '合成旧账户未发起取消' });
checks.push('account_changes_revoke_identity_and_invalidate_old_unpaid_snapshot');
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') {
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma });
}
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
} finally { await app.close(); await prisma.$disconnect(); }
};
+1 -593
查看文件
@@ -1,596 +1,4 @@
{
"version": 1,
"entries": [
{"file":"server/src/plugins/auth.js","fingerprint":"736318b046fb0f03c3c1e1db2630c7d84a63d0bb47a9ba15d56444a75cbc3bba","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:authPlugin"},
{"file":"server/src/routes/ai.js","fingerprint":"08c44e703511941756749328a4035ccb43674cc82d91dee795211ef829fe01bc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"},
{"file":"server/src/routes/announcements.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/announcements.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"},
{"file":"server/src/routes/announcements.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/announcements.js","fingerprint":"9fcc268e8e61e61364ca617c982d1cff4a1b76290ff19822ed363383be4c3cda","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/announcements.js","fingerprint":"f2b59eab3ecb251e0fc247952125ed33c0c87173c26052b01af7eb6c7fed2595","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeAnnouncementPolicyFields"},
{"file":"server/src/routes/announcements.js","fingerprint":"b7bbbb68fab3f1f34753f2cd1dd5d3834cc220df4be0dc915c252db56125ddd9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/announcements"},
{"file":"server/src/routes/announcements.js","fingerprint":"16e3a77398374a9da0ba54d34380f16866000cd809d649d13c68a262437cecb8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/announcements/:id"},
{"file":"server/src/routes/announcements.js","fingerprint":"b489caa88f9c4b0f9cbdd4ddaa317a550973dc746fe11c44f556d6b0be5c5c58","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/announcements/:id/acknowledge"},
{"file":"server/src/routes/announcements.js","fingerprint":"6f2ecb599e0390bc141c7c6a8874a4a0b04b55c3f9c9f249432829ef2aa4c21f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/announcements/:id/acknowledge"},
{"file":"server/src/routes/apOperations.js","fingerprint":"48951ae2ad49593089e10d00afd95a5f18e10c2cbcb87f7956eb47a59d60cfa0","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/apOperations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/ap-operations/reimbursement-ledger"},
{"file":"server/src/routes/approvalRules.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/approvalRules.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseApprovalRuleId"},
{"file":"server/src/routes/approvalRules.js","fingerprint":"cd6c77086d44835903ad40dc099ee6640c1ddfb1323f346af4ba86ed8a0ea323","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAmountRangeValid"},
{"file":"server/src/routes/approvalRules.js","fingerprint":"993d7526060943199799457112b59cf24e63a46341d3dc1080ddb6c8d0af317c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAmountRangeValid"},
{"file":"server/src/routes/approvalRules.js","fingerprint":"b8698fe0fd39540fb0a1d21bf610947927564bdf7150e35b98ee0077317cc84b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureOwnerSelfFallbackRoleValid"},
{"file":"server/src/routes/approvalRules.js","fingerprint":"996fd561bfa6933b8a36273a25fecfecc94d0b366da830685336ddc7f1212eca","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/approval-rules/:id"},
{"file":"server/src/routes/assetMovements.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/assetMovements.js","fingerprint":"6c58fb0c0b2fb699a6a013d4d40a2b87c072068b06e766e74b0e092cc7a335d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"},
{"file":"server/src/routes/assetMovements.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/assetMovements.js","fingerprint":"62bc45cfdb1f68c9dd134641c77cdfdfb653555f2e498e397e39a40e41e166e8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureMovementAllowed"},
{"file":"server/src/routes/assetMovements.js","fingerprint":"d5b13762c5f0318690bb04b0e4d006e066524b06b185f9615a335396e6f0c93c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAssetExists"},
{"file":"server/src/routes/assetMovements.js","fingerprint":"22b2935f24450ede35fa143304840ecaa3496a12c034f5ad284c2eef01c50cba","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureActiveEmployee"},
{"file":"server/src/routes/assetMovements.js","fingerprint":"186bfa660e6ea4282e9f63afccc7348f2096c8402e8e901439fb15627837565a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureActiveEmployee"},
{"file":"server/src/routes/assetMovements.js","fingerprint":"9c55abdac47794fe22e2d5663060d2db6753aa236adba4be735827e00b11cbdb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/assets/:id/assign"},
{"file":"server/src/routes/assetMovements.js","fingerprint":"6a2623801e3905473f5af5a6aeb45e5ad43aa309026772ccdd536c6ffbad2e8f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/assets/:id/return"},
{"file":"server/src/routes/assetMovements.js","fingerprint":"d5a1ffc7fafbd0cab7a4ba98d3a9da8817c9166e452d4e1997bb6dbc93ca052d","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/assets/:id/transfer"},
{"file":"server/src/routes/assetMovements.js","fingerprint":"b239d697b3f7d3d616328076dfc48f7f081e0d74f178031d8760214a2b333137","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/assets/:id/transfer"},
{"file":"server/src/routes/assets.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/assets.js","fingerprint":"6c58fb0c0b2fb699a6a013d4d40a2b87c072068b06e766e74b0e092cc7a335d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"},
{"file":"server/src/routes/assets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCategoryListQuery"},
{"file":"server/src/routes/assets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseAssetListQuery"},
{"file":"server/src/routes/assets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseAssigneeListQuery"},
{"file":"server/src/routes/assets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"},
{"file":"server/src/routes/assets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"},
{"file":"server/src/routes/assets.js","fingerprint":"c871135b601c023c98f31d5555f893efbefce44a1c0964220df341897674629c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"},
{"file":"server/src/routes/assets.js","fingerprint":"01a2842a9be803e2beaabaec7fe403c509d868712d5e38b0650eafe19567fb96","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateAssetTextBoundaries"},
{"file":"server/src/routes/assets.js","fingerprint":"01a2842a9be803e2beaabaec7fe403c509d868712d5e38b0650eafe19567fb96","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateRedactedImportBoundaries"},
{"file":"server/src/routes/assets.js","fingerprint":"940b4a2172252aa6e6bc4f8dd8865e3361ba2c7fd53c77414f3d9ef73c1fdffa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDocumentCategoryAvailable"},
{"file":"server/src/routes/assets.js","fingerprint":"ea52a43d4fba3f07b80c3328f4ff96fedf5a51348bee2ef60d5bf721deaef49c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDocumentCategoryAvailable"},
{"file":"server/src/routes/assets.js","fingerprint":"d701b972a1ed447a07f76be872f47a5988c6c9dd5ae4edeb4badaadec3f4bf85","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDocumentCategoryAvailable"},
{"file":"server/src/routes/assets.js","fingerprint":"b50f5f62f57a8d2e85808fd7633568a49e91b8a821e2afdead6647a7ea84b159","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAssetCategoryExists"},
{"file":"server/src/routes/assets.js","fingerprint":"0ba3108bacfe6c81125153caf72003c1bd3d445e8493290d1e7e30b56e456b84","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAssetCategoryExists"},
{"file":"server/src/routes/assets.js","fingerprint":"80914f548ee5899eb71a178c4a13ad10646bdd2c0fed7acd44c5924581dfc2aa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAssetCategoryExists"},
{"file":"server/src/routes/assets.js","fingerprint":"6f091706e1ec75d17b76658dd81f7a3f7347110b8c6a5e7a81b7fda6020358f4","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"},
{"file":"server/src/routes/assets.js","fingerprint":"b1fab48ce07d1d7f2308476dd38dfbcce526a64bd50de2ac484d2e5b0e3e8fff","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"},
{"file":"server/src/routes/assets.js","fingerprint":"d5aa541584c0cf873d161fc6e31f82c8a1ec62b67eebd4444c5d52270123eb13","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"},
{"file":"server/src/routes/assets.js","fingerprint":"96523ea82ed258eb92ca4990dc8bc6bb84d3e3bbf76d66dca60b15931455cdc8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"},
{"file":"server/src/routes/assets.js","fingerprint":"57ccb19330174bae06b385dca2cb8c41ce487980099e934fef19f8fc3a15cba7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"},
{"file":"server/src/routes/assets.js","fingerprint":"22b2935f24450ede35fa143304840ecaa3496a12c034f5ad284c2eef01c50cba","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureActiveEmployee"},
{"file":"server/src/routes/assets.js","fingerprint":"186bfa660e6ea4282e9f63afccc7348f2096c8402e8e901439fb15627837565a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureActiveEmployee"},
{"file":"server/src/routes/assets.js","fingerprint":"49c2624e15c3d37dbae668f8c668461f5451fc4bf15fea7cac68c42ec099fdc8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureApprovedPurchaseRequest"},
{"file":"server/src/routes/assets.js","fingerprint":"4ae96897d6ee2f02a7aa80c4d966629721cb8ea4c47b5713c32dfc6274ad3e5a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureApprovedPurchaseRequest"},
{"file":"server/src/routes/assets.js","fingerprint":"d5b13762c5f0318690bb04b0e4d006e066524b06b185f9615a335396e6f0c93c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/asset-categories/:id"},
{"file":"server/src/routes/assets.js","fingerprint":"442e2a0cd3f40bb741d5d4727944806abae5ce494465168d0a45fa8e9c3063ca","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/assets/import/redacted-doc-index"},
{"file":"server/src/routes/assets.js","fingerprint":"d5b13762c5f0318690bb04b0e4d006e066524b06b185f9615a335396e6f0c93c","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/assets/:id"},
{"file":"server/src/routes/attendance.js","fingerprint":"6ed67e68f41d5964a43a8a6e8d0ed71207418c81f7ad013aa3cfd9158f1ae29c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseInput"},
{"file":"server/src/routes/attendance.js","fingerprint":"2a45170a8628f3a739cf666505b0f43784def012f00d0ed87a220b3d70d79589","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/attendance.js","fingerprint":"4ff302aeefae7b9ad6f35e657c3d2abf738531a34d8f7e655f31e09a27dc41f8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureUnifiedFeatureEnabled"},
{"file":"server/src/routes/attendance.js","fingerprint":"a657a09fd09b5fe31ee45ebfaaeda99ba30d6944bade370d5d0d7ac3c7456a5a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseOptionalDateTime"},
{"file":"server/src/routes/attendance.js","fingerprint":"99ebe0d43dca95cb33f043545c5302d439e6822f524e9f2c083f1d9f46feb362","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseMonthRange"},
{"file":"server/src/routes/attendance.js","fingerprint":"02626f66ac5688562048fbc15a189c27d9c88c08778562564396aa8534eed7d1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/attendance/clock-in"},
{"file":"server/src/routes/attendance.js","fingerprint":"02626f66ac5688562048fbc15a189c27d9c88c08778562564396aa8534eed7d1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/attendance/clock-out"},
{"file":"server/src/routes/attendance.js","fingerprint":"287252e0c5d2fdaadef7cfed6b3ba8982e97a10db47a8e422a877eda7aa4d4bb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/attendance/clock-out"},
{"file":"server/src/routes/attendance.js","fingerprint":"8e69dcaef6fa42597a8ca6804bbcc30d18e4641c50a5945a4171e2f3fec1f59f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/attendance/admin/backfill"},
{"file":"server/src/routes/attendance.js","fingerprint":"3bc8430a72bd41e66bb2de2fc5b38cb78b7306a353e0beb21adf56eefce42840","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/attendance/admin/backfill"},
{"file":"server/src/routes/audit.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"},
{"file":"server/src/routes/audit.js","fingerprint":"510d8ad88dde0075df37838cd0b7ff6241145d700817ea5f203086abbae5b568","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"},
{"file":"server/src/routes/budgetControlRules.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/budgetControlRules.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseRuleId"},
{"file":"server/src/routes/budgetControlRules.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"},
{"file":"server/src/routes/budgetControlRules.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/budgetControlRules.js","fingerprint":"7c065e9daea0385b2bfb3ab1e1948e602a6376bac9e44d5d8c90564cd0f1c1f8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/budgetControlRules.js","fingerprint":"c92ca88007ea087989f14e6555add522f0ab717298fc801fafc942e1187c93cf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/budgets.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/budgets.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBudgetId"},
{"file":"server/src/routes/budgets.js","fingerprint":"4bcacc7fa10fe5395c9233195e0941962c3f9fa6115492445e654f070fe58075","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDepartmentAvailable"},
{"file":"server/src/routes/budgets.js","fingerprint":"7f37e2cba05a7d3afeaf7a814afb721bad3a3ef590eb388aeb9149b612ab9cef","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDepartmentAvailable"},
{"file":"server/src/routes/budgets.js","fingerprint":"8506eaeef8adc8cc277fcffb3ce92086e955ecadb4149df7b5a01363da60d337","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"},
{"file":"server/src/routes/budgets.js","fingerprint":"c4967a64f972aa0396e182d2d06880ab684c25d2545466aad0854828740ad39e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"},
{"file":"server/src/routes/budgets.js","fingerprint":"3cc514556b50dae2ebe09ef19b335d02d760c8375bf6d85dae2b9cce523f1870","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"},
{"file":"server/src/routes/budgets.js","fingerprint":"dc93103cf32df2eb4384550e3ae7ebcc290879e69e11aad7eaea6481f8f414fb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"},
{"file":"server/src/routes/budgets.js","fingerprint":"0bd9235198a75999811434ce3e70e584019d64555ede15c2202bd5f1030edf89","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"},
{"file":"server/src/routes/budgets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/budgets"},
{"file":"server/src/routes/budgets.js","fingerprint":"12506f55725f28e98268fb5ffef82d9e96e5b7738fea52108977bb3a0efd511f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/budgets"},
{"file":"server/src/routes/budgets.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/budgets/:id"},
{"file":"server/src/routes/budgets.js","fingerprint":"92e4ca56a07acf54433ae06c7af4755bd7aa80956a055e41da4388cb5ac61962","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/budgets/:id"},
{"file":"server/src/routes/confidentialDocRefs.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/confidentialDocRefs.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/confidentialDocRefs.js","fingerprint":"09dcc86e0b4bc71f7bd78b881d1bb47f727736ba45d232d08ed0bf87c23072de","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/confidentialDocRefs.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/confidential-doc-refs"},
{"file":"server/src/routes/configRead.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseEnabledQuery"},
{"file":"server/src/routes/configRead.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseDocumentTypeParams"},
{"file":"server/src/routes/contractsRegistry.js","fingerprint":"0d7d8d325c8629980a1d6f1c569255c2995059d5b19667be27f57eed2ea837af","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/contractsRegistry.js","fingerprint":"254541548938a93942be3a6f1bd3b33b17c82aa977fd75228871a5842d9c10c1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/contractsRegistry.js","fingerprint":"92558dc9d5ba5ab7224a8267f2b79920feaec4457df73b8f30cacb6aef9f12d3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:updateContractWithStateGuard"},
{"file":"server/src/routes/contractsRegistry.js","fingerprint":"cba9e7b82b29c4ec1f89720620c1f78ea2d67133338d9957c8baece08306bd2b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id"},
{"file":"server/src/routes/contractsRegistry.js","fingerprint":"60347bec707618636867c4310250919b6443c10f22f88f68f6c8d2c3de565d13","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id"},
{"file":"server/src/routes/contractsRegistry.js","fingerprint":"0f95d3875c822fa7d8c016aa7be48ad2d20a658b57bed0cbed14dcfe4196a2b9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id/terminate"},
{"file":"server/src/routes/contractsRegistry.js","fingerprint":"c5fc8ab4ac98ddff39aa922bbd24744a815c5c423aa5b18687dc59611f1273da","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id/terminate"},
{"file":"server/src/routes/contractsRegistry.js","fingerprint":"ad732dac27597f2483143c7f845399a79c2a276d4b89e32fcb96f583930b4240","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id/corrections"},
{"file":"server/src/routes/contractsRegistry.js","fingerprint":"d3204f15378a5bfcdea2de26e96d097a89e142fa3329a2d7f742b917f2f22c39","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id/corrections"},
{"file":"server/src/routes/contractsRegistry.js","fingerprint":"80c9775ffbd6d8c8f43d0baf4539ffa3649a923f74b3973d8fe78fff4a51ab26","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/contracts-registry/:id/renew"},
{"file":"server/src/routes/cutover.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/cutover.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"},
{"file":"server/src/routes/cutover.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/cutover.js","fingerprint":"43aaacd8708bb5c07aa913145a5dfa84cd402038b1fee3ada860ee249f8ce9c3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/dashboard.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"},
{"file":"server/src/routes/dashboard.js","fingerprint":"510d8ad88dde0075df37838cd0b7ff6241145d700817ea5f203086abbae5b568","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"},
{"file":"server/src/routes/delegations.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseDelegationId"},
{"file":"server/src/routes/delegations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"},
{"file":"server/src/routes/delegations.js","fingerprint":"3e5bc2343859761fdfae1aedfba4dd9d8d252d16e479b01a5ac477e25b700209","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"},
{"file":"server/src/routes/delegations.js","fingerprint":"abc5f3ef3dcaeb55ac80eb31853eed3c582d7c7ded7421c3903effdb2b83fabb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"},
{"file":"server/src/routes/delegations.js","fingerprint":"1f1a149c83f74bc04b86baf4116b9601c8956a4a0bf75a66c3848108e9681fbb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"},
{"file":"server/src/routes/delegations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateGrantBody"},
{"file":"server/src/routes/delegations.js","fingerprint":"3e5bc2343859761fdfae1aedfba4dd9d8d252d16e479b01a5ac477e25b700209","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateGrantBody"},
{"file":"server/src/routes/delegations.js","fingerprint":"abc5f3ef3dcaeb55ac80eb31853eed3c582d7c7ded7421c3903effdb2b83fabb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateGrantBody"},
{"file":"server/src/routes/delegations.js","fingerprint":"1f1a149c83f74bc04b86baf4116b9601c8956a4a0bf75a66c3848108e9681fbb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateGrantBody"},
{"file":"server/src/routes/delegations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseStatusQuery"},
{"file":"server/src/routes/delegations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListDelegationGrantsQuery"},
{"file":"server/src/routes/delegations.js","fingerprint":"c65eb61b3d8ca109854fc10890d4307723bbd03dc93d1549964d328ae9bc5069","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDelegateEligible"},
{"file":"server/src/routes/delegations.js","fingerprint":"2d1c39e41522f8863cd6928b50a11bb9958d348b7a9f20c5292608e650667c17","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDelegateEligible"},
{"file":"server/src/routes/delegations.js","fingerprint":"dd4725b4c2c79ef5e59dbe55b5eb4c177d167186dc7ef8b6d17f6f61bebc43a5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDelegateEligible"},
{"file":"server/src/routes/delegations.js","fingerprint":"86ec38bd74707cd6917fe2a7ffc79440867b6a37e71efb3b6981aa9a7b560194","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/delegation-grants"},
{"file":"server/src/routes/delegations.js","fingerprint":"1ba976c328b41f63396aeb96618d8c525df8255f3ce17510fd019421207bd3d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/delegation-grants"},
{"file":"server/src/routes/delegations.js","fingerprint":"86ec38bd74707cd6917fe2a7ffc79440867b6a37e71efb3b6981aa9a7b560194","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/delegations"},
{"file":"server/src/routes/departments.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseDepartmentId"},
{"file":"server/src/routes/departments.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/departments.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"},
{"file":"server/src/routes/departments.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"},
{"file":"server/src/routes/departments.js","fingerprint":"dd4c7435c5433e19eb9893f762c76225772c4cf62eb01c6c45d7b8d1b207705a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"},
{"file":"server/src/routes/departments.js","fingerprint":"5326d4c4b0ac27a96de8b209570f3d8ee687aad427a8cb2b9957484b287e02ed","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments"},
{"file":"server/src/routes/departments.js","fingerprint":"8d444b923b812b3afe71913b82f132c3955e73935ebd12361bab0edd67214d28","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments"},
{"file":"server/src/routes/departments.js","fingerprint":"fa47bac9301143e633b50167c8fab4a40a1edb9397734104569905b99f5ad4aa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id"},
{"file":"server/src/routes/departments.js","fingerprint":"0c6eb41240d31840a48750871c49320b8feecf58984a2a969144276fac1298f7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id"},
{"file":"server/src/routes/departments.js","fingerprint":"5326d4c4b0ac27a96de8b209570f3d8ee687aad427a8cb2b9957484b287e02ed","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id"},
{"file":"server/src/routes/departments.js","fingerprint":"8d444b923b812b3afe71913b82f132c3955e73935ebd12361bab0edd67214d28","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id"},
{"file":"server/src/routes/departments.js","fingerprint":"71cf92240dcb163524d928e3f792ac176896ff89107986f3fd9ad119ad49aa47","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id"},
{"file":"server/src/routes/departments.js","fingerprint":"17e1fd34460204ab475dc670a1b4ea61a1f369ebbd8dd2efe5eec265255701f7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id/archive"},
{"file":"server/src/routes/departments.js","fingerprint":"1e15674977106d900a40e34fe64657152da9dfe383e63fa88e6c0de70c4cb8d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/departments/:id/archive"},
{"file":"server/src/routes/employees.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/employees.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"},
{"file":"server/src/routes/employees.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/employees.js","fingerprint":"64ec8f0736b31d4a76082fa397b9adf9107f0628c97b9547026f55a3629f71cd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/employees.js","fingerprint":"ab1ed75076ba8a91674136da33198f3bd082cf5c9633b45e97f78c456a0471dc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureWecomHrMasterSyncEnabled"},
{"file":"server/src/routes/employees.js","fingerprint":"cc8894f77bdedd30913ca06dae19111faa1c97bc65078db3821925a514742afa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureWecomHrMasterSyncEnabled"},
{"file":"server/src/routes/employees.js","fingerprint":"99617ed3d2198ffedead181d3d347c8c6b3be4d886c92bcfa7c58f39389a6d57","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateContractDates"},
{"file":"server/src/routes/employees.js","fingerprint":"cdf73963d211c5dc959bbd8d24d489f7d33a2024f7613c6033341c15e4d8dc4e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateContractDates"},
{"file":"server/src/routes/employees.js","fingerprint":"99617ed3d2198ffedead181d3d347c8c6b3be4d886c92bcfa7c58f39389a6d57","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateTrainingDates"},
{"file":"server/src/routes/employees.js","fingerprint":"606fa5a41e86d696a118edf385c6a6e2f4bf1b2c41288420ebcbe01d46eac084","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateCertificateDates"},
{"file":"server/src/routes/employees.js","fingerprint":"9d60ee3ecc95ce9a699e29cf140233c55b0d8a3ee95946b4ec5f07f66d9610c9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildContractAttachmentBlockedError"},
{"file":"server/src/routes/employees.js","fingerprint":"7ccdc0b4a4431f13ce618cd19f4da8fe8167d21ca3bcb55ae0bc7249f23b5607","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"},
{"file":"server/src/routes/employees.js","fingerprint":"3efb722614f1cdee938215738adeb5b2d4930fdb24c470e560b69244f0bcae7e","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"},
{"file":"server/src/routes/employees.js","fingerprint":"fd1f07cbb59e6103dfac77bafd01b3ed7a0ce88a5b45ea9f3d7238776249d498","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"},
{"file":"server/src/routes/employees.js","fingerprint":"71935e997078aefd6df41afdb612ae8b79e5e67356bcaea3c984b414cff95f57","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"},
{"file":"server/src/routes/employees.js","fingerprint":"0c5c7e91b73ae8e0c05e8c3a34dbbdb250559b2f46385fe5c66ed1aff3a02166","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"},
{"file":"server/src/routes/employees.js","fingerprint":"561527fb2f4fdce80bdcb3c9b02a1afc9c2664dfd9bcb592f7e554b0b447b8fa","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"},
{"file":"server/src/routes/employees.js","fingerprint":"cabd6d4585a224ce1759dbcb60d03a9544950051604a6cffaf0ad7b530f92a2c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"},
{"file":"server/src/routes/employees.js","fingerprint":"37cc3fcf7eb9efd9a9270eb9a0bcf68b4d083d484e70541c6afd5bb67ea1b3ce","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/employees/:id/wecom-directory-sync"},
{"file":"server/src/routes/employees.js","fingerprint":"37cc3fcf7eb9efd9a9270eb9a0bcf68b4d083d484e70541c6afd5bb67ea1b3ce","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/employees/:id/wecom-directory-sync/retry"},
{"file":"server/src/routes/employees.js","fingerprint":"bddf88642c81d891ef3da8820dc127dfa2ceffb3b74983791dd7deb00d2b32c5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/employees/:id/contracts"},
{"file":"server/src/routes/employees.js","fingerprint":"bddf88642c81d891ef3da8820dc127dfa2ceffb3b74983791dd7deb00d2b32c5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/employees/:id/contracts/:contractId"},
{"file":"server/src/routes/employees.js","fingerprint":"7c5310f7b2163f74cc5120711effbbd1f1bdc2cdfcd49d4e00b99665e2b1580a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:employeesRoutes"},
{"file":"server/src/routes/employees.js","fingerprint":"06f4a2e303107e87d1e94009275b39ed2838c7ec0fc45595a64dbbc4331b1106","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/employees/:id/contracts/:contractId"},
{"file":"server/src/routes/employees.js","fingerprint":"2b6b9e0ebbca3a448aaa94b95a2d6d43e3a14ccc3f5aeca4f956deec94aa3107","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/employees/:id/profile"},
{"file":"server/src/routes/expenseStandards.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/expenseStandards.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseExpenseStandardId"},
{"file":"server/src/routes/expenseStandards.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/expenseStandards.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/expenseStandards.js","fingerprint":"b9dc511484348ded712b71280db3e49c789be0666623eed6cb9cf7a4468aedf9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/expenseStandards.js","fingerprint":"30b8f6d233001091ad09d7f6dc365136091288a663f1458bbce7201de1218b2c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureExpenseTypeExists"},
{"file":"server/src/routes/expenseStandards.js","fingerprint":"a0b01e1f14b7ede96e7cd1e8252c748afdf125feaa51441a37b9aabb363a328b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureScopeKeyValid"},
{"file":"server/src/routes/expenseStandards.js","fingerprint":"a5ad69546d526d962f12d1c7e1d0e24a153f585fa6d61daa123fc1a2843f580e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureScopeKeyValid"},
{"file":"server/src/routes/expenseStandards.js","fingerprint":"d67e306450dee53a89c46327da7ca8273417de0f4f74f80a46b9711f513301c9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/expense-standards"},
{"file":"server/src/routes/expenseStandards.js","fingerprint":"c871135b601c023c98f31d5555f893efbefce44a1c0964220df341897674629c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/expense-standards/:id"},
{"file":"server/src/routes/expenseStandards.js","fingerprint":"d67e306450dee53a89c46327da7ca8273417de0f4f74f80a46b9711f513301c9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/expense-standards/:id"},
{"file":"server/src/routes/expenseTypes.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/expenseTypes.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/expenseTypes.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseExpenseTypeId"},
{"file":"server/src/routes/expenseTypes.js","fingerprint":"6fefcf7886d8aca7c344962a4621880f7da29f0915282c8763cf48d3049279a8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/expense-types"},
{"file":"server/src/routes/expenseTypes.js","fingerprint":"dd4c7435c5433e19eb9893f762c76225772c4cf62eb01c6c45d7b8d1b207705a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/expense-types/:id"},
{"file":"server/src/routes/expenseTypes.js","fingerprint":"6fefcf7886d8aca7c344962a4621880f7da29f0915282c8763cf48d3049279a8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/expense-types/:id"},
{"file":"server/src/routes/exports.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"},
{"file":"server/src/routes/exports.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/exports.js","fingerprint":"510d8ad88dde0075df37838cd0b7ff6241145d700817ea5f203086abbae5b568","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:toDateRange"},
{"file":"server/src/routes/exports.js","fingerprint":"48951ae2ad49593089e10d00afd95a5f18e10c2cbcb87f7956eb47a59d60cfa0","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureApOperationsExportEnabled"},
{"file":"server/src/routes/exports.js","fingerprint":"0160d938808a2f4bbee5beb67caad7ddcbea52af30db293b600c23db638e57f3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureContractsRegistryExportEnabled"},
{"file":"server/src/routes/exports.js","fingerprint":"a0125087df9691c0e5f6f2989910035efba279b7f0bffdc10579765966fb150c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:toDateOnlyRange"},
{"file":"server/src/routes/exports.js","fingerprint":"67b9d4a42e614d1bf96832420680b7ca2ea4749f910828802ff3170eaeda6e64","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/exports/payslips"},
{"file":"server/src/routes/exports.js","fingerprint":"6d38b77a953af94b8bff5a99e5b3ab22f28507853694cd6fd50d38589850f8f1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/exports/presence-history"},
{"file":"server/src/routes/exports.js","fingerprint":"510d8ad88dde0075df37838cd0b7ff6241145d700817ea5f203086abbae5b568","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/exports/audit-logs"},
{"file":"server/src/routes/featureFlags.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/featureFlags.js","fingerprint":"5fad3764129696d62548c50a8b78ee8f322b718e877eb8950d98ac0c331173c3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildVersionConflictError"},
{"file":"server/src/routes/featureFlags.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseKey"},
{"file":"server/src/routes/featureFlags.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePageQuery"},
{"file":"server/src/routes/featureFlags.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"},
{"file":"server/src/routes/featureFlags.js","fingerprint":"6ef26793cac05e83ddd65bf556897f795f41584fe69f21c6b4842554f2e1c087","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"},
{"file":"server/src/routes/featureFlags.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBatchBody"},
{"file":"server/src/routes/featureFlags.js","fingerprint":"4d213e5dda91586732d96d078bbff9ee63351c094c47bc8d31b3e7450f8f1aab","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:featureFlagsRoutes"},
{"file":"server/src/routes/feeControl.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/feeControl.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/integrations.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/integrations.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseOutboxListQuery"},
{"file":"server/src/routes/integrations.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBindingsListQuery"},
{"file":"server/src/routes/integrations.js","fingerprint":"6c58fb0c0b2fb699a6a013d4d40a2b87c072068b06e766e74b0e092cc7a335d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuidParam"},
{"file":"server/src/routes/integrations.js","fingerprint":"48d75541b43988964ecf85d59f32007713a3be1bd381ef2bf4bcb3b38dc839bf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:requireErpManagementGate"},
{"file":"server/src/routes/integrations.js","fingerprint":"8a83e372ba9211a169ca20935d21336c95349f5db935bb4b4e73fa4c62912a43","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensurePaidReimbursement"},
{"file":"server/src/routes/integrations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeProcurementTargets"},
{"file":"server/src/routes/integrations.js","fingerprint":"ac52892b0856c64c0b8918ee2e323d5965f8db02f2026199498e89128c7542fb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureProcurementTargetEligible"},
{"file":"server/src/routes/integrations.js","fingerprint":"e1eaff595d742e37768ec5e6b5859a29604d1a54fbee9da795bebb9397abf95c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureProcurementTargetEligible"},
{"file":"server/src/routes/integrations.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeResourcesInput"},
{"file":"server/src/routes/integrations.js","fingerprint":"efc285e1cd886a88a6e57e61ce5da1a202409c21a930621af47f4f8052ba90b9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/outbox/:id/retry"},
{"file":"server/src/routes/integrations.js","fingerprint":"26c3c1c908fda8c7350351ee2f11769acdbf1bdee51bce704759d7d07f5eadae","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/master-data/sync"},
{"file":"server/src/routes/integrations.js","fingerprint":"399f9ec607c4403c3c6c8763433114d192a5df162ba7d0fdd2fe03aaeb572ac7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/journals/reimbursements/:reimbursementId/preview"},
{"file":"server/src/routes/integrations.js","fingerprint":"399f9ec607c4403c3c6c8763433114d192a5df162ba7d0fdd2fe03aaeb572ac7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/journals/reimbursements/:reimbursementId/export"},
{"file":"server/src/routes/integrations.js","fingerprint":"095bb8a253c5934e8f8629e16c38b388f6d2cc6354343bf8506cf0787917c09e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/procurement/purchase-requests/:purchaseRequestId/track"},
{"file":"server/src/routes/integrations.js","fingerprint":"095bb8a253c5934e8f8629e16c38b388f6d2cc6354343bf8506cf0787917c09e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/procurement/purchase-requests/:purchaseRequestId/requeue"},
{"file":"server/src/routes/integrations.js","fingerprint":"6392bfcb435833f01d57d08f451784a96334daa7151cef168939c1160ad93fcd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/assets/:assetId/track"},
{"file":"server/src/routes/integrations.js","fingerprint":"6392bfcb435833f01d57d08f451784a96334daa7151cef168939c1160ad93fcd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/assets/:assetId/requeue"},
{"file":"server/src/routes/integrations.js","fingerprint":"1298b6503bead86f2a1f836887a4019b863e86cf40e33200ff554fac72afe4a6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/travel/reconciliation-batches/:batchId/track"},
{"file":"server/src/routes/integrations.js","fingerprint":"1298b6503bead86f2a1f836887a4019b863e86cf40e33200ff554fac72afe4a6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/travel/reconciliation-batches/:batchId/requeue"},
{"file":"server/src/routes/integrations.js","fingerprint":"9c6bf2a19d60e08f038020fe4faef2823e46f32d5d5523e4a484c5175840fc1c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/travel/reconciliation-batches/:batchId/requeue"},
{"file":"server/src/routes/integrations.js","fingerprint":"399f9ec607c4403c3c6c8763433114d192a5df162ba7d0fdd2fe03aaeb572ac7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/integrations/erp/sync/:reimbursementId"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"6c58fb0c0b2fb699a6a013d4d40a2b87c072068b06e766e74b0e092cc7a335d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"435181ebd3fd0f7b87dc2c1708ae0454eec366f3084a133cf0bf7e292259dd27","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureScopeReference"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"da55de4248c5c6a9283d50da6dd09edba0ce3f0a9e6a91af8e2d7d62b0785a4b","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureScopeReference"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"d5b13762c5f0318690bb04b0e4d006e066524b06b185f9615a335396e6f0c93c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureInventoryCheck"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"1bf348c5073f0a17d62a5ae67b998e93fe35c77c2abe4cb0784c153ca4f8272a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCheckStatus"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"0492f0a2dab5df4df9bdd0504d07e51d03f2c56d6e0e516abb2c5ecac159c545","count":3,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureAssetInScope"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"5a020ee415b339c0a8064f07afa8fd5c6e3b305186e24d2d1653cdc3143f1277","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/inventory-checks/:id/items"},
{"file":"server/src/routes/inventoryChecks.js","fingerprint":"466c3a313730aa3a9720bb5b37447aa5d1fa274961d8f64838c66b2780e69ef3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/inventory-checks/:id/items"},
{"file":"server/src/routes/leave-types.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/leave-types.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTypeId"},
{"file":"server/src/routes/leave-types.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTypeQuery"},
{"file":"server/src/routes/leave-types.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTypeBody"},
{"file":"server/src/routes/leave-types.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/leave-types"},
{"file":"server/src/routes/leave-types.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/leave-types/:id"},
{"file":"server/src/routes/masterData.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/masterData.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"},
{"file":"server/src/routes/masterData.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/masterData.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"},
{"file":"server/src/routes/masterData.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"},
{"file":"server/src/routes/masterData.js","fingerprint":"c871135b601c023c98f31d5555f893efbefce44a1c0964220df341897674629c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"},
{"file":"server/src/routes/masterData.js","fingerprint":"8506eaeef8adc8cc277fcffb3ce92086e955ecadb4149df7b5a01363da60d337","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"},
{"file":"server/src/routes/masterData.js","fingerprint":"fdc96cb05717a00bf966b3db85f546eb54af6008074759298bb2532b1af926fa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"},
{"file":"server/src/routes/masterData.js","fingerprint":"57bed79bc70017e6d9ed3c59bfab4bbea560ee2b688d24e44d3a9e180bbb2d89","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureOwnerEmployeeAvailable"},
{"file":"server/src/routes/masterData.js","fingerprint":"7d66e43d79549021e2d29e57f896c77cb1a141106745c7fcef0921ef92eac9c6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterParent"},
{"file":"server/src/routes/masterData.js","fingerprint":"28c3d33c45d69dc5760112592b2e589e13c781f795e0ee212e8fb44b363af293","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterParent"},
{"file":"server/src/routes/masterData.js","fingerprint":"84943edb161885fdf0358991a25cb76ab086fbd61abc0a918c98260319a2b631","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterParent"},
{"file":"server/src/routes/masterData.js","fingerprint":"ed6740bc6dd3fe1dcf7c6f61dcf0faaee12827f3d31984c9837dcc3e87a79070","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterParent"},
{"file":"server/src/routes/masterData.js","fingerprint":"246729bbf97af47de4eb151bba65d11dd270b868a2b928b3fab16ff1c5a230e7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterParent"},
{"file":"server/src/routes/masterData.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/legal-entities"},
{"file":"server/src/routes/masterData.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/legal-entities/:id"},
{"file":"server/src/routes/masterData.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/legal-entities/:id"},
{"file":"server/src/routes/masterData.js","fingerprint":"ca92f646662d11a4962bd5affd2bd7355fdfc5e89c32d3a9ab63a55f83b80f55","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/legal-entities/:id/archive"},
{"file":"server/src/routes/masterData.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/cost-centers"},
{"file":"server/src/routes/masterData.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/cost-centers/:id"},
{"file":"server/src/routes/masterData.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/cost-centers/:id"},
{"file":"server/src/routes/masterData.js","fingerprint":"693964faf025faa1aa110ecdd0ddc2a00538646cd68ab9956d46ef51ad584c9b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/cost-centers/:id/archive"},
{"file":"server/src/routes/masterData.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/document-categories"},
{"file":"server/src/routes/masterData.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/document-categories/:id"},
{"file":"server/src/routes/masterData.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/document-categories/:id"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"c871135b601c023c98f31d5555f893efbefce44a1c0964220df341897674629c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseReplaceDaysBody"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"8837b7f15919ad77abb757977808b0de05190f54a8df757a34637464b2d00d20","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseReplaceDaysBody"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"8506eaeef8adc8cc277fcffb3ce92086e955ecadb4149df7b5a01363da60d337","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"fdc96cb05717a00bf966b3db85f546eb54af6008074759298bb2532b1af926fa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/holiday-calendars"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/holiday-calendars/:id"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/holiday-calendars/:id"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:masterDataHolidayCalendarsRoutes"},
{"file":"server/src/routes/masterDataHolidayCalendars.js","fingerprint":"27733508586c12b8ad87835d0b8eba2a47d1d70c27a7dd86b9424d106b0cc3df","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/holiday-calendars/:id/archive"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"c871135b601c023c98f31d5555f893efbefce44a1c0964220df341897674629c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"8506eaeef8adc8cc277fcffb3ce92086e955ecadb4149df7b5a01363da60d337","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"fdc96cb05717a00bf966b3db85f546eb54af6008074759298bb2532b1af926fa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/suppliers"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"c57bb2bf24ae236cb93cf16651897e8c0b3da2be2744c6e5421311359c385c12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/suppliers/:id"},
{"file":"server/src/routes/masterDataSuppliers.js","fingerprint":"3a1dcc123e523c78566da31e7be2f3c32ec6cca7a1405869d9ff7cec8f51c970","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/master-data/suppliers/:id"},
{"file":"server/src/routes/migrationRuns.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/migrationRuns.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"},
{"file":"server/src/routes/migrationRuns.js","fingerprint":"43aaacd8708bb5c07aa913145a5dfa84cd402038b1fee3ada860ee249f8ce9c3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/migrationRuns.js","fingerprint":"3efb722614f1cdee938215738adeb5b2d4930fdb24c470e560b69244f0bcae7e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/migration-runs/dry-run"},
{"file":"server/src/routes/migrationRuns.js","fingerprint":"f19072dfa882b5edb238bce9545f1adb9ffb544f3571b0bf3f7ba50e28cc5afe","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/migration-runs/dry-run"},
{"file":"server/src/routes/notifications.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/notifications.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/notifications.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseDeliveryQuery"},
{"file":"server/src/routes/notifications.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseNotificationId"},
{"file":"server/src/routes/notifications.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/notifications.js","fingerprint":"57c60265b18cb39c11ef9a4e5ef528ea29ffae46c51f52b11e64a44564993d74","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:handleNotificationChannelWriteError"},
{"file":"server/src/routes/notifications.js","fingerprint":"8413330dba32e6adedc5c566d53c40d6e596465bf96637bcd76a272a9940c24f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:handleNotificationChannelWriteError"},
{"file":"server/src/routes/payroll.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/payroll.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"},
{"file":"server/src/routes/payroll.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/payroll.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"},
{"file":"server/src/routes/payroll.js","fingerprint":"8d8b2a6b503e009a90b28a9371fedbaf81f53d1fd0904330ff1a589b18a15868","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseMoneyList"},
{"file":"server/src/routes/payroll.js","fingerprint":"c2549c5ddc23dc22598ff4d22aa465eedb561a6b130059d44bcf3e279bb18a4b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseMonthOrThrow"},
{"file":"server/src/routes/payroll.js","fingerprint":"eb4ae752a39b2db5f9a01b4a18a2ef4a033542477df821fde95a073332334a2a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/items"},
{"file":"server/src/routes/payroll.js","fingerprint":"eb4ae752a39b2db5f9a01b4a18a2ef4a033542477df821fde95a073332334a2a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/items/:itemId"},
{"file":"server/src/routes/payroll.js","fingerprint":"8f9fcc9a5f96c1d1ddc3792737989e17225d9e62ed663a6323e40c2182878439","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:payrollRoutes"},
{"file":"server/src/routes/payroll.js","fingerprint":"29feee7aa021e87ec66e539ddd587225b345306848abc928b326193854df5bbb","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/generate"},
{"file":"server/src/routes/payroll.js","fingerprint":"5cbd8ccfbb4a49806897f695843745f0f6978cf06798722867a1a86f8895ee49","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/generate"},
{"file":"server/src/routes/payroll.js","fingerprint":"cd1730346f59b6dd1ef61a96541d6b14fae7427014a4df660622d5db1971e7cb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/generate"},
{"file":"server/src/routes/payroll.js","fingerprint":"d504875855dd432ce8fd8e3712b810057bc498752f4f20d7b1ecdb1f0974b458","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/generate"},
{"file":"server/src/routes/payroll.js","fingerprint":"2f529b8652d855f222007a9aaf7efda679de466eb009646f68dec1c8af9878d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/:payslipId"},
{"file":"server/src/routes/payroll.js","fingerprint":"8f9fcc9a5f96c1d1ddc3792737989e17225d9e62ed663a6323e40c2182878439","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/:payslipId"},
{"file":"server/src/routes/payroll.js","fingerprint":"e5bed6a1ef46e69a472e6a8e0f1637ef20b8a9a7b1b913981c715e6fa0ca53c5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/issue"},
{"file":"server/src/routes/payroll.js","fingerprint":"3d6f069f1e14a67b816a3f19cf9547f70835b326a63c8e97a730403c189fb0ff","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/payroll/payslips/issue"},
{"file":"server/src/routes/performance.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/performance.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"},
{"file":"server/src/routes/performance.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/performance.js","fingerprint":"99617ed3d2198ffedead181d3d347c8c6b3be4d886c92bcfa7c58f39389a6d57","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateCycleDates"},
{"file":"server/src/routes/performance.js","fingerprint":"64ec8f0736b31d4a76082fa397b9adf9107f0628c97b9547026f55a3629f71cd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/performance.js","fingerprint":"5feffb51751e2ea37516e71a87d53134a4dd4c62f8d1365f85071e9e669af061","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureNoOtherActiveCycle"},
{"file":"server/src/routes/performance.js","fingerprint":"e5be30f4e74036474ea74e5a14b970e139c14c3d245c0db50d115a51aa739287","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/performance/cycles/:cycleId"},
{"file":"server/src/routes/performance.js","fingerprint":"7ecf4a716d093c397f13de0cf527550ee5338bda3bb148ab77927eae1e278ec4","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/performance/cycles/:cycleId"},
{"file":"server/src/routes/performance.js","fingerprint":"ae0076b9e368da3ee5b33929325622ec7245142706ddc9b1b9ffc654bdb039a9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/performance/cycles/:cycleId"},
{"file":"server/src/routes/performance.js","fingerprint":"f45ef4d4efbe70b8b8d9986fbfa81ab17ec19b4be5d49a05c50e4113870bf090","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/performance/cycles/:cycleId"},
{"file":"server/src/routes/performance.js","fingerprint":"b2271cd4b603af5a201ffeacd3f23408030b50cb56e9f7380996a3a90cfe241d","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:performanceRoutes"},
{"file":"server/src/routes/performance.js","fingerprint":"a36e4a2edab739bdd3595d96a3d8ac1c1e6dd814915bfa5b0a189c17da335abe","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:performanceRoutes"},
{"file":"server/src/routes/portal.js","fingerprint":"de0048472baa2ade9251177868ca94a4e86e149b88bb49f48d8cc7d10549767b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/portal.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/presence.js","fingerprint":"8082c82f99fcf677784600a080881baa1d83e621985e6c5945f8c90cd05d4297","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseInput"},
{"file":"server/src/routes/presence.js","fingerprint":"2adc3ff7dd515743a159e4289f5872beaed9785c8fb49532b3c37485d6053cf1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/presence.js","fingerprint":"ab9d9bd7ccc0aad6c6dd652ce7bc50b2c92691ab967336b355c61af3aa1ab0db","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:presenceRoutes"},
{"file":"server/src/routes/projects.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/projects.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/projects.js","fingerprint":"50daa180b4259d12a5b9bfa3bdf8c1dc8bf65b66fe736780985e0f5e0645bd87","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"},
{"file":"server/src/routes/projects.js","fingerprint":"786f7b3c31e737656476b5bf3d9ce110029b4d21a45c73d7bd0a9cd2141b77e4","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/projects.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/projects"},
{"file":"server/src/routes/projects.js","fingerprint":"05cb62a133436678bfe2db9f1af0acaf82a799ff8b253bd29820783f91d6f5ae","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/projects/:id/closure-checklist/:itemId"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePurchaseRequestId"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"74a25a3be91eff6f6378ec4bb7b73f089e32a91c4b72813dd6d8737f53072a12","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseAttachmentId"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePatchBody"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseArchiveBody"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"32c90a20fefee508b70375039699b0944f7f0e7393868e6f484667d827ec59eb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildPrecheckBlockedError"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"0d724434a86f1930647935a2be2f6d0c3cb0f6f972e32a28568a9db808b300de","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:decimalInputToString"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"abfc2c646e9b5a2faa92673d2e933810ba72e4f21f242139cbcb418f504322bd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:decimalInputToString"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"d74cf60fb2fbfb6c420e156df0c230eccd33b37dd768c62e4cddc729d2b8d36e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"c4967a64f972aa0396e182d2d06880ab684c25d2545466aad0854828740ad39e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLegalEntityAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"1e04b9db6494f88b9adbdfbc2cafcc8970bccbb039c99e67ce3ccbbfc868ace1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"dc93103cf32df2eb4384550e3ae7ebcc290879e69e11aad7eaea6481f8f414fb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"0bd9235198a75999811434ce3e70e584019d64555ede15c2202bd5f1030edf89","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCostCenterAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"36aa7be0c8e53d562f66944b903fb548652b9a5d882b3ca6bbbd15a71a3d70f8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureSupplierAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"31b1543cd9b1084ee05a0a1bd62272225e06a4c2ef43dbe002d5923c73fa46ae","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureSupplierAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"5e473411e4ac8c5e89cc4593424ed01cab03c258c8d7ec3254ee4ed3a57ae9d4","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureSupplierAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"1f616caf65363bde2fcebb6b65f22a06972c60903568ff1a1669f3198b66a322","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDocumentCategoryAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"9c0bd30f0d8350f57b3e84625952980393265ba93a5faaab83814a12f8b97009","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDocumentCategoryAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"1d5e08f2e6e5f0de7f26fb28c9a18afcad9bbca93ee7c289de73a80085d2d726","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDocumentCategoryAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"6aeceeea0061576ecdce5bb9ba919e00f69c3ce34c7699223e15a0679d6d646e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureBudgetPoolAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"acc1967cece89a983569e89f0e9a6993c85bcf6b85ab6322b6a8cd603ad29ac3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureBudgetPoolAvailable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"625cfacc8d63ac5a09613bd85f9ce9bb3a6aef3b1c45c631d48e9c9c5effb54a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureProjectLinkingEnabled"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"e3a1f2bab30aec1c86d5589d1dbdc3350bafc1178f33178f1e33ece354de6567","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureProjectLinkable"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePurchaseAttachmentDownloadBody"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parsePurchaseAttachmentDeleteBody"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"09e6ed8568ec152bc9e258a6af9d3087ca261ab91afb35dacc64b39f0d7f2f77","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildAttachmentBlockedError"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"df339a0b9ae2b0568bbc36f464165a9786d9e7b82d19110b5be663c92adf6307","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"d99112098e6e3ce510653305291db41171fc8ed1e720cadfc66fcb32f6608eb6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"020f59053f8ead4a770124fc5ccae72fdd5e7a75fcde95800f1b6348b64ad0ff","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"426a08b378106f678291428f006adcf189c14e99dc30a36b13ef515aa2022ed5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"af136d0a2f077abe83abf80e534806deca3c33ebd048d5f8bd610662dad2234f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"b045c685e631112457b97aefd603efbe2d2dc7b4ba1f7e40eac3405cefe2adfd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"ac621f7cc93e03e3255a541cbf63faea101f6ae1c017c732634c8d64a2d298f7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"f79cec61c75f3cc4d3a3cd8fa999f2c9c90a3f056541020a4e3ac5ea4507f151","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"c0e7abb9c3ac55801d691f055e8fa5f6f2d94915023a0684d6e9ad1768d85e87","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"4fc911e68f5f8710662d80ffbdc0214968e726f4f25c06969c57cb11643d6147","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"c6f59dbc2caae66192a4f48e0d9b6071d52c7e004e099b0413b68335b725a254","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/submit"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"f52ae13c892cffc99bb85b3059629d4b449f12f79fe89234a9a4fb4b741428f3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/withdraw"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"08f8022fc7683b6eb3dbd6b92c8ebcb12cc6679b6ca4414842d575a6a7522d36","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/archive-order"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"df339a0b9ae2b0568bbc36f464165a9786d9e7b82d19110b5be663c92adf6307","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"5c5ab17d2c4d8884cbe06e4f4597264afd5afa49353d4727355711193f6fe26b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"ed01eee13031abab1b3ab75739b4e3a06f1ffb3d4d6cda0e91a7dcc56b92383f","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"fd1f07cbb59e6103dfac77bafd01b3ed7a0ce88a5b45ea9f3d7238776249d498","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"561527fb2f4fdce80bdcb3c9b02a1afc9c2664dfd9bcb592f7e554b0b447b8fa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"9eca7119004b10837ed03641c72eae0ec83e46d3e5ca20024bb41b95ce1b6f60","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"2b4bec423c5af9d8e18b6ba3196b28911651662dd0294bd835c51d2685146ba2","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"3efb722614f1cdee938215738adeb5b2d4930fdb24c470e560b69244f0bcae7e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"df339a0b9ae2b0568bbc36f464165a9786d9e7b82d19110b5be663c92adf6307","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments/:attachmentId"},
{"file":"server/src/routes/purchaseRequests.js","fingerprint":"85a20df57650243bd107434cf30f3652f8c466f8040b9446a23bf5999dc24946","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/purchase-requests/:id/attachments/:attachmentId/download"},
{"file":"server/src/routes/reimbursementTemplates.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/reimbursementTemplates.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTemplateId"},
{"file":"server/src/routes/reimbursementTemplates.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/reimbursementTemplates.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/reimbursementTemplates.js","fingerprint":"01ede183f16765ade77b433028000084374d4f113f6255e49d41f5c3d7f38646","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureExpenseTypesEnabled"},
{"file":"server/src/routes/reimbursementTemplates.js","fingerprint":"c871135b601c023c98f31d5555f893efbefce44a1c0964220df341897674629c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/reimbursement-templates/:id"},
{"file":"server/src/routes/riskFlags.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/riskFlags.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseQuery"},
{"file":"server/src/routes/riskFlags.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"},
{"file":"server/src/routes/riskFlags.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/riskFlags.js","fingerprint":"ccadcec1520fec31ce3906b9792f9f33e3633ce278244816db0e0c53a5f847ad","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:riskFlagsRoutes"},
{"file":"server/src/routes/search.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/search.js","fingerprint":"ca31c72fdd0b89c6ce11ac23fe5887903daf75f09eeef0e4af5e1330a9f747f7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/search.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/search"},
{"file":"server/src/routes/systemWorkspace.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/systemWorkspace.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/todos.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/todos.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/todos.js","fingerprint":"6c58fb0c0b2fb699a6a013d4d40a2b87c072068b06e766e74b0e092cc7a335d5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUuid"},
{"file":"server/src/routes/todos.js","fingerprint":"309a572d3996ecefcf2cd76ff4d999f27066193832e45339137025bd0444a326","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/todos.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/todos"},
{"file":"server/src/routes/travelAuthorizations.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/travelAuthorizations.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/travelAuthorizations.js","fingerprint":"ba1f56c27bbfd403f3f2ec44f1cc63120cd8619bb07758590c204ca433ea6195","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/authorizations/import"},
{"file":"server/src/routes/travelAuthorizations.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/authorizations/import"},
{"file":"server/src/routes/travelAuthorizations.js","fingerprint":"c25ad9fd794672ca12ee3f985568d7f60fcd71e6bf4f98397bbf0faa51e57835","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/authorizations/import"},
{"file":"server/src/routes/travelOrders.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/travelOrders.js","fingerprint":"ba1f56c27bbfd403f3f2ec44f1cc63120cd8619bb07758590c204ca433ea6195","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/orders/import"},
{"file":"server/src/routes/travelOrders.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/orders/import"},
{"file":"server/src/routes/travelOrders.js","fingerprint":"401a1e7b894d5bd123a72d89ca48ec5055bf228f8ab41429bc25304b4946b7ef","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/orders/import"},
{"file":"server/src/routes/travelOrders.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-orders/:id/mark-paid"},
{"file":"server/src/routes/travelOrders.js","fingerprint":"6ed4360839cd3ae903e4ef9b4e339575c30c0f20a7166c1ea449f17d292ff844","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-orders/:id/mark-paid"},
{"file":"server/src/routes/travelOrders.js","fingerprint":"f868afb0ae5510d8969c9b43b2f628a8af144ad8ff05768844e9139c7c6a5ecd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-orders/:id/mark-paid"},
{"file":"server/src/routes/travelOrders.js","fingerprint":"1c393ac35b7be8c743b2904d8587c2e6fd564fa21eb31be689718225cb108291","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-orders/:id/mark-paid"},
{"file":"server/src/routes/travelOrders.js","fingerprint":"c974531164266ba82a417ecc4fb030096b4abeff568aba3b70b613985301e819","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-orders/:id/mark-paid"},
{"file":"server/src/routes/travelReconciliation.js","fingerprint":"4ce8a4863c616aaef038fd59c2bd34754e1f4a07a817afc733e8a0bd7dd6398e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/travelReconciliation.js","fingerprint":"9752938eeee08ad1684e1f901484bf811d47d2ebffbebd74ec34f73be5de7605","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureValidPeriod"},
{"file":"server/src/routes/travelReconciliation.js","fingerprint":"8506eaeef8adc8cc277fcffb3ce92086e955ecadb4149df7b5a01363da60d337","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateBatchScopeContext"},
{"file":"server/src/routes/travelReconciliation.js","fingerprint":"3cc514556b50dae2ebe09ef19b335d02d760c8375bf6d85dae2b9cce523f1870","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateBatchScopeContext"},
{"file":"server/src/routes/travelReconciliation.js","fingerprint":"4b8d375f7b60f2922bcd07343e3ea3a6e3b80c22cacb7f698540885224a1a259","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateBatchScopeContext"},
{"file":"server/src/routes/travelReconciliation.js","fingerprint":"4ce8a4863c616aaef038fd59c2bd34754e1f4a07a817afc733e8a0bd7dd6398e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/batches"},
{"file":"server/src/routes/travelReconciliation.js","fingerprint":"41b9715742da06f74902865fe5a1d9cf214f8fe8b47345e3aa827a458e3053b6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/batches"},
{"file":"server/src/routes/travelReconciliation.js","fingerprint":"7754d00c7af4946d48b4e723e1e6165805cacbff52289e221127d905441f2823","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/batches/:batchId"},
{"file":"server/src/routes/travelReconciliation.js","fingerprint":"87cb200635db2286f2444af5847774354326bf8162317cdf6012214505a835ec","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/items/:itemId/confirm"},
{"file":"server/src/routes/travelReconciliation.js","fingerprint":"88ca8fcd24be33d22f8814a459047b1041e99774a9bce0a77991481749ee3fcc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/items/:itemId/confirm"},
{"file":"server/src/routes/travelReconciliation.js","fingerprint":"87cb200635db2286f2444af5847774354326bf8162317cdf6012214505a835ec","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/items/:itemId/reopen"},
{"file":"server/src/routes/travelReconciliation.js","fingerprint":"7754d00c7af4946d48b4e723e1e6165805cacbff52289e221127d905441f2823","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/reconciliation/batches/:batchId/finalize"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTravelRequestId"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"e6cf16bb46d3d378312e4d0c034325e4c365748b562a58468eb66fcd1f021db0","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseDateTimeValue"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"0d724434a86f1930647935a2be2f6d0c3cb0f6f972e32a28568a9db808b300de","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:decimalInputToString"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"abfc2c646e9b5a2faa92673d2e933810ba72e4f21f242139cbcb418f504322bd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:decimalInputToString"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"6cb2563bca7c74fed4a6c76d781843eae305be58d2d29908ea589906b6bcef83","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeSegments"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"b1404f502f7dc79f6e49855018d6715052f64db61e4f93376bcde2f8c0934700","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDateRangeValid"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"32c90a20fefee508b70375039699b0944f7f0e7393868e6f484667d827ec59eb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildPrecheckBlockedError"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"646fc9e91a562d0983f9b5e15d73334df2faf1b7760e3c03bae4917cf83292b0","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFinancialDimensions"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"7de438154ec7f3addf2aa384c147760463b5b61a961d3026988b6ff9f71c051d","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFinancialDimensions"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"57ccb19330174bae06b385dca2cb8c41ce487980099e934fef19f8fc3a15cba7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFinancialDimensions"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"92e4ca56a07acf54433ae06c7af4755bd7aa80956a055e41da4388cb5ac61962","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"df339a0b9ae2b0568bbc36f464165a9786d9e7b82d19110b5be663c92adf6307","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"426a08b378106f678291428f006adcf189c14e99dc30a36b13ef515aa2022ed5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id/submit"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"5fd193c785794e7bfeca6cba08789d583371b625c54caad5e6ab70750d0381e8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id/submit"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"d99112098e6e3ce510653305291db41171fc8ed1e720cadfc66fcb32f6608eb6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id/submit"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"020f59053f8ead4a770124fc5ccae72fdd5e7a75fcde95800f1b6348b64ad0ff","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id/submit"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"5130090900bde10ab9d5955020cc5c2227dd5680b6036f83e7d14337c8fa5057","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id/link-project"},
{"file":"server/src/routes/travelRequests.js","fingerprint":"f52ae13c892cffc99bb85b3059629d4b449f12f79fe89234a9a4fb4b741428f3","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel-requests/:id/withdraw"},
{"file":"server/src/routes/travelTransactions.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/travelTransactions.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/travelTransactions.js","fingerprint":"ba1f56c27bbfd403f3f2ec44f1cc63120cd8619bb07758590c204ca433ea6195","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/transactions/import"},
{"file":"server/src/routes/travelTransactions.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/transactions/import"},
{"file":"server/src/routes/travelTransactions.js","fingerprint":"9d03fb23e8d0a0b4abb5044684e8f43268dacf0c6a6fb983034511ee56d598a7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/transactions/import"},
{"file":"server/src/routes/travelTransactions.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/transactions/:id/freeze-suggestion"},
{"file":"server/src/routes/travelTransactions.js","fingerprint":"6ed4360839cd3ae903e4ef9b4e339575c30c0f20a7166c1ea449f17d292ff844","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/travel/transactions/:id/freeze-suggestion"},
{"file":"server/src/routes/users.js","fingerprint":"eafa3d7b708b3d3b0052c67493ee3cf3f1ee62121cdbc3612f6d0c16b057eddc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUserId"},
{"file":"server/src/routes/users.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseListQuery"},
{"file":"server/src/routes/users.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"},
{"file":"server/src/routes/users.js","fingerprint":"9629ec80c0f796bc6a00f7dfecb68242c56c3d9ff5fa981740c2afbee0bc5c21","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseCreateBody"},
{"file":"server/src/routes/users.js","fingerprint":"8aa13b8fbc71173fb12f1a2d9f197b2c993f28bd29015eee8d9a06e58b2e2aa7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"},
{"file":"server/src/routes/users.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"},
{"file":"server/src/routes/users.js","fingerprint":"dd4c7435c5433e19eb9893f762c76225772c4cf62eb01c6c45d7b8d1b207705a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseUpdateBody"},
{"file":"server/src/routes/users.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseSensitiveBody"},
{"file":"server/src/routes/users.js","fingerprint":"dd4c7435c5433e19eb9893f762c76225772c4cf62eb01c6c45d7b8d1b207705a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseSensitiveBody"},
{"file":"server/src/routes/users.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/users.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBatchBody"},
{"file":"server/src/routes/users.js","fingerprint":"e60ad7bc69f342e2ea14a2ef0d0debd98d71a4358ad3db7f1273d77e331d0188","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBatchBody"},
{"file":"server/src/routes/users.js","fingerprint":"64ec8f0736b31d4a76082fa397b9adf9107f0628c97b9547026f55a3629f71cd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureLifecycleFeatureEnabled"},
{"file":"server/src/routes/users.js","fingerprint":"ace2fbe941e2d06d8f7aac2467ce8475df71d7c14253228443c68ef83d294799","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDepartmentUsable"},
{"file":"server/src/routes/users.js","fingerprint":"0bd10e8819ebb46f6802f2308035459543c5aa995c12e10b3db34b1c0e4bcae4","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDepartmentUsable"},
{"file":"server/src/routes/users.js","fingerprint":"0369c70693eba9ea471e80c6d9c427eb0aa8818467889d3616cef56a3b6b678f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureManagerUsable"},
{"file":"server/src/routes/users.js","fingerprint":"ace2fbe941e2d06d8f7aac2467ce8475df71d7c14253228443c68ef83d294799","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureManagerUsable"},
{"file":"server/src/routes/users.js","fingerprint":"8d5fb35f3a4cca58bc32a3966938d9c038cb755122c201e79191f648b3831393","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureManagerUsable"},
{"file":"server/src/routes/users.js","fingerprint":"facb330f41026823036cb195e4e3d9b76a3af9516e9353a9e4bfd944e9de68fd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:generateOneTimePassword"},
{"file":"server/src/routes/users.js","fingerprint":"561527fb2f4fdce80bdcb3c9b02a1afc9c2664dfd9bcb592f7e554b0b447b8fa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"},
{"file":"server/src/routes/users.js","fingerprint":"1b27bf379328573c70d6d1995fab5ec7ddb58308a9058235fc86451eb943efdb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"},
{"file":"server/src/routes/users.js","fingerprint":"0df105fd6c424e2d4cf00942f34c4cd306bbcb55116207f24d365de76fe02d68","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"},
{"file":"server/src/routes/users.js","fingerprint":"ce0ac252d216b04fd3edb044c4e83b79094808a6950e9443fd55c7231bb53b15","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"},
{"file":"server/src/routes/users.js","fingerprint":"3714c500736e5dc6b230fb71e2895fc7fd653da4134811192579cb2a8f3403cf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"},
{"file":"server/src/routes/users.js","fingerprint":"0c5c7e91b73ae8e0c05e8c3a34dbbdb250559b2f46385fe5c66ed1aff3a02166","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"},
{"file":"server/src/routes/users.js","fingerprint":"6bc44520602f42ec038dcfd6f58b0c2f56dae06e8e26a50870b576066fad1eeb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:readUserImportPayload"},
{"file":"server/src/routes/users.js","fingerprint":"2255ec084503939a7653b2dc67783bca3f1affb9fa99c046d39b0b2d7fb35f98","count":3,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"},
{"file":"server/src/routes/users.js","fingerprint":"ba37f131e8e7ec10223c5cbf578abe550f01988bd91f594862771bfae88a223c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"},
{"file":"server/src/routes/users.js","fingerprint":"94d1f0e015e7774bcc0adaa3550ca8bebabea49c52e7ea9679ce22310f6c888d","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"},
{"file":"server/src/routes/users.js","fingerprint":"f642f8ed261072a289ce343e133ac9b0954ebac4c531e1993209f7ea9876a7a6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"},
{"file":"server/src/routes/users.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":3,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"},
{"file":"server/src/routes/users.js","fingerprint":"c2c42563d5083e1fcffc70061e11f05a5fb622030879e07c9112e128f780f0e4","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"},
{"file":"server/src/routes/users.js","fingerprint":"8863d485ac227a65846c44668d0c84fb88de50f56f4f921f66a02685297720e7","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"},
{"file":"server/src/routes/users.js","fingerprint":"a4ed2cb425167eb0564f1d3f38574b1caacfd0fbff3544381dd70f1992bade04","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"},
{"file":"server/src/routes/users.js","fingerprint":"8c73684345b1f5a10d5cb4f2911293eab416bb4ee406354f3a8c4c0d4caee34a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:usersRoutes"},
{"file":"server/src/routes/wecomAuth.js","fingerprint":"a86ce70414af8f63b8b3ffc060802dcc2540bc915bfa8669802ff91f167a728f","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeJsSdkPageUrl"},
{"file":"server/src/routes/wecomAuth.js","fingerprint":"55c80c9479f485c70513c36176f96c81b8ee4ebdad4d77bcd5cab252f5370808","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:assertWecomOauthReady"},
{"file":"server/src/routes/wecomAuth.js","fingerprint":"d70cc75afc07320183256873a1bda11d5ec98169057ce9dd8af5ad976c573533","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:getWecomAccessToken"},
{"file":"server/src/routes/wecomAuth.js","fingerprint":"a86ce70414af8f63b8b3ffc060802dcc2540bc915bfa8669802ff91f167a728f","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/wecom/js-sdk-signature"},
{"file":"server/src/routes/wecomAuth.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/wecom/login"},
{"file":"server/src/routes/wecomAuth.js","fingerprint":"2b0b99fd226589d42c63361d123e4721928f1243cb62e242ec6563dd3349e58e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/wecom/login"},
{"file":"server/src/routes/wecomAuth.js","fingerprint":"4673e0a3f7e965475ce8a75bc2dd25a14481692a28acdad66e22e8f66d5b2910","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/wecom/bind"},
{"file":"server/src/routes/weeklyReports.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/weeklyReports.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/weeklyReports.js","fingerprint":"503ec8814ed94eae417ed7863bf5f57b8f222346eeb202dd07517c08bacef9fc","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/weeklyReports.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/weekly-reports/me"},
{"file":"server/src/routes/weeklyReports.js","fingerprint":"c467510c074544d5abb39af6d212600918c78bae7e46bb8c1837b82b1eb16ccb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:weeklyReportsRoutes"},
{"file":"server/src/routes/weeklyReports.js","fingerprint":"d3e6e1b5646c7292abc574c53b3894ca82620c1f2cb70d3c0f968e0bea4764df","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:weeklyReportsRoutes"},
{"file":"server/src/routes/weeklyReports.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/weekly-reports/team"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"aec1243e92a1893e8584e71f90bbf22620de1e5299a2d6f7a1ce88308b3448ac","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseId"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseBody"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"440871aaf1bf287ce45b8aff11b1c04f2f21151f3898d17f7752253ea9debfed","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseEntityType"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"39168f5e747cc5770558e5a046456e986f365b93b4072c93a56dc14761b770dd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureFeatureEnabled"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"2236cc64fcafe74edc1e79d11a6e93efc7379d02257b7d5fc1e454ee5f9bde95","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow-definitions"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"2236cc64fcafe74edc1e79d11a6e93efc7379d02257b7d5fc1e454ee5f9bde95","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow-definitions/:id"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"567632d90a6968fcc24a576a4699526e2b0fbe15ff285494c449014f955cac77","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow-definitions/:id"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"2236cc64fcafe74edc1e79d11a6e93efc7379d02257b7d5fc1e454ee5f9bde95","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow-definitions/:id/publish"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"9e2ed33d6382c6f9d820878049ac1c9d4b11cdecfae6efe6e5a4a4924b7e87b5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow-definitions/:id/publish"},
{"file":"server/src/routes/workflowDefinitions.js","fingerprint":"2236cc64fcafe74edc1e79d11a6e93efc7379d02257b7d5fc1e454ee5f9bde95","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"/workflow-definitions/:id/archive"},
{"file":"server/src/services/ai/suggestions.js","fingerprint":"43c8e843f54d9ce819d8d943da46acf1637e682537b116b3d070d3e67523e256","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildAiDisabledError"},
{"file":"server/src/services/attendance.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildAttendanceValidationError"},
{"file":"server/src/services/attendance.js","fingerprint":"2ee8bb29f955fb3062828a3dcf1e0e9cdf801091629163c8a5e7f500dd8ec64d","count":2,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseTimeToMinutes"},
{"file":"server/src/services/attendance.js","fingerprint":"362096848d0df743442bfb66cdf57a594b74d7ad1d8c49068b4e623fadd51483","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateAttendancePolicyInput"},
{"file":"server/src/services/attendance.js","fingerprint":"57d8963b1bfd4124fc9dd309a0fe0d6fa2286251aa94ba702cde045a4fba70fa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateAttendancePolicyInput"},
{"file":"server/src/services/attendance.js","fingerprint":"33f1a6a204fc84205fa86787d743080b63ccbe0c1c29700bbb6a6987870fad24","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateAttendancePolicyInput"},
{"file":"server/src/services/attendance.js","fingerprint":"541024df6a9deeb726d27470531467a9244f66d9196f1658867f494c04757798","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:validateAttendancePolicyInput"},
{"file":"server/src/services/attendance.js","fingerprint":"2ce1b8dcfb0321bbef8d1102f6c60cd5dfe1aab6fd4b4ccf7f86e3dedaf4e6ff","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildMonthRange"},
{"file":"server/src/services/attendance.js","fingerprint":"99ebe0d43dca95cb33f043545c5302d439e6822f524e9f2c083f1d9f46feb362","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildMonthRange"},
{"file":"server/src/services/budgetPool.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/services/budgetPool.js","fingerprint":"8ea8274148c5fc65a473197d6a2617bf27971d7d8de4b26f9c3ec4e28bc7879e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveBudgetScope"},
{"file":"server/src/services/budgetPool.js","fingerprint":"15b3efedb6c7af2094e04906a764b073066e16614492a2e8eadb52d4cea4899c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveBudgetScope"},
{"file":"server/src/services/budgetPool.js","fingerprint":"78bb3b7b0cb97341c844a151c521cb679fc0c4115369dffcadcae5f15b48b594","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveBudgetScope"},
{"file":"server/src/services/budgetPool.js","fingerprint":"8ea8274148c5fc65a473197d6a2617bf27971d7d8de4b26f9c3ec4e28bc7879e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeBudgetContext"},
{"file":"server/src/services/budgetPool.js","fingerprint":"c10503d03230e1ac3fb2c83e632f28ff6054d39cb3522e43daa8929b4085fc62","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:selectBudgetPoolCandidate"},
{"file":"server/src/services/budgetPool.js","fingerprint":"b9800e3b093f9036da6d9eb06e688b99636297e64ae73052c309b2724c24fd22","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveApplicableBudgetPoolByContext"},
{"file":"server/src/services/contractRegistryFilters.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildContractRegistryValidationError"},
{"file":"server/src/services/contractRegistryFilters.js","fingerprint":"0d7d8d325c8629980a1d6f1c569255c2995059d5b19667be27f57eed2ea837af","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseContractRegistryFilters"},
{"file":"server/src/services/contractRegistryFilters.js","fingerprint":"0157cfe6b95bfb9824b92b3187d51e79af6759e2082f6f829c29ec79dc41eed1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseContractRegistryFilters"},
{"file":"server/src/services/contractRegistryFilters.js","fingerprint":"0d7d8d325c8629980a1d6f1c569255c2995059d5b19667be27f57eed2ea837af","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseContractRegistryExpiringQuery"},
{"file":"server/src/services/cutover.js","fingerprint":"547aac9dca5ebf4d86d1488f348d7cc03c32103a801898e4c8acc9866dd47da7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureCutoverAllowed"},
{"file":"server/src/services/documentSequence.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/services/documentSequence.js","fingerprint":"b929462215d217481e0021dbc6414f9dd0d13a7ff37a3485e70e251a34649f96","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:normalizeDocumentType"},
{"file":"server/src/services/documentSequence.js","fingerprint":"52251a414cebd986f1a66ada25aa84ad8201ef118fffda7cb25a4b0c5eb7b3c6","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:findDocumentCategory"},
{"file":"server/src/services/documentSequence.js","fingerprint":"17742cab588bf1fa29dcb863cac76aa8e313cc9fbe831e384e6076865c9f4c92","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:findDocumentCategory"},
{"file":"server/src/services/documentSequence.js","fingerprint":"218b770702cc4205ce09e22bf23938845fbe17e9677d717994df7ef74cf7c4c9","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:findDocumentCategory"},
{"file":"server/src/services/employeeDirectoryFilters.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/services/employeeDirectoryFilters.js","fingerprint":"278003b27036a26a6c6c2bac8e175081f3d2f7f950a5d3c2ed2c8e71f604bfdd","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseEmployeeDirectoryFilters"},
{"file":"server/src/services/employeeDirectoryFilters.js","fingerprint":"c7d5cb33dcb5fc3619be1f8aa436be5129922d43e88e5723b1077127d3980e9c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseEmployeeDirectoryFilters"},
{"file":"server/src/services/feeControl.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/services/feeControl.js","fingerprint":"82e83bd1db5eb446f90f9a600d75e13a0f9d71bd48454bb770ab8a1055fa4a96","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:loadPrecheckEntityContext"},
{"file":"server/src/services/feeControl.js","fingerprint":"82e83bd1db5eb446f90f9a600d75e13a0f9d71bd48454bb770ab8a1055fa4a96","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildEntityPrecheck"},
{"file":"server/src/services/presenceHistory.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildPresenceValidationError"},
{"file":"server/src/services/presenceHistory.js","fingerprint":"819ebeb74216f9c73d4d3677beea18e8919b8d4803a7f52d7d0b01c2dfdbe1cb","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolvePresenceHistoryRange"},
{"file":"server/src/services/presenceHistory.js","fingerprint":"903f7ff892fca342b08ad67d8edd56defbb3981edc8974890b0151a0fe53bee0","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolvePresenceHistoryRange"},
{"file":"server/src/services/purchaseBudget.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildPurchaseBudgetValidationError"},
{"file":"server/src/services/purchaseBudget.js","fingerprint":"e4d46dd9d7b30343b07d84605262f5a96f639d93f3362edbc9eb0f4448ff0d0b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:reserveBudgetForEntity"},
{"file":"server/src/services/purchaseBudget.js","fingerprint":"5d79c097496ad908123fee937c35dbd8ddcb6d0f79bfb3dc697bff0cee946c0c","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:reserveBudgetForEntity"},
{"file":"server/src/services/travelPayments.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildTravelPaymentValidationError"},
{"file":"server/src/services/travelPayments.js","fingerprint":"70cfb750ebbaa9e398ab6a8ab743ea042422ada7a3ee87be85ab9314c5ce6f09","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:applyBudgetPoolUsageDelta"},
{"file":"server/src/services/travelReconciliation.js","fingerprint":"1ccd0d7139b90bcb4360218527df65fcbec8936865ee49a7abf1f8eb175deabf","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildValidationError"},
{"file":"server/src/services/travelReconciliation.js","fingerprint":"387721ed02fa83f31bd471035145206c36af7484532afe5353955e667dfc89d1","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureDraftBatch"},
{"file":"server/src/services/travelReconciliation.js","fingerprint":"17733bab2d92da224cf69fdedb3ee146cbf121eef58e881794e407a51b058f68","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensurePendingReconciliationItem"},
{"file":"server/src/services/travelReconciliation.js","fingerprint":"0443a8ffa4324e1e4e08fa89037c0b28b24213ab09941a8ceff40126a457ea1e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureConfirmedReconciliationItem"},
{"file":"server/src/services/travelReconciliation.js","fingerprint":"bbe9cd46973ca0c77ae1763e6adba68e26441e9584168373a97324864b6697ea","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureReopenableReconciliationItem"},
{"file":"server/src/services/travelReconciliation.js","fingerprint":"3ac5e1d7ea04918b94a7e5083b0a6c4ef002a6fe68b1b168d6be7a58125ed0a8","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:ensureBatchFinalizable"},
{"file":"server/src/utils/workflow.js","fingerprint":"04bd03a6f4a2518dcecf7d1702e4695d0104b403a6b929a1147a17e9a32ba6a5","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:parseWorkflowSteps"},
{"file":"server/src/utils/workflow.js","fingerprint":"0cd1c6b1f3fa1355f63b8def66e42f047c1ac5bacbdea9a4a8258212cd401648","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:assertBusinessApprovalRoleCode"},
{"file":"server/src/utils/workflow.js","fingerprint":"207961eec0e9985eb51a9108be430c27f2365413a4cefe00cdec2d58df4e228a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildWorkflowStepsFromApprovalRule"},
{"file":"server/src/utils/workflow.js","fingerprint":"7ba594f9bf352b163a734e664825e1e964ed4491d0120eef78a9fc3aa8b43a07","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildWorkflowStepsFromApprovalRule"},
{"file":"server/src/utils/workflow.js","fingerprint":"b8698fe0fd39540fb0a1d21bf610947927564bdf7150e35b98ee0077317cc84b","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:assertOwnerSelfFallbackRole"},
{"file":"server/src/utils/workflow.js","fingerprint":"ca50ced3104a846b28238b26edb8fcb7af6d10be5fa07ff92438f33b65b659aa","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:assertOwnerSelfFallbackRole"},
{"file":"server/src/utils/workflow.js","fingerprint":"d08fdd02992c489e39264bf0b214f52d0bd1ce5a1c58b6e5342d5bd4347a7057","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildApprovalWorkflowSnapshot"},
{"file":"server/src/utils/workflow.js","fingerprint":"d8fafe98ccaff8cefb614917926b0bc1f90244ac555e5a8eb18d0d32ee5a1d72","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:buildApprovalWorkflowSnapshot"},
{"file":"server/src/utils/workflow.js","fingerprint":"f5c8f2ab31ac7144fd05d20bdcc8db4cf6d92a7fb6e8405326dc77f9e68e2b84","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveAssigneeFromRule"},
{"file":"server/src/utils/workflow.js","fingerprint":"feb0686cc3f1b63906adb701af0d69479951f3a81172d7b2c0414e5b1b6b7ce2","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveAssigneeFromRule"},
{"file":"server/src/utils/workflow.js","fingerprint":"5206020abf57bcd7c7ba728ca164031668424f199e062694d69a57cfdaf5980e","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveAssigneeFromRule"},
{"file":"server/src/utils/workflow.js","fingerprint":"806e44515d6d7eb6dc41a7fa24363b1fb61a6ab6a28dd1168b2d855ca8e0b994","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveAssigneeFromRule"},
{"file":"server/src/utils/workflow.js","fingerprint":"ce63d28720f7d951355348d5ab600ff5009a35ed855613caf5706f9fb007a2d7","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveAssigneeFromRule"},
{"file":"server/src/utils/workflow.js","fingerprint":"b312a21b28c9dbe1efc4bd7f5fd029b62a2c0cceedf88f4e621a84f948896e8a","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveActionableStep"},
{"file":"server/src/utils/workflow.js","fingerprint":"7e3347da9ac0499d2aa163fe5f743ed01b3abeb75a3267626b76e4a566d246de","count":1,"owner":"shunli","reason":"非首批迁移域;修改此调用时须完成原因码注册、客户端映射和具体内容断言","expiresOn":"2026-10-05","operation":"function:resolveActionableStep"}
]
"entries": []
}
@@ -0,0 +1,232 @@
{
"schemaVersion": 1,
"approvalReference": "09-06-release-gate-integrity G19; 2026-09-18 用户批准分层验收",
"exceptions": [
{
"file": "client/tests/e2e/live-smoke.spec.ts",
"id": "365489eee4d4394f3125-8b8398ac223b126b928f",
"title": "公网基础探活与企微配置状态",
"projectName": "",
"repeatEachIndex": 0,
"kind": "post-deploy",
"sourceSha256": "5a8dcb90b7a747343120c68743dc3e726b6f56fdc7abbefb9318611cc3dc6f16",
"reason": "需要公网部署及经批准的真实只读身份",
"entry": "scripts/qa/run-web-gates.sh smoke-live",
"responsibilityRole": "发布/企微入口验收",
"namedOwner": null,
"requiredEvidence": "当前候选的匿名配置边界和管理员/员工只读页面",
"replacementFile": null,
"replacementTitle": null
},
{
"file": "client/tests/e2e/live-smoke.spec.ts",
"id": "365489eee4d4394f3125-45091a332e611ca4d4b6",
"title": "管理员只读网页 smoke",
"projectName": "",
"repeatEachIndex": 0,
"kind": "post-deploy",
"sourceSha256": "5a8dcb90b7a747343120c68743dc3e726b6f56fdc7abbefb9318611cc3dc6f16",
"reason": "需要公网部署及经批准的真实只读身份",
"entry": "scripts/qa/run-web-gates.sh smoke-live",
"responsibilityRole": "发布/企微入口验收",
"namedOwner": null,
"requiredEvidence": "当前候选的匿名配置边界和管理员/员工只读页面",
"replacementFile": null,
"replacementTitle": null
},
{
"file": "client/tests/e2e/live-smoke.spec.ts",
"id": "365489eee4d4394f3125-2a8b3f6ab858d5ed2edf",
"title": "员工只读网页 smoke",
"projectName": "",
"repeatEachIndex": 0,
"kind": "post-deploy",
"sourceSha256": "5a8dcb90b7a747343120c68743dc3e726b6f56fdc7abbefb9318611cc3dc6f16",
"reason": "需要公网部署及经批准的真实只读身份",
"entry": "scripts/qa/run-web-gates.sh smoke-live",
"responsibilityRole": "发布/企微入口验收",
"namedOwner": null,
"requiredEvidence": "当前候选的匿名配置边界和管理员/员工只读页面",
"replacementFile": null,
"replacementTitle": null
},
{
"file": "client/tests/e2e/oafront3-pc-readonly-visual.spec.ts",
"id": "e2a48d9364fe7e78ebe4-db965786c46856b925ec",
"title": "角色化访问 PC 关键页面并生成只读视觉证据",
"projectName": "",
"repeatEachIndex": 0,
"kind": "post-deploy",
"sourceSha256": "dfe9e58e53d12deb7e67afb210d62351ac1527bb4ad38d1e4ed17946ee70409d",
"reason": "需要公网部署及经批准的真实只读身份",
"entry": "scripts/qa/run-oafront3-pc-readonly-visual.sh",
"responsibilityRole": "角色只读视觉验收",
"namedOwner": null,
"requiredEvidence": "经批准的真实只读角色及页面;不得默认加载真实 env",
"replacementFile": null,
"replacementTitle": null
},
{
"file": "client/tests/e2e/r104-public-visual-acceptance.spec.ts",
"id": "30adf2c642da2c48495e-9f9637e1bdbfe16c765e",
"title": "公网桌面登录页与企业微信配置可见",
"projectName": "",
"repeatEachIndex": 0,
"kind": "post-deploy",
"sourceSha256": "6062d85f1417f7f81823600ef354db1c1110ee4a867151e145e76771f73cde05",
"reason": "需要公网部署及经批准的真实只读身份",
"entry": "E2E_MODE=live npx playwright test tests/e2e/r104-public-visual-acceptance.spec.ts --retries=0",
"responsibilityRole": "公网 PC/移动体验验收",
"namedOwner": null,
"requiredEvidence": "真实 OAuth 边界、员工移动及 PC 页面",
"replacementFile": null,
"replacementTitle": null
},
{
"file": "client/tests/e2e/r104-public-visual-acceptance.spec.ts",
"id": "30adf2c642da2c48495e-c96d1dc362b0c90bef45",
"title": "公网企业微信 WebView 入口跳转到 OAuth 边界",
"projectName": "",
"repeatEachIndex": 0,
"kind": "post-deploy",
"sourceSha256": "6062d85f1417f7f81823600ef354db1c1110ee4a867151e145e76771f73cde05",
"reason": "需要公网部署及经批准的真实只读身份",
"entry": "E2E_MODE=live npx playwright test tests/e2e/r104-public-visual-acceptance.spec.ts --retries=0",
"responsibilityRole": "公网 PC/移动体验验收",
"namedOwner": null,
"requiredEvidence": "真实 OAuth 边界、员工移动及 PC 页面",
"replacementFile": null,
"replacementTitle": null
},
{
"file": "client/tests/e2e/r104-public-visual-acceptance.spec.ts",
"id": "30adf2c642da2c48495e-89c56782b69f34a7da95",
"title": "公网员工移动工作台与高频页面保持移动体验",
"projectName": "",
"repeatEachIndex": 0,
"kind": "post-deploy",
"sourceSha256": "6062d85f1417f7f81823600ef354db1c1110ee4a867151e145e76771f73cde05",
"reason": "需要公网部署及经批准的真实只读身份",
"entry": "E2E_MODE=live npx playwright test tests/e2e/r104-public-visual-acceptance.spec.ts --retries=0",
"responsibilityRole": "公网 PC/移动体验验收",
"namedOwner": null,
"requiredEvidence": "真实 OAuth 边界、员工移动及 PC 页面",
"replacementFile": null,
"replacementTitle": null
},
{
"file": "client/tests/e2e/r104-public-visual-acceptance.spec.ts",
"id": "30adf2c642da2c48495e-5e7cbbdeb955eb29a064",
"title": "公网 PC 报销与票据页面不回退到路径式标题",
"projectName": "",
"repeatEachIndex": 0,
"kind": "post-deploy",
"sourceSha256": "6062d85f1417f7f81823600ef354db1c1110ee4a867151e145e76771f73cde05",
"reason": "需要公网部署及经批准的真实只读身份",
"entry": "E2E_MODE=live npx playwright test tests/e2e/r104-public-visual-acceptance.spec.ts --retries=0",
"responsibilityRole": "公网 PC/移动体验验收",
"namedOwner": null,
"requiredEvidence": "真实 OAuth 边界、员工移动及 PC 页面",
"replacementFile": null,
"replacementTitle": null
},
{
"file": "client/tests/e2e/r105-pretrial-visual.spec.ts",
"id": "671b0bc6dbfa8f5c6309-bcbb26db989ad12bb527",
"title": "公网登录与企业微信入口仍默认进入移动工作台",
"projectName": "",
"repeatEachIndex": 0,
"kind": "post-deploy",
"sourceSha256": "643a8f55df63f56b053aea770d1bfc54e4ef8278193991b244b692c7712e16db",
"reason": "需要公网部署及经批准的真实只读身份",
"entry": "scripts/qa/run-pretrial-readiness.sh --visual",
"responsibilityRole": "试运行准备/公网体验验收",
"namedOwner": null,
"requiredEvidence": "入口、员工和管理员 PC/移动;历史 admin 审批假设须先核当前权限",
"replacementFile": null,
"replacementTitle": null
},
{
"file": "client/tests/e2e/r105-pretrial-visual.spec.ts",
"id": "671b0bc6dbfa8f5c6309-8f75d16673e2a1fb5141",
"title": "员工移动首页、票据上传和我的页面保持手机体验",
"projectName": "",
"repeatEachIndex": 0,
"kind": "post-deploy",
"sourceSha256": "643a8f55df63f56b053aea770d1bfc54e4ef8278193991b244b692c7712e16db",
"reason": "需要公网部署及经批准的真实只读身份",
"entry": "scripts/qa/run-pretrial-readiness.sh --visual",
"responsibilityRole": "试运行准备/公网体验验收",
"namedOwner": null,
"requiredEvidence": "入口、员工和管理员 PC/移动;历史 admin 审批假设须先核当前权限",
"replacementFile": null,
"replacementTitle": null
},
{
"file": "client/tests/e2e/r105-pretrial-visual.spec.ts",
"id": "671b0bc6dbfa8f5c6309-f93f8021b0b1357f1d3d",
"title": "管理员移动审批页不退回桌面表格体验",
"projectName": "",
"repeatEachIndex": 0,
"kind": "post-deploy",
"sourceSha256": "643a8f55df63f56b053aea770d1bfc54e4ef8278193991b244b692c7712e16db",
"reason": "需要公网部署及经批准的真实只读身份",
"entry": "scripts/qa/run-pretrial-readiness.sh --visual",
"responsibilityRole": "试运行准备/公网体验验收",
"namedOwner": null,
"requiredEvidence": "入口、员工和管理员 PC/移动;历史 admin 审批假设须先核当前权限",
"replacementFile": null,
"replacementTitle": null
},
{
"file": "client/tests/e2e/r105-pretrial-visual.spec.ts",
"id": "671b0bc6dbfa8f5c6309-f68af4e323d362a886a7",
"title": "PC Portal、报销和票据保持试运行首页一致性",
"projectName": "",
"repeatEachIndex": 0,
"kind": "post-deploy",
"sourceSha256": "643a8f55df63f56b053aea770d1bfc54e4ef8278193991b244b692c7712e16db",
"reason": "需要公网部署及经批准的真实只读身份",
"entry": "scripts/qa/run-pretrial-readiness.sh --visual",
"responsibilityRole": "试运行准备/公网体验验收",
"namedOwner": null,
"requiredEvidence": "入口、员工和管理员 PC/移动;历史 admin 审批假设须先核当前权限",
"replacementFile": null,
"replacementTitle": null
},
{
"file": "client/tests/e2e/travel-payment-flow.spec.ts",
"id": "dcd93a716d5f1dd3dbad-b611c274a721c4b81f48",
"title": "E2E 差旅支付主链路:申请→审批→支付→绑票",
"projectName": "",
"repeatEachIndex": 0,
"kind": "frozen",
"sourceSha256": "f8a0afba7ddf7a19b5ddb91bfcf42697c2ea582d49715d3c022157d88cf83b33",
"reason": "差旅业务冻结,保留旧整链,不恢复权限",
"entry": "bash scripts/qa/run-web-gates.sh full-local; replacement: client/tests/e2e/travel-freeze-protection.spec.ts",
"responsibilityRole": "冻结差旅支付业务责任",
"namedOwner": null,
"requiredEvidence": "当前合成角色不可达、正式接口拒绝、共享付款冻结及入站导入权限拒绝",
"replacementFile": "client/tests/e2e/travel-freeze-protection.spec.ts",
"replacementTitle": "冻结差旅:员工入口与共享付款边界",
"historicalEntry": "full-local; scripts/qa/run-business-flow-gates.sh"
},
{
"file": "client/tests/e2e/travel-reconciliation-flow.spec.ts",
"id": "4a81bdc0eb898c93e44d-b5124d64393296358132",
"title": "E2E 差旅对账批次:创建→确认异常→收口",
"projectName": "",
"repeatEachIndex": 0,
"kind": "frozen",
"sourceSha256": "c2c535cccdd5add2d2005037e7c2946e528ccaf481aaa5d2522b2c482d6dff4e",
"reason": "差旅业务冻结,保留旧整链,不恢复权限",
"entry": "bash scripts/qa/run-web-gates.sh full-local; replacement: client/tests/e2e/travel-freeze-protection.spec.ts",
"responsibilityRole": "冻结差旅对账业务责任",
"namedOwner": null,
"requiredEvidence": "当前合成角色不可达、正式接口拒绝、共享付款冻结及入站导入权限拒绝",
"replacementFile": "client/tests/e2e/travel-freeze-protection.spec.ts",
"replacementTitle": "冻结差旅:财务对账权限与入站导入权限边界",
"historicalEntry": "full-local; scripts/qa/run-business-flow-gates.sh"
}
]
}
+130
查看文件
@@ -0,0 +1,130 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { verifyFullLocalDiscovery } from "./verify-full-local-discovery.mjs";
import { sourceSnapshot } from "./full-local-diagnostic.mjs";
const hash = value => createHash("sha256").update(value).digest("hex");
export const caseKey = entry => JSON.stringify([entry.file, entry.id, entry.title, entry.projectName ?? "", entry.repeatEachIndex ?? 0]);
const sameCandidate = (a, b) => Boolean(a && b && /^[a-f0-9]{40}$/.test(a.commit ?? "") && /^[a-f0-9]{64}$/.test(a.digest ?? "") && a.commit === b.commit && a.digest === b.digest);
export const localSpecArgs = plan => plan.localSpecs.map(file => `${file.slice(7).replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}$`);
export function coveragePlan(root, discovery, contract, candidate) {
const full = verifyFullLocalDiscovery(root, discovery);
const issues = [...full.issues];
const exceptions = contract?.exceptions ?? [];
if (contract?.schemaVersion !== 1 || !Array.isArray(contract?.exceptions) || !contract.approvalReference) issues.push("invalid_coverage_contract");
if (!sameCandidate(candidate, candidate)) issues.push("invalid_candidate");
const keys = full.cases.map(caseKey);
if (new Set(keys).size !== keys.length) issues.push("duplicate_discovery_case");
const registered = new Map();
for (const entry of exceptions) {
const key = caseKey(entry);
if (registered.has(key)) issues.push("duplicate_exception");
registered.set(key, entry);
if (!keys.includes(key)) issues.push("exception_identity_drift");
if (!["post-deploy", "frozen"].includes(entry.kind) || !entry.reason || !entry.entry || !entry.responsibilityRole || !entry.requiredEvidence) issues.push("invalid_exception_obligation");
if (!full.expectedSpecs.includes(entry.file)) issues.push("exception_source_unknown");
else if (hash(readFileSync(path.join(root, entry.file))) !== entry.sourceSha256) issues.push("exception_source_changed");
if (entry.kind === "frozen" && !full.cases.some(row => row.file === entry.replacementFile && row.title === entry.replacementTitle && !exceptions.some(other => caseKey(other) === caseKey(row)))) issues.push("frozen_protection_missing");
}
const exceptionFiles = new Set(exceptions.map(entry => entry.file));
const localCases = full.cases.filter(entry => !registered.has(caseKey(entry)));
if (localCases.some(entry => exceptionFiles.has(entry.file))) issues.push("unregistered_case_in_exception_file");
if (!localCases.length) issues.push("empty_local_coverage");
const localSpecs = [...new Set(localCases.map(entry => entry.file))].sort();
return {
schemaVersion: 1, kind: "LAYERED_LOCAL_COVERAGE_PLAN", status: issues.length ? "FAIL" : "PASS",
candidate: { commit: candidate?.commit, digest: candidate?.digest }, contractSha256: hash(JSON.stringify(contract)),
discoverySha256: hash(JSON.stringify(discovery)), localSpecs, localCases,
obligations: exceptions.map(entry => ({ ...entry, status: entry.kind === "post-deploy" ? "NOT_RUN_LOCAL" : "FROZEN_PROTECTION_REQUIRED" })),
totalCases: full.cases.length, issues: [...new Set(issues)], normalReleaseEligible: false,
};
}
function reportCases(report) {
const cases = [];
function visit(suites, inherited = "") {
for (const suite of suites ?? []) {
for (const spec of suite.specs ?? []) {
let file = (spec.file ?? suite.file ?? inherited).replaceAll("\\", "/");
if (!file.startsWith("client/tests/e2e/")) file = `client/tests/e2e/${file.replace(/^tests\/e2e\//u, "")}`;
for (const test of spec.tests ?? []) cases.push({ file, id: spec.id, title: spec.title, projectName: test.projectName ?? "", repeatEachIndex: test.repeatEachIndex ?? 0, ...test });
}
visit(suite.suites, suite.file ?? inherited);
}
}
visit(report?.suites);
return cases;
}
export function verifyCaseExecution(expected, report) {
const issues = [];
if (!Array.isArray(report?.suites) || !Array.isArray(report?.errors)) issues.push("invalid_execution_report");
if (report?.errors?.length) issues.push("execution_errors");
const actual = reportCases(report);
const byKey = new Map();
for (const entry of actual) {
const key = caseKey(entry);
if (byKey.has(key)) issues.push("duplicate_execution_case");
byKey.set(key, entry);
}
const cases = expected.map(entry => {
const observed = byKey.get(caseKey(entry));
byKey.delete(caseKey(entry));
const passed = observed?.expectedStatus === "passed" && observed?.status === "expected" && observed?.results?.length === 1 && observed.results[0].status === "passed" && observed.results[0].retry === 0;
if (!passed) issues.push(observed ? "case_not_clean_pass" : "missing_execution_case");
return { ...entry, status: passed ? "PASS" : "FAIL", observedStatus: observed?.results?.at(-1)?.status ?? "NOT_RUN" };
});
if (byKey.size) issues.push("unexpected_execution_case");
return { status: issues.length ? "FAIL" : "PASS", cases, issues: [...new Set(issues)] };
}
export function verifyLocalCoverage(plan, report, candidate) {
const result = verifyCaseExecution(plan.localCases, report);
if (plan.status !== "PASS") result.issues.push("coverage_plan_invalid");
if (!sameCandidate(plan.candidate, candidate)) result.issues.push("candidate_changed");
result.status = result.issues.length ? "FAIL" : "PASS";
return { ...result, kind: "LOCAL_BROWSER_COVERAGE_ONLY", candidate: plan.candidate,
obligations: plan.obligations.map(entry => ({ ...entry, status: entry.kind === "post-deploy" ? "NOT_RUN_LOCAL" : result.cases.some(row => row.file === entry.replacementFile && row.title === entry.replacementTitle && row.status === "PASS") ? "FROZEN_PROTECTION_PASS" : "FROZEN_PROTECTION_FAIL" })),
deliveryStatus: "INCOMPLETE", normalReleaseEligible: false };
}
// Pure offline verifier. This does not run public tests or authorize deployment.
export function verifyPostDeployCoverage(plan, evidence) {
const expected = plan.obligations.filter(entry => entry.kind === "post-deploy");
const result = verifyCaseExecution(expected, evidence?.report);
if (plan.status !== "PASS" || !expected.length) result.issues.push("coverage_plan_invalid");
if (evidence?.profile !== "post-deploy-live" || evidence?.mode !== "live") result.issues.push("live_profile_required");
if (!sameCandidate(plan.candidate, evidence?.candidate) || !sameCandidate(plan.candidate, evidence?.deployedCandidate)) result.issues.push("deployed_candidate_mismatch");
result.status = result.issues.length ? "FAIL" : "PASS";
return { ...result, normalReleaseEligible: false };
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try {
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const [mode, directory] = process.argv.slice(2);
const read = file => JSON.parse(readFileSync(path.join(directory, file), "utf8"));
const contract = JSON.parse(readFileSync(path.join(root, "scripts/qa/full-local-coverage-contract.json"), "utf8"));
const current = coveragePlan(root, read("discovery.json"), contract, sourceSnapshot(root));
if (mode === "plan") {
console.log(JSON.stringify(current, null, 2));
if (current.status === "PASS") writeFileSync(path.join(directory, "local-spec-args.txt"), localSpecArgs(current).join("\n") + "\n", { flag: "wx" });
process.exitCode = current.status === "PASS" ? 0 : 1;
} else if (mode === "verify-local") {
const saved = read("coverage-plan.json");
const result = verifyLocalCoverage(saved, read("playwright-results.json"), current.candidate);
if (JSON.stringify(saved) !== JSON.stringify(current)) result.issues.push("coverage_plan_changed");
result.status = result.issues.length ? "FAIL" : "PASS";
console.log(JSON.stringify(result, null, 2));
process.exitCode = result.status === "PASS" ? 0 : 1;
} else throw new Error("unknown_mode");
} catch {
console.log(JSON.stringify({ status: "FAIL", issues: ["coverage_input_invalid"], normalReleaseEligible: false }));
process.exitCode = 1;
}
}
+9
查看文件
@@ -223,6 +223,15 @@ async function main() {
let discovery = null;
try {
discovery = existsSync(discoveryFile) ? readJson(discoveryFile) : null;
const planFile = path.join(reportDir, "full-local-report/coverage-plan.json");
if (existsSync(planFile)) {
const plan = readJson(planFile);
if (plan.status !== "PASS") throw new Error("invalid_layered_plan");
discovery = { ...discovery, cases: plan.localCases };
report.coverageObligations = plan.obligations;
const resultFile = path.join(reportDir, "full-local-report/local-coverage-result.json");
report.localCoverage = existsSync(resultFile) ? readJson(resultFile) : { status: "NOT_RUN" };
}
report.browser = browserInventory(discovery, existsSync(browserFile) ? readJson(browserFile) : null);
} catch {
report.browser = browserInventory(discovery, null);
@@ -22,10 +22,15 @@ ip addr add 192.0.2.1/30 dev qaonline
ip addr add 192.0.2.2/30 dev qapeer
ip link set qaonline up
ip link set qapeer up
# Both veth ends live here; this route has no gateway or path to the host.
# GLib/WebKit needs a link default route to report native network availability.
ip route add default dev qaonline scope link
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" initialize
node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" native
mkdir "$QA_MANAGED_SCRATCH/tmp" "$QA_MANAGED_SCRATCH/storage"
export TMPDIR="$QA_MANAGED_SCRATCH/tmp" STORAGE_DIR="$QA_MANAGED_SCRATCH/storage"
# Force smoke-test to create its own synthetic fallback, never read a shared /tmp sample.
export SAMPLE_FILE="$QA_MANAGED_SCRATCH/tmp/synthetic-smoke-input.pdf"
python3 "$ROOT_DIR/scripts/qa/local-postgres-proxy.py" "$QA_MANAGED_SOCKET/.s.PGSQL.5432" &
PROXY_PID=$!
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" verify
+18 -6
查看文件
@@ -9,7 +9,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { sourceSnapshot, compareSnapshots } from "./full-local-diagnostic.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const policyFiles = ["scripts/qa/full-local-environment.mjs", "scripts/qa/full-local-environment-inner.sh", "scripts/qa/local-postgres-proxy.py", "scripts/qa/browser-environment-probe.mjs", "scripts/qa/run-web-gates.sh", "scripts/qa/full-local-diagnostic.mjs", "client/playwright.config.ts"];
const policyFiles = ["scripts/qa/full-local-environment.mjs", "scripts/qa/full-local-environment-inner.sh", "scripts/qa/local-postgres-proxy.py", "scripts/qa/browser-environment-probe.mjs", "scripts/qa/run-web-gates.sh", "scripts/qa/prepare-local-e2e-accounts.mjs", "scripts/qa/full-local-diagnostic.mjs", "client/playwright.config.ts"];
const sha = value => createHash("sha256").update(value).digest("hex");
const readJson = file => JSON.parse(readFileSync(file, "utf8"));
export function atomicEnvironmentJson(file, value) {
@@ -60,17 +60,27 @@ export async function requestSupervisorObservation(context) {
});
});
}
export function topologyMatches(addresses, routes, ipv6Routes) {
export function topologyMatches(addresses, routes, ipv6Routes, links) {
const names = addresses.map(entry => entry.ifname).sort();
if (JSON.stringify(names) !== JSON.stringify(["lo", "qaonline", "qapeer"])) return false;
for (const [name, address] of [["qaonline", "192.0.2.1"], ["qapeer", "192.0.2.2"]]) {
const entry = addresses.find(item => item.ifname === name);
if (!entry.flags?.includes("UP") || entry.operstate !== "UP" || !entry.addr_info?.some(item => item.local === address && item.prefixlen === 30)) return false;
}
return ![...routes, ...ipv6Routes].some(entry => entry.dst === "default" || entry.gateway);
if (!Array.isArray(links) || JSON.stringify(links.map(entry => entry.ifname).sort()) !== JSON.stringify(names)) return false;
const left = links.find(entry => entry.ifname === "qaonline"), right = links.find(entry => entry.ifname === "qapeer");
if (left.ifindex === right.ifindex) return false;
for (const [entry, peer] of [[left, right], [right, left]]) {
if (!Number.isInteger(entry.ifindex) || entry.ifindex <= 0 || entry.linkinfo?.info_kind !== "veth" || entry.link_netnsid !== undefined || entry.master !== undefined) return false;
if (entry.link !== peer.ifname && entry.link_index !== peer.ifindex) return false;
}
const defaults = routes.filter(entry => entry.dst === "default");
if (defaults.length !== 1 || defaults[0].dev !== "qaonline" || defaults[0].scope !== "link" || (defaults[0].table !== undefined && defaults[0].table !== "main")) return false;
if (ipv6Routes.some(entry => entry.dst === "default")) return false;
return ![...routes, ...ipv6Routes].some(entry => entry.gateway || entry.via || entry.multipath || entry.nexthops || entry.encap || !names.includes(entry.dev));
}
function topology() {
return topologyMatches(JSON.parse(command("ip", ["-j", "address", "show"])), JSON.parse(command("ip", ["-j", "route", "show", "table", "all"])), JSON.parse(command("ip", ["-j", "-6", "route", "show", "table", "all"])));
return topologyMatches(JSON.parse(command("ip", ["-j", "address", "show"])), JSON.parse(command("ip", ["-j", "route", "show", "table", "all"])), JSON.parse(command("ip", ["-j", "-6", "route", "show", "table", "all"])), JSON.parse(command("ip", ["-j", "-d", "link", "show"])));
}
export function validateEnvironmentFacts(context, facts) {
const failures = [];
@@ -112,6 +122,8 @@ export function validateEnvironmentReceipt(receipt, digest, requireGuest = true)
if (!namespace?.inheritedRunner || !namespace.ownedChild || namespace.inheritedRunner === namespace.ownedChild || namespace.runnerCreated !== true || namespace.currentMatchesOwnedChild !== true || namespace.topologyMatched !== true) failures.push("environment_receipt_namespace_invalid");
if (receipt?.nativeOnlineProbe?.browserNamespace !== namespace?.ownedChild || receipt?.nativeOnlineProbe?.noNavigatorOverride !== true) failures.push("native_probe_namespace_invalid");
if (!receipt?.nativeOnlineProbe?.valid || receipt.nativeOnlineProbe.online !== true || !receipt.nativeOnlineProbe.browserMatchesOwnedChild) failures.push("native_probe_invalid");
const engines = receipt?.nativeOnlineProbe?.engines;
if (!Array.isArray(engines) || JSON.stringify(engines.map(engine => engine.name)) !== JSON.stringify(["chromium", "webkit"]) || engines.some(engine => engine.online !== true || engine.valid !== true || engine.noNavigatorOverride !== true || engine.browserNamespace !== namespace?.ownedChild || engine.browserMatchesOwnedChild !== true || !engine.version)) failures.push("native_engine_probe_invalid");
if (requireGuest) {
const observed = receipt?.bootstrapObservation;
if (observed?.browserNamespace !== namespace?.ownedChild || observed?.noNavigatorOverride !== true || observed?.loginRequestCount !== 0 || observed?.documentStatus !== 200 || observed?.stayedOnLogin !== true) failures.push("guest_probe_evidence_invalid");
@@ -136,7 +148,7 @@ export function environmentReceipt(contextFile = process.env.QA_ENVIRONMENT_CONT
return receipt;
}
async function launch(mode) {
if (!["full-local", "full-local-diagnostic"].includes(mode)) throw new Error("environment_mode_invalid");
if (!["full-local", "full-local-diagnostic", "retained-local", "retained-server-local"].includes(mode)) throw new Error("environment_mode_invalid");
if (process.platform !== "linux") throw new Error("environment_platform_unsupported");
if (mode === "full-local-diagnostic") for (const name of ["REASON", "OPERATOR", "APPROVAL_REF"]) {
const value = process.env[`QA_DIAGNOSTIC_${name}`]?.trim();
@@ -191,7 +203,7 @@ async function launch(mode) {
});
chmodSync(path.join(scratch, "supervisor.sock"), 0o600);
const env = {};
for (const name of ["PATH", "HOME", "LANG", "TERM", "CI", "NODE_OPTIONS", "PAYMENT_ORDER_PYTHON_BIN", "QA_DIAGNOSTIC_REASON", "QA_DIAGNOSTIC_OPERATOR", "QA_DIAGNOSTIC_APPROVAL_REF"]) if (process.env[name] !== undefined) env[name] = process.env[name];
for (const name of ["PATH", "HOME", "LANG", "TERM", "CI", "NODE_OPTIONS", "PAYMENT_ORDER_PYTHON_BIN", "QA_DIAGNOSTIC_REASON", "QA_DIAGNOSTIC_OPERATOR", "QA_DIAGNOSTIC_APPROVAL_REF", "QA_RETAINED_SPECS", "QA_RETAINED_GREP", "QA_RETAINED_SERVER_SUITE", "DOCKER_CONFIG", "PLAYWRIGHT_BROWSERS_PATH", "npm_config_userconfig", "npm_config_globalconfig"]) if (process.env[name] !== undefined) env[name] = process.env[name];
Object.assign(env, { QA_ENVIRONMENT_CONTEXT: contextFile, QA_FULL_REPORT_DIR: path.join(base, "results"), QA_DIAGNOSTIC_REPORT_DIR: path.join(base, "results"), QA_MANAGED_SCRATCH: scratch, QA_MANAGED_SOCKET: socket, QA_HOST: "127.0.0.1", QA_PORT: "23000", QA_DB_HOST: "127.0.0.1", QA_DB_PORT: "25432", QA_DB_CLIENT: "host", QA_DB_USER: "postgres", QA_DB_PASSWORD: "local_qa_only", QA_KEEP_DB: "0", QA_SKIP_CLIENT_BUILD: "0", E2E_MODE: "local", WECOM_ENABLED: "false", EMAIL_ENABLED: "false", OIDC_ENABLED: "false", STORAGE_DRIVER: "local", NODE_ENV: "test", npm_config_offline: "true", NO_COLOR: "1" });
const outcome = await new Promise(resolve => {
child = spawn("unshare", ["-Urn", "bash", path.join(root, "scripts/qa/full-local-environment-inner.sh"), mode], { cwd: root, env, stdio: "inherit", detached: true });
+82
查看文件
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { createRequire } from "node:module";
import { verifyManagedEnvironment } from "./full-local-environment.mjs";
// Public synthetic fixtures already used by the local browser suite. They are
// never read from an operator account, environment file or production service.
export const localAccounts = [
["CEO", "ceo", "Ceo"], ["ADMIN", "admin", "Admin"],
["MANAGER", "manager", "Manager"], ["EMPLOYEE", "employee", "Employee"],
["FINANCE", "finance", "Finance"], ["FINANCE_MGR", "finance_mgr", "FinanceMgr"],
["HR", "hr", "Hr"], ["ADMINISTRATION", "administration", "Administration"],
].map(([role, identifier, prefix]) => ({ role, identifier: `${identifier}@hyinda.com`, password: `${prefix}Password1234` }));
export function validateAccountPreparation({ origin, initialPassword, databaseName }) {
const url = new URL(origin);
assert.equal(url.protocol, "http:", "synthetic_origin_required");
assert.equal(url.hostname, "127.0.0.1", "synthetic_loopback_required");
assert.ok(url.port && url.pathname === "/" && !url.search && !url.hash && !url.username && !url.password, "synthetic_origin_invalid");
assert.match(databaseName ?? "", /^hyb_oa_webgate_[a-zA-Z0-9_]+$/, "synthetic_database_required");
assert.ok(initialPassword?.length >= 24 && localAccounts.every(a => a.password !== initialPassword), "distinct_initial_password_required");
}
export async function prepareAccounts({ origin, initialPassword, databaseName, fetchImpl = fetch }) {
validateAccountPreparation({ origin, initialPassword, databaseName });
const checks = [];
for (const account of localAccounts) {
const request = async (route, payload, token) => {
const response = await fetchImpl(`${origin}/api/auth/${route}`, {
method: "POST", redirect: "error", signal: AbortSignal.timeout(15000),
headers: { "content-type": "application/json", "x-device-fingerprint": `qa-initialize-${account.role}`,
...(token ? { authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(payload),
});
// Do not expose raw auth response/payload in failures or receipts.
const body = await response.json();
return { status: response.status, data: body.data };
};
const login = await request("login", { identifier: account.identifier, password: initialPassword });
assert.equal(login.status, 200, `initial_login_${account.role}`);
assert.equal(login.data?.mustChangePassword, true, `initial_change_required_${account.role}`);
const changed = await request("change-password", { oldPassword: initialPassword, newPassword: account.password }, login.data.accessToken);
assert.equal(changed.status, 200, `change_password_${account.role}`);
assert.equal(changed.data?.mustChangePassword, false, `changed_password_${account.role}`);
const old = await request("login", { identifier: account.identifier, password: initialPassword });
assert.equal(old.status, 401, `old_password_rejected_${account.role}`);
const fresh = await request("login", { identifier: account.identifier, password: account.password });
assert.equal(fresh.status, 200, `fresh_login_${account.role}`);
assert.equal(fresh.data?.mustChangePassword, false, `fresh_password_ready_${account.role}`);
checks.push({ role: account.role, initialChangeRequired: true, changedThroughApi: true, oldPasswordRejected: true, newLoginReady: true });
}
return { status: "PASS", scope: "run-owned synthetic accounts only", accounts: checks };
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
verifyManagedEnvironment(process.env.QA_ENVIRONMENT_CONTEXT);
validateAccountPreparation({ origin: process.env.E2E_BASE_URL,
initialPassword: process.env.QA_SYNTHETIC_INITIAL_PASSWORD, databaseName: process.env.SEED_DEV_DATABASE_CONFIRM });
const require = createRequire(new URL("../../client/package.json", import.meta.url));
const { chromium } = require("@playwright/test");
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.goto(`${process.env.E2E_BASE_URL}/login`);
await page.getByTestId("login-identifier").fill(localAccounts[1].identifier);
await page.getByTestId("login-password").fill(process.env.QA_SYNTHETIC_INITIAL_PASSWORD);
await page.getByTestId("login-submit").click();
await page.waitForURL(url => url.pathname === "/change-password");
assert.equal(await page.getByTestId("change-password-old").isVisible(), true);
} finally { await browser.close(); }
const result = await prepareAccounts({ origin: process.env.E2E_BASE_URL,
initialPassword: process.env.QA_SYNTHETIC_INITIAL_PASSWORD, databaseName: process.env.SEED_DEV_DATABASE_CONFIRM });
result.initialBrowserChangePasswordGate = true;
writeFileSync(process.argv[2], `${JSON.stringify(result, null, 2)}\n`, { flag: "wx", mode: 0o600 });
console.log(`Synthetic account preparation: PASS (${result.accounts.length} roles)`);
} catch {
console.error("Synthetic account preparation failed; business browser tests were not started");
process.exitCode = 1;
}
}
+1 -1
查看文件
@@ -12,7 +12,7 @@ pwa_build_candidate() {
(
cd "$client_dir"
VITE_BUILD_SHA="$build_sha" npx vite build --outDir "$output_dir" --emptyOutDir
NODE_ENV=production VITE_BUILD_SHA="$build_sha" npx vite build --outDir "$output_dir" --emptyOutDir
) > "$log_file" 2>&1 || build_status="$?"
if [[ "$build_status" -ne 0 ]]; then
+68
查看文件
@@ -0,0 +1,68 @@
#!/usr/bin/env node
import { existsSync, lstatSync, readFileSync, realpathSync, writeFileSync, renameSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
export const FULL_LOCAL_STAGES = ['inputs', 'database', 'prisma', 'server-unit', 'typecheck', 'client-build', 'server-start', 'accounts', 'workflow-actions', 'server-regressions', 'browser-server', 'browser'];
const now = () => new Date().toISOString();
export function validTime(value) { return typeof value === 'string' && Number.isFinite(Date.parse(value)); }
export function validateStages(record, expected, profile) {
const issues = [];
if (record?.schemaVersion !== 1 || record?.profile !== profile || record?.status !== 'PASS' || record?.exitCode !== 0 || !validTime(record.startedAt) || !validTime(record.endedAt) || Date.parse(record.endedAt) < Date.parse(record.startedAt)) issues.push('stages_not_complete');
const stages = Array.isArray(record?.stages) ? record.stages : [];
if (JSON.stringify(stages.map(s => s?.id)) !== JSON.stringify(expected)) issues.push('stage_set_or_order_invalid');
let previous = Date.parse(record?.startedAt);
for (const stage of stages) {
if (stage?.status !== 'PASS' || stage?.exitCode !== 0 || !validTime(stage?.startedAt) || !validTime(stage?.endedAt) || Date.parse(stage.startedAt) < previous || Date.parse(stage.endedAt) < Date.parse(stage.startedAt)) issues.push('stage_not_clean_pass');
previous = Date.parse(stage?.endedAt);
}
if (previous > Date.parse(record?.endedAt)) issues.push('stage_after_completion');
return [...new Set(issues)];
}
export function updateStageRecord(record, action, id, exitCode) {
const stages = record.stages;
const stage = stages.find(s => s.id === id);
if (record.status !== 'INCOMPLETE') throw Error('stage_record_already_finished');
if (action === 'start') {
const next = stages.find(s => s.status !== 'PASS');
if (!stage || next !== stage || stage.status !== 'NOT_RUN') throw Error('stage_start_out_of_order');
Object.assign(stage, {status:'RUNNING', startedAt:now()});
} else if (action === 'end') {
if (!stage || stage.status !== 'RUNNING' || !Number.isSafeInteger(exitCode) || exitCode < 0 || exitCode > 255) throw Error('stage_end_invalid');
Object.assign(stage, {status:exitCode === 0 ? 'PASS':'FAIL', exitCode, endedAt:now()});
} else if (action === 'finish') {
if (!Number.isSafeInteger(exitCode) || exitCode < 0 || exitCode > 255) throw Error('stage_exit_invalid');
for (const running of stages.filter(s => s.status === 'RUNNING')) Object.assign(running, {status:'FAIL', exitCode:exitCode || 1, endedAt:now()});
const complete = stages.every(s => s.status === 'PASS' && s.exitCode === 0);
Object.assign(record, {status:exitCode === 0 && complete ? 'PASS':'FAIL', exitCode:exitCode || (complete ? 0:1), endedAt:now()});
} else throw Error('stage_action_invalid');
return record;
}
export function newStageRecord(ids, profile) {
return {schemaVersion:1, profile, status:'INCOMPLETE', startedAt:now(), endedAt:null, exitCode:null, stages:ids.map(id => ({id,status:'NOT_RUN',startedAt:null,endedAt:null,exitCode:null}))};
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try {
const [action,input,id,rawExit] = process.argv.slice(2);
const file = path.resolve(input);
if (realpathSync(path.dirname(file)) !== path.dirname(file)) throw Error('stage_path_invalid');
try { if (!lstatSync(file).isFile()) throw Error('stage_path_invalid'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
let record;
if (action === 'selection') {
if (existsSync(file) || !/^\d+$/.test(id ?? '')) throw Error('selection_invalid');
const args = process.argv.slice(5), count = Number(id);
if (count < 1 || args.length < count) throw Error('selection_invalid');
record = {schemaVersion:1,profile:'full-local-selection',specArgs:args.slice(0,count),additionalArgs:args.slice(count),retries:0,forbidOnly:true,workers:process.env.CI?2:4,mode:'local'};
} else if (action === 'init') {
if (existsSync(file)) throw Error('stage_record_exists');
record = newStageRecord(FULL_LOCAL_STAGES, 'full-local-stages');
} else {
record = JSON.parse(readFileSync(file,'utf8'));
if (record.schemaVersion !== 1 || record.profile !== 'full-local-stages' || JSON.stringify(record.stages.map(s=>s.id)) !== JSON.stringify(FULL_LOCAL_STAGES)) throw Error('stage_record_invalid');
const raw = action === 'finish' ? id : rawExit;
if (action !== 'start' && !/^\d{1,3}$/.test(raw ?? '')) throw Error('stage_exit_invalid');
updateStageRecord(record,action,id,Number(raw));
}
writeFileSync(file+'.tmp',JSON.stringify(record,null,2)+'\n',{flag:'wx',mode:0o600});renameSync(file+'.tmp',file);
} catch { console.error('[stage-record] invalid_or_unwritable'); process.exitCode=1; }
}
+166
查看文件
@@ -0,0 +1,166 @@
#!/usr/bin/env node
import { createHash, randomBytes } from 'node:crypto';
import { existsSync, lstatSync, readlinkSync, readFileSync, readdirSync, realpathSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { sourceSnapshot, compareSnapshots } from './full-local-diagnostic.mjs';
import { environmentPolicyDigest, validateEnvironmentReceipt } from './full-local-environment.mjs';
import { coveragePlan, verifyLocalCoverage, localSpecArgs } from './full-local-coverage.mjs';
import { verifyLocalCandidateResults } from './verify-local-candidate-results.mjs';
import { FULL_LOCAL_STAGES, newStageRecord, updateStageRecord, validateStages, validTime } from './qa-stage-record.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const hash = value => createHash('sha256').update(value).digest('hex');
const read = file => JSON.parse(readFileSync(file,'utf8'));
const PHASES = ['static','full-local'];
const POLICY_FILES = ['scripts/qa/run-release-gate.sh','scripts/qa/release-gate-result.mjs','scripts/qa/qa-stage-record.mjs','scripts/qa/full-local-coverage.mjs','scripts/qa/full-local-coverage-contract.json','scripts/qa/verify-full-local-discovery.mjs','scripts/qa/verify-local-candidate-results.mjs','scripts/qa/route-manifest.json','client/playwright.config.ts','client/package.json','client/package-lock.json','server/package.json','server/package-lock.json'];
const EVIDENCE_FILES = {environment:'environment/result.json',phases:'results/full-local-stages.json',discovery:'results/discovery.json',plan:'results/coverage-plan.json',execution:'results/playwright-results.json',selection:'results/execution-selection.json'};
function atomic(file, value) { writeFileSync(file+'.tmp',JSON.stringify(value,null,2)+'\n',{flag:'wx',mode:0o600});renameSync(file+'.tmp',file); }
function compactSource(value) {
const {commit,tree,dirty,digest,repository}=value;
return {commit,tree,dirty,digest,repository:{trackedDigest:repository.trackedDigest,indexDigest:repository.indexDigest,porcelainV1Z:repository.porcelainV1Z,trackedCoverageComplete:repository.trackedCoverageComplete}};
}
export function captureReleaseSource(root=ROOT) {
const source = compactSource(sourceSnapshot(root));
// Include untracked task/docs/deployment inputs which the shared product
// snapshot does not hash. Ignored private/runtime files remain excluded.
const result = spawnSync('git', ['ls-files','--others','--exclude-standard','-z'], {cwd:root,encoding:'utf8',maxBuffer:16*1024*1024});
if (result.status !== 0) throw Error('untracked_inputs_unavailable');
const files = result.stdout.split('\0').filter(Boolean).sort().map(file => {
const target = path.join(root,file);
if (realpathSync(path.dirname(target)) !== path.dirname(target)) throw Error('input_parent_symlink');
const stat = lstatSync(target);
if (!stat.isFile() && !stat.isSymbolicLink()) throw Error('input_kind_invalid');
return [file,stat.mode & 0o777,stat.isSymbolicLink() ? 'symlink':'file',hash(stat.isSymbolicLink() ? readlinkSync(target):readFileSync(target))];
});
return {...source,untrackedDigest:hash(JSON.stringify(files))};
}
export function currentPolicy(root=ROOT) {
return {digest:hash(JSON.stringify(POLICY_FILES.map(file=>[file,hash(readFileSync(path.join(root,file)))]))), environmentDigest:environmentPolicyDigest(), runnerDigest:hash(readFileSync(path.join(root,'scripts/qa/full-local-environment.mjs'))), probeDigest:hash(readFileSync(path.join(root,'scripts/qa/browser-environment-probe.mjs'))), toolchain:{node:process.version,playwright:read(path.join(root,'client/node_modules/@playwright/test/package.json')).version,browserRegistry:hash(readFileSync(path.join(root,'client/node_modules/playwright-core/browsers.json'))),browserVersions:Object.fromEntries(read(path.join(root,'client/node_modules/playwright-core/browsers.json')).browsers.filter(browser=>['chromium','webkit'].includes(browser.name)).map(browser=>[browser.name,browser.browserVersion])),platform:process.platform,arch:process.arch,ci:Boolean(process.env.CI),workers:process.env.CI?2:4}};
}
function saneSource(source) {
return /^[a-f0-9]{40}$/.test(source?.commit ?? '') && /^[a-f0-9]{40}$/.test(source?.tree ?? '') && /^[a-f0-9]{64}$/.test(source?.digest ?? '') && source?.repository?.trackedCoverageComplete === true && typeof source.repository.porcelainV1Z === 'string' && source.dirty === Boolean(source.repository.porcelainV1Z.length);
}
function releaseMatches(a,b) { return matches(a,b) && /^[a-f0-9]{64}$/.test(a?.untrackedDigest ?? '') && a.untrackedDigest === b?.untrackedDigest; }
function matches(a,b) { return saneSource(a) && saneSource(b) && compareSnapshots(a,b).candidateIntegrityStatus === 'UNCHANGED'; }
export function verifyReleaseEvidence(record,bundle,facts) {
const issues=[];
if (record?.schemaVersion!==1 || record?.profile!=='release-predeploy' || record?.mode!=='verify-candidate' || !/^[a-f0-9]{24}$/.test(record?.runId??'')) issues.push('receipt_profile_invalid');
if (!validTime(record?.startedAt) || !validTime(record?.finishedAt) || Date.parse(record.finishedAt)<Date.parse(record.startedAt) || record?.executionExit!==0 || record?.interrupted) issues.push('run_incomplete_or_failed');
issues.push(...validateStages(record?.phases,PHASES,'release-predeploy-stages'));
if (JSON.stringify(record?.policy)!==JSON.stringify(facts.policy)) issues.push('policy_or_toolchain_changed');
if (!releaseMatches(record?.sourceBefore,record?.sourceAfter) || !releaseMatches(record?.sourceBefore,facts.source)) issues.push('source_changed_or_invalid');
if (Date.parse(record?.phases?.startedAt)<Date.parse(record?.startedAt) || Date.parse(record?.phases?.endedAt)>Date.parse(record?.finishedAt)) issues.push('release_time_binding_invalid');
const env=bundle?.environment;
if (env?.schemaVersion!==1 || env?.mode!=='full-local' || env?.profile!=='isolated-online-v1' || env?.policyDigest!==facts.policy.environmentDigest || env?.status!=='PASS' || env?.exitCode!==0 || env?.childExit!==0 || env?.childSignal || env?.interrupted || env?.error || !validTime(env?.startedAt) || !validTime(env?.endedAt) || Date.parse(env.endedAt)<Date.parse(env.startedAt)) issues.push('managed_run_invalid');
issues.push(...validateEnvironmentReceipt(env?.environment,facts.policy.environmentDigest,true));
if (env?.platform!==facts.policy.toolchain.platform || env?.environment?.platform!==facts.policy.toolchain.platform || env?.environment?.runnerDigest!==facts.policy.runnerDigest || env?.environment?.probeDigest!==facts.policy.probeDigest) issues.push('environment_runtime_changed');
if (['chromium','webkit'].some(name=>!facts.policy.toolchain.browserVersions?.[name] || env?.environment?.nativeOnlineProbe?.engines?.find(engine=>engine.name===name)?.version!==facts.policy.toolchain.browserVersions[name])) issues.push('browser_version_changed');
if (env?.cleanup?.containerOwnerVerified!==true || env?.cleanup?.containerRemoved!==true || env?.cleanup?.scratchRemoved!==true || env?.cleanup?.error) issues.push('cleanup_incomplete');
if (!matches(env?.sourceBefore,env?.sourceAfter) || !matches(env?.sourceBefore,facts.source) || env?.sourceUnchanged!==true || env?.repositoryUnchanged!==true || env?.candidateIntegrityStatus!=='UNCHANGED') issues.push('managed_source_mismatch');
if (bundle?.phases?.schemaVersion!==1 || bundle?.phases?.profile!=='full-local-stages') issues.push('full_stage_profile_invalid');
issues.push(...validateStages(bundle?.phases,FULL_LOCAL_STAGES,'full-local-stages'));
const outerFull=record?.phases?.stages?.find(s=>s.id==='full-local');
if (env && outerFull && (Date.parse(env.startedAt)<Date.parse(outerFull.startedAt) || Date.parse(env.endedAt)>Date.parse(outerFull.endedAt) || Date.parse(bundle?.phases?.startedAt)<Date.parse(env.startedAt) || Date.parse(bundle?.phases?.endedAt)>Date.parse(env.endedAt))) issues.push('stage_time_binding_invalid');
let coverage=null,witness=null;
try {
const plan=coveragePlan(facts.root,bundle.discovery,facts.contract,facts.source);
if (plan.status!=='PASS' || JSON.stringify(plan)!==JSON.stringify(bundle.plan)) issues.push('discovery_plan_invalid');
coverage=verifyLocalCoverage(plan,bundle.execution,facts.source);
issues.push(...coverage.issues);
witness=verifyLocalCandidateResults(facts.manifest,bundle.execution);
if (!witness.ok) issues.push(...witness.issues);
const specs=new Set(plan.localSpecs);
if (!facts.manifest.acceptanceSpecs?.length || facts.manifest.acceptanceSpecs.some(file=>!specs.has(file))) issues.push('acceptance_spec_missing');
const expectedSelection={schemaVersion:1,profile:'full-local-selection',specArgs:localSpecArgs(plan),additionalArgs:[],retries:0,forbidOnly:true,workers:facts.policy.toolchain.workers,mode:'local'};
if (JSON.stringify(bundle.selection)!==JSON.stringify(expectedSelection)) issues.push('recorded_selection_invalid');
const config=bundle.execution?.config, projects=config?.projects;
if (config?.forbidOnly!==true || !Array.isArray(projects) || projects.length!==1 || projects.some(project=>project.retries!==0 || project.repeatEach!==1 || project.name!=='' || project.timeout!==180000 || JSON.stringify(project.testIgnore)!==JSON.stringify(['**/manual/**'])) || config?.shard!==null || config?.maxFailures!==0 || config?.workers!==facts.policy.toolchain.workers || config?.version!==facts.policy.toolchain.playwright) issues.push('execution_selection_invalid');
const stats=bundle.execution?.stats;
if (!validTime(stats?.startTime) || !Number.isFinite(stats?.duration) || stats.duration<0 || stats.expected!==plan.localCases.length || stats.unexpected!==0 || stats.flaky!==0 || stats.skipped!==0) issues.push('execution_statistics_invalid');
const browser=bundle?.phases?.stages?.find(s=>s.id==='browser');
if (Date.parse(stats?.startTime)<Date.parse(browser?.startedAt) || Date.parse(stats?.startTime)+stats?.duration>Date.parse(browser?.endedAt)) issues.push('browser_time_binding_invalid');
} catch { issues.push('coverage_evidence_invalid'); }
const localChecksPassed=issues.length===0;
const clean=[record?.sourceBefore,record?.sourceAfter,facts.source,env?.sourceBefore,env?.sourceAfter].every(source=>saneSource(source) && source.dirty===false);
if (!clean) issues.push('dirty_candidate');
return {schemaVersion:1,profile:'release-predeploy',status:!record?.finishedAt?'INCOMPLETE':localChecksPassed ? (clean?'PASS':'PARTIAL'):'FAIL',localChecksPassed,deploymentEligible:localChecksPassed&&clean,deliveryStatus:'INCOMPLETE',issues:[...new Set(issues)],localCases:coverage?.cases??[],obligations:coverage?.obligations??[],witnessSummary:witness?{ok:witness.ok,expected:witness.expectedTagCount,passed:witness.passedTagCount}:null};
}
export function validateSkipRequest(mode,env) {
const affected=[];
for (const [name,stage] of [['PROJECT_WIDE_SKIP_BUSINESS_FLOW','project-wide-business-flow'],['PROJECT_WIDE_SKIP_VISUAL','project-wide-visual']]) {
const value=env[name]??'0';
if (!['0','1'].includes(value)) throw Error('skip_value_invalid');
if (value==='1') affected.push(stage);
}
if (!affected.length) return null;
if (mode!=='project-wide') throw Error('skip_forbidden_for_release');
const values=['PROJECT_WIDE_SKIP_REASON','PROJECT_WIDE_SKIP_OPERATOR','PROJECT_WIDE_SKIP_APPROVAL_REF'].map(name=>env[name]?.trim());
if (values.some(value=>!value || value.length>500 || /[\r\n\0]/u.test(value))) throw Error('skip_approval_metadata_required');
return {profile:'project-wide',status:'PARTIAL',deploymentEligible:false,affectedStages:affected,reason:values[0],operator:values[1],approvalRef:values[2],approvalTime:null,observedAt:new Date().toISOString()};
}
export function confinedPath(root,relative) {
if (typeof relative!=='string' || !relative || path.isAbsolute(relative) || relative.split('/').some(part=>part==='..'||part===''||part==='.') || relative.includes('\\')) throw Error('evidence_path_invalid');
if (realpathSync(root)!==root) throw Error('evidence_root_symlink');
const resolved=path.join(root,relative);
let cursor=root;
for (const part of relative.split('/')) { cursor=path.join(cursor,part); try { if (lstatSync(cursor).isSymbolicLink()) throw Error('evidence_symlink'); } catch (error) { if (error.code!=='ENOENT') throw error; } }
return resolved;
}
function readBundle(record,seal=false) {
const bundle={};
if (!/^[a-f0-9]{24}$/.test(record.runId??'') || record.fullLocalDirectory!==`artifacts/qa-flow/release-predeploy/${record.runId}`) throw Error('managed_report_path_invalid');
const base=confinedPath(ROOT,record.fullLocalDirectory);
for (const [key,relative] of Object.entries(EVIDENCE_FILES)) {
const file=confinedPath(base,relative);if (!lstatSync(file).isFile()) throw Error('evidence_not_file');const raw=readFileSync(file);const digest=hash(raw);
if (seal) record.evidence[key]={relative,sha256:digest};
else if (record.evidence?.[key]?.relative!==relative || record.evidence[key].sha256!==digest) throw Error('evidence_digest_mismatch');
bundle[key]=JSON.parse(raw);
}
return bundle;
}
function facts() {return {root:ROOT,source:captureReleaseSource(),policy:currentPolicy(),contract:read(path.join(ROOT,'scripts/qa/full-local-coverage-contract.json')),manifest:read(path.join(ROOT,'scripts/qa/route-manifest.json'))};}
function ownedDirectory(input,create=false) {
const relative=path.relative(ROOT,path.resolve(input)).split(path.sep).join('/');
if (!relative.startsWith('artifacts/release-gate/')) throw Error('release_report_outside_artifacts');
const dir=confinedPath(ROOT,relative);
if (create && existsSync(dir) && readdirSync(dir).length) throw Error('release_report_not_empty');
if (create) mkdirSync(dir,{recursive:true,mode:0o700});
if (realpathSync(dir)!==dir) throw Error('release_report_symlink');
return dir;
}
if (process.argv[1] && import.meta.url===pathToFileURL(path.resolve(process.argv[1])).href) {
try {
const [action,input,arg,rawExit]=process.argv.slice(2);
if (action==='skip') {
const result=validateSkipRequest(arg,process.env);
if (result) { const dir=ownedDirectory(input,true);atomic(confinedPath(dir,'skip-receipt.json'),result);console.log('[release-gate] approved diagnostic skips: PARTIAL'); }
} else {
const dir=ownedDirectory(input,action==='init');const file=confinedPath(dir,'release-result.json');
if (action==='init') {
validateSkipRequest('verify-candidate',process.env);
const runId=randomBytes(12).toString('hex');
const record={schemaVersion:1,profile:'release-predeploy',mode:'verify-candidate',runId,startedAt:new Date().toISOString(),finishedAt:null,status:'INCOMPLETE',deploymentEligible:false,policy:currentPolicy(),sourceBefore:captureReleaseSource(),sourceAfter:null,phases:newStageRecord(PHASES,'release-predeploy-stages'),fullLocalDirectory:`artifacts/qa-flow/release-predeploy/${runId}`,evidence:{},executionExit:null};
atomic(file,record);
} else {
const record=read(file);
if (record.profile!=='release-predeploy'||record.mode!=='verify-candidate') throw Error('receipt_profile_invalid');
if (action==='full-path') console.log(confinedPath(ROOT,record.fullLocalDirectory));
else if (action==='stage-start'||action==='stage-end') {
if (!releaseMatches(record.sourceBefore,captureReleaseSource())) throw Error('stage_source_changed');
updateStageRecord(record.phases,action==='stage-start'?'start':'end',arg,Number(rawExit));atomic(file,record);
} else if (action==='finish') {
if (!/^\d{1,3}$/.test(arg??'')) throw Error('exit_invalid');
const exitCode=Number(arg);updateStageRecord(record.phases,'finish',null,exitCode);record.executionExit=exitCode;record.sourceAfter=captureReleaseSource();record.finishedAt=new Date().toISOString();
let result;
try {const bundle=readBundle(record,true);result=verifyReleaseEvidence(record,bundle,facts());}
catch {result={status:exitCode?'FAIL':'INCOMPLETE',localChecksPassed:false,deploymentEligible:false,deliveryStatus:'INCOMPLETE',issues:['required_evidence_unavailable']};}
Object.assign(record,{status:result.status,deploymentEligible:result.deploymentEligible,verification:result});atomic(file,record);console.log(JSON.stringify({status:result.status,localChecksPassed:result.localChecksPassed,deploymentEligible:result.deploymentEligible,issues:result.issues}));process.exitCode=result.deploymentEligible?0:result.status==='PARTIAL'?2:1;
} else if (action==='verify') {
const result=verifyReleaseEvidence(record,readBundle(record),facts());console.log(JSON.stringify(result));process.exitCode=result.deploymentEligible?0:result.status==='PARTIAL'?2:1;
} else throw Error('action_invalid');
}
}
} catch {console.error('[release-receipt] invalid_or_incomplete');process.exitCode=1;}
}
+3 -3
查看文件
@@ -47,7 +47,7 @@
"bundle": "approval",
"witnessRoute": "/workflow/tasks",
"witnessTag": "@local-candidate:approval",
"expectedH1": "待我处理",
"expectedH1": "待我审批",
"roleFixture": "manager",
"primaryAction": { "allowed": "处理", "forbidden": "通过" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
@@ -85,7 +85,7 @@
"witnessTag": "@local-candidate:payment-ap",
"expectedH1": "付款",
"roleFixture": "finance",
"primaryAction": { "allowed": "新建批次", "forbidden": "复核批次" },
"primaryAction": { "allowed": "搜索待组批报销", "forbidden": "复核批次" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
},
{
@@ -110,7 +110,7 @@
"bundle": "mobile-workspace",
"witnessRoute": "/mobile",
"witnessTag": "@local-candidate:mobile-workspace",
"expectedH1": "今天待办",
"expectedH1": "今日待处理",
"roleFixture": "employee",
"primaryAction": { "allowed": "新建报销", "forbidden": "财务" },
"witnessSpec": "client/tests/e2e/local-candidate-witnesses.spec.ts"
+45 -3
查看文件
@@ -6,13 +6,12 @@ ARTIFACT_ROOT="${RELEASE_GATE_ARTIFACT_DIR:-${ROOT_DIR}/artifacts/release-gate/$
MODE="${1:-all}"
CURRENT_STAGE=""
mkdir -p "$ARTIFACT_ROOT"
usage() {
cat <<USAGE
Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|project-wide|deploy|ci-release|r66-trialrun|help>
Usage: bash scripts/qa/run-release-gate.sh <verify-candidate|all|static|smoke-local|full-local|project-wide|deploy|ci-release|r66-trialrun|help>
说明:
verify-candidate 仅 static + 完整受管 full-local,生成共享回执;绝不部署
static 运行部署契约、server Prisma 校验、client typecheck/build
smoke-local 运行本地网页 smoke 门禁
full-local 运行本地全量门禁(server regression + client full E2E)
@@ -28,6 +27,8 @@ Usage: bash scripts/qa/run-release-gate.sh <all|static|smoke-local|full-local|pr
LIVE_SMOKE_* 透传给 deploy.sh 与 live smoke
PROJECT_WIDE_SKIP_BUSINESS_FLOW=1 跳过业务流 E2E
PROJECT_WIDE_SKIP_VISUAL=1 跳过项目级视觉 E2E
PROJECT_WIDE_SKIP_REASON / PROJECT_WIDE_SKIP_OPERATOR / PROJECT_WIDE_SKIP_APPROVAL_REF
skip 仅限有批准记录的 project-wide 诊断,PARTIAL/退出2
DEPLOY_SERVER / REMOTE_DIR / REMOTE_ENV_FILE / SYNC_ENV
COMPOSE_PROJECT_NAME / POSTGRES_DATA_DIR / STORAGE_HOST_DIR / DEPLOY_DATA_MODE
USAGE
@@ -54,7 +55,13 @@ run_stage() {
shift
CURRENT_STAGE="$stage_name"
echo ">>> [release-gate] ${stage_name}"
if [[ "$MODE" == "verify-candidate" ]]; then
node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" stage-start "$ARTIFACT_ROOT" "$stage_name"
fi
"$@"
if [[ "$MODE" == "verify-candidate" ]]; then
node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" stage-end "$ARTIFACT_ROOT" "$stage_name" 0
fi
}
stage_static() {
@@ -125,10 +132,40 @@ stage_deploy() {
bash "$ROOT_DIR/deploy/trialrun/deploy.sh"
}
# This entry does not install enforcement in any existing deployment path.
finish_candidate() {
local original_status=$? receipt_status=0
trap - EXIT ERR INT TERM
node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" finish "$ARTIFACT_ROOT" "$original_status" || receipt_status=$?
if (( original_status )); then exit "$original_status"; fi
exit "$receipt_status"
}
if [[ "$MODE" != "help" && "$MODE" != "-h" && "$MODE" != "--help" ]]; then
# Validate before static, remote readiness or deployment side effects.
trap - ERR
node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" skip "$ARTIFACT_ROOT" "$MODE"
if [[ "$MODE" != "verify-candidate" ]]; then trap handle_error ERR; fi
fi
if [[ "$MODE" != "verify-candidate" ]]; then mkdir -p "$ARTIFACT_ROOT"; fi
case "$MODE" in
help|-h|--help)
usage
;;
verify-candidate)
node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" init "$ARTIFACT_ROOT"
trap finish_candidate EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
unset QA_ENVIRONMENT_CONTEXT QA_DIAGNOSTIC_REPORT_DIR QA_DIAGNOSTIC_STAGE
QA_FULL_REPORT_DIR="$(node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" full-path "$ARTIFACT_ROOT")"
export QA_FULL_REPORT_DIR
run_stage static stage_static
run_stage full-local stage_full_local
exit 0
;;
static)
run_stage static stage_static
;;
@@ -170,5 +207,10 @@ case "$MODE" in
;;
esac
if [[ "$MODE" == "project-wide" && ( "${PROJECT_WIDE_SKIP_BUSINESS_FLOW:-0}" == "1" || "${PROJECT_WIDE_SKIP_VISUAL:-0}" == "1" ) ]]; then
echo "[release-gate][PARTIAL] 诊断跳过不具备发布资格"
exit 2
fi
echo "[release-gate][PASS] 模式 ${MODE} 通过"
echo "[release-gate] 产物目录:${ARTIFACT_ROOT}"
+193 -48
查看文件
@@ -7,7 +7,7 @@ CLIENT_DIR="$ROOT_DIR/client"
source "$ROOT_DIR/scripts/qa/pwa-cross-version-fixture.sh"
MODE="${1:-}"
if [[ "$MODE" == "full-local" || "$MODE" == "full-local-diagnostic" ]]; then
if [[ "$MODE" == "full-local" || "$MODE" == "full-local-diagnostic" || "$MODE" == "retained-local" || "$MODE" == "retained-server-local" ]]; then
if [[ -z "${QA_ENVIRONMENT_CONTEXT:-}" ]]; then
exec node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" "$MODE"
fi
@@ -43,6 +43,7 @@ QA_READY_TIMEOUT_SECONDS="${QA_READY_TIMEOUT_SECONDS:-90}"
QA_BASE_URL=""
QA_DB_NAME="${QA_DB_NAME:-${QA_DB_PREFIX}_$(date +%Y%m%d%H%M%S)_$RANDOM}"
QA_DB_CREATED=0
QA_FULL_STAGE_FILE=""
QA_SERVER_STARTED=0
QA_SERVER_OWNED=0
DATABASE_URL="postgresql://${QA_DB_USER}:${QA_DB_PASSWORD}@${QA_DB_HOST}:${QA_DB_PORT}/${QA_DB_NAME}?schema=public"
@@ -57,7 +58,7 @@ qa_require_cmd() {
qa_usage() {
cat <<USAGE
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|auth-security-local|payment-concurrency-local|business-flow-local|project-wide-visual-local|qg6-visual-matrix-local|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|r110-mobile-local|smoke-live>
Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|retained-local|retained-server-local|auth-security-local|payment-concurrency-local|business-flow-local|project-wide-visual-local|qg6-visual-matrix-local|r70-assist-local|r106-visual-local|r108-mobile-local|r109-mobile-local|r110-mobile-local|smoke-live>
可选环境变量:
QA_PORT / QA_HOST
@@ -66,6 +67,9 @@ Usage: bash scripts/qa/run-web-gates.sh <smoke-local|full-local|auth-security-lo
QA_DB_DOCKER_CONTAINER / QA_DOCKER_USE_SG=1
QA_DB_PREFIX / QA_DB_NAME / QA_KEEP_DB
QA_FULL_REPORT_DIR full-local 独立发现、构建日志与浏览器结果目录
retained-local 受管限定重验,无普通发布资格;必填 QA_RETAINED_SPECS
QA_RETAINED_GREP 可选历史用例标题筛选,不改变 full-local 分母
QA_RETAINED_SERVER_SUITE retained-server-local 必填 workflow-actions 或 server-regressions
full-local-diagnostic 仅本地诊断,始终 PARTIAL/退出 2,无发布资格
QA_DIAGNOSTIC_REASON / QA_DIAGNOSTIC_OPERATOR / QA_DIAGNOSTIC_APPROVAL_REF
QA_DIAGNOSTIC_REPORT_DIR 本次诊断专用空目录;禁止覆盖历史报告
@@ -305,6 +309,13 @@ qa_cleanup() {
fi
fi
if [[ -n "$QA_FULL_STAGE_FILE" ]]; then
local stage_status="$original_status"
if [[ "$stage_status" == "0" ]]; then stage_status="$cleanup_status"; fi
if ! node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" finish "$QA_FULL_STAGE_FILE" "$stage_status"; then
cleanup_status=1
fi
fi
if [[ "$original_status" != "0" ]]; then
exit "$original_status"
fi
@@ -513,6 +524,17 @@ qa_start_server() {
tail -n 200 "$QA_SERVER_LOG" >&2 || true
exit 1
}
# Derive browser capabilities from this successfully started server, never
# from a caller's PLAYWRIGHT_OCR_MOCK assertion. A restart recalculates it.
export PLAYWRIGHT_OCR_MOCK=0
if [[ "$OCR_PROVIDER" == "mock" && "$JOBS_ENABLED" == "true" ]]; then
export PLAYWRIGHT_OCR_MOCK=1
fi
if [[ -n "${QA_FULL_REPORT_DIR:-}" ]]; then
mkdir -p "$QA_FULL_REPORT_DIR"
node -e 'require("node:fs").appendFileSync(process.argv[1], JSON.stringify({provider:process.env.OCR_PROVIDER,jobsEnabled:process.env.JOBS_ENABLED === "true",mockCorrectionEnabled:process.env.PLAYWRIGHT_OCR_MOCK === "1",source:"owned-server-start"})+"\n")' "$QA_FULL_REPORT_DIR/server-capabilities.jsonl"
fi
}
qa_build_client() {
@@ -530,7 +552,7 @@ qa_build_client() {
(
cd "$CLIENT_DIR"
npx tsc --noEmit --pretty false --extendedDiagnostics
npx vite build
NODE_ENV=production npx vite build
)
}
@@ -563,75 +585,182 @@ qa_prepare_full_inputs() {
pwa_with_fixture env E2E_MODE=local \
PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/discovery.json" \
QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" \
npx playwright test --list --retries=0 --reporter=json \
npx playwright test --list --retries=0 --forbid-only --reporter=json \
> "$QA_FULL_REPORT_DIR/discovery.stdout.log" 2> "$QA_FULL_REPORT_DIR/discovery.stderr.log"
)
node "$ROOT_DIR/scripts/qa/verify-full-local-discovery.mjs" "$QA_FULL_REPORT_DIR/discovery.json" \
> "$QA_FULL_REPORT_DIR/discovery-coverage.json"
node "$ROOT_DIR/scripts/qa/full-local-coverage.mjs" plan "$QA_FULL_REPORT_DIR" \
> "$QA_FULL_REPORT_DIR/coverage-plan.json"
}
# One owner prepares synthetic accounts before any business suite runs.
qa_seed_synthetic_credentials() {
export QA_SYNTHETIC_INITIAL_PASSWORD="Init!$(node -e 'process.stdout.write(require("node:crypto").randomBytes(24).toString("hex"))')"
local role
for role in CEO ADMIN MANAGER EMPLOYEE FINANCE FINANCE_MGR HR ADMINISTRATION; do
export "SEED_${role}_PASSWORD=$QA_SYNTHETIC_INITIAL_PASSWORD"
done
}
qa_prepare_synthetic_accounts() {
mkdir -p "$QA_FULL_REPORT_DIR"
export SEED_DEV_DATABASE_CONFIRM="$QA_DB_NAME"
node "$ROOT_DIR/scripts/qa/prepare-local-e2e-accounts.mjs" "$QA_FULL_REPORT_DIR/synthetic-account-preparation.json"
unset QA_SYNTHETIC_INITIAL_PASSWORD
for role in CEO ADMIN MANAGER EMPLOYEE FINANCE FINANCE_MGR HR ADMINISTRATION; do unset "SEED_${role}_PASSWORD"; done
}
# Focused historical-case diagnosis; never a normal full-local release receipt.
qa_run_retained_local() {
[[ -n "${QA_RETAINED_SPECS:-}" ]] || { echo "retained-local requires an explicit spec list" >&2; return 1; }
local -a retained_specs
read -r -a retained_specs <<< "$QA_RETAINED_SPECS"
local spec
for spec in "${retained_specs[@]}"; do
[[ "$spec" =~ ^[a-zA-Z0-9_-]+\.spec\.ts$ && -f "$CLIENT_DIR/tests/e2e/$spec" ]] || { echo "invalid local spec selector" >&2; return 1; }
done
mkdir -p "$QA_FULL_REPORT_DIR"
node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify({scope:"focused-historical-retest",normalReleaseEligible:false,specs:process.argv.slice(2),grep:process.env.QA_RETAINED_GREP||null})+"\n",{flag:"wx"})' "$QA_FULL_REPORT_DIR/selection.json" "${retained_specs[@]}"
qa_select_port
export JOBS_ENABLED=true RATE_LIMIT_ENABLED=false IM_NOTIFIER_ENABLED=false
qa_export_app_env
export IM_NOTIFIER_ENABLED=false
qa_seed_synthetic_credentials
qa_prepare_db
qa_build_client
qa_start_server
mkdir -p "$QA_FULL_REPORT_DIR"
qa_prepare_synthetic_accounts
node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL"
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" report-guest > "$QA_FULL_REPORT_DIR/environment-before-browser.json"
local -a pwa_env=()
if [[ " $QA_RETAINED_SPECS " == *" pwa-cross-version-upgrade.spec.ts "* ]]; then
pwa_prepare_fixture "$CLIENT_DIR" "$QA_FULL_REPORT_DIR"
pwa_env=("PWA_BUILD_A_DIR=$PWA_BUILD_A_DIR" "PWA_BUILD_B_DIR=$PWA_BUILD_B_DIR" "PWA_BUILD_A_SHA=$PWA_BUILD_A_SHA" "PWA_BUILD_B_SHA=$PWA_BUILD_B_SHA")
fi
local -a grep_args=()
if [[ -n "${QA_RETAINED_GREP:-}" ]]; then grep_args=(--grep "$QA_RETAINED_GREP"); fi
(
cd "$CLIENT_DIR"
env "${pwa_env[@]}" E2E_MODE=local PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/playwright-results.json" \
QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" QA_VISUAL_ARTIFACT_DIR="$QA_FULL_REPORT_DIR/visual-artifacts" \
node node_modules/@playwright/test/cli.js test "${retained_specs[@]}" "${grep_args[@]}" \
--retries=0 --reporter=line,json --output="$QA_FULL_REPORT_DIR/test-results"
)
}
# A fixed server suite in the same managed environment; never full-local coverage.
qa_run_retained_server_local() {
case "${QA_RETAINED_SERVER_SUITE:-}" in
workflow-actions|server-regressions) ;;
*) echo "retained-server-local requires an allowed server suite" >&2; return 1 ;;
esac
mkdir -p "$QA_FULL_REPORT_DIR"
node -e 'require("node:fs").writeFileSync(process.argv[1], JSON.stringify({scope:"focused-server-regression",normalReleaseEligible:false,suite:process.argv[2]})+"\n",{flag:"wx"})' "$QA_FULL_REPORT_DIR/selection.json" "$QA_RETAINED_SERVER_SUITE"
qa_select_port
qa_export_app_env
export JOBS_ENABLED=false RATE_LIMIT_ENABLED=false IM_NOTIFIER_ENABLED=false
if [[ "$QA_RETAINED_SERVER_SUITE" == "server-regressions" ]]; then export IM_NOTIFIER_ENABLED=true; fi
qa_seed_synthetic_credentials
qa_prepare_db
qa_build_client
qa_start_server
node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL"
qa_prepare_synthetic_accounts
(
cd "$SERVER_DIR"
case "$QA_RETAINED_SERVER_SUITE" in
workflow-actions) ./scripts/regression-workflow-actions.sh ;;
server-regressions) ./scripts/regression-all.sh ;;
esac
)
}
# Plain calls preserve errexit inside compound helpers. Failure is sealed by
# qa_cleanup; wrapping "$@" in an if/|| would suppress their inner failures.
qa_full_stage() {
local stage="$1"
shift
node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" start "$QA_FULL_STAGE_FILE" "$stage"
echo ">>> [full-local] $stage"
"$@"
node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" end "$QA_FULL_STAGE_FILE" "$stage" 0
}
qa_full_in_directory() (
cd "$1"
shift
"$@"
)
qa_full_database() {
qa_seed_synthetic_credentials
qa_prepare_db
}
qa_full_server_start() {
qa_start_server
node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL"
}
qa_full_browser_server() {
qa_cleanup_server
export JOBS_ENABLED="${JOBS_ENABLED_FOR_E2E:-true}"
qa_start_server
}
qa_run_full_local() {
qa_select_port
export JOBS_ENABLED="${JOBS_ENABLED:-false}"
qa_export_app_env
qa_prepare_full_inputs
qa_prepare_db
echo ">>> 执行 server Prisma 校验"
(
cd "$SERVER_DIR"
npm run prisma:validate
)
echo ">>> 执行 server 单元测试"
(
cd "$SERVER_DIR"
npm test
)
echo ">>> 执行 client typecheck"
(
cd "$CLIENT_DIR"
npx tsc --noEmit --pretty false --extendedDiagnostics
)
qa_build_client
qa_start_server
echo ">>> 执行 workflow-actions 回归"
(
cd "$SERVER_DIR"
./scripts/regression-workflow-actions.sh
)
echo ">>> 执行 server 全量回归"
(
cd "$SERVER_DIR"
./scripts/regression-all.sh
)
echo ">>> 切换 server 到 Playwright E2E 模式(启用后台 job)"
qa_cleanup_server
export JOBS_ENABLED="${JOBS_ENABLED_FOR_E2E:-true}"
qa_start_server
qa_run_full_browser
export RATE_LIMIT_ENABLED=false
QA_FULL_REPORT_DIR="${QA_FULL_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/full-local/$QA_RUN_ID}"
mkdir -p "$QA_FULL_REPORT_DIR"
node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" init "$QA_FULL_REPORT_DIR/full-local-stages.json"
QA_FULL_STAGE_FILE="$QA_FULL_REPORT_DIR/full-local-stages.json"
qa_full_stage inputs qa_prepare_full_inputs
qa_full_stage database qa_full_database
qa_full_stage prisma qa_full_in_directory "$SERVER_DIR" npm run prisma:validate
qa_full_stage server-unit qa_full_in_directory "$SERVER_DIR" npm test
qa_full_stage typecheck qa_full_in_directory "$CLIENT_DIR" npx tsc --noEmit --pretty false --extendedDiagnostics
qa_full_stage client-build qa_build_client
qa_full_stage server-start qa_full_server_start
qa_full_stage accounts qa_prepare_synthetic_accounts
qa_full_stage workflow-actions qa_full_in_directory "$SERVER_DIR" ./scripts/regression-workflow-actions.sh
qa_full_stage server-regressions qa_full_in_directory "$SERVER_DIR" ./scripts/regression-all.sh
qa_full_stage browser-server qa_full_browser_server
qa_full_stage browser qa_run_full_browser
}
qa_run_full_browser() {
# Only the diagnostic owner may add tracing; ordinary callers cannot narrow coverage.
if (( $# )) && [[ "${QA_DIAGNOSTIC_STAGE:-}" != "browser" || "$*" != "--trace=on" ]]; then
echo "[ERROR] full-local browser scope override rejected" >&2
return 1
fi
local -a local_specs
mapfile -t local_specs < "$QA_FULL_REPORT_DIR/local-spec-args.txt"
(( ${#local_specs[@]} > 0 )) || return 1
if [[ -n "${QA_DIAGNOSTIC_STAGE:-}" ]]; then QA_DIAGNOSTIC_PHASE=environment; fi
node "$ROOT_DIR/scripts/qa/browser-environment-probe.mjs" guest "$QA_BASE_URL"
node "$ROOT_DIR/scripts/qa/full-local-environment.mjs" report-guest > "$QA_FULL_REPORT_DIR/environment-before-browser.json"
if [[ -n "${QA_DIAGNOSTIC_STAGE:-}" ]]; then QA_DIAGNOSTIC_PHASE=test; fi
echo ">>> 执行 Playwright full E2E"
local browser_exit=0 coverage_exit=0
node "$ROOT_DIR/scripts/qa/qa-stage-record.mjs" selection "$QA_FULL_REPORT_DIR/execution-selection.json" "${#local_specs[@]}" "${local_specs[@]}" "$@"
(
cd "$CLIENT_DIR"
pwa_with_fixture env E2E_MODE=local \
PLAYWRIGHT_JSON_OUTPUT_FILE="$QA_FULL_REPORT_DIR/playwright-results.json" \
QA_VISUAL_OUTPUT_DIR="$QA_FULL_REPORT_DIR/visual" \
PLAYWRIGHT_HTML_OUTPUT_DIR="$QA_FULL_REPORT_DIR/playwright-report" PLAYWRIGHT_HTML_OPEN=never \
npm run test:e2e:full -- "$@" --retries=0 --reporter=list,json,html --output="$QA_FULL_REPORT_DIR/test-results"
)
npm run test:e2e:full -- "${local_specs[@]}" "$@" --retries=0 --forbid-only --reporter=list,json,html --output="$QA_FULL_REPORT_DIR/test-results"
) || browser_exit=$?
node "$ROOT_DIR/scripts/qa/full-local-coverage.mjs" verify-local "$QA_FULL_REPORT_DIR" \
> "$QA_FULL_REPORT_DIR/local-coverage-result.json" || coverage_exit=$?
if (( browser_exit )); then return "$browser_exit"; fi
return "$coverage_exit"
}
qa_run_r70_assist_local() {
@@ -815,6 +944,22 @@ case "$MODE" in
source "$ROOT_DIR/scripts/qa/full-local-diagnostic-stage.sh"
qa_run_diagnostic_stage "${2:-}"
;;
retained-local)
qa_require_cmd curl
qa_require_db_client
qa_require_cmd node
qa_require_cmd npm
trap qa_cleanup EXIT
qa_run_retained_local
;;
retained-server-local)
qa_require_cmd curl
qa_require_db_client
qa_require_cmd node
qa_require_cmd npm
trap qa_cleanup EXIT
qa_run_retained_server_local
;;
full-local)
qa_require_cmd curl
qa_require_db_client
+77
查看文件
@@ -0,0 +1,77 @@
const assert = require('node:assert/strict');
const { execFileSync, spawnSync } = require('node:child_process');
const { randomUUID } = require('node:crypto');
const { mkdtempSync, mkdirSync, chmodSync, readFileSync, readdirSync, rmSync } = require('node:fs');
const { tmpdir } = require('node:os');
const path = require('node:path');
const { createRequire } = require('node:module');
const { setTimeout: delay } = require('node:timers/promises');
const root = path.resolve(__dirname, '../..');
const serverRequire = createRequire(path.join(root, 'server/package.json'));
const { Pool } = serverRequire('pg');
async function main() {
assert.equal(process.env.RUN_ISOLATED_BUDGET_DB_TEST, '1');
const scratch = mkdtempSync(path.join(tmpdir(), 'hyb-budget-synthetic-'));
const socket = path.join(scratch, 'socket');
const dockerConfig = path.join(scratch, 'docker');
mkdirSync(socket); chmodSync(scratch, 0o755); chmodSync(socket, 0o777); mkdirSync(dockerConfig);
const owner = randomUUID();
const docker = (args, input) => execFileSync('docker', ['--config', dockerConfig, ...args], {
input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 60000,
env: { PATH: process.env.PATH, LANG: 'C.UTF-8' },
}).trim();
let container; let pool; let failure;
const passed = [];
try {
container = docker(['run', '-d', '--pull=never', '--network', 'none', '--read-only',
'--label', `hyb.qa.owner=${owner}`, '--tmpfs', '/var/lib/postgresql/data', '--tmpfs', '/var/run/postgresql',
'--tmpfs', '/tmp', '--mount', `type=bind,src=${socket},dst=/qa-socket`,
'-e', 'POSTGRES_HOST_AUTH_METHOD=trust', '-e', 'POSTGRES_USER=synthetic', '-e', 'POSTGRES_DB=budget_synthetic',
'postgres:16-alpine', '-c', 'listen_addresses=', '-c', 'unix_socket_directories=/qa-socket,/var/run/postgresql']);
assert.equal(docker(['inspect', '--format', '{{.HostConfig.NetworkMode}}', container]), 'none');
pool = new Pool({ host: socket, user: 'synthetic', database: 'budget_synthetic', password: '', max: 8,
connectionTimeoutMillis: 1500, statement_timeout: 10000 });
let ready = false;
for (let i = 0; i < 40; i++) {
try { await pool.query('SELECT 1'); ready = true; break; } catch { await delay(250); }
}
if (!ready) {
const logs = spawnSync('docker', ['--config', dockerConfig, 'logs', container], {
encoding: 'utf8', env: { PATH: process.env.PATH }, timeout: 10000,
});
console.error(logs.stdout, logs.stderr);
throw new Error('isolated database did not become ready');
}
const migrations = path.join(root, 'server/prisma/migrations');
let count = 0;
for (const name of readdirSync(migrations).sort()) {
if (name === 'migration_lock.toml') continue;
// psql autocommit preserves PostgreSQL's new-enum-value transaction boundary.
docker(['exec', '-i', container, 'psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', '-h', '/qa-socket', '-U', 'synthetic', '-d', 'budget_synthetic'],
readFileSync(path.join(migrations, name, 'migration.sql')));
count++;
}
console.log(JSON.stringify({ stage: 'migrations', count, network: 'none', persistentVolumes: false }));
passed.push('clean_database_all_migrations');
await require('./budget-pool-db-fixture.cjs')(pool, scratch);
console.log(JSON.stringify({status:'PASS',checks:passed,scope:'isolated real PostgreSQL budget concurrency'}));
} catch (error) {
failure = error;
throw error;
} finally {
try {
if(pool) await pool.end();
if(container){
assert.equal(docker(['inspect','--format','{{ index .Config.Labels "hyb.qa.owner" }}',container]),owner);
docker(['rm','-f',container]);
}
rmSync(scratch,{recursive:true,force:true});
console.log(JSON.stringify({cleanup:'PASS',onlyRunOwnedResources:true}));
} catch (cleanupError) {
console.error(JSON.stringify({cleanup:'FAIL',code:cleanupError.code??null}));
if (!failure) throw cleanupError;
}
}
}
main().catch(error=>{console.error(JSON.stringify({status:'FAIL',name:error.name,code:error.code??null,actual:typeof error.actual==='string'&&/^[A-Z0-9-]{1,60}$/.test(error.actual)?error.actual:undefined,expected:typeof error.expected==='string'&&/^[A-Z0-9-]{1,60}$/.test(error.expected)?error.expected:undefined,fixtureLine:/budget-pool-db-fixture.cjs:(\d+)/.exec(error.stack??'')?.[1]??null,reason:error.name==='AssertionError'?String(error.message).split('\n')[0]:'budget_fixture_failed'}));process.exitCode=1;});
+77
查看文件
@@ -0,0 +1,77 @@
const assert = require('node:assert/strict');
const { execFileSync, spawnSync } = require('node:child_process');
const { randomUUID } = require('node:crypto');
const { mkdtempSync, mkdirSync, chmodSync, readFileSync, readdirSync, rmSync } = require('node:fs');
const { tmpdir } = require('node:os');
const path = require('node:path');
const { createRequire } = require('node:module');
const { setTimeout: delay } = require('node:timers/promises');
const root = path.resolve(__dirname, '../..');
const serverRequire = createRequire(path.join(root, 'server/package.json'));
const { Pool } = serverRequire('pg');
async function main() {
assert.equal(process.env.RUN_ISOLATED_NUMBERING_DB_TEST, '1');
const scratch = mkdtempSync(path.join(tmpdir(), 'hyb-numbering-synthetic-'));
const socket = path.join(scratch, 'socket');
const dockerConfig = path.join(scratch, 'docker');
mkdirSync(socket); chmodSync(scratch, 0o755); chmodSync(socket, 0o777); mkdirSync(dockerConfig);
const owner = randomUUID();
const docker = (args, input) => execFileSync('docker', ['--config', dockerConfig, ...args], {
input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 60000,
env: { PATH: process.env.PATH, LANG: 'C.UTF-8' },
}).trim();
let container; let pool; let failure;
const passed = [];
try {
container = docker(['run', '-d', '--pull=never', '--network', 'none', '--read-only',
'--label', `hyb.qa.owner=${owner}`, '--tmpfs', '/var/lib/postgresql/data', '--tmpfs', '/var/run/postgresql',
'--tmpfs', '/tmp', '--mount', `type=bind,src=${socket},dst=/qa-socket`,
'-e', 'POSTGRES_HOST_AUTH_METHOD=trust', '-e', 'POSTGRES_USER=synthetic', '-e', 'POSTGRES_DB=numbering_synthetic',
'postgres:16-alpine', '-c', 'listen_addresses=', '-c', 'unix_socket_directories=/qa-socket,/var/run/postgresql']);
assert.equal(docker(['inspect', '--format', '{{.HostConfig.NetworkMode}}', container]), 'none');
pool = new Pool({ host: socket, user: 'synthetic', database: 'numbering_synthetic', password: '', max: 4,
connectionTimeoutMillis: 1500, statement_timeout: 10000 });
let ready = false;
for (let i = 0; i < 40; i++) {
try { await pool.query('SELECT 1'); ready = true; break; } catch { await delay(250); }
}
if (!ready) {
const logs = spawnSync('docker', ['--config', dockerConfig, 'logs', container], {
encoding: 'utf8', env: { PATH: process.env.PATH }, timeout: 10000,
});
console.error(logs.stdout, logs.stderr);
throw new Error('isolated database did not become ready');
}
const migrations = path.join(root, 'server/prisma/migrations');
let count = 0;
for (const name of readdirSync(migrations).sort()) {
if (name === 'migration_lock.toml') continue;
// psql autocommit preserves PostgreSQL's new-enum-value transaction boundary.
docker(['exec', '-i', container, 'psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', '-h', '/qa-socket', '-U', 'synthetic', '-d', 'numbering_synthetic'],
readFileSync(path.join(migrations, name, 'migration.sql')));
count++;
}
console.log(JSON.stringify({ stage: 'migrations', count, network: 'none', persistentVolumes: false }));
passed.push('clean_database_all_migrations');
await require('./business-numbering-db-fixture.cjs')(pool, scratch);
console.log(JSON.stringify({status:'PASS',checks:passed,scope:'isolated real PostgreSQL numbering and synthetic loan HTTP regression'}));
} catch (error) {
failure = error;
throw error;
} finally {
try {
if(pool) await pool.end();
if(container){
assert.equal(docker(['inspect','--format','{{ index .Config.Labels "hyb.qa.owner" }}',container]),owner);
docker(['rm','-f',container]);
}
rmSync(scratch,{recursive:true,force:true});
console.log(JSON.stringify({cleanup:'PASS',onlyRunOwnedResources:true}));
} catch (cleanupError) {
console.error(JSON.stringify({cleanup:'FAIL',code:cleanupError.code??null}));
if (!failure) throw cleanupError;
}
}
}
main().catch(error=>{console.error(JSON.stringify({status:'FAIL',name:error.name,code:error.code??null,actual:typeof error.actual==='string'&&/^[A-Z0-9-]{1,60}$/.test(error.actual)?error.actual:undefined,expected:typeof error.expected==='string'&&/^[A-Z0-9-]{1,60}$/.test(error.expected)?error.expected:undefined,fixtureLine:/business-numbering-db-fixture.cjs:(\d+)/.exec(error.stack??'')?.[1]??null,reason:error.name==='AssertionError'?String(error.message).split('\n')[0]:'numbering_fixture_failed'}));process.exitCode=1;});
+178
查看文件
@@ -0,0 +1,178 @@
const assert = require('node:assert/strict');
const { execFileSync, spawnSync } = require('node:child_process');
const { randomUUID } = require('node:crypto');
const { mkdtempSync, mkdirSync, chmodSync, readFileSync, readdirSync, rmSync } = require('node:fs');
const { tmpdir } = require('node:os');
const path = require('node:path');
const { createRequire } = require('node:module');
const { setTimeout: delay } = require('node:timers/promises');
const root = path.resolve(__dirname, '../..');
const serverRequire = createRequire(path.join(root, 'server/package.json'));
const { Pool } = serverRequire('pg');
async function main() {
assert.equal(process.env.RUN_ISOLATED_LOAN_DB_TEST, '1');
const scratch = mkdtempSync(path.join(tmpdir(), 'hyb-loan-synthetic-'));
const socket = path.join(scratch, 'socket');
const dockerConfig = path.join(scratch, 'docker');
mkdirSync(socket); chmodSync(scratch, 0o755); chmodSync(socket, 0o777); mkdirSync(dockerConfig);
const owner = randomUUID();
const docker = (args, input) => execFileSync('docker', ['--config', dockerConfig, ...args], {
input, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 60000,
env: { PATH: process.env.PATH, LANG: 'C.UTF-8' },
}).trim();
let container; let pool; let failure;
const passed = [];
try {
container = docker(['run', '-d', '--pull=never', '--network', 'none', '--read-only',
'--label', `hyb.qa.owner=${owner}`, '--tmpfs', '/var/lib/postgresql/data', '--tmpfs', '/var/run/postgresql',
'--tmpfs', '/tmp', '--mount', `type=bind,src=${socket},dst=/qa-socket`,
'-e', 'POSTGRES_HOST_AUTH_METHOD=trust', '-e', 'POSTGRES_USER=synthetic', '-e', 'POSTGRES_DB=loan_synthetic',
'postgres:16-alpine', '-c', 'listen_addresses=', '-c', 'unix_socket_directories=/qa-socket,/var/run/postgresql']);
assert.equal(docker(['inspect', '--format', '{{.HostConfig.NetworkMode}}', container]), 'none');
pool = new Pool({ host: socket, user: 'synthetic', database: 'loan_synthetic', password: '', max: 4,
connectionTimeoutMillis: 1500, statement_timeout: 10000 });
let ready = false;
for (let i = 0; i < 40; i++) {
try { await pool.query('SELECT 1'); ready = true; break; } catch { await delay(250); }
}
if (!ready) {
const logs = spawnSync('docker', ['--config', dockerConfig, 'logs', container], {
encoding: 'utf8', env: { PATH: process.env.PATH }, timeout: 10000,
});
console.error(logs.stdout, logs.stderr);
throw new Error('isolated database did not become ready');
}
const migrations = path.join(root, 'server/prisma/migrations');
let count = 0;
for (const name of readdirSync(migrations).sort()) {
if (name === 'migration_lock.toml') continue;
// psql autocommit preserves PostgreSQL's new-enum-value transaction boundary.
docker(['exec', '-i', container, 'psql', '-X', '-q', '-v', 'ON_ERROR_STOP=1', '-h', '/qa-socket', '-U', 'synthetic', '-d', 'loan_synthetic'],
readFileSync(path.join(migrations, name, 'migration.sql')));
count++;
}
console.log(JSON.stringify({ stage: 'migrations', count, network: 'none', persistentVolumes: false }));
passed.push('clean_database_all_migrations');
const tables = new Set(['legal_entities','bank_institutions','loan_lenders','company_loans','loan_repayments',
'loan_repayment_allocations','payment_batches','payment_batch_items']);
async function insert(client, table, data) {
assert.ok(tables.has(table));
const keys = Object.keys(data);
await client.query(`INSERT INTO ${table} (${keys.join(',')}) VALUES (${keys.map((_, i) => '$'+(i+1)).join(',')})`, Object.values(data));
}
async function transaction(fn) {
const client = await pool.connect();
try { await client.query('BEGIN'); const result = await fn(client); await client.query('COMMIT'); return result; }
catch (error) { await client.query('ROLLBACK'); throw error; }
finally { client.release(); }
}
const companyId = randomUUID(); const bankId = randomUUID(); const actorId = randomUUID();
await insert(pool,'legal_entities',{id:companyId,code:'SYNTHETIC-LOAN',name:'synthetic company'});
await insert(pool,'bank_institutions',{id:bankId,code:'SYNTHETIC-LOAN',name:'synthetic bank'});
async function fixture(options={}) {
const lenderId = randomUUID(); const loanId = randomUUID();
await insert(pool,'loan_lenders',{id:lenderId,legal_entity_id:companyId,name:'synthetic lender',identity_status:'verified',
identity_confirmed_by:actorId,identity_confirmed_at:new Date(),identity_confirmation:'synthetic external identity checked',
bank_institution_id:bankId,bank_name:'synthetic',bank_account_encrypted:'synthetic ciphertext',account_name_encrypted:'synthetic ciphertext',
created_by:actorId,updated_by:actorId,updated_at:new Date(),request_key:randomUUID()});
const loan={id:loanId,loan_no:'SYN-'+loanId,origin_reference:'SYN-'+loanId,legal_entity_id:companyId,lender_id:lenderId,
original_principal:'100.00',opening_outstanding_principal:'100.00',received_date:'2025-01-01',balance_as_of:'2026-09-17',
historical_entry:true,confirmation_status:'confirmed',confirmed_by:actorId,confirmed_at:new Date(),accounting_confirmation_ref:'synthetic check',
created_by:actorId,updated_by:actorId,updated_at:new Date(),request_key:randomUUID(),...options};
await insert(pool,'company_loans',loan);
return {lenderId,loanId,loan};
}
async function repayment(client, f, options={}) {
const id=randomUUID(); const batchId=randomUUID(); const itemId=randomUUID();
const amount=options.amount ?? '60.00';
await insert(client,'loan_repayments',{id,repayment_no:'SYN-'+id,legal_entity_id:companyId,lender_id:f.lenderId,
reason:'synthetic principal repayment',amount,submitted_by:actorId,lender_identity_version:1,lender_account_version:1,
payee_snapshot_json:'{}',request_key:randomUUID()});
await insert(client,'payment_batches',{id:batchId,batch_no:'SYN-'+batchId,entity_type:'loan_repayment',loan_repayment_id:id,
legal_entity_id:companyId,name:'synthetic batch',default_payment_channel:'bank',created_by:actorId,updated_at:new Date()});
await insert(client,'payment_batch_items',{id:itemId,payment_batch_id:batchId,entity_id:id,entity_code:'SYN-'+id,
entity_title:'synthetic principal',amount:options.itemAmount??amount,snapshot_json:'{}'});
await insert(client,'loan_repayment_allocations',{id:randomUUID(),repayment_id:id,loan_id:f.loanId,
amount:options.allocationAmount??amount,loan_version:1});
return {id,batchId,itemId};
}
const rejectsCheck = (fn) => assert.rejects(fn, (error) => error.code === '23514');
await rejectsCheck(() => fixture({original_principal:'0.00'}));
await rejectsCheck(() => fixture({opening_outstanding_principal:'100.01'}));
await rejectsCheck(() => fixture({accounting_confirmation_ref:null}));
passed.push('money_and_confirmation_checks');
const f=await fixture();
await assert.rejects(() => insert(pool,'company_loans',{...f.loan,id:randomUUID(),loan_no:'SYN-DUP',request_key:randomUUID()}), e=>e.code==='23505');
passed.push('duplicate_incoming_reference');
await rejectsCheck(()=>transaction(c=>repayment(c,f,{itemAmount:'59.00'})));
await rejectsCheck(()=>transaction(c=>repayment(c,f,{allocationAmount:'59.00'})));
const unconfirmed=await fixture({confirmation_status:'unconfirmed'});
await rejectsCheck(()=>transaction(c=>repayment(c,unconfirmed)));
const other=await fixture();
await rejectsCheck(()=>transaction(c=>repayment(c,{...f,loanId:other.loanId})));
passed.push('allocation_total_source_and_batch_identity');
const first=await transaction(c=>repayment(c,f));
const balances=await pool.query(`SELECT opening_outstanding_principal::text AS outstanding,
(SELECT sum(amount)::text FROM loan_repayment_allocations WHERE loan_id=$1 AND reservation_state='reserved') AS reserved
FROM company_loans WHERE id=$1`,[f.loanId]);
assert.deepEqual(balances.rows[0],{outstanding:'100.00',reserved:'60.00'});
await pool.query("UPDATE payment_batches SET status='reviewed' WHERE id=$1",[first.batchId]);
assert.equal((await pool.query('SELECT opening_outstanding_principal::text AS value FROM company_loans WHERE id=$1',[f.loanId])).rows[0].value,'100.00');
await rejectsCheck(()=>transaction(c=>repayment(c,f)));
passed.push('approval_reserves_without_settlement_and_total_capacity');
await rejectsCheck(()=>pool.query('UPDATE company_loans SET opening_outstanding_principal=50 WHERE id=$1',[f.loanId]));
passed.push('confirmed_opening_balance_immutable');
await pool.query("UPDATE loan_repayments SET bank_result_state='unknown' WHERE id=$1",[first.id]);
await rejectsCheck(()=>transaction(async c=>{
await c.query("UPDATE payment_batches SET status='cancelled' WHERE id=$1",[first.batchId]);
await c.query("UPDATE loan_repayment_allocations SET reservation_state='released',released_at=now(),release_reason='synthetic' WHERE repayment_id=$1",[first.id]);
}));
assert.equal((await pool.query('SELECT status FROM payment_batches WHERE id=$1',[first.batchId])).rows[0].status,'reviewed');
passed.push('unknown_bank_state_cannot_release_and_transaction_rolls_back');
await transaction(async c=>{
await c.query("UPDATE loan_repayments SET bank_result_state='confirmed_failed',bank_result_ref='synthetic confirmed failure' WHERE id=$1",[first.id]);
await c.query("UPDATE payment_batches SET status='cancelled' WHERE id=$1",[first.batchId]);
await c.query("UPDATE loan_repayment_allocations SET reservation_state='released',released_at=now(),release_reason='synthetic confirmed failure' WHERE repayment_id=$1",[first.id]);
});
passed.push('confirmed_failure_atomic_release');
const concurrent=await fixture();
const left=await pool.connect();const right=await pool.connect();
try {
await left.query('BEGIN');await right.query('BEGIN');
await left.query('SELECT id FROM company_loans WHERE id=$1 FOR UPDATE',[concurrent.loanId]);
const pid=(await right.query('SELECT pg_backend_pid() AS pid')).rows[0].pid;
const waiting=right.query('SELECT id FROM company_loans WHERE id=$1 FOR UPDATE',[concurrent.loanId]);
let blocked=false;
for(let i=0;i<30;i++){
const state=(await pool.query('SELECT wait_event_type FROM pg_stat_activity WHERE pid=$1',[pid])).rows[0];
if(state?.wait_event_type==='Lock'){blocked=true;break;}
await delay(10);
}
assert.ok(blocked,'second connection must actually wait for the same loan lock');
await repayment(left,concurrent);await left.query('COMMIT');await waiting;
await repayment(right,concurrent);await rejectsCheck(()=>right.query('COMMIT'));await right.query('ROLLBACK');
assert.equal((await pool.query("SELECT sum(amount)::text AS value FROM loan_repayment_allocations WHERE loan_id=$1",[concurrent.loanId])).rows[0].value,'60.00');
} finally {await left.query('ROLLBACK');await right.query('ROLLBACK');left.release();right.release();}
passed.push('two_connections_serialize_and_reject_over_reservation');
await require('./company-loan-http-fixture.cjs')(pool, scratch);
console.log(JSON.stringify({status:'PASS',checks:passed,scope:'schema and real PostgreSQL constraints; not HTTP/auth/payment acceptance'}));
} catch (error) {
failure = error;
throw error;
} finally {
try {
if(pool) await pool.end();
if(container){
assert.equal(docker(['inspect','--format','{{ index .Config.Labels "hyb.qa.owner" }}',container]),owner);
docker(['rm','-f',container]);
}
rmSync(scratch,{recursive:true,force:true});
console.log(JSON.stringify({cleanup:'PASS',onlyRunOwnedResources:true}));
} catch (cleanupError) {
console.error(JSON.stringify({cleanup:'FAIL',code:cleanupError.code??null}));
if (!failure) throw cleanupError;
}
}
}
main().catch(error=>{console.error(JSON.stringify({status:'FAIL',name:error.name,code:error.code??null,message:String(error.message).replace(/\/home\/[^\s]+/g,'<local-path>')}));process.exitCode=1;});
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { coveragePlan, verifyLocalCoverage, verifyPostDeployCoverage } from "../full-local-coverage.mjs";
const candidate = { commit: "a".repeat(40), digest: "b".repeat(64) };
function report(rows, executed = false) {
return { errors: [], suites: rows.map(row => ({ file: row.file, specs: [{ ...row, tests: [{ projectName: "", repeatEachIndex: 0, ...(executed ? { expectedStatus: "passed", status: "expected", results: [{ status: "passed", retry: 0 }] } : {}) }] }] })) };
}
function fixture(context) {
const root = mkdtempSync(path.join(os.tmpdir(), "hyb-coverage-"));
context.after(() => rmSync(root, { recursive: true, force: true }));
const rows = ["local", "live", "frozen", "protection"].map(name => ({ file: `client/tests/e2e/${name}.spec.ts`, id: name, title: name, projectName: "", repeatEachIndex: 0 }));
for (const row of rows) { mkdirSync(path.dirname(path.join(root, row.file)), { recursive: true }); writeFileSync(path.join(root, row.file), row.title); }
const contract = { schemaVersion: 1, approvalReference: "synthetic", exceptions: rows.slice(1, 3).map(row => ({ ...row, kind: row.id === "live" ? "post-deploy" : "frozen", reason: "synthetic", entry: "synthetic", responsibilityRole: "synthetic", namedOwner: null, requiredEvidence: "synthetic", sourceSha256: createHash("sha256").update(row.title).digest("hex"), replacementFile: rows[3].file, replacementTitle: rows[3].title })) };
const discovery = report(rows);
const plan = () => coveragePlan(root, discovery, contract, candidate);
return { root, rows, contract, discovery, plan };
}
test("全发现分层,本地通过仍保留公网必跑且不能获得交付资格", context => {
const f = fixture(context), p = f.plan();
assert.equal(p.status, "PASS"); assert.equal(p.totalCases, 4); assert.equal(p.localCases.length, 2);
const r = verifyLocalCoverage(p, report(p.localCases, true), candidate);
assert.equal(r.status, "PASS"); assert.equal(r.deliveryStatus, "INCOMPLETE"); assert.equal(r.normalReleaseEligible, false);
assert.deepEqual(r.obligations.map(row => row.status), ["NOT_RUN_LOCAL", "FROZEN_PROTECTION_PASS"]);
});
for (const mutation of ["missing-file", "new-file", "new-live-case", "identity-drift", "duplicate", "source-drift", "no-protection", "unknown-exception", "missing-reason"]) test(`发现/归属反例:${mutation}`, context => {
const f = fixture(context);
if (mutation === "missing-file") f.discovery.suites.pop();
if (mutation === "new-file") writeFileSync(path.join(f.root, "client/tests/e2e/new.spec.ts"), "new");
if (mutation === "new-live-case") f.discovery.suites[1].specs.push({ ...f.discovery.suites[1].specs[0], id: "new-live", title: "new-live" });
if (mutation === "identity-drift") f.discovery.suites[1].specs[0].id = "drift";
if (mutation === "duplicate") f.discovery.suites.push(f.discovery.suites[0]);
if (mutation === "source-drift") writeFileSync(path.join(f.root, f.rows[1].file), "changed");
if (mutation === "no-protection") f.contract.exceptions[1].replacementTitle = "missing";
if (mutation === "unknown-exception") f.contract.exceptions[1].kind = "ignore";
if (mutation === "missing-reason") delete f.contract.exceptions[0].reason;
assert.equal(f.plan().status, "FAIL");
});
for (const mutation of ["missing", "unexpected", "duplicate", "skipped", "failed", "timedOut", "interrupted", "retry", "expected-failure", "fake-pass", "candidate", "errors", "title-drift"]) test(`本地结果反例:${mutation}`, context => {
const f = fixture(context), p = f.plan(), r = report(p.localCases, true);
let current = candidate;
const row = r.suites[0].specs[0].tests[0];
if (mutation === "missing") r.suites.pop();
if (mutation === "unexpected") r.suites.push(report([f.rows[1]], true).suites[0]);
if (mutation === "duplicate") r.suites.push(r.suites[0]);
if (["skipped", "failed", "timedOut", "interrupted"].includes(mutation)) row.results[0].status = mutation;
if (mutation === "retry") row.results.push({ status: "passed", retry: 1 });
if (mutation === "expected-failure") row.expectedStatus = "failed";
if (mutation === "fake-pass") { r.passed = true; delete row.results; }
if (mutation === "candidate") current = { ...candidate, digest: "c".repeat(64) };
if (mutation === "errors") r.errors.push({ message: "synthetic" });
if (mutation === "title-drift") r.suites[0].specs[0].title = "changed";
assert.equal(verifyLocalCoverage(p, r, current).status, "FAIL");
});
test("公网只接受同候选 live 逐项结果,不接受本地或布尔通过", context => {
const f = fixture(context), p = f.plan();
const good = { profile: "post-deploy-live", mode: "live", candidate, deployedCandidate: candidate, report: report([f.rows[1]], true) };
assert.equal(verifyPostDeployCoverage(p, good).status, "PASS");
for (const bad of [{ passed: true }, { ...good, profile: "full-local" }, { ...good, mode: "local" }, { ...good, deployedCandidate: { ...candidate, commit: "c".repeat(40) } }, { ...good, candidate: { ...candidate, digest: "d".repeat(64) } }, { ...good, report: report(p.localCases, true) }]) assert.equal(verifyPostDeployCoverage(p, bad).status, "FAIL");
});
@@ -13,7 +13,7 @@ function receipt() {
schemaVersion: 1, profile: "isolated-online-v1", policyDigest: "policy",
supervisorObservation: { source: "live-supervisor-unix-ipc", sampledAt: "2026-09-07T00:00:00Z", namespace: "parent" },
namespace: { inheritedRunner: "parent", ownedChild: "child", runnerCreated: true, currentMatchesOwnedChild: true, topologyMatched: true },
nativeOnlineProbe: { valid: true, online: true, browserNamespace: "child", browserMatchesOwnedChild: true, noNavigatorOverride: true },
nativeOnlineProbe: { valid: true, online: true, browserNamespace: "child", browserMatchesOwnedChild: true, noNavigatorOverride: true, engines: ["chromium", "webkit"].map(name => ({ name, version: "synthetic", online: true, valid: true, noNavigatorOverride: true, browserNamespace: "child", browserMatchesOwnedChild: true })) },
bootstrapObservation: {
valid: true, observationPoint: "guest-entry-black-box", freshContext: true, initialSessionHint: false,
listenerBeforeNavigation: true, noRouteMocks: true, loginFormVisible: true, refreshRequestCount: 0, loginRequestCount: 0,
@@ -66,13 +66,21 @@ test("缺 context 不接受 GATE_PASSED 或已有 true", () => {
assert.deepEqual(validateEnvironmentFacts(context, facts), []);
for (const key of Object.keys(facts)) assert.ok(validateEnvironmentFacts(context, { ...facts, [key]: null }).length, key);
});
test("拓扑只接纳 owned veth 且禁止默认出口", () => {
test("拓扑只接纳同 namespace 内部 veth 默认路由,拒绝外部对端和网关", () => {
const addresses = [{ ifname: "lo" }, ...["qaonline", "qapeer"].map((ifname, index) => ({ ifname, flags: ["UP"], operstate: "UP", addr_info: [{ local: `192.0.2.${index + 1}`, prefixlen: 30 }] }))];
assert.equal(topologyMatches(addresses, [], []), true);
assert.equal(topologyMatches(addresses, [{ dst: "default" }], []), false);
assert.equal(topologyMatches(addresses, [], [{ gateway: "::1" }]), false);
assert.equal(topologyMatches([...addresses, { ifname: "eth0" }], [], []), false);
assert.equal(topologyMatches(addresses.slice(0, 2), [], []), false);
const links = [{ifname:"lo",ifindex:1},{ifname:"qaonline",ifindex:2,link:"qapeer",linkinfo:{info_kind:"veth"}},{ifname:"qapeer",ifindex:3,link:"qaonline",linkinfo:{info_kind:"veth"}}];
const routes = [{dst:"default",dev:"qaonline",scope:"link"}];
assert.equal(topologyMatches(addresses, routes, [], links), true);
for(const mutate of [
f=>{f.routes=[];}, f=>{f.routes.push(f.routes[0]);}, f=>{f.routes[0].gateway="192.0.2.2";},
f=>{f.routes[0].dev="eth0";}, f=>{f.routes[0].scope="global";}, f=>{f.routes[0].multipath=[];},
f=>{f.ipv6=[{dst:"default",dev:"qaonline"}];}, f=>{f.addresses.push({ifname:"eth0"});},
f=>{f.links[1].link_netnsid=0;}, f=>{f.links[1].link="outside";}, f=>{f.links[1].linkinfo.info_kind="bridge";},
f=>{f.links[1].master="outside";}, f=>{f.links.pop();}, f=>{f.links=undefined;},
]) {
const f=structuredClone({addresses,links,routes,ipv6:[]});mutate(f);
assert.equal(topologyMatches(f.addresses,f.routes,f.ipv6,f.links),false);
}
});
for (const mismatch of [false, true]) test(`namespace 创建失败不降级,清理须匹配 owner:mismatch=${mismatch}`, context => {
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { prepareAccounts, localAccounts } from "../prepare-local-e2e-accounts.mjs";
const input = { origin: "http://127.0.0.1:12345", databaseName: "hyb_oa_webgate_synthetic", initialPassword: "Initial!synthetic-only-123456789" };
test("preparation refuses remote origins, wrong database and reused initial credentials before any request", async () => {
for (const change of [{ origin: "https://example.invalid" }, { origin: "http://127.0.0.1:1/api" },
{ databaseName: "production" }, { initialPassword: localAccounts[0].password }]) {
let calls = 0;
await assert.rejects(prepareAccounts({ ...input, ...change, fetchImpl: async () => { calls++; } }));
assert.equal(calls, 0);
}
});
test("all roles must really require change, reject old password and login again; receipts contain no credentials", async () => {
const calls = [];
const result = await prepareAccounts({ ...input, fetchImpl: async (url, request) => {
const step = calls.length % 4;
calls.push({ url, body: JSON.parse(request.body) });
assert.equal(request.redirect, "error");
assert.equal(new URL(url).pathname, step === 1 ? "/api/auth/change-password" : "/api/auth/login");
if (step === 1) assert.equal(request.headers.authorization, "Bearer synthetic");
return { status: step === 2 ? 401 : 200, json: async () => ({ data: { accessToken: "synthetic", mustChangePassword: step === 0 } }) };
} });
assert.equal(calls.length, localAccounts.length * 4);
assert.equal(result.accounts.length, 8);
assert.ok(!JSON.stringify(result).includes(input.initialPassword));
assert.ok(localAccounts.every(a => !JSON.stringify(result).includes(a.password)));
});
test("one false precondition stops before changing any further account", async () => {
for (const failingStep of [0, 1, 2, 3]) {
let calls = 0;
await assert.rejects(prepareAccounts({ ...input, fetchImpl: async () => {
const step = calls++;
return { status: step === failingStep ? 503 : step === 2 ? 401 : 200,
json: async () => ({ data: { accessToken: "synthetic", mustChangePassword: step === 0 } }) };
} }));
assert.equal(calls, failingStep + 1);
}
});
+153 -4
查看文件
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import os from "node:os";
@@ -31,8 +32,16 @@ function fixture(context, overrides = {}) {
context.after(() => rmSync(root, { recursive: true, force: true }));
for (const directory of ["bin", "tmp", "scripts/qa", "server/scripts", "client/dist", "client/tests/e2e", "output"]) mkdirSync(path.join(root, directory), { recursive: true });
writeFileSync(path.join(root, "output/archive.png"), "historical evidence");
for (const file of ["run-web-gates.sh", "run-pwa-cross-version-upgrade.sh", "pwa-cross-version-fixture.sh", "verify-full-local-discovery.mjs", "full-local-diagnostic.mjs", "full-local-diagnostic-stage.sh"]) copyFileSync(path.join(projectRoot, "scripts/qa", file), path.join(root, "scripts/qa", file));
for (const file of ["run-web-gates.sh", "run-pwa-cross-version-upgrade.sh", "pwa-cross-version-fixture.sh", "verify-full-local-discovery.mjs", "full-local-diagnostic.mjs", "full-local-diagnostic-stage.sh", "full-local-coverage.mjs", "qa-stage-record.mjs"]) copyFileSync(path.join(projectRoot, "scripts/qa", file), path.join(root, "scripts/qa", file));
for (const file of ["pwa-cross-version-upgrade.spec.ts", "ordinary.spec.ts"]) writeFileSync(path.join(root, "client/tests/e2e", file), "");
const exceptions = [];
if (overrides.LAYERED_FIXTURE) {
for (const [file, kind] of [["live.spec.ts", "post-deploy"], ["frozen.spec.ts", "frozen"]]) {
writeFileSync(path.join(root, "client/tests/e2e", file), "preserved");
exceptions.push({ file: `client/tests/e2e/${file}`, id: file, title: file, projectName: "", repeatEachIndex: 0, kind, sourceSha256: createHash("sha256").update("preserved").digest("hex"), reason: "fixture", entry: "fixture", responsibilityRole: "fixture", requiredEvidence: "fixture", replacementFile: "client/tests/e2e/ordinary.spec.ts", replacementTitle: "ordinary.spec.ts" });
}
}
writeFileSync(path.join(root, "scripts/qa/full-local-coverage-contract.json"), JSON.stringify({ schemaVersion: 1, approvalReference: "fixture", exceptions }));
writeFileSync(path.join(root, "client/dist/keep.txt"), "original-dist");
for (const [file, stage] of [["regression-workflow-actions.sh", "workflow-actions"], ["regression-all.sh", "server-regressions"]]) {
writeFileSync(path.join(root, "server/scripts", file), `#!${process.execPath}
@@ -49,20 +58,21 @@ const root = process.env.HYB_QA_FIXTURE_ROOT;
if (!root || !path.basename(root).startsWith('hyb-full-local-test-') || path.dirname(process.argv[1]) !== path.join(root, 'bin')) throw Error('fixture_only');
const command = path.basename(process.argv[1]);
const args = process.argv.slice(2);
fs.appendFileSync(path.join(root, 'commands.jsonl'), JSON.stringify({command,args,visualOutput:process.env.QA_VISUAL_OUTPUT_DIR,stage:process.env.QA_DIAGNOSTIC_STAGE,database:process.env.QA_DB_NAME,sha:process.env.VITE_BUILD_SHA,inputs:[process.env.PWA_BUILD_A_DIR,process.env.PWA_BUILD_B_DIR,process.env.PWA_BUILD_A_SHA,process.env.PWA_BUILD_B_SHA]})+'\\n');
fs.appendFileSync(path.join(root, 'commands.jsonl'), JSON.stringify({command,args,nodeEnv:process.env.NODE_ENV,imNotifierEnabled:process.env.IM_NOTIFIER_ENABLED,ocrProvider:process.env.OCR_PROVIDER,jobsEnabled:process.env.JOBS_ENABLED,ocrMock:process.env.PLAYWRIGHT_OCR_MOCK,syntheticInitialPresent:Boolean(process.env.QA_SYNTHETIC_INITIAL_PASSWORD),seedRolesPrepared:['CEO','ADMIN','MANAGER','EMPLOYEE','FINANCE','FINANCE_MGR','HR','ADMINISTRATION'].every(role=>Boolean(process.env['SEED_'+role+'_PASSWORD'])),visualOutput:process.env.QA_VISUAL_OUTPUT_DIR,stage:process.env.QA_DIAGNOSTIC_STAGE,database:process.env.QA_DB_NAME,sha:process.env.VITE_BUILD_SHA,inputs:[process.env.PWA_BUILD_A_DIR,process.env.PWA_BUILD_B_DIR,process.env.PWA_BUILD_A_SHA,process.env.PWA_BUILD_B_SHA]})+'\\n');
if (command === 'node' && args[0]?.endsWith('full-local-environment.mjs')) {
if (process.env.FAIL_ENVIRONMENT) process.exit(41);
if (process.env.FAIL_ENVIRONMENT_VERIFY && args[1] === 'verify') process.exit(41);
if (process.env.FAIL_ENVIRONMENT_CONSUME && args[1] === 'report-guest') process.exit(41);
if (['full-local','full-local-diagnostic'].includes(args[1])) {
if (['full-local','full-local-diagnostic','retained-server-local'].includes(args[1])) {
const result = spawnSync('bash',[path.join(root,'scripts/qa/run-web-gates.sh'),args[1]],{env:{...process.env,QA_ENVIRONMENT_CONTEXT:'synthetic-reference'},stdio:'inherit'});
process.exit(result.status ?? 1);
}
console.log(JSON.stringify({bootstrapObservation:{valid:true}}));
process.exit(0);
}
if (command === 'node' && args[0]?.endsWith('prepare-local-e2e-accounts.mjs')) process.exit(process.env.FAIL_ACCOUNT_PREPARATION ? 43 : 0);
if (command === 'node' && args[0]?.endsWith('browser-environment-probe.mjs')) process.exit(process.env.FAIL_ENVIRONMENT_PROBE ? 42 : 0);
if (command === 'node' && (args[0]?.endsWith('verify-full-local-discovery.mjs') || args[0]?.endsWith('full-local-diagnostic.mjs') || args[0] === '-e')) {
if (command === 'node' && (args[0]?.endsWith('verify-full-local-discovery.mjs') || args[0]?.endsWith('full-local-diagnostic.mjs') || args[0]?.endsWith('full-local-coverage.mjs') || args[0]?.endsWith('qa-stage-record.mjs') || args[0] === '-e')) {
const result = spawnSync(process.execPath, args, {env:process.env, stdio:'inherit'});
process.exit(result.status ?? 1);
}
@@ -91,7 +101,10 @@ if (command === 'npm' && args.includes('test:e2e:full')) {
if (process.env.MUTATE_ARCHIVE) fs.writeFileSync(path.join(root,'output/archive.png'),'overwritten');
if (process.env.PLAYWRIGHT_JSON_OUTPUT_FILE) {
const discovery = JSON.parse(fs.readFileSync(path.join(root,process.env.QA_DIAGNOSTIC_STAGE?'report/full-local-report/discovery.json':'report/discovery.json')));
if (process.env.LAYERED_FIXTURE) discovery.suites = discovery.suites.filter(suite => args.some(arg => {try { return new RegExp(arg).test('tests/e2e/'+suite.file); } catch { return false; }}));
if (process.env.OMIT_EXECUTION_CASE) discovery.suites.pop();
for (const suite of discovery.suites) for (const spec of suite.specs) for (const test of spec.tests) Object.assign(test,{status:process.env.FAIL_BROWSER?'unexpected':'expected',expectedStatus:'passed',results:[{status:process.env.FAIL_BROWSER?'failed':'passed',retry:0,duration:1}]});
if (process.env.SKIP_EXECUTION_CASE) discovery.suites[0].specs[0].tests[0].results[0].status = 'skipped';
fs.writeFileSync(process.env.PLAYWRIGHT_JSON_OUTPUT_FILE,JSON.stringify(discovery));
}
if (process.env.FAIL_BROWSER) process.exit(26);
@@ -115,6 +128,10 @@ if (!['node','npm','npx','curl','psql','createdb','dropdb'].includes(command)) t
return {
root,
run(script = "run-web-gates.sh", args = ["full-local"]) {
// Each mutation invocation owns a fresh report; do not fail on a prior
// invocation's exclusive output before reaching the mutated guard.
rmSync(path.join(root, "report"), { recursive: true, force: true });
rmSync(path.join(root, "commands.jsonl"), { force: true });
const result = spawnSync("bash", [path.join(root, "scripts/qa", script), ...args], { env, cwd: root, encoding: "utf8", timeout: 30000 });
return { ...result, commands: existsSync(path.join(root, "commands.jsonl")) ? readFileSync(path.join(root, "commands.jsonl"), "utf8").trim().split("\n").map((line) => JSON.parse(line)) : [] };
},
@@ -128,12 +145,15 @@ for (const ci of [undefined, "1"]) {
assert.equal(result.status, 0, result.stderr);
const builds = result.commands.filter((command) => command.args.includes("--outDir"));
assert.deepEqual(builds.map((command) => command.sha), ["111111111111", "222222222222"]);
assert.ok(builds.every(command => command.nodeEnv === "production"));
const discovery = result.commands.find((command) => command.args.includes("--list"));
const browser = result.commands.find((command) => command.args.includes("test:e2e:full"));
assert.equal(browser.imNotifierEnabled, "true");
assert.ok(!browser.args.some(argument => argument.startsWith("--trace")));
for (const command of [discovery, browser]) {
assert.ok(command);
assert.ok(command.args.includes("--retries=0"));
assert.ok(command.args.includes("--forbid-only"));
assert.ok(!command.args.some((argument) => argument.startsWith("--workers") || argument.startsWith("--grep") || argument.startsWith("--shard")));
assert.ok(command.inputs.every(Boolean));
assert.equal(command.visualOutput, path.join(owned.root, "report/visual"));
@@ -330,3 +350,132 @@ test("移除诊断 trace 实参的源码变异会被真实命令捕获拒绝", c
assert.ok(!browser.args.includes("--trace=on"));
assert.throws(() => assert.deepEqual(browser.args.filter(argument => argument.startsWith("--trace")), ["--trace=on"]));
});
test("普通 full-local 在回归前串行准备账号,随后不传播初始密码", context => {
const owned = fixture(context);
const result = owned.run();
assert.equal(result.status, 0, result.stderr);
const seed = result.commands.find(c => c.args.includes("seed:dev"));
const preparations = result.commands.filter(c => c.args[0]?.endsWith("prepare-local-e2e-accounts.mjs"));
assert.equal(preparations.length, 1);
assert.equal(seed.seedRolesPrepared, true);
assert.equal(preparations[0].syntheticInitialPresent, true);
assert.ok(result.commands.indexOf(seed) < result.commands.indexOf(preparations[0]));
const regression = result.commands.find(c => c.command === "regression");
assert.ok(result.commands.indexOf(preparations[0]) < result.commands.indexOf(regression));
const browser = result.commands.find(c => c.args.includes("test:e2e:full"));
assert.equal(browser.syntheticInitialPresent, false);
assert.equal(browser.seedRolesPrepared, false);
});
test("普通 full-local 账号准备失败阻断回归及浏览器,删前置反例会被抓到", context => {
const owned = fixture(context, { FAIL_ACCOUNT_PREPARATION: "1" });
const verify = result => {
assert.equal(result.status, 43);
assert.ok(!result.commands.some(c => c.command === "regression" || c.args.includes("test:e2e:full")));
assert.ok(result.commands.some(c => c.command === "dropdb"));
};
verify(owned.run());
const file = path.join(owned.root, "scripts/qa/run-web-gates.sh");
const source = readFileSync(file, "utf8");
writeFileSync(file, source.replace("qa_full_stage accounts qa_prepare_synthetic_accounts", "qa_full_stage accounts : # removed preparation"));
assert.throws(() => verify(owned.run()), assert.AssertionError);
});
for (const suite of ["workflow-actions", "server-regressions"]) {
test(`受管限定服务端只运行已选套件并保持无发布资格 ${suite}`, context => {
const owned = fixture(context, { QA_RETAINED_SERVER_SUITE: suite });
const result = owned.run("run-web-gates.sh", ["retained-server-local"]);
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(result.commands.filter(c => c.command === "regression").map(c => c.stage), [suite]);
assert.ok(!result.commands.some(c => c.args.includes("--list") || c.args.includes("test:e2e:full")));
const selection = JSON.parse(readFileSync(path.join(owned.root, "report/selection.json")));
assert.equal(selection.normalReleaseEligible, false);
assert.equal(selection.scope, "focused-server-regression");
assert.equal(selection.suite, suite);
const regressionIndex = result.commands.findIndex(c => c.command === "regression");
const accountPreparation = result.commands.find(c => c.args[0]?.endsWith("prepare-local-e2e-accounts.mjs"));
assert.equal(accountPreparation.imNotifierEnabled, suite === "server-regressions" ? "true" : "false");
for (const script of ["browser-environment-probe.mjs", "prepare-local-e2e-accounts.mjs"]) {
const index = result.commands.findIndex(c => c.args[0]?.endsWith(script));
assert.ok(index >= 0 && index < regressionIndex);
}
assert.ok(result.commands.some(c => c.command === "dropdb"));
});
}
for (const suite of [undefined, "../workflow-actions", "workflow-actions; true"]) {
test(`限定服务端拒绝缺失或任意命令选择 ${suite}`, context => {
const owned = fixture(context, suite ? { QA_RETAINED_SERVER_SUITE: suite } : {});
const result = owned.run("run-web-gates.sh", ["retained-server-local"]);
assert.notEqual(result.status, 0);
assert.ok(!result.commands.some(c => c.command === "createdb" || c.command === "regression"));
});
}
for (const flag of ["FAIL_ENVIRONMENT_PROBE", "FAIL_ACCOUNT_PREPARATION"]) {
test(`限定服务端前置失败不进入回归 ${flag}`, context => {
const owned = fixture(context, { QA_RETAINED_SERVER_SUITE: "workflow-actions", [flag]: "1" });
const result = owned.run("run-web-gates.sh", ["retained-server-local"]);
assert.notEqual(result.status, 0);
assert.ok(!result.commands.some(c => c.command === "regression"));
assert.ok(result.commands.some(c => c.command === "dropdb"));
});
}
for (const [overrides, enabled] of [
[{}, true],
[{ PLAYWRIGHT_OCR_MOCK: "0" }, true],
[{ PLAYWRIGHT_OCR_MOCK: "1", JOBS_ENABLED_FOR_E2E: "false" }, false],
[{ PLAYWRIGHT_OCR_MOCK: "1", OCR_PROVIDER: "external-fixture" }, false],
]) {
test(`OCR 修正能力绑定已启动服务而非调用者标志 ${JSON.stringify(overrides)}`, context => {
const owned = fixture(context, overrides);
const result = owned.run();
assert.equal(result.status, 0, result.stderr);
const browser = result.commands.find(c => c.args.includes("test:e2e:full"));
assert.equal(browser.ocrMock, enabled ? "1" : "0");
assert.equal(browser.ocrMock === "1", browser.ocrProvider === "mock" && browser.jobsEnabled === "true");
const capabilities = readFileSync(path.join(owned.root, "report/server-capabilities.jsonl"), "utf8").trim().split("\n").map(JSON.parse);
assert.equal(capabilities.at(-1).mockCorrectionEnabled, enabled);
assert.equal(capabilities.at(-1).source, "owned-server-start");
});
}
test("分层 full-local 真实入口从总发现派生集合并保留公网与冻结义务", context => {
const owned = fixture(context, { LAYERED_FIXTURE: "1" });
const result = owned.run();
assert.equal(result.status, 0, result.stderr);
const plan = JSON.parse(readFileSync(path.join(owned.root, "report/coverage-plan.json")));
const receipt = JSON.parse(readFileSync(path.join(owned.root, "report/local-coverage-result.json")));
assert.equal(plan.totalCases, 4);
assert.equal(plan.localCases.length, 2);
assert.deepEqual(receipt.obligations.map(row => row.status), ["NOT_RUN_LOCAL", "FROZEN_PROTECTION_PASS"]);
assert.equal(receipt.deliveryStatus, "INCOMPLETE");
assert.equal(receipt.normalReleaseEligible, false);
const browser = result.commands.find(command => command.args.includes("test:e2e:full"));
assert.ok(!browser.args.some(arg => arg.includes("live.spec") || arg.includes("frozen.spec")));
});
for (const flag of ["OMIT_EXECUTION_CASE", "SKIP_EXECUTION_CASE"]) test(`即使浏览器退出零也拒绝漏例或跳过:${flag}`, context => {
const owned = fixture(context, { LAYERED_FIXTURE: "1", [flag]: "1" });
const result = owned.run();
assert.notEqual(result.status, 0);
assert.equal(JSON.parse(readFileSync(path.join(owned.root, "report/local-coverage-result.json"))).status, "FAIL");
});
test("删除真实覆盖消费必须使跳过反例失去阻断", context => {
const owned = fixture(context, { LAYERED_FIXTURE: "1", SKIP_EXECUTION_CASE: "1" });
const assertBlocked = result => assert.notEqual(result.status, 0);
assertBlocked(owned.run());
const source = path.join(owned.root, "scripts/qa/run-web-gates.sh");
const original = readFileSync(source, "utf8");
const protectedCall = 'node "$ROOT_DIR/scripts/qa/full-local-coverage.mjs" verify-local "$QA_FULL_REPORT_DIR"';
assert.ok(original.includes(protectedCall));
writeFileSync(source, original.replace(protectedCall, ':'));
assert.throws(() => assertBlocked(owned.run()), assert.AssertionError);
});
@@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
import { captureReleaseSource, verifyReleaseEvidence, validateSkipRequest, confinedPath } from '../release-gate-result.mjs';
import { coveragePlan, localSpecArgs } from '../full-local-coverage.mjs';
import { FULL_LOCAL_STAGES, newStageRecord, updateStageRecord, validateStages } from '../qa-stage-record.mjs';
const time = seconds => new Date(Date.UTC(2026,8,18,0,0,seconds)).toISOString();
function stages(ids, profile, start=1) {
return {schemaVersion:1,profile,status:'PASS',exitCode:0,startedAt:time(start),endedAt:time(start+ids.length*2+1),stages:ids.map((id,index)=>({id,status:'PASS',exitCode:0,startedAt:time(start+index*2),endedAt:time(start+index*2+1)}))};
}
function fixture(context) {
const root=mkdtempSync(path.join(os.tmpdir(),'hyb-release-evidence-'));
context.after(()=>rmSync(root,{recursive:true,force:true}));
mkdirSync(path.join(root,'client/tests/e2e'),{recursive:true});
const rows=Array.from({length:10},(_,i)=>({file:`client/tests/e2e/flow-${i}.spec.ts`,id:`flow-${i}`,title:`flow-${i} @local-candidate:flow-${i}`,line:1}));
for(const row of rows) writeFileSync(path.join(root,row.file),'synthetic');
const source={commit:'a'.repeat(40),tree:'b'.repeat(40),digest:'c'.repeat(64),untrackedDigest:'d'.repeat(64),dirty:false,repository:{trackedCoverageComplete:true,trackedDigest:'e'.repeat(64),indexDigest:'f'.repeat(64),porcelainV1Z:''}};
const policy={digest:'policy',environmentDigest:'environment',runnerDigest:'runner',probeDigest:'probe',toolchain:{node:process.version,playwright:'1.58.2',browserRegistry:'browser',browserVersions:{chromium:'synthetic',webkit:'synthetic'},platform:'linux',arch:'x64',ci:false,workers:4}};
const facts={root,source,policy,contract:{schemaVersion:1,approvalReference:'synthetic',exceptions:[]},manifest:{localCandidateFlows:rows.map((row,i)=>({bundle:`flow-${i}`,witnessTag:`@local-candidate:flow-${i}`,witnessSpec:row.file})),acceptanceSpecs:rows.map(row=>row.file)}};
const report=executed=>({errors:[],suites:rows.map(row=>({file:row.file,specs:[{...row,tests:[{projectName:'',repeatEachIndex:0,...(executed?{expectedStatus:'passed',status:'expected',results:[{status:'passed',retry:0}]}:{})}]}]}))});
const discovery=report(false),execution=report(true);
execution.config={forbidOnly:true,projects:[{name:'',retries:0,repeatEach:1,timeout:180000,testIgnore:['**/manual/**']}],shard:null,maxFailures:0,workers:4,version:'1.58.2'};
execution.stats={startTime:time(26),duration:500,expected:10,unexpected:0,flaky:0,skipped:0};
const environment={schemaVersion:1,profile:'isolated-online-v1',mode:'full-local',policyDigest:'environment',platform:'linux',status:'PASS',exitCode:0,childExit:0,childSignal:null,interrupted:null,startedAt:time(3),endedAt:time(30),sourceBefore:structuredClone(source),sourceAfter:structuredClone(source),sourceUnchanged:true,repositoryUnchanged:true,candidateIntegrityStatus:'UNCHANGED',cleanup:{containerOwnerVerified:true,containerRemoved:true,scratchRemoved:true},environment:{schemaVersion:1,profile:'isolated-online-v1',policyDigest:'environment',platform:'linux',runnerDigest:'runner',probeDigest:'probe',supervisorObservation:{source:'live-supervisor-unix-ipc',sampledAt:time(3),namespace:'parent'},namespace:{inheritedRunner:'parent',ownedChild:'child',runnerCreated:true,currentMatchesOwnedChild:true,topologyMatched:true},nativeOnlineProbe:{valid:true,online:true,browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,engines:['chromium','webkit'].map(name=>({name,version:'synthetic',online:true,valid:true,noNavigatorOverride:true,browserNamespace:'child',browserMatchesOwnedChild:true}))},bootstrapObservation:{valid:true,observationPoint:'guest-entry-black-box',freshContext:true,initialSessionHint:false,listenerBeforeNavigation:true,noRouteMocks:true,loginFormVisible:true,refreshRequestCount:0,loginRequestCount:0,nativeOnlineSamples:{beforeNavigation:true,documentStart:true,afterForm:true},browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,documentStatus:200,stayedOnLogin:true}}};
const record={schemaVersion:1,profile:'release-predeploy',mode:'verify-candidate',runId:'a'.repeat(24),startedAt:time(0),finishedAt:time(33),executionExit:0,policy:structuredClone(policy),sourceBefore:structuredClone(source),sourceAfter:structuredClone(source),phases:stages(['static','full-local'],'release-predeploy-stages')};
record.phases.endedAt=time(32);record.phases.stages[1].endedAt=time(31);
const bundle={environment,phases:stages(FULL_LOCAL_STAGES,'full-local-stages',4),discovery,execution,plan:coveragePlan(root,discovery,facts.contract,source)};
bundle.selection={schemaVersion:1,profile:'full-local-selection',specArgs:localSpecArgs(bundle.plan),additionalArgs:[],retries:0,forbidOnly:true,workers:4,mode:'local'};
return {root,record,bundle,facts,verify:()=>verifyReleaseEvidence(record,bundle,facts)};
}
test('完整同源分阶段结果获得发布前资格,但公网交付仍未完成',context=>{
const f=fixture(context), result=f.verify();assert.equal(result.status,'PASS',JSON.stringify(result.issues));assert.equal(result.deploymentEligible,true);assert.equal(result.deliveryStatus,'INCOMPLETE');assert.equal(result.localCases.length,10);assert.equal(result.witnessSummary.passed,10);
});
test('dirty 开发树可完成本地检查,但始终 PARTIAL/无发布资格',context=>{
const f=fixture(context);
for(const source of [f.facts.source,f.record.sourceBefore,f.record.sourceAfter,f.bundle.environment.sourceBefore,f.bundle.environment.sourceAfter]){source.dirty=true;source.repository.porcelainV1Z=' M client/file\0';}
const result=f.verify();assert.equal(result.localChecksPassed,true,JSON.stringify(result.issues));assert.equal(result.status,'PARTIAL');assert.equal(result.deploymentEligible,false);
});
const mutations={
'missing-receipt':f=>{f.record={};},
'old-schema':f=>{f.record.schemaVersion=0;},
'focused-profile':f=>{f.record.profile='local-candidate';},
'no-finish':f=>{f.record.finishedAt=null;},
'failed-run':f=>{f.record.executionExit=17;},
'interrupted':f=>{f.record.interrupted='SIGTERM';},
'phase-missing':f=>{f.record.phases.stages.pop();},
'phase-skipped':f=>{f.record.phases.stages[0].status='SKIP';},
'phase-failed':f=>{f.record.phases.stages[0].exitCode=3;},
'phase-reversed':f=>{f.record.phases.stages.reverse();},
'phase-after-completion':f=>{f.record.phases.endedAt=time(40);},
'inner-phase-missing':f=>{f.bundle.phases.stages.pop();},
'inner-phase-failed':f=>{f.bundle.phases.stages[2].status='FAIL';},
'inner-profile-invalid':f=>{f.bundle.phases.profile='diagnostic';},
'inner-time-outside-run':f=>{f.bundle.phases.startedAt=time(0);},
'changed-commit':f=>{f.facts.source.commit='1'.repeat(40);},
'changed-source':f=>{f.facts.source.digest='1'.repeat(64);},
'changed-index':f=>{f.facts.source.repository.indexDigest='1'.repeat(64);},
'changed-untracked-input':f=>{f.facts.source.untrackedDigest='1'.repeat(64);},
'source-unknown':f=>{f.record.sourceBefore.repository.trackedCoverageComplete=false;},
'policy-drift':f=>{f.record.policy.digest='old';},
'toolchain-drift':f=>{f.record.policy.toolchain.playwright='old';},
'environment-missing':f=>{delete f.bundle.environment;},
'environment-focused':f=>{f.bundle.environment.mode='retained-local';},
'environment-child-failed':f=>{f.bundle.environment.childExit=2;},
'environment-exit-failed':f=>{f.bundle.environment.exitCode=2;},
'environment-source-mismatch':f=>{f.bundle.environment.sourceBefore.digest='1'.repeat(64);},
'environment-self-claimed':f=>{delete f.bundle.environment.environment.namespace;},
'native-offline':f=>{f.bundle.environment.environment.nativeOnlineProbe.online=false;},
'webkit-version-drift':f=>{f.bundle.environment.environment.nativeOnlineProbe.engines[1].version='old';},
'webkit-native-offline':f=>{f.bundle.environment.environment.nativeOnlineProbe.engines[1].online=false;},
'guest-refresh':f=>{f.bundle.environment.environment.bootstrapObservation.refreshRequestCount=1;},
'guest-mock':f=>{f.bundle.environment.environment.bootstrapObservation.noRouteMocks=false;},
'runner-digest-drift':f=>{f.bundle.environment.environment.runnerDigest='old';},
'cleanup-missing':f=>{f.bundle.environment.cleanup.containerRemoved=false;},
'discovery-truncated':f=>{f.bundle.discovery.suites.pop();},
'plan-truncated':f=>{f.bundle.plan.localCases.pop();},
'execution-missing':f=>{f.bundle.execution.suites.pop();},
'execution-duplicate':f=>{f.bundle.execution.suites.push(f.bundle.execution.suites[0]);},
'execution-unknown':f=>{f.bundle.execution.suites[0].specs[0].id='unknown';},
'execution-skipped':f=>{f.bundle.execution.suites[0].specs[0].tests[0].results[0].status='skipped';},
'execution-retried':f=>{f.bundle.execution.suites[0].specs[0].tests[0].results[0].retry=1;},
'execution-xfail':f=>{f.bundle.execution.suites[0].specs[0].tests[0].expectedStatus='failed';},
'execution-global-error':f=>{f.bundle.execution.errors.push({message:'synthetic'});},
'selection-grep':f=>{f.bundle.selection.additionalArgs=['--grep=one'];},
'selection-missing':f=>{delete f.bundle.selection;},
'config-missing':f=>{delete f.bundle.execution.config;},
'projects-missing':f=>{delete f.bundle.execution.config.projects;},
'only-allowed':f=>{f.bundle.execution.config.forbidOnly=false;},
'sharded':f=>{f.bundle.execution.config.shard={current:1,total:2};},
'serialized':f=>{f.bundle.execution.config.workers=1;},
'budget-increased':f=>{f.bundle.execution.config.projects[0].timeout=360000;},
'stats-false-pass':f=>{f.bundle.execution.stats.expected=11;},
'browser-time-mismatch':f=>{f.bundle.execution.stats.startTime=time(0);},
'acceptance-missing':f=>{f.facts.manifest.acceptanceSpecs.push('client/tests/e2e/absent.spec.ts');},
'witness-missing':f=>{f.bundle.execution.suites[0].specs[0].title='missing';},
};
for(const [name,mutate] of Object.entries(mutations))test(`拒绝伪通过:${name}`,context=>{
const f=fixture(context);mutate(f);const r=verifyReleaseEvidence(f.record,f.bundle,f.facts);assert.equal(r.deploymentEligible,false);assert.ok(r.issues.length);
});
test('阶段记录保留真实失败、未跑阶段与清理失败,不吞早期错误',()=>{
const r=newStageRecord(['a','b'],'fixture');assert.throws(()=>updateStageRecord(r,'start','b'));
updateStageRecord(r,'start','a');updateStageRecord(r,'finish',null,23);
assert.equal(r.status,'FAIL');assert.equal(r.exitCode,23);assert.equal(r.stages[0].exitCode,23);assert.equal(r.stages[1].status,'NOT_RUN');assert.throws(()=>updateStageRecord(r,'start','b'));
const complete=newStageRecord(['a'],'fixture');updateStageRecord(complete,'start','a');updateStageRecord(complete,'end','a',0);updateStageRecord(complete,'finish',null,31);assert.ok(validateStages(complete,['a'],'fixture').length);
});
test('skip 精确值和批准记录,仅诊断 PARTIAL,普通发布前入口拒绝',()=>{
assert.equal(validateSkipRequest('verify-candidate',{}),null);
for(const value of ['true','yes','01','', '2'])assert.throws(()=>validateSkipRequest('project-wide',{PROJECT_WIDE_SKIP_VISUAL:value}));
const env={PROJECT_WIDE_SKIP_VISUAL:'1',PROJECT_WIDE_SKIP_REASON:'synthetic',PROJECT_WIDE_SKIP_OPERATOR:'fixture',PROJECT_WIDE_SKIP_APPROVAL_REF:'fixture-only'};
assert.throws(()=>validateSkipRequest('verify-candidate',env));
for(const key of ['PROJECT_WIDE_SKIP_REASON','PROJECT_WIDE_SKIP_OPERATOR','PROJECT_WIDE_SKIP_APPROVAL_REF'])assert.throws(()=>validateSkipRequest('project-wide',{...env,[key]:''}));
const r=validateSkipRequest('project-wide',env);assert.equal(r.status,'PARTIAL');assert.equal(r.deploymentEligible,false);assert.deepEqual(r.affectedStages,['project-wide-visual']);
});
test('回执路径拒绝越界、现存或断裂符号链接',context=>{
const f=fixture(context);
for(const input of ['../outside','/absolute','a/../b','a//b','a\\b'])assert.throws(()=>confinedPath(f.root,input));
symlinkSync('/nonexistent-synthetic-target',path.join(f.root,'link'));
assert.throws(()=>confinedPath(f.root,'link'));
symlinkSync(f.root,path.join(f.root,'directory'));
assert.throws(()=>confinedPath(f.root,'directory/file'));
});
test('原有未跟踪部署文件内容变化,即使 Git status 相同也使来源变化',context=>{
const f=fixture(context);
for(const args of [['init','-q'],['add','.'],['-c','user.name=QA Fixture','-c','[email protected]','commit','-qm','synthetic fixture']])assert.equal(spawnSync('git',args,{cwd:f.root}).status,0);
mkdirSync(path.join(f.root,'deploy'));writeFileSync(path.join(f.root,'deploy/new.sh'),'first');
const before=captureReleaseSource(f.root);writeFileSync(path.join(f.root,'deploy/new.sh'),'second');const after=captureReleaseSource(f.root);
assert.equal(before.repository.porcelainV1Z,after.repository.porcelainV1Z);assert.notEqual(before.untrackedDigest,after.untrackedDigest);
});
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
const projectRoot=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../../..');
const receiptFiles={environment:'environment/result.json',phases:'results/full-local-stages.json',discovery:'results/discovery.json',plan:'results/coverage-plan.json',execution:'results/playwright-results.json',selection:'results/execution-selection.json'};
function fixture(context,overrides={}) {
const root=mkdtempSync(path.join(os.tmpdir(),'hyb-release-shell-'));
context.after(()=>rmSync(root,{recursive:true,force:true}));
for(const dir of ['scripts/qa','deploy/trialrun','client/tests/e2e','server','bin','client/node_modules/@playwright/test','client/node_modules/playwright-core'])mkdirSync(path.join(root,dir),{recursive:true});
for(const file of readdirSync(path.join(projectRoot,'scripts/qa')).filter(file=>file.endsWith('.mjs')||file.endsWith('.sh')||file.endsWith('.py')))copyFileSync(path.join(projectRoot,'scripts/qa',file),path.join(root,'scripts/qa',file));
const rows=Array.from({length:10},(_,i)=>({file:`client/tests/e2e/flow-${i}.spec.ts`,id:`flow-${i}`,title:`flow-${i} @local-candidate:flow-${i}`,line:1}));
rows.push({file:'client/tests/e2e/ordinary.spec.ts',id:'ordinary',title:'ordinary',line:1});
for(const row of rows)writeFileSync(path.join(root,row.file),'synthetic');
const manifest={localCandidateFlows:rows.slice(0,10).map((row,i)=>({bundle:`flow-${i}`,witnessTag:`@local-candidate:flow-${i}`,witnessSpec:row.file})),acceptanceSpecs:rows.map(row=>row.file)};
const contract={schemaVersion:1,approvalReference:'synthetic',exceptions:[]};
writeFileSync(path.join(root,'scripts/qa/route-manifest.json'),JSON.stringify(manifest));
writeFileSync(path.join(root,'scripts/qa/full-local-coverage-contract.json'),JSON.stringify(contract));
for(const file of ['client/package.json','client/package-lock.json','server/package.json','server/package-lock.json','client/node_modules/playwright-core/browsers.json'])writeFileSync(path.join(root,file),'{}');
writeFileSync(path.join(root,'client/node_modules/playwright-core/browsers.json'),JSON.stringify({browsers:['chromium','webkit'].map(name=>({name,browserVersion:'synthetic'}))}));
writeFileSync(path.join(root,'client/node_modules/@playwright/test/package.json'),JSON.stringify({version:'1.58.2'}));
writeFileSync(path.join(root,'client/playwright.config.ts'),'// synthetic policy');
writeFileSync(path.join(root,'deploy/trialrun/validate-env-contract.js'),'// fake tooling owns this command');
writeFileSync(path.join(root,'.gitignore'),'artifacts/\nbin/\nnode_modules/\n');
const env={PATH:`${path.join(root,'bin')}:${process.env.PATH}`,LANG:'C.UTF-8',HYB_RELEASE_FIXTURE_ROOT:root,RELEASE_GATE_ARTIFACT_DIR:path.join(root,'artifacts/release-gate/run'),...overrides};
const fake=`#!${process.execPath}
const fs=require('node:fs'),path=require('node:path'),{spawnSync}=require('node:child_process');
const root=process.env.HYB_RELEASE_FIXTURE_ROOT,args=process.argv.slice(2),name=path.basename(process.argv[1]);
fs.mkdirSync(path.join(root,'artifacts'),{recursive:true});fs.appendFileSync(path.join(root,'artifacts/commands.jsonl'),JSON.stringify({name,args})+'\\n');
if(name==='node' && args[0]?.endsWith('release-gate-result.mjs')){const result=spawnSync(process.execPath,args,{env:process.env,stdio:'inherit'});process.exit(result.status??1);}
if(name==='node' && args[0]?.endsWith('validate-env-contract.js') && process.env.FAIL_STATIC)process.exit(23);
if(name==='bash' && args[0]?.endsWith('run-web-gates.sh') && args[1]==='full-local'){if(process.env.OMIT_EVIDENCE)process.exit(0);const result=spawnSync(process.execPath,[path.join(root,'bin/evidence.mjs')],{env:process.env,stdio:'inherit'});process.exit(result.status??1);}
if(name==='bash' && args[0]?.includes('deploy/trialrun'))process.exit(91);
if(!['node','npm','bash','python3'].includes(name))throw Error('unexpected command');
`;
writeFileSync(path.join(root,'bin/fake'),fake,{mode:0o755});
for(const name of ['node','npm','bash','python3'])symlinkSync('fake',path.join(root,'bin',name));
writeFileSync(path.join(root,'bin/evidence.mjs'),`
import {mkdirSync,writeFileSync,readFileSync} from 'node:fs';
import path from 'node:path';
import {captureReleaseSource,currentPolicy} from '../scripts/qa/release-gate-result.mjs';
import {FULL_LOCAL_STAGES,newStageRecord,updateStageRecord} from '../scripts/qa/qa-stage-record.mjs';
import {coveragePlan,localSpecArgs} from '../scripts/qa/full-local-coverage.mjs';
const root=process.env.HYB_RELEASE_FIXTURE_ROOT,base=process.env.QA_FULL_REPORT_DIR,now=()=>new Date().toISOString();
const source=captureReleaseSource(root),policy=currentPolicy(root),startedAt=now();
const phases=newStageRecord(FULL_LOCAL_STAGES,'full-local-stages');
const rows=${JSON.stringify(rows)},contract=${JSON.stringify(contract)};
const report=executed=>({errors:[],suites:rows.map(row=>({file:row.file,specs:[{...row,tests:[{projectName:'',repeatEachIndex:0,...(executed?{expectedStatus:'passed',status:'expected',results:[{status:'passed',retry:0}]}:{})}]}]}))});
const discovery=report(false),execution=report(true);
execution.config={forbidOnly:true,projects:[{name:'',retries:0,repeatEach:1,timeout:180000,testIgnore:['**/manual/**']}],shard:null,maxFailures:0,workers:policy.toolchain.workers,version:policy.toolchain.playwright};
for(const stage of FULL_LOCAL_STAGES){updateStageRecord(phases,'start',stage);if(stage==='browser')execution.stats={startTime:now(),duration:0,expected:rows.length,unexpected:0,flaky:0,skipped:0};updateStageRecord(phases,'end',stage,0);}updateStageRecord(phases,'finish',null,0);
const environment={schemaVersion:1,profile:'isolated-online-v1',mode:'full-local',policyDigest:policy.environmentDigest,platform:process.platform,status:'PASS',exitCode:0,childExit:0,childSignal:null,interrupted:null,startedAt,endedAt:now(),sourceBefore:source,sourceAfter:source,sourceUnchanged:true,repositoryUnchanged:true,candidateIntegrityStatus:'UNCHANGED',cleanup:{containerOwnerVerified:true,containerRemoved:true,scratchRemoved:true},environment:{schemaVersion:1,profile:'isolated-online-v1',policyDigest:policy.environmentDigest,platform:process.platform,runnerDigest:policy.runnerDigest,probeDigest:policy.probeDigest,supervisorObservation:{source:'live-supervisor-unix-ipc',sampledAt:now(),namespace:'parent'},namespace:{inheritedRunner:'parent',ownedChild:'child',runnerCreated:true,currentMatchesOwnedChild:true,topologyMatched:true},nativeOnlineProbe:{valid:true,online:true,browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,engines:['chromium','webkit'].map(name=>({name,version:'synthetic',online:true,valid:true,noNavigatorOverride:true,browserNamespace:'child',browserMatchesOwnedChild:true}))},bootstrapObservation:{valid:true,observationPoint:'guest-entry-black-box',freshContext:true,initialSessionHint:false,listenerBeforeNavigation:true,noRouteMocks:true,loginFormVisible:true,refreshRequestCount:0,loginRequestCount:0,nativeOnlineSamples:{beforeNavigation:true,documentStart:true,afterForm:true},browserNamespace:'child',browserMatchesOwnedChild:true,noNavigatorOverride:true,documentStatus:200,stayedOnLogin:true}}};
const bundle={environment,phases,discovery,execution,plan:coveragePlan(root,discovery,contract,source)};
bundle.selection={schemaVersion:1,profile:'full-local-selection',specArgs:localSpecArgs(bundle.plan),additionalArgs:[],retries:0,forbidOnly:true,workers:policy.toolchain.workers,mode:'local'};
if(process.env.BAD_PROFILE)environment.mode='retained-local';
if(process.env.BAD_SOURCE)environment.sourceBefore={...source,digest:'0'.repeat(64)};
if(process.env.BAD_COVERAGE)execution.suites.pop();
if(process.env.BAD_CLEANUP)environment.cleanup.containerRemoved=false;
if(process.env.BAD_STAGE)phases.stages.splice(2,1);
for(const [key,relative] of Object.entries(${JSON.stringify(receiptFiles)})){const file=path.join(base,relative);mkdirSync(path.dirname(file),{recursive:true});writeFileSync(file,JSON.stringify(bundle[key]));}
if(process.env.FAIL_FULL)process.exit(17);
`);
for(const args of [['init','-q'],['add','.'],['-c','user.name=QA Fixture','-c','[email protected]','commit','-qm','synthetic fixture']])assert.equal(spawnSync('git',args,{cwd:root}).status,0);
return {root,env,run(mode='verify-candidate') {
rmSync(path.join(root,'artifacts'),{recursive:true,force:true});
const result=spawnSync('/bin/bash',[path.join(root,'scripts/qa/run-release-gate.sh'),mode],{cwd:root,env,encoding:'utf8',timeout:30000});
const file=path.join(env.RELEASE_GATE_ARTIFACT_DIR,'release-result.json');
return {...result,record:existsSync(file)?JSON.parse(readFileSync(file)):null,commands:existsSync(path.join(root,'artifacts/commands.jsonl'))?readFileSync(path.join(root,'artifacts/commands.jsonl'),'utf8').trim().split('\n').map(JSON.parse):[]};
},verify() {return spawnSync(process.execPath,[path.join(root,'scripts/qa/release-gate-result.mjs'),'verify',env.RELEASE_GATE_ARTIFACT_DIR],{cwd:root,env,encoding:'utf8',timeout:30000});}};
}
function noDeploy(result) {assert.ok(!result.commands.some(c=>c.args.some(arg=>arg.includes('deploy/trialrun/deploy'))));}
test('真实 shell 复用 static/full-local、回执离线重验、部署命令零调用',context=>{
const f=fixture(context),r=f.run();assert.equal(r.status,0,r.stdout+r.stderr);assert.equal(r.record.status,'PASS');assert.equal(r.record.verification.localChecksPassed,true);assert.deepEqual(r.record.phases.stages.map(s=>s.id),['static','full-local']);noDeploy(r);assert.equal(f.verify().status,0);
});
for(const flag of ['FAIL_STATIC','FAIL_FULL','OMIT_EVIDENCE','BAD_COVERAGE','BAD_CLEANUP','BAD_STAGE'])test(`shell 假工具退出零也不能绕过:${flag}`,context=>{
const f=fixture(context,{[flag]:'1'}),r=f.run();assert.notEqual(r.status,0,r.stdout+r.stderr);assert.equal(r.record.deploymentEligible,false);noDeploy(r);
if(flag==='FAIL_STATIC'){assert.equal(r.status,23);assert.equal(r.record.phases.stages[0].exitCode,23);assert.equal(r.record.phases.stages[1].status,'NOT_RUN');assert.ok(!r.commands.some(c=>c.name==='bash'));}
if(flag==='FAIL_FULL')assert.equal(r.status,17);
});
test('dirty 本地树执行完整阶段但 PARTIAL/退出2',context=>{
const f=fixture(context);writeFileSync(path.join(f.root,'deploy/untracked.sh'),'synthetic');const r=f.run();assert.equal(r.status,2,r.stdout+r.stderr);assert.equal(r.record.verification.localChecksPassed,true);assert.equal(r.record.status,'PARTIAL');assert.equal(f.verify().status,2);noDeploy(r);
});
test('实际回执拒绝文件摘要篡改、截断、删除和链接',context=>{
const f=fixture(context),r=f.run();assert.equal(r.status,0,r.stdout+r.stderr);
const file=path.join(f.root,r.record.fullLocalDirectory,'results/playwright-results.json'),raw=readFileSync(file);
for(const mutate of [()=>writeFileSync(file,'{'),()=>writeFileSync(file,raw.toString()+' '),()=>rmSync(file),()=>{rmSync(file);symlinkSync('/outside-synthetic',file); }]){
writeFileSync(file,raw);mutate();assert.notEqual(f.verify().status,0);rmSync(file,{force:true});
}
});
for(const flags of [{PROJECT_WIDE_SKIP_VISUAL:'1'},{PROJECT_WIDE_SKIP_BUSINESS_FLOW:'1'},{PROJECT_WIDE_SKIP_VISUAL:'1',PROJECT_WIDE_SKIP_BUSINESS_FLOW:'1'}])test(`跳过有批准元数据时只限诊断 PARTIAL/2:${JSON.stringify(flags)}`,context=>{
const f=fixture(context,{...flags,PROJECT_WIDE_SKIP_REASON:'synthetic',PROJECT_WIDE_SKIP_OPERATOR:'fixture',PROJECT_WIDE_SKIP_APPROVAL_REF:'fixture-only'});
const rejected=f.run();assert.notEqual(rejected.status,0);assert.ok(!rejected.commands.some(c=>c.name==='npm'||c.name==='bash'));noDeploy(rejected);
const r=f.run('project-wide');assert.equal(r.status,2,r.stdout+r.stderr);assert.doesNotMatch(r.stdout,/\[PASS\]/);noDeploy(r);
const receipt=JSON.parse(readFileSync(path.join(f.env.RELEASE_GATE_ARTIFACT_DIR,'skip-receipt.json')));assert.equal(receipt.status,'PARTIAL');assert.equal(receipt.deploymentEligible,false);
});
test('删掉真实 skip 前置校验会使反例失败',context=>{
const f=fixture(context,{PROJECT_WIDE_SKIP_VISUAL:'yes'});
const blocked=r=>{assert.notEqual(r.status,0);assert.ok(!r.commands.some(c=>c.name==='npm'));};blocked(f.run('project-wide'));
const file=path.join(f.root,'scripts/qa/run-release-gate.sh'),original=readFileSync(file,'utf8'),mutated=original.replace(' node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" skip "$ARTIFACT_ROOT" "$MODE"',' : # removed skip validation');assert.notEqual(original,mutated);writeFileSync(file,mutated);
assert.throws(()=>blocked(f.run('project-wide')),assert.AssertionError);
});
test('删除最终回执消费会使缺证据反例失败',context=>{
const f=fixture(context,{OMIT_EVIDENCE:'1'});const blocked=r=>assert.notEqual(r.status,0);blocked(f.run());
const file=path.join(f.root,'scripts/qa/run-release-gate.sh'),original=readFileSync(file,'utf8'),mutated=original.replace(' node "$ROOT_DIR/scripts/qa/release-gate-result.mjs" finish "$ARTIFACT_ROOT" "$original_status" || receipt_status=$?',' receipt_status=0 # removed verification');assert.notEqual(original,mutated);writeFileSync(file,mutated);
assert.throws(()=>blocked(f.run()),assert.AssertionError);
});
test('放宽真实阶段验证使缺阶段反例失败',context=>{
const f=fixture(context,{BAD_STAGE:'1'});const blocked=r=>assert.notEqual(r.status,0);blocked(f.run());
const file=path.join(f.root,'scripts/qa/qa-stage-record.mjs'),original=readFileSync(file,'utf8'),mutated=original.replace('export function validateStages(record, expected, profile) {','export function validateStages(record, expected, profile) { return [];');assert.notEqual(original,mutated);writeFileSync(file,mutated);
const r=f.run();assert.equal(r.status,2,r.stdout+r.stderr); // mutation makes source dirty, still no release eligibility
assert.equal(r.record.verification.localChecksPassed,true);
});
for(const [flag,from,to] of [
['BAD_PROFILE', "env?.mode!=='full-local' || ", ''],
['BAD_SOURCE', "issues.push('managed_source_mismatch');", "; // removed source validation"],
['BAD_COVERAGE', 'issues.push(...coverage.issues);', '; // removed coverage validation'],
])test(`移除实际 profile/source/coverage 保护使反例失效:${flag}`,context=>{
const f=fixture(context,{[flag]:'1'}),first=f.run();
assert.notEqual(first.status,0);assert.equal(first.record.verification.localChecksPassed,false);noDeploy(first);
const file=path.join(f.root,'scripts/qa/release-gate-result.mjs'),original=readFileSync(file,'utf8'),mutated=original.replace(from,to);
assert.notEqual(original,mutated);writeFileSync(file,mutated);
const result=f.run();assert.equal(result.status,2,result.stdout+result.stderr);
assert.equal(result.record.verification.localChecksPassed,true);noDeploy(result);
});
test('候选路径越界在建目录和静态执行前拒绝',context=>{
const f=fixture(context);f.env.RELEASE_GATE_ARTIFACT_DIR=path.join(f.root,'unapproved-directory');
const r=f.run();assert.notEqual(r.status,0);assert.equal(existsSync(f.env.RELEASE_GATE_ARTIFACT_DIR),false);assert.ok(!r.commands.some(c=>c.name==='npm'));noDeploy(r);
});
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const source = readFileSync(path.join(root, 'server/scripts/regression-workflow-actions.sh'), 'utf8');
function checkResponse(context, code, details, candidate = source) {
const directory = mkdtempSync(path.join(os.tmpdir(), 'hyb-payment-profile-'));
context.after(() => rmSync(directory, { recursive: true, force: true }));
writeFileSync(path.join(directory, 'pay-ok-pay.json'), JSON.stringify({ error: { code: 'VALID_001', details } }));
// Execute the candidate's real shell assertions. No app, database, or HTTP tools are started.
const helpers = ['assert_status', 'assert_error_code', 'assert_error_detail'].map(name => {
const start = candidate.indexOf(name + '() {');
assert.ok(start >= 0);
const end = candidate.indexOf('\n}\n', start);
assert.ok(end > start);
return candidate.slice(start, end + 3);
}).join('\n');
const start = candidate.indexOf('if [[', candidate.indexOf('\npay_ok_status='));
const end = candidate.indexOf(' echo "[19/23] batch-required profile:', start);
assert.ok(start >= 0 && end > start);
const script = 'set -euo pipefail\n' + helpers + '\n' + candidate.slice(start, end) + '\nfi\n';
return spawnSync('bash', ['-c', script], {
env: { PATH: process.env.PATH, TMP_DIR: directory, PAYMENT_BATCH_ENABLED: 'true', pay_ok_status: String(code) },
encoding: 'utf8', timeout: 10000,
});
}
test('configured batch mode accepts only the exact batch-required refusal', context => {
const valid = { payment: 'payment_batch_v1_required', reasonCode: 'reimbursement.payment_payment_batch_v1_required' };
assert.equal(checkResponse(context, 409, valid).status, 0);
for (const code of [200, 400, 403, 500]) assert.notEqual(checkResponse(context, code, valid).status, 0);
assert.notEqual(checkResponse(context, 409, { payment: 'some_other_reason' }).status, 0);
});
test('choosing the branch from the observed response is detected as a false-green mutation', context => {
const before = 'if [[ "${PAYMENT_BATCH_ENABLED:-false}" == "true" ]]; then';
assert.ok(source.includes(before));
const mutant = source.replace(before, 'if [[ "$pay_ok_status" == "409" ]]; then');
const verify = candidate => assert.notEqual(checkResponse(context, 200, {}, candidate).status, 0);
verify(source);
assert.throws(() => verify(mutant), assert.AssertionError);
});