feat(server): 完成 P2 后端能力(支付幂等、委派回收、OCR异步、审计)

这个提交包含在:
2026-02-07 21:54:29 +08:00
父节点 7ea25e9d72
当前提交 c4df9a419a
共修改 23 个文件,包含 2102 行新增和 70 行删除
+184 -26
查看文件
@@ -188,8 +188,13 @@ pay_reimbursement() {
local token="$1"
local reimbursement_id="$2"
local out_file="$3"
local body="${4:-}"
api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/pay" "$token" "$out_file" '{}'
if [[ -z "$body" ]]; then
body='{}'
fi
api_json_auth POST "$BASE_URL/api/reimbursements/$reimbursement_id/pay" "$token" "$out_file" "$body"
}
transfer_task() {
@@ -213,6 +218,25 @@ withdraw_reimbursement() {
assert_status "$status" "200" "withdraw reimbursement $reimbursement_id" "$out_file"
}
create_delegation() {
local token="$1"
local delegate_id="$2"
local scope="$3"
local valid_from="$4"
local valid_to="$5"
local out_file="$6"
api_json_auth POST "$BASE_URL/api/delegations" "$token" "$out_file" "{\"delegateEmployeeId\":\"$delegate_id\",\"scope\":\"$scope\",\"validFrom\":\"$valid_from\",\"validTo\":\"$valid_to\"}"
}
revoke_delegation() {
local token="$1"
local delegation_id="$2"
local out_file="$3"
api_json_auth POST "$BASE_URL/api/delegations/$delegation_id/revoke" "$token" "$out_file" '{}'
}
create_and_bind_invoice() {
local token="$1"
local reimbursement_id="$2"
@@ -761,7 +785,86 @@ const { createPrismaClient } = require('./src/prismaClient');
NODE
}
echo "[1/19] Health check + login baseline users"
assert_delegation_history_evidence() {
local task_id="$1"
local delegation_id="$2"
local principal_id="$3"
local delegate_id="$4"
local scenario="$5"
DATABASE_URL="$DATABASE_URL" node - "$task_id" "$delegation_id" "$principal_id" "$delegate_id" "$scenario" <<'NODE'
const [taskId, delegationId, principalId, delegateId, scenario] = process.argv.slice(2);
const { createPrismaClient } = require('./src/prismaClient');
(async () => {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
const history = await prisma.workflowHistory.findFirst({
where: { task_id: taskId },
orderBy: { created_at: 'desc' },
select: { payload: true },
});
if (!history) {
console.error(`[ERROR] ${scenario} history not found`);
process.exit(1);
}
const payload = history.payload ?? {};
if (
payload.delegation_id !== delegationId ||
payload.principal_id !== principalId ||
payload.delegate_id !== delegateId
) {
console.error(
`[ERROR] ${scenario} delegation payload mismatch: delegation_id=${payload.delegation_id}, principal_id=${payload.principal_id}, delegate_id=${payload.delegate_id}`,
);
process.exit(1);
}
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
assert_delegation_revoked_db() {
local delegation_id="$1"
local scenario="$2"
DATABASE_URL="$DATABASE_URL" node - "$delegation_id" "$scenario" <<'NODE'
const [delegationId, scenario] = process.argv.slice(2);
const { createPrismaClient } = require('./src/prismaClient');
(async () => {
const { prisma, pool } = createPrismaClient();
await prisma.$connect();
try {
const delegation = await prisma.delegationGrant.findUnique({
where: { id: delegationId },
select: { revoked_at: true },
});
if (!delegation || delegation.revoked_at == null) {
console.error(`[ERROR] ${scenario} expected revoked delegation but got none`);
process.exit(1);
}
} finally {
await prisma.$disconnect();
await pool.end();
}
})().catch((error) => {
console.error(error);
process.exit(1);
});
NODE
}
echo "[1/23] Health check + login baseline users"
health_status="$(curl -sS -o "$TMP_DIR/health.json" -w "%{http_code}" "$BASE_URL/health")"
assert_status "$health_status" "200" "health check" "$TMP_DIR/health.json"
@@ -783,7 +886,7 @@ ceo_me_status="$(api_json_auth GET "$BASE_URL/api/me" "$CEO_TOKEN" "$TMP_DIR/me-
assert_status "$ceo_me_status" "200" "get /api/me ceo" "$TMP_DIR/me-ceo.json"
CEO_ID="$(jq -er '.data.id' "$TMP_DIR/me-ceo.json")"
echo "[2/19] reject: instance=rejected, reimbursement=rejected"
echo "[2/23] reject: instance=rejected, reimbursement=rejected"
RID_REJECT="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/reject-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_REJECT" "reject" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_REJECT" "$TMP_DIR/reject-submit.json"
@@ -796,7 +899,7 @@ assert_jq "$TMP_DIR/reject-action.json" '.data.outcome == "rejected" and .data.r
assert_instance_status "$INSTANCE_REJECT" "rejected" "reject scenario"
assert_reimbursement_status_db "$RID_REJECT" "rejected" "reject scenario"
echo "[3/19] return + resubmit: terminated/draft and new instance"
echo "[3/23] return + resubmit: terminated/draft and new instance"
RID_RETURN="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/return-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_RETURN" "return" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "$TMP_DIR/return-submit-1.json"
@@ -821,7 +924,7 @@ withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_RETURN" "cleanup after return-res
assert_instance_status "$INSTANCE_RETURN_2" "cancelled" "return resubmit cleanup"
assert_reimbursement_status_db "$RID_RETURN" "draft" "return resubmit cleanup"
echo "[4/19] withdraw: before approver processing"
echo "[4/23] withdraw: before approver processing"
RID_WITHDRAW="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/withdraw-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "withdraw" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_WITHDRAW" "$TMP_DIR/withdraw-submit.json"
@@ -832,7 +935,7 @@ assert_jq "$TMP_DIR/withdraw-action.json" '.data.outcome == "withdrawn" and .dat
assert_instance_status "$INSTANCE_WITHDRAW" "cancelled" "withdraw scenario"
assert_reimbursement_status_db "$RID_WITHDRAW" "draft" "withdraw scenario"
echo "[5/19] PERM_002: non-assignee cannot operate"
echo "[5/23] PERM_002: non-assignee cannot operate"
RID_PERM="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/perm-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PERM" "perm" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PERM" "$TMP_DIR/perm-submit.json"
@@ -847,7 +950,7 @@ withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_PERM" "cleanup after PERM_002" "$
assert_instance_status "$INSTANCE_PERM" "cancelled" "PERM_002 cleanup"
assert_reimbursement_status_db "$RID_PERM" "draft" "PERM_002 cleanup"
echo "[6/19] negative_not_allowed: submit blocked when total_amount < 0"
echo "[6/23] negative_not_allowed: submit blocked when total_amount < 0"
RID_NEGATIVE="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/negative-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_NEGATIVE" "negative" >/dev/null
set_reimbursement_total_amount "$RID_NEGATIVE" "-1.00"
@@ -858,11 +961,11 @@ assert_error_code "$TMP_DIR/negative-submit.json" "VALID_001" "negative_not_allo
assert_error_detail "$TMP_DIR/negative-submit.json" '.error.details.total_amount' "negative_not_allowed" "negative_not_allowed submit"
assert_no_workflow_instance "$RID_NEGATIVE" "negative_not_allowed"
echo "[7/19] Prepare manager-missing context"
echo "[7/23] Prepare manager-missing context"
set_employee_direct_manager "$EMPLOYEE_ID" "__NULL__"
EMPLOYEE_MANAGER_PATCHED=1
echo "[8/19] manager skip: missing manager -> skip with history + finance task"
echo "[8/23] manager skip: missing manager -> skip with history + finance task"
RID_SKIP="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/skip-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_SKIP" "skip" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_SKIP" "$TMP_DIR/skip-submit.json"
@@ -876,7 +979,7 @@ skip_finance_approve_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/
assert_status "$skip_finance_approve_status" "200" "manager skip finance approve" "$TMP_DIR/skip-finance-approve.json"
assert_reimbursement_status_db "$RID_SKIP" "approved" "manager skip scenario"
echo "[9/19] no_actionable_approver: full chain non-actionable"
echo "[9/23] no_actionable_approver: full chain non-actionable"
TMP_APPROVAL_RULE_ID="$(create_temp_manager_only_rule "tmp-manager-only-$RUN_SUFFIX" "9000.00" "9001.00" "1")"
RID_NO_ACTIONABLE="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/no-actionable-create.json")"
@@ -892,11 +995,11 @@ assert_no_workflow_instance "$RID_NO_ACTIONABLE" "no_actionable_approver"
delete_approval_rule "$TMP_APPROVAL_RULE_ID"
TMP_APPROVAL_RULE_ID=""
echo "[10/19] restore manager relation before transfer/pay scenarios"
echo "[10/23] restore manager relation before transfer/pay scenarios"
set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID"
EMPLOYEE_MANAGER_PATCHED=0
echo "[11/19] transfer success: manager -> ceo with 3 evidences"
echo "[11/23] transfer success: manager -> ceo with 3 evidences"
RID_TRANSFER_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-ok-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_OK" "transfer-ok" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_OK" "$TMP_DIR/transfer-ok-submit.json"
@@ -923,7 +1026,7 @@ fi
assert_reimbursement_status_db "$RID_TRANSFER_OK" "approved" "transfer success approval"
echo "[12/19] transfer reason too short => VALID_002"
echo "[12/23] transfer reason too short => VALID_002"
RID_TRANSFER_SHORT="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-short-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "transfer-short" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "$TMP_DIR/transfer-short-submit.json"
@@ -936,7 +1039,7 @@ assert_error_code "$TMP_DIR/transfer-short-action.json" "VALID_002" "transfer re
withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SHORT" "cleanup transfer short" "$TMP_DIR/transfer-short-withdraw-cleanup.json"
assert_reimbursement_status_db "$RID_TRANSFER_SHORT" "draft" "transfer short cleanup"
echo "[13/19] transfer target not approver => VALID_001"
echo "[13/23] transfer target not approver => VALID_001"
RID_TRANSFER_TARGET="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-target-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "transfer-target" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "$TMP_DIR/transfer-target-submit.json"
@@ -950,7 +1053,7 @@ assert_error_detail "$TMP_DIR/transfer-target-action.json" '.error.details.toApp
withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_TARGET" "cleanup transfer target" "$TMP_DIR/transfer-target-withdraw-cleanup.json"
assert_reimbursement_status_db "$RID_TRANSFER_TARGET" "draft" "transfer target cleanup"
echo "[14/19] transfer same assignee => VALID_001"
echo "[14/23] transfer same assignee => VALID_001"
RID_TRANSFER_SAME="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-same-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "transfer-same" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "$TMP_DIR/transfer-same-submit.json"
@@ -964,7 +1067,7 @@ assert_error_detail "$TMP_DIR/transfer-same-action.json" '.error.details.transfe
withdraw_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_SAME" "cleanup transfer same assignee" "$TMP_DIR/transfer-same-withdraw-cleanup.json"
assert_reimbursement_status_db "$RID_TRANSFER_SAME" "draft" "transfer same cleanup"
echo "[15/19] transfer chain limit: 4th transfer blocked"
echo "[15/23] transfer chain limit: 4th transfer blocked"
RID_TRANSFER_CHAIN="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/transfer-chain-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_TRANSFER_CHAIN" "transfer-chain" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_TRANSFER_CHAIN" "$TMP_DIR/transfer-chain-submit.json"
@@ -998,7 +1101,54 @@ fi
assert_reimbursement_status_db "$RID_TRANSFER_CHAIN" "approved" "transfer chain final approval"
echo "[16/19] pay success: approved -> paid and invoices archived"
echo "[17/23] delegation active window: delegate can approve"
RID_DELEGATION_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/delegation-ok-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_DELEGATION_OK" "delegation-ok" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_DELEGATION_OK" "$TMP_DIR/delegation-ok-submit.json"
TASK_DELEGATION_OK="$(jq -er '.data.currentTaskId' "$TMP_DIR/delegation-ok-submit.json")"
DELEGATION_VALID_FROM="$(date -u -d '-5 minutes' '+%Y-%m-%dT%H:%M:%SZ')"
DELEGATION_VALID_TO="$(date -u -d '+1 hour' '+%Y-%m-%dT%H:%M:%SZ')"
delegation_create_status="$(create_delegation "$MANAGER_TOKEN" "$CEO_ID" "all" "$DELEGATION_VALID_FROM" "$DELEGATION_VALID_TO" "$TMP_DIR/delegation-create-ok.json")"
assert_status "$delegation_create_status" "200" "create delegation active" "$TMP_DIR/delegation-create-ok.json"
DELEGATION_OK_ID="$(jq -er '.data.delegation.id' "$TMP_DIR/delegation-create-ok.json")"
ceo_approve_via_delegation_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_OK/approve" "$CEO_TOKEN" "$TMP_DIR/delegation-ok-ceo-approve.json" '{}')"
assert_status "$ceo_approve_via_delegation_status" "200" "delegated approve by ceo" "$TMP_DIR/delegation-ok-ceo-approve.json"
assert_delegation_history_evidence "$TASK_DELEGATION_OK" "$DELEGATION_OK_ID" "$MANAGER_ID" "$CEO_ID" "delegation active approve"
revoke_delegation_status="$(revoke_delegation "$MANAGER_TOKEN" "$DELEGATION_OK_ID" "$TMP_DIR/delegation-revoke-ok.json")"
assert_status "$revoke_delegation_status" "200" "revoke delegation" "$TMP_DIR/delegation-revoke-ok.json"
assert_delegation_revoked_db "$DELEGATION_OK_ID" "delegation revoke"
echo "[18/23] delegation expired: delegate forbidden"
DELEGATION_EXPIRED_FROM="$(date -u -d '-2 hours' '+%Y-%m-%dT%H:%M:%SZ')"
DELEGATION_EXPIRED_TO="$(date -u -d '-1 hour' '+%Y-%m-%dT%H:%M:%SZ')"
delegation_expired_create_status="$(create_delegation "$MANAGER_TOKEN" "$CEO_ID" "all" "$DELEGATION_EXPIRED_FROM" "$DELEGATION_EXPIRED_TO" "$TMP_DIR/delegation-create-expired.json")"
assert_status "$delegation_expired_create_status" "200" "create expired delegation" "$TMP_DIR/delegation-create-expired.json"
DELEGATION_EXPIRED_ID="$(jq -er '.data.delegation.id' "$TMP_DIR/delegation-create-expired.json")"
RID_DELEGATION_EXPIRED="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/delegation-expired-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_DELEGATION_EXPIRED" "delegation-expired" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_DELEGATION_EXPIRED" "$TMP_DIR/delegation-expired-submit.json"
TASK_DELEGATION_EXPIRED="$(jq -er '.data.currentTaskId' "$TMP_DIR/delegation-expired-submit.json")"
ceo_approve_expired_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_EXPIRED/approve" "$CEO_TOKEN" "$TMP_DIR/delegation-expired-ceo-approve.json" '{}')"
assert_status "$ceo_approve_expired_status" "403" "expired delegation should be forbidden" "$TMP_DIR/delegation-expired-ceo-approve.json"
assert_error_code "$TMP_DIR/delegation-expired-ceo-approve.json" "PERM_002" "expired delegation should be forbidden"
revoke_job_output="$(npm run --silent job:revoke-expired-delegations)"
revoke_job_status="$(printf '%s\n' "$revoke_job_output" | tail -n 1)"
if [[ "$revoke_job_status" != revoked=* ]]; then
echo "[ERROR] revoke expired delegations script output invalid: $revoke_job_output" >&2
exit 1
fi
assert_delegation_revoked_db "$DELEGATION_EXPIRED_ID" "delegation auto revoke"
manager_approve_after_expired_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_DELEGATION_EXPIRED/approve" "$MANAGER_TOKEN" "$TMP_DIR/delegation-expired-manager-approve.json" '{}')"
assert_status "$manager_approve_after_expired_status" "200" "manager approve after expired delegation" "$TMP_DIR/delegation-expired-manager-approve.json"
echo "[19/23] pay success: approved -> paid and invoices archived"
RID_PAY_OK="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-ok-create.json")"
IID_PAY_OK="$(create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "pay-ok")"
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-ok-submit.json"
@@ -1015,13 +1165,14 @@ fi
assert_reimbursement_status_db "$RID_PAY_OK" "approved" "pay success precondition"
pay_ok_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-ok-pay.json")"
pay_ok_body="{\"paymentVoucherNo\":\"VOUCHER-$RUN_SUFFIX\",\"paymentDate\":\"2026-02-07\",\"paymentChannel\":\"bank\"}"
pay_ok_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-ok-pay.json" "$pay_ok_body")"
assert_status "$pay_ok_status" "200" "pay approved reimbursement" "$TMP_DIR/pay-ok-pay.json"
assert_jq "$TMP_DIR/pay-ok-pay.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 1' "pay success response"
assert_jq "$TMP_DIR/pay-ok-pay.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 1 and .data.payment.idempotent == false and .data.payment.paymentChannel == "bank"' "pay success response"
assert_reimbursement_status_db "$RID_PAY_OK" "paid" "pay success"
assert_invoices_archived_used_complete "$RID_PAY_OK" "pay success"
echo "[17/19] pay invalid status: draft/reviewing/rejected/cancelled"
echo "[20/23] pay invalid status: draft/reviewing/rejected/cancelled"
pay_invalid_draft_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_NEGATIVE" "$TMP_DIR/pay-invalid-draft.json")"
assert_status "$pay_invalid_draft_status" "409" "pay invalid status draft" "$TMP_DIR/pay-invalid-draft.json"
assert_error_code "$TMP_DIR/pay-invalid-draft.json" "VALID_001" "pay invalid status draft"
@@ -1050,20 +1201,27 @@ assert_status "$pay_invalid_cancelled_status" "409" "pay invalid status cancelle
assert_error_code "$TMP_DIR/pay-invalid-cancelled.json" "VALID_001" "pay invalid status cancelled"
assert_error_detail "$TMP_DIR/pay-invalid-cancelled.json" '.error.details.status' "invalid_pay_status" "pay invalid status cancelled"
echo "[18/19] pay permission: non-finance forbidden"
echo "[21/23] pay permission: non-finance forbidden"
pay_perm_status="$(pay_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-perm-employee.json")"
assert_status "$pay_perm_status" "403" "pay forbidden for non-finance" "$TMP_DIR/pay-perm-employee.json"
assert_error_code "$TMP_DIR/pay-perm-employee.json" "PERM_001" "pay forbidden for non-finance"
echo "[19/19] pay idempotent: paid reimbursement can be paid again"
pay_idempotent_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-idempotent.json")"
echo "[22/23] pay idempotent: paid reimbursement can be paid again"
pay_idempotent_body="{\"paymentVoucherNo\":\"VOUCHER-$RUN_SUFFIX\",\"paymentDate\":\"2026-02-07\",\"paymentChannel\":\"bank\"}"
pay_idempotent_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-idempotent.json" "$pay_idempotent_body")"
assert_status "$pay_idempotent_status" "200" "pay idempotent" "$TMP_DIR/pay-idempotent.json"
assert_jq "$TMP_DIR/pay-idempotent.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 0' "pay idempotent response"
assert_jq "$TMP_DIR/pay-idempotent.json" '.data.outcome == "paid" and .data.reimbursementStatus == "paid" and (.data.archivedInvoiceCount | type == "number") and .data.archivedInvoiceCount >= 0 and .data.payment.idempotent == true and .data.payment.paymentVoucherNo != null' "pay idempotent response"
assert_reimbursement_status_db "$RID_PAY_OK" "paid" "pay idempotent"
assert_invoices_archived_used_complete "$RID_PAY_OK" "pay idempotent"
pay_conflict_body="{\"paymentVoucherNo\":\"VOUCHER-CONFLICT-$RUN_SUFFIX\",\"paymentDate\":\"2026-02-06\",\"paymentChannel\":\"alipay\"}"
pay_conflict_status="$(pay_reimbursement "$FINANCE_TOKEN" "$RID_PAY_OK" "$TMP_DIR/pay-conflict.json" "$pay_conflict_body")"
assert_status "$pay_conflict_status" "409" "pay idempotent conflict" "$TMP_DIR/pay-conflict.json"
assert_error_code "$TMP_DIR/pay-conflict.json" "VALID_001" "pay idempotent conflict"
assert_error_detail "$TMP_DIR/pay-conflict.json" '.error.details.payment' "already_paid_conflict" "pay idempotent conflict"
if [[ "$RUN_PAY_CONCURRENCY_CHECK" == "1" ]]; then
echo "[19.1/19] optional pay concurrency check"
echo "[22.1/23] optional pay concurrency check"
RID_PAY_CONCURRENCY="$(create_reimbursement "$EMPLOYEE_TOKEN" "$TMP_DIR/pay-concurrency-create.json")"
create_and_bind_invoice "$EMPLOYEE_TOKEN" "$RID_PAY_CONCURRENCY" "pay-concurrency" >/dev/null
submit_reimbursement "$EMPLOYEE_TOKEN" "$RID_PAY_CONCURRENCY" "$TMP_DIR/pay-concurrency-submit.json"
@@ -1090,7 +1248,7 @@ if [[ "$RUN_PAY_CONCURRENCY_CHECK" == "1" ]]; then
assert_invoices_archived_used_complete "$RID_PAY_CONCURRENCY" "pay concurrency"
fi
echo "[19/19] Final manager relation restore"
echo "[23/23] Final manager relation restore"
set_employee_direct_manager "$EMPLOYEE_ID" "$ORIGINAL_EMPLOYEE_MANAGER_ID"
EMPLOYEE_MANAGER_PATCHED=0