汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
210 行
16 KiB
JavaScript
210 行
16 KiB
JavaScript
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(); }
|
|
};
|