- workflow: 任务详情/转审候选/列表分页字段(D5) - reimbursements: 列表API + pay凭证字段/幂等增强(D7) - invoice-pool: 列表/详情API + 上传pending_ocr(D9) - ocr/audit: mock provider + 回归脚本 + regression-all开关(D2/D3) - CI: 新增 ci-minimal / ci-regression 工作流(D4)
478 行
14 KiB
Bash
可执行文件
478 行
14 KiB
Bash
可执行文件
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
cd "$ROOT_DIR"
|
|
|
|
if ! command -v node >/dev/null 2>&1; then
|
|
echo "[ERROR] node not found" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -z "${DATABASE_URL:-}" && -f ".env" ]]; then
|
|
set -a
|
|
# shellcheck disable=SC1091
|
|
source .env
|
|
set +a
|
|
fi
|
|
|
|
if [[ -z "${DATABASE_URL:-}" ]]; then
|
|
echo "[ERROR] DATABASE_URL is required (export it or provide server/.env)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}"
|
|
RUN_SUFFIX="$(date +%s)-$RANDOM"
|
|
TMP_DIR="$(mktemp -d)"
|
|
|
|
cleanup() {
|
|
rm -rf "$TMP_DIR"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
run_ocr_once() {
|
|
OCR_PROVIDER=mock OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once
|
|
}
|
|
|
|
create_pending_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 || 'MOCK_OK';
|
|
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('ocr-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: `ocr-${runSuffix}.txt`,
|
|
mime_type: 'text/plain',
|
|
file_size: Buffer.byteLength(marker),
|
|
file_sha256: fileSha256,
|
|
status: 'pending_ocr',
|
|
ocr_type: 'vat',
|
|
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_status_bound() {
|
|
local invoice_id="$1"
|
|
DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" node <<'NODE'
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const invoiceId = process.env.INVOICE_ID;
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
|
|
try {
|
|
const invoice = await prisma.invoicePool.findUnique({
|
|
where: { id: invoiceId },
|
|
select: {
|
|
owner_employee_id: true,
|
|
},
|
|
});
|
|
|
|
if (!invoice) {
|
|
throw new Error(`invoice not found: ${invoiceId}`);
|
|
}
|
|
|
|
const owner = await prisma.employee.findUnique({
|
|
where: { id: invoice.owner_employee_id },
|
|
select: {
|
|
id: true,
|
|
department_id: true,
|
|
},
|
|
});
|
|
|
|
if (!owner) {
|
|
throw new Error(`invoice owner not found: ${invoice.owner_employee_id}`);
|
|
}
|
|
|
|
const reimbursement = await prisma.reimbursementForm.create({
|
|
data: {
|
|
submitter_id: owner.id,
|
|
owner_id: owner.id,
|
|
department_id: owner.department_id,
|
|
reimbursement_date: new Date('2026-02-08T00:00:00.000Z'),
|
|
description: 'regression-ocr synthetic reimbursement',
|
|
status: 'reviewing',
|
|
created_by: owner.id,
|
|
updated_by: owner.id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
await prisma.invoicePool.update({
|
|
where: { id: invoiceId },
|
|
data: {
|
|
status: 'bound',
|
|
linked_reimbursement_id: reimbursement.id,
|
|
updated_by: owner.id,
|
|
},
|
|
});
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_available_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,
|
|
invoice_code: true,
|
|
invoice_number: true,
|
|
ocr_attempts: true,
|
|
ocr_last_error: true,
|
|
ocr_locked_at: true,
|
|
ocr_lock_owner: true,
|
|
},
|
|
});
|
|
|
|
if (!invoice || invoice.status !== 'available') {
|
|
console.error(`[ERROR] ${scenario} expected status=available but got ${invoice?.status ?? 'N/A'}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!invoice.invoice_code || !invoice.invoice_number) {
|
|
console.error(`[ERROR] ${scenario} expected invoice_code and invoice_number to be filled`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (invoice.ocr_locked_at || invoice.ocr_lock_owner) {
|
|
console.error(`[ERROR] ${scenario} expected lock released`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if ((invoice.ocr_attempts ?? 0) < 1) {
|
|
console.error(`[ERROR] ${scenario} expected ocr_attempts >= 1 but got ${invoice.ocr_attempts}`);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_duplicate_failed_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,
|
|
ocr_last_error: true,
|
|
ocr_attempts: true,
|
|
ocr_locked_at: true,
|
|
ocr_lock_owner: true,
|
|
},
|
|
});
|
|
|
|
if (!invoice || invoice.status !== 'ocr_failed') {
|
|
console.error(`[ERROR] ${scenario} expected status=ocr_failed but got ${invoice?.status ?? 'N/A'}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (invoice.ocr_last_error !== 'duplicate_invoice_code_number') {
|
|
console.error(`[ERROR] ${scenario} expected ocr_last_error=duplicate_invoice_code_number but got ${invoice.ocr_last_error}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if ((invoice.ocr_attempts ?? 0) < 1) {
|
|
console.error(`[ERROR] ${scenario} expected ocr_attempts >= 1 but got ${invoice.ocr_attempts}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (invoice.ocr_locked_at || invoice.ocr_lock_owner) {
|
|
console.error(`[ERROR] ${scenario} expected lock released`);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_pending_attempt() {
|
|
local invoice_id="$1"
|
|
local expected_attempts="$2"
|
|
local scenario="$3"
|
|
DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" EXPECTED_ATTEMPTS="$expected_attempts" SCENARIO="$scenario" node <<'NODE'
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const invoiceId = process.env.INVOICE_ID;
|
|
const expectedAttempts = Number(process.env.EXPECTED_ATTEMPTS);
|
|
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,
|
|
ocr_attempts: true,
|
|
ocr_last_error: true,
|
|
ocr_locked_at: true,
|
|
ocr_lock_owner: true,
|
|
},
|
|
});
|
|
|
|
if (!invoice || invoice.status !== 'pending_ocr') {
|
|
console.error(`[ERROR] ${scenario} expected status=pending_ocr but got ${invoice?.status ?? 'N/A'}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (invoice.ocr_attempts !== expectedAttempts) {
|
|
console.error(`[ERROR] ${scenario} expected attempts=${expectedAttempts} but got ${invoice.ocr_attempts}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (invoice.ocr_last_error !== 'mock_ocr_failed') {
|
|
console.error(`[ERROR] ${scenario} expected ocr_last_error=mock_ocr_failed but got ${invoice.ocr_last_error}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (invoice.ocr_locked_at || invoice.ocr_lock_owner) {
|
|
console.error(`[ERROR] ${scenario} expected lock released`);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_terminal_failed_attempt() {
|
|
local invoice_id="$1"
|
|
local expected_attempts="$2"
|
|
local scenario="$3"
|
|
DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" EXPECTED_ATTEMPTS="$expected_attempts" SCENARIO="$scenario" node <<'NODE'
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const invoiceId = process.env.INVOICE_ID;
|
|
const expectedAttempts = Number(process.env.EXPECTED_ATTEMPTS);
|
|
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,
|
|
ocr_attempts: true,
|
|
ocr_last_error: true,
|
|
ocr_completed_at: true,
|
|
ocr_locked_at: true,
|
|
ocr_lock_owner: true,
|
|
},
|
|
});
|
|
|
|
if (!invoice || invoice.status !== 'ocr_failed') {
|
|
console.error(`[ERROR] ${scenario} expected status=ocr_failed but got ${invoice?.status ?? 'N/A'}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (invoice.ocr_attempts !== expectedAttempts) {
|
|
console.error(`[ERROR] ${scenario} expected attempts=${expectedAttempts} but got ${invoice.ocr_attempts}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (invoice.ocr_last_error !== 'mock_ocr_failed') {
|
|
console.error(`[ERROR] ${scenario} expected ocr_last_error=mock_ocr_failed but got ${invoice.ocr_last_error}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!invoice.ocr_completed_at) {
|
|
console.error(`[ERROR] ${scenario} expected ocr_completed_at not null`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (invoice.ocr_locked_at || invoice.ocr_lock_owner) {
|
|
console.error(`[ERROR] ${scenario} expected lock released`);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_attempt_count() {
|
|
local invoice_id="$1"
|
|
local expected_attempts="$2"
|
|
local scenario="$3"
|
|
DATABASE_URL="$DATABASE_URL" INVOICE_ID="$invoice_id" EXPECTED_ATTEMPTS="$expected_attempts" SCENARIO="$scenario" node <<'NODE'
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const invoiceId = process.env.INVOICE_ID;
|
|
const expectedAttempts = Number(process.env.EXPECTED_ATTEMPTS);
|
|
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,
|
|
ocr_attempts: true,
|
|
},
|
|
});
|
|
|
|
if (!invoice || invoice.status !== 'available') {
|
|
console.error(`[ERROR] ${scenario} expected status=available but got ${invoice?.status ?? 'N/A'}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (invoice.ocr_attempts !== expectedAttempts) {
|
|
console.error(`[ERROR] ${scenario} expected attempts=${expectedAttempts} but got ${invoice.ocr_attempts}`);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
echo "[1/4] 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"
|
|
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"
|
|
set_invoice_status_bound "$INVOICE_BASE"
|
|
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"
|
|
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"
|
|
run_ocr_once >"$TMP_DIR/ocr-fail-2.log"
|
|
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"
|
|
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"
|
|
) &
|
|
PID_A=$!
|
|
(
|
|
OCR_PROVIDER=mock OCR_BATCH_SIZE=1 OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once >"$TMP_DIR/ocr-concurrent-b.log"
|
|
) &
|
|
PID_B=$!
|
|
wait "$PID_A"
|
|
wait "$PID_B"
|
|
assert_available_invoice "$INVOICE_CONCURRENT" "ocr concurrency available"
|
|
assert_attempt_count "$INVOICE_CONCURRENT" "1" "ocr concurrency attempts"
|
|
|
|
echo "[OK] OCR regression passed"
|