feat(server): 票据增强闭环(F9/F10/F11/F12/F13/F28)
- /invoice-pool 新增 unbind、PATCH 修正、manual archive、OCR retry、判重例外 override(含 VALID_004/006/007) - signed-url 签发/下载写入审计日志(脱敏 extra) - 新增未绑定票据自动归档作业 + 一次性执行脚本 - 回归脚本增强:workflow-actions/dedup/audit/ocr 覆盖上述场景;v3 CSV 状态推进至 DONE
这个提交包含在:
+159
-4
@@ -34,6 +34,10 @@ run_ocr_once() {
|
||||
OCR_PROVIDER=mock OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once
|
||||
}
|
||||
|
||||
run_archive_once() {
|
||||
INVOICE_ARCHIVE_UNUSED_DAYS=90 npm run --silent job:archive-unused-invoices
|
||||
}
|
||||
|
||||
create_pending_invoice() {
|
||||
local marker="$1"
|
||||
DATABASE_URL="$DATABASE_URL" EMPLOYEE_IDENTIFIER="$EMPLOYEE_IDENTIFIER" RUN_SUFFIX="$RUN_SUFFIX" MARKER="$marker" node <<'NODE'
|
||||
@@ -96,6 +100,97 @@ const { getConfig } = require('./src/config');
|
||||
NODE
|
||||
}
|
||||
|
||||
create_available_invoice() {
|
||||
local marker="$1"
|
||||
DATABASE_URL="$DATABASE_URL" EMPLOYEE_IDENTIFIER="$EMPLOYEE_IDENTIFIER" RUN_SUFFIX="$RUN_SUFFIX" MARKER="$marker" node <<'NODE'
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
const { getConfig } = require('./src/config');
|
||||
|
||||
(async () => {
|
||||
const marker = process.env.MARKER || 'AVAILABLE';
|
||||
const runSuffix = process.env.RUN_SUFFIX || 'default';
|
||||
const email = process.env.EMPLOYEE_IDENTIFIER || '[email protected]';
|
||||
const config = getConfig();
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
const owner = await prisma.employee.findUnique({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!owner) {
|
||||
throw new Error(`employee not found: ${email}`);
|
||||
}
|
||||
|
||||
const relPath = path.join('archive-regression', runSuffix, `${crypto.randomUUID()}.txt`);
|
||||
const absPath = path.resolve(config.storageDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, marker, 'utf8');
|
||||
|
||||
const fileSha256 = crypto.createHash('sha256').update(marker).digest('hex');
|
||||
const created = await prisma.invoicePool.create({
|
||||
data: {
|
||||
owner_employee_id: owner.id,
|
||||
uploaded_by_employee_id: owner.id,
|
||||
storage_path: relPath,
|
||||
original_name: `archive-${runSuffix}.txt`,
|
||||
mime_type: 'text/plain',
|
||||
file_size: Buffer.byteLength(marker),
|
||||
file_sha256: fileSha256,
|
||||
status: 'available',
|
||||
ocr_type: 'general',
|
||||
created_by: owner.id,
|
||||
updated_by: owner.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
process.stdout.write(`${created.id}\n`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
set_invoice_created_at() {
|
||||
local invoice_id="$1"
|
||||
local created_at="$2"
|
||||
DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" CREATED_AT="$created_at" node <<'NODE'
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
|
||||
(async () => {
|
||||
const invoiceId = process.env.INVOICE_ID;
|
||||
const createdAt = process.env.CREATED_AT;
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE "invoice_pool"
|
||||
SET created_at = ${new Date(createdAt)}
|
||||
WHERE id = ${invoiceId}::uuid
|
||||
`;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
set_invoice_status_bound() {
|
||||
local invoice_id="$1"
|
||||
DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" node <<'NODE'
|
||||
@@ -436,12 +531,65 @@ const { createPrismaClient } = require('./src/prismaClient');
|
||||
NODE
|
||||
}
|
||||
|
||||
echo "[1/4] pending_ocr -> available"
|
||||
assert_unused_archived_invoice() {
|
||||
local invoice_id="$1"
|
||||
local scenario="$2"
|
||||
DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" SCENARIO="$scenario" node <<'NODE'
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
|
||||
(async () => {
|
||||
const invoiceId = process.env.INVOICE_ID;
|
||||
const scenario = process.env.SCENARIO;
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
|
||||
try {
|
||||
const invoice = await prisma.invoicePool.findUnique({
|
||||
where: { id: invoiceId },
|
||||
select: {
|
||||
status: true,
|
||||
archived_reason: true,
|
||||
archived_at: true,
|
||||
linked_reimbursement_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
console.error(`[ERROR] ${scenario} invoice not found: ${invoiceId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (invoice.status !== 'archived' || invoice.archived_reason !== 'unused_timeout') {
|
||||
console.error(`[ERROR] ${scenario} expected archived unused_timeout but got status=${invoice.status} reason=${invoice.archived_reason}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!invoice.archived_at) {
|
||||
console.error(`[ERROR] ${scenario} expected archived_at not null`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (invoice.linked_reimbursement_id) {
|
||||
console.error(`[ERROR] ${scenario} expected linked_reimbursement_id null`);
|
||||
process.exit(1);
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
echo "[1/5] pending_ocr -> available"
|
||||
INVOICE_OK="$(create_pending_invoice "MOCK_OK:${RUN_SUFFIX}")"
|
||||
run_ocr_once >"$TMP_DIR/ocr-ok.log"
|
||||
assert_available_invoice "$INVOICE_OK" "ocr pending->available"
|
||||
|
||||
echo "[2/4] duplicate invoice -> ocr_failed"
|
||||
echo "[2/5] duplicate invoice -> ocr_failed"
|
||||
INVOICE_BASE="$(create_pending_invoice "MOCK_DUP:${RUN_SUFFIX}")"
|
||||
run_ocr_once >"$TMP_DIR/ocr-dup-base.log"
|
||||
assert_available_invoice "$INVOICE_BASE" "ocr duplicate base"
|
||||
@@ -450,7 +598,7 @@ INVOICE_DUP="$(create_pending_invoice "MOCK_DUP:${RUN_SUFFIX}")"
|
||||
run_ocr_once >"$TMP_DIR/ocr-dup-second.log"
|
||||
assert_duplicate_failed_invoice "$INVOICE_DUP" "ocr duplicate second"
|
||||
|
||||
echo "[3/4] failed retry until terminal"
|
||||
echo "[3/5] failed retry until terminal"
|
||||
INVOICE_FAIL="$(create_pending_invoice "MOCK_OCR_FAIL:${RUN_SUFFIX}")"
|
||||
run_ocr_once >"$TMP_DIR/ocr-fail-1.log"
|
||||
assert_pending_attempt "$INVOICE_FAIL" "1" "ocr fail attempt 1"
|
||||
@@ -459,7 +607,7 @@ assert_pending_attempt "$INVOICE_FAIL" "2" "ocr fail attempt 2"
|
||||
run_ocr_once >"$TMP_DIR/ocr-fail-3.log"
|
||||
assert_terminal_failed_attempt "$INVOICE_FAIL" "3" "ocr fail terminal"
|
||||
|
||||
echo "[4/4] concurrency lock semantics"
|
||||
echo "[4/5] concurrency lock semantics"
|
||||
INVOICE_CONCURRENT="$(create_pending_invoice "MOCK_CONCURRENT:${RUN_SUFFIX}")"
|
||||
(
|
||||
OCR_PROVIDER=mock OCR_BATCH_SIZE=1 OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once >"$TMP_DIR/ocr-concurrent-a.log"
|
||||
@@ -474,4 +622,11 @@ wait "$PID_B"
|
||||
assert_available_invoice "$INVOICE_CONCURRENT" "ocr concurrency available"
|
||||
assert_attempt_count "$INVOICE_CONCURRENT" "1" "ocr concurrency attempts"
|
||||
|
||||
echo "[5/5] unused invoice auto archive (90d)"
|
||||
INVOICE_UNUSED="$(create_available_invoice "UNUSED:${RUN_SUFFIX}")"
|
||||
OLD_DATE="$(date -u -d "-120 days" +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
set_invoice_created_at "$INVOICE_UNUSED" "$OLD_DATE"
|
||||
run_archive_once >"$TMP_DIR/archive-unused.log"
|
||||
assert_unused_archived_invoice "$INVOICE_UNUSED" "invoice unused archive"
|
||||
|
||||
echo "[OK] OCR regression passed"
|
||||
|
||||
在新工单中引用
屏蔽一个用户