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
这个提交包含在:
@@ -21,6 +21,26 @@ if ! command -v jq >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "[ERROR] node not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
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
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
RUN_SUFFIX="$(date +%s)-$RANDOM"
|
||||
cleanup() {
|
||||
@@ -148,6 +168,183 @@ revoke_delegation() {
|
||||
assert_status "$status" "200" "revoke delegation" "$out_file"
|
||||
}
|
||||
|
||||
upload_invoice() {
|
||||
local token="$1"
|
||||
local file_path="$2"
|
||||
local out_file="$3"
|
||||
local ocr_type="${4:-}"
|
||||
|
||||
if [[ -n "$ocr_type" ]]; then
|
||||
curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/invoice-pool/upload" \
|
||||
-H "authorization: Bearer $token" \
|
||||
-F "file=@$file_path" \
|
||||
-F "ocr_type=$ocr_type"
|
||||
else
|
||||
curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/invoice-pool/upload" \
|
||||
-H "authorization: Bearer $token" \
|
||||
-F "file=@$file_path"
|
||||
fi
|
||||
}
|
||||
|
||||
create_reimbursement() {
|
||||
local token="$1"
|
||||
local out_file="$2"
|
||||
|
||||
local status
|
||||
status="$(api_json_auth POST "$BASE_URL/api/reimbursements" "$token" "$out_file" '{}')"
|
||||
assert_status "$status" "200" "create reimbursement" "$out_file"
|
||||
jq -er '.data.id' "$out_file"
|
||||
}
|
||||
|
||||
bind_invoice() {
|
||||
local token="$1"
|
||||
local invoice_id="$2"
|
||||
local reimbursement_id="$3"
|
||||
local out_file="$4"
|
||||
|
||||
local status
|
||||
status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/bind" "$token" "$out_file" "{\"reimbursementId\":\"$reimbursement_id\"}")"
|
||||
assert_status "$status" "200" "bind invoice" "$out_file"
|
||||
}
|
||||
|
||||
patch_invoice() {
|
||||
local token="$1"
|
||||
local invoice_id="$2"
|
||||
local body="$3"
|
||||
local out_file="$4"
|
||||
|
||||
local status
|
||||
status="$(api_json_auth PATCH "$BASE_URL/api/invoice-pool/$invoice_id" "$token" "$out_file" "$body")"
|
||||
assert_status "$status" "200" "patch invoice" "$out_file"
|
||||
}
|
||||
|
||||
archive_invoice_manual() {
|
||||
local token="$1"
|
||||
local invoice_id="$2"
|
||||
local out_file="$3"
|
||||
|
||||
local status
|
||||
status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/archive" "$token" "$out_file" '{}')"
|
||||
assert_status "$status" "200" "archive invoice" "$out_file"
|
||||
}
|
||||
|
||||
issue_signed_url() {
|
||||
local token="$1"
|
||||
local invoice_id="$2"
|
||||
local out_file="$3"
|
||||
|
||||
local status
|
||||
status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/signed-url" "$token" "$out_file" '{}')"
|
||||
assert_status "$status" "200" "issue signed url" "$out_file"
|
||||
jq -er '.data.signedUrl' "$out_file"
|
||||
}
|
||||
|
||||
override_duplicate() {
|
||||
local token="$1"
|
||||
local invoice_id="$2"
|
||||
local ref_form_number="$3"
|
||||
local reason="$4"
|
||||
local out_file="$5"
|
||||
|
||||
local status
|
||||
status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/override-duplicate" "$token" "$out_file" "{\"refFormNumber\":\"$ref_form_number\",\"reason\":\"$reason\"}")"
|
||||
echo "$status"
|
||||
}
|
||||
|
||||
retry_ocr() {
|
||||
local token="$1"
|
||||
local invoice_id="$2"
|
||||
local out_file="$3"
|
||||
|
||||
api_json_auth POST "$BASE_URL/api/invoice-pool/$invoice_id/retry-ocr" "$token" "$out_file" '{}'
|
||||
}
|
||||
|
||||
assert_audit_has_target() {
|
||||
local file_path="$1"
|
||||
local target_id="$2"
|
||||
local scenario="$3"
|
||||
|
||||
if ! jq -e --arg tid "$target_id" '.data.items | any(.targetId == $tid)' "$file_path" >/dev/null; then
|
||||
echo "[ERROR] $scenario expected audit log targetId=$target_id" >&2
|
||||
cat "$file_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_invoice_record() {
|
||||
local owner_email="$1"
|
||||
local marker="$2"
|
||||
local status="$3"
|
||||
local attempts="$4"
|
||||
local last_requested_at="$5"
|
||||
|
||||
DATABASE_URL="$DATABASE_URL" OWNER_EMAIL="$owner_email" MARKER="$marker" STATUS="$status" ATTEMPTS="$attempts" LAST_REQUESTED_AT="$last_requested_at" 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 email = process.env.OWNER_EMAIL;
|
||||
const marker = process.env.MARKER || 'MOCK_OK';
|
||||
const status = process.env.STATUS || 'ocr_failed';
|
||||
const attempts = Number(process.env.ATTEMPTS || 0);
|
||||
const lastRequestedAtRaw = process.env.LAST_REQUESTED_AT || '';
|
||||
|
||||
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('audit-regression', `${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: `audit-${Date.now()}.txt`,
|
||||
mime_type: 'text/plain',
|
||||
file_size: Buffer.byteLength(marker),
|
||||
file_sha256: fileSha256,
|
||||
status,
|
||||
ocr_type: 'vat',
|
||||
ocr_attempts: attempts,
|
||||
ocr_last_requested_at: lastRequestedAtRaw ? new Date(lastRequestedAtRaw) : null,
|
||||
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
|
||||
}
|
||||
|
||||
echo "[1/6] login users"
|
||||
EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$DEVICE_FINGERPRINT-employee-$RUN_SUFFIX" "$TMP_DIR/login-employee.json")"
|
||||
MANAGER_TOKEN="$(login_user "$MANAGER_IDENTIFIER" "$MANAGER_PASSWORD" "$DEVICE_FINGERPRINT-manager-$RUN_SUFFIX" "$TMP_DIR/login-manager.json")"
|
||||
@@ -190,4 +387,91 @@ assert_status "$date_invalid_status" "400" "audit invalid date" "$TMP_DIR/audit-
|
||||
assert_error_code "$TMP_DIR/audit-invalid-date.json" "VALID_001" "audit invalid date"
|
||||
assert_jq "$TMP_DIR/audit-invalid-date.json" '.error.details.dateTo == "must_be_after_dateFrom"' "audit invalid date"
|
||||
|
||||
echo "[7/10] invoice edit/archive + signed-url/download audit"
|
||||
EMPLOYEE_ID="$(get_me_id "$EMPLOYEE_TOKEN" "$TMP_DIR/me-employee.json")"
|
||||
FINANCE_MGR_ID="$(get_me_id "$FINANCE_MGR_TOKEN" "$TMP_DIR/me-fm.json")"
|
||||
|
||||
INVOICE_FILE="$TMP_DIR/invoice-audit-$RUN_SUFFIX.pdf"
|
||||
printf '%%PDF-1.4\nINVOICE-AUDIT-%s\n' "$RUN_SUFFIX" > "$INVOICE_FILE"
|
||||
|
||||
upload_status="$(upload_invoice "$EMPLOYEE_TOKEN" "$INVOICE_FILE" "$TMP_DIR/invoice-upload.json")"
|
||||
assert_status "$upload_status" "200" "upload invoice audit" "$TMP_DIR/invoice-upload.json"
|
||||
INVOICE_ID="$(jq -er '.data.invoicePoolId' "$TMP_DIR/invoice-upload.json")"
|
||||
|
||||
patch_invoice "$EMPLOYEE_TOKEN" "$INVOICE_ID" "{\"remarks\":\"audit-$RUN_SUFFIX\",\"invoiceCode\":\"AUD$RUN_SUFFIX\",\"invoiceNumber\":\"$RUN_SUFFIX\"}" "$TMP_DIR/invoice-patch.json"
|
||||
archive_invoice_manual "$EMPLOYEE_TOKEN" "$INVOICE_ID" "$TMP_DIR/invoice-archive.json"
|
||||
|
||||
SIGNED_URL="$(issue_signed_url "$EMPLOYEE_TOKEN" "$INVOICE_ID" "$TMP_DIR/invoice-signed.json")"
|
||||
download_status="$(curl -sS -L -o "$TMP_DIR/invoice-download.bin" -w "%{http_code}" "$SIGNED_URL")"
|
||||
assert_status "$download_status" "200" "invoice signed url download"
|
||||
if [[ ! -s "$TMP_DIR/invoice-download.bin" ]]; then
|
||||
echo "[ERROR] invoice signed url download is empty" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for action in INVOICE_POOL_EDITED INVOICE_POOL_ARCHIVED INVOICE_SIGNED_URL_ISSUED INVOICE_FILE_DOWNLOADED; do
|
||||
audit_file="$TMP_DIR/audit-$action.json"
|
||||
audit_status="$(auth_get "$BASE_URL/api/audit-logs?action=$action&targetType=invoice_pool&page=1&pageSize=50" "$FINANCE_MGR_TOKEN" "$audit_file")"
|
||||
assert_status "$audit_status" "200" "audit query $action" "$audit_file"
|
||||
assert_audit_has_target "$audit_file" "$INVOICE_ID" "audit $action"
|
||||
done
|
||||
|
||||
echo "[8/10] duplicate override audit"
|
||||
RID_OVERRIDE_A="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/override-reim-a.json")"
|
||||
RID_OVERRIDE_B="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/override-reim-b.json")"
|
||||
|
||||
OV_FILE="$TMP_DIR/override-$RUN_SUFFIX.pdf"
|
||||
printf '%%PDF-1.4\nMOCK_DUP:%s\n' "$RUN_SUFFIX" > "$OV_FILE"
|
||||
|
||||
upload_status="$(upload_invoice "$EMPLOYEE_TOKEN" "$OV_FILE" "$TMP_DIR/override-upload-a.json")"
|
||||
assert_status "$upload_status" "200" "upload override invoice A" "$TMP_DIR/override-upload-a.json"
|
||||
IID_OVERRIDE_A="$(jq -er '.data.invoicePoolId' "$TMP_DIR/override-upload-a.json")"
|
||||
bind_invoice "$EMPLOYEE_TOKEN" "$IID_OVERRIDE_A" "$RID_OVERRIDE_A" "$TMP_DIR/override-bind-a.json"
|
||||
|
||||
upload_status="$(upload_invoice "$EMPLOYEE_TOKEN" "$OV_FILE" "$TMP_DIR/override-upload-b.json")"
|
||||
assert_status "$upload_status" "200" "upload override invoice B" "$TMP_DIR/override-upload-b.json"
|
||||
IID_OVERRIDE_B="$(jq -er '.data.invoicePoolId' "$TMP_DIR/override-upload-b.json")"
|
||||
|
||||
bind_status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$IID_OVERRIDE_B/bind" "$EMPLOYEE_TOKEN" "$TMP_DIR/override-bind-b.json" "{\"reimbursementId\":\"$RID_OVERRIDE_B\"}")"
|
||||
assert_status "$bind_status" "409" "bind duplicate before override" "$TMP_DIR/override-bind-b.json"
|
||||
assert_error_code "$TMP_DIR/override-bind-b.json" "VALID_003" "bind duplicate before override"
|
||||
|
||||
override_status="$(override_duplicate "$FINANCE_MGR_TOKEN" "$IID_OVERRIDE_B" "REF-$RUN_SUFFIX" "regression override" "$TMP_DIR/override.json")"
|
||||
assert_status "$override_status" "200" "override duplicate" "$TMP_DIR/override.json"
|
||||
|
||||
bind_invoice "$EMPLOYEE_TOKEN" "$IID_OVERRIDE_B" "$RID_OVERRIDE_B" "$TMP_DIR/override-bind-b2.json"
|
||||
|
||||
override_audit_status="$(auth_get "$BASE_URL/api/audit-logs?action=INVOICE_DUPLICATE_OVERRIDDEN&targetType=invoice_pool&actorId=$FINANCE_MGR_ID&page=1&pageSize=50" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-override.json")"
|
||||
assert_status "$override_audit_status" "200" "audit override" "$TMP_DIR/audit-override.json"
|
||||
assert_audit_has_target "$TMP_DIR/audit-override.json" "$IID_OVERRIDE_B" "audit override"
|
||||
|
||||
echo "[9/10] OCR retry audit + VALID_006/VALID_007"
|
||||
NOW_ISO="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
OLD_ISO="$(date -u -d "-2 minutes" +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
|
||||
INVOICE_FREQ="$(create_invoice_record "$EMPLOYEE_IDENTIFIER" "MOCK_OK:$RUN_SUFFIX" "ocr_failed" "0" "$NOW_ISO")"
|
||||
retry_freq_status="$(retry_ocr "$EMPLOYEE_TOKEN" "$INVOICE_FREQ" "$TMP_DIR/retry-freq.json")"
|
||||
assert_status "$retry_freq_status" "409" "retry too frequent" "$TMP_DIR/retry-freq.json"
|
||||
assert_error_code "$TMP_DIR/retry-freq.json" "VALID_007" "retry too frequent"
|
||||
|
||||
INVOICE_EXCEEDED="$(create_invoice_record "$EMPLOYEE_IDENTIFIER" "MOCK_OK:$RUN_SUFFIX" "ocr_failed" "3" "$OLD_ISO")"
|
||||
retry_exceeded_status="$(retry_ocr "$EMPLOYEE_TOKEN" "$INVOICE_EXCEEDED" "$TMP_DIR/retry-exceeded.json")"
|
||||
assert_status "$retry_exceeded_status" "409" "retry attempts exceeded" "$TMP_DIR/retry-exceeded.json"
|
||||
assert_error_code "$TMP_DIR/retry-exceeded.json" "VALID_006" "retry attempts exceeded"
|
||||
|
||||
INVOICE_RETRY_OK="$(create_invoice_record "$EMPLOYEE_IDENTIFIER" "MOCK_OK:$RUN_SUFFIX" "ocr_failed" "1" "$OLD_ISO")"
|
||||
retry_ok_status="$(retry_ocr "$EMPLOYEE_TOKEN" "$INVOICE_RETRY_OK" "$TMP_DIR/retry-ok.json")"
|
||||
assert_status "$retry_ok_status" "200" "retry ok" "$TMP_DIR/retry-ok.json"
|
||||
assert_jq "$TMP_DIR/retry-ok.json" '.data.status == "pending_ocr"' "retry ok status"
|
||||
|
||||
OCR_PROVIDER=mock OCR_MAX_ATTEMPTS=3 npm run --silent job:ocr-once >"$TMP_DIR/ocr-retry-run.log"
|
||||
|
||||
invoice_after_ocr_status="$(auth_get "$BASE_URL/api/invoice-pool/$INVOICE_RETRY_OK" "$EMPLOYEE_TOKEN" "$TMP_DIR/invoice-after-ocr.json")"
|
||||
assert_status "$invoice_after_ocr_status" "200" "invoice after ocr" "$TMP_DIR/invoice-after-ocr.json"
|
||||
assert_jq "$TMP_DIR/invoice-after-ocr.json" '.data.status == "available"' "invoice after ocr"
|
||||
|
||||
retry_audit_status="$(auth_get "$BASE_URL/api/audit-logs?action=INVOICE_OCR_RETRIED&targetType=invoice_pool&actorId=$EMPLOYEE_ID&page=1&pageSize=50" "$FINANCE_MGR_TOKEN" "$TMP_DIR/audit-retry.json")"
|
||||
assert_status "$retry_audit_status" "200" "audit retry" "$TMP_DIR/audit-retry.json"
|
||||
assert_audit_has_target "$TMP_DIR/audit-retry.json" "$INVOICE_RETRY_OK" "audit retry"
|
||||
|
||||
echo "[OK] Audit regression passed"
|
||||
|
||||
@@ -5,6 +5,8 @@ BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
||||
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-dedup}"
|
||||
EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}"
|
||||
EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}"
|
||||
FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}"
|
||||
FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}"
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "[ERROR] curl not found" >&2
|
||||
@@ -38,7 +40,7 @@ fi
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
TMP_INDEX_CREATED=0
|
||||
RUN_SUFFIX="$(date +%s)-$RANDOM"
|
||||
RUN_SUFFIX="$(date +%s%N)-$RANDOM"
|
||||
DEDUP_CODE="RCODE-$RUN_SUFFIX"
|
||||
DEDUP_NUMBER="RNO-$RUN_SUFFIX"
|
||||
|
||||
@@ -84,6 +86,26 @@ api_json() {
|
||||
fi
|
||||
}
|
||||
|
||||
api_json_with_token() {
|
||||
local method="$1"
|
||||
local url="$2"
|
||||
local token="$3"
|
||||
local out_file="$4"
|
||||
local body="${5:-}"
|
||||
|
||||
if [[ -n "$body" ]]; then
|
||||
curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X "$method" "$url" \
|
||||
-H "authorization: Bearer $token" \
|
||||
-H "content-type: application/json" \
|
||||
-d "$body"
|
||||
else
|
||||
curl -sS -o "$out_file" -w "%{http_code}" \
|
||||
-X "$method" "$url" \
|
||||
-H "authorization: Bearer $token"
|
||||
fi
|
||||
}
|
||||
|
||||
upload_file() {
|
||||
local file_path="$1"
|
||||
local out_file="$2"
|
||||
@@ -135,6 +157,34 @@ const { createPrismaClient } = require('./src/prismaClient');
|
||||
NODE
|
||||
}
|
||||
|
||||
set_reimbursement_status_db() {
|
||||
local reimbursement_id="$1"
|
||||
local status="$2"
|
||||
|
||||
DATABASE_URL="$DATABASE_URL" REIMBURSEMENT_ID="$reimbursement_id" STATUS="$status" node <<'NODE'
|
||||
const { createPrismaClient } = require('./src/prismaClient');
|
||||
|
||||
(async () => {
|
||||
const reimbursementId = process.env.REIMBURSEMENT_ID;
|
||||
const status = process.env.STATUS;
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
await prisma.$connect();
|
||||
try {
|
||||
await prisma.reimbursementForm.update({
|
||||
where: { id: reimbursementId },
|
||||
data: { status },
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
await pool.end();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
NODE
|
||||
}
|
||||
|
||||
create_tmp_non_dedup_index() {
|
||||
local reimbursement_id="$1"
|
||||
DATABASE_URL="$DATABASE_URL" REIMBURSEMENT_ID="$reimbursement_id" node <<'NODE'
|
||||
@@ -205,6 +255,15 @@ login_code="$(curl -sS -o "$TMP_DIR/login.json" -w "%{http_code}" \
|
||||
assert_status "$login_code" "200" "login" "$TMP_DIR/login.json"
|
||||
TOKEN="$(jq -er '.data.accessToken' "$TMP_DIR/login.json")"
|
||||
|
||||
echo "[1.1/7] Login finance_mgr"
|
||||
login_fm_code="$(curl -sS -o "$TMP_DIR/login-fm.json" -w "%{http_code}" \
|
||||
-X POST "$BASE_URL/api/auth/login" \
|
||||
-H "content-type: application/json" \
|
||||
-H "x-device-fingerprint: $DEVICE_FINGERPRINT-finance-mgr" \
|
||||
-d "{\"identifier\":\"$FINANCE_MGR_IDENTIFIER\",\"password\":\"$FINANCE_MGR_PASSWORD\"}")"
|
||||
assert_status "$login_fm_code" "200" "login finance_mgr" "$TMP_DIR/login-fm.json"
|
||||
TOKEN_FM="$(jq -er '.data.accessToken' "$TMP_DIR/login-fm.json")"
|
||||
|
||||
echo "[2/7] Dedup by file_sha256 => VALID_003"
|
||||
cat > "$TMP_DIR/same-file.pdf" <<PDF
|
||||
%PDF-1.4
|
||||
@@ -226,6 +285,12 @@ status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_B/bind" "$TMP_DIR/bB.js
|
||||
assert_status "$status" "409" "file_sha256 dedup bind" "$TMP_DIR/bB.json"
|
||||
assert_error_code "$TMP_DIR/bB.json" "VALID_003"
|
||||
|
||||
echo "[2.1/7] finance_mgr override duplicate (file_sha256) -> bind ok"
|
||||
override_status="$(api_json_with_token POST "$BASE_URL/api/invoice-pool/$IID_B/override-duplicate" "$TOKEN_FM" "$TMP_DIR/override-file.json" "{\"refFormNumber\":\"REF-$RUN_SUFFIX\",\"reason\":\"regression override file_sha256\"}")"
|
||||
assert_status "$override_status" "200" "override duplicate (file_sha256)" "$TMP_DIR/override-file.json"
|
||||
rebind_status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_B/bind" "$TMP_DIR/bB2.json" "{\"reimbursementId\":\"$RID_B\"}")"
|
||||
assert_status "$rebind_status" "200" "bind after override (file_sha256)" "$TMP_DIR/bB2.json"
|
||||
|
||||
echo "[3/7] Dedup by invoice_code+invoice_number => VALID_003"
|
||||
cat > "$TMP_DIR/code-1.pdf" <<PDF
|
||||
%PDF-1.4
|
||||
@@ -253,6 +318,47 @@ status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_D/bind" "$TMP_DIR/bD.js
|
||||
assert_status "$status" "409" "code+number dedup bind" "$TMP_DIR/bD.json"
|
||||
assert_error_code "$TMP_DIR/bD.json" "VALID_003"
|
||||
|
||||
echo "[3.1/7] finance_mgr override duplicate (code+number) -> bind ok"
|
||||
override_status="$(api_json_with_token POST "$BASE_URL/api/invoice-pool/$IID_D/override-duplicate" "$TOKEN_FM" "$TMP_DIR/override-code.json" "{\"refFormNumber\":\"REF-$RUN_SUFFIX\",\"reason\":\"regression override code+number\"}")"
|
||||
assert_status "$override_status" "200" "override duplicate (code+number)" "$TMP_DIR/override-code.json"
|
||||
rebind_status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_D/bind" "$TMP_DIR/bD2.json" "{\"reimbursementId\":\"$RID_D\"}")"
|
||||
assert_status "$rebind_status" "200" "bind after override (code+number)" "$TMP_DIR/bD2.json"
|
||||
|
||||
echo "[3.2/7] override blocked when original reimbursement approved => VALID_004"
|
||||
BLOCK_CODE="BCODE-$RUN_SUFFIX"
|
||||
BLOCK_NUMBER="BNO-$RUN_SUFFIX"
|
||||
|
||||
cat > "$TMP_DIR/block-1.pdf" <<PDF
|
||||
%PDF-1.4
|
||||
block-one-$RUN_SUFFIX
|
||||
PDF
|
||||
cat > "$TMP_DIR/block-2.pdf" <<PDF
|
||||
%PDF-1.4
|
||||
block-two-$RUN_SUFFIX
|
||||
PDF
|
||||
|
||||
RID_BLOCK_A="$(create_reimbursement "$TMP_DIR/r-block-a.json")"
|
||||
status="$(upload_file "$TMP_DIR/block-1.pdf" "$TMP_DIR/u-block-a.json")"
|
||||
assert_status "$status" "200" "upload block-1" "$TMP_DIR/u-block-a.json"
|
||||
IID_BLOCK_A="$(jq -er '.data.invoicePoolId' "$TMP_DIR/u-block-a.json")"
|
||||
set_invoice_code_number "$IID_BLOCK_A" "$BLOCK_CODE" "$BLOCK_NUMBER"
|
||||
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_BLOCK_A/bind" "$TMP_DIR/b-block-a.json" "{\"reimbursementId\":\"$RID_BLOCK_A\"}")"
|
||||
assert_status "$status" "200" "bind block-1" "$TMP_DIR/b-block-a.json"
|
||||
set_reimbursement_status_db "$RID_BLOCK_A" "approved"
|
||||
|
||||
RID_BLOCK_B="$(create_reimbursement "$TMP_DIR/r-block-b.json")"
|
||||
status="$(upload_file "$TMP_DIR/block-2.pdf" "$TMP_DIR/u-block-b.json")"
|
||||
assert_status "$status" "200" "upload block-2" "$TMP_DIR/u-block-b.json"
|
||||
IID_BLOCK_B="$(jq -er '.data.invoicePoolId' "$TMP_DIR/u-block-b.json")"
|
||||
set_invoice_code_number "$IID_BLOCK_B" "$BLOCK_CODE" "$BLOCK_NUMBER"
|
||||
status="$(api_json POST "$BASE_URL/api/invoice-pool/$IID_BLOCK_B/bind" "$TMP_DIR/b-block-b.json" "{\"reimbursementId\":\"$RID_BLOCK_B\"}")"
|
||||
assert_status "$status" "409" "bind block-2 dedup" "$TMP_DIR/b-block-b.json"
|
||||
assert_error_code "$TMP_DIR/b-block-b.json" "VALID_003"
|
||||
|
||||
override_block_status="$(api_json_with_token POST "$BASE_URL/api/invoice-pool/$IID_BLOCK_B/override-duplicate" "$TOKEN_FM" "$TMP_DIR/override-block.json" "{\"refFormNumber\":\"REF-$RUN_SUFFIX\",\"reason\":\"regression override blocked\"}")"
|
||||
assert_status "$override_block_status" "409" "override blocked" "$TMP_DIR/override-block.json"
|
||||
assert_error_code "$TMP_DIR/override-block.json" "VALID_004"
|
||||
|
||||
echo "[4/7] Non-dedup P2002 should not map to VALID_003"
|
||||
cat > "$TMP_DIR/non-dedup-1.pdf" <<PDF
|
||||
%PDF-1.4
|
||||
|
||||
+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"
|
||||
|
||||
@@ -1317,6 +1317,17 @@ if [[ "$RUN_PAY_CONCURRENCY_CHECK" == "1" ]]; then
|
||||
assert_invoices_archived_used_complete "$RID_PAY_CONCURRENCY" "pay concurrency"
|
||||
fi
|
||||
|
||||
echo "[22.2/23] invoice unbind/rebind"
|
||||
RID_UNBIND_A="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind-a-create.json")"
|
||||
INVOICE_UNBIND="$(create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_UNBIND_A" "unbind")"
|
||||
unbind_status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$INVOICE_UNBIND/unbind" "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind.json" '{}')"
|
||||
assert_status "$unbind_status" "200" "invoice unbind" "$TMP_DIR/unbind.json"
|
||||
assert_jq "$TMP_DIR/unbind.json" '.data.status == "available" and .data.linkedReimbursementId == null and .data.linkedItemId == null' "invoice unbind response"
|
||||
|
||||
RID_UNBIND_B="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind-b-create.json")"
|
||||
rebind_status="$(api_json_auth POST "$BASE_URL/api/invoice-pool/$INVOICE_UNBIND/bind" "$EMPLOYEE_TOKEN" "$TMP_DIR/unbind-rebind.json" "{\"reimbursementId\":\"$RID_UNBIND_B\"}")"
|
||||
assert_status "$rebind_status" "200" "invoice rebind after unbind" "$TMP_DIR/unbind-rebind.json"
|
||||
|
||||
echo "[23/23] Final manager relation restore"
|
||||
set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID"
|
||||
EMPLOYEE_MANAGER_PATCHED=0
|
||||
|
||||
在新工单中引用
屏蔽一个用户