305 行
14 KiB
Bash
可执行文件
305 行
14 KiB
Bash
可执行文件
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
BASE_URL="${BASE_URL:-http://127.0.0.1:3000}"
|
|
DEVICE_FINGERPRINT="${DEVICE_FINGERPRINT:-regression-procurement}"
|
|
EMPLOYEE_IDENTIFIER="${EMPLOYEE_IDENTIFIER:-employee@hyinda.com}"
|
|
EMPLOYEE_PASSWORD="${EMPLOYEE_PASSWORD:-EmployeePassword1234}"
|
|
MANAGER_IDENTIFIER="${MANAGER_IDENTIFIER:-manager@hyinda.com}"
|
|
MANAGER_PASSWORD="${MANAGER_PASSWORD:-ManagerPassword1234}"
|
|
FINANCE_MGR_IDENTIFIER="${FINANCE_MGR_IDENTIFIER:-finance_mgr@hyinda.com}"
|
|
FINANCE_MGR_PASSWORD="${FINANCE_MGR_PASSWORD:-FinanceMgrPassword1234}"
|
|
PROCUREMENT_REGRESSION_MARKER_PREFIX="${PROCUREMENT_REGRESSION_MARKER_PREFIX:-}"
|
|
PROCUREMENT_APPROVED_TITLE="${PROCUREMENT_APPROVED_TITLE:-${PROCUREMENT_REGRESSION_MARKER_PREFIX:+$PROCUREMENT_REGRESSION_MARKER_PREFIX }procurement regression approved}"
|
|
PROCUREMENT_WITHDRAWN_TITLE="${PROCUREMENT_WITHDRAWN_TITLE:-${PROCUREMENT_REGRESSION_MARKER_PREFIX:+$PROCUREMENT_REGRESSION_MARKER_PREFIX }procurement regression withdrawn}"
|
|
PROCUREMENT_APPROVED_ITEM_DESCRIPTION="${PROCUREMENT_APPROVED_ITEM_DESCRIPTION:-${PROCUREMENT_REGRESSION_MARKER_PREFIX:+$PROCUREMENT_REGRESSION_MARKER_PREFIX }Laptop}"
|
|
PROCUREMENT_WITHDRAWN_ITEM_DESCRIPTION="${PROCUREMENT_WITHDRAWN_ITEM_DESCRIPTION:-${PROCUREMENT_REGRESSION_MARKER_PREFIX:+$PROCUREMENT_REGRESSION_MARKER_PREFIX }Desk}"
|
|
PROCUREMENT_PO_NO="${PROCUREMENT_PO_NO:-PO-P17B-001}"
|
|
PROCUREMENT_CONTRACT_NO="${PROCUREMENT_CONTRACT_NO:-CT-P17B-001}"
|
|
|
|
if ! command -v curl >/dev/null 2>&1; then
|
|
echo "[ERROR] curl not found" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
echo "[ERROR] jq not found" >&2
|
|
exit 1
|
|
fi
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
cd "$ROOT_DIR"
|
|
|
|
# shellcheck source=scripts/lib/load-env-defaults.sh
|
|
source "$ROOT_DIR/scripts/lib/load-env-defaults.sh"
|
|
load_env_defaults ".env"
|
|
|
|
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)"
|
|
cleanup() {
|
|
rm -rf "$TMP_DIR"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
assert_status() {
|
|
local actual="$1"
|
|
local expected="$2"
|
|
local name="$3"
|
|
local body_file="${4:-}"
|
|
|
|
if [[ "$actual" != "$expected" ]]; then
|
|
echo "[ERROR] $name expected HTTP $expected but got $actual" >&2
|
|
[[ -n "$body_file" && -f "$body_file" ]] && cat "$body_file" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
assert_jq() {
|
|
local file_path="$1"
|
|
local jq_expr="$2"
|
|
local name="$3"
|
|
shift 3
|
|
if ! jq -e "$@" "$jq_expr" "$file_path" >/dev/null; then
|
|
echo "[ERROR] $name assertion failed: $jq_expr" >&2
|
|
cat "$file_path" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
login_user() {
|
|
local identifier="$1"
|
|
local password="$2"
|
|
local out_file="$3"
|
|
|
|
local status
|
|
status="$(curl -sS -o "$out_file" -w "%{http_code}" \
|
|
-X POST "$BASE_URL/api/auth/login" \
|
|
-H "content-type: application/json" \
|
|
-H "x-device-fingerprint: $DEVICE_FINGERPRINT" \
|
|
-d "{\"identifier\":\"$identifier\",\"password\":\"$password\"}")"
|
|
assert_status "$status" "200" "login $identifier" "$out_file"
|
|
jq -er '.data.accessToken' "$out_file"
|
|
}
|
|
|
|
api_json_auth() {
|
|
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
|
|
}
|
|
|
|
resolve_fixture_ids() {
|
|
DATABASE_URL="$DATABASE_URL" node - <<'NODE'
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
const { resolveApplicableBudgetPoolByContext } = require('./src/services/budgetPool');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const employee = await prisma.employee.findUnique({
|
|
where: { email: process.env.SEED_EMPLOYEE_EMAIL ?? '[email protected]' },
|
|
select: { department_id: true, legal_entity_id: true },
|
|
});
|
|
const costCenter = await prisma.costCenter.findFirst({
|
|
where: {
|
|
enabled: true,
|
|
archived_at: null,
|
|
legal_entity_id: employee.legal_entity_id,
|
|
},
|
|
orderBy: { created_at: 'asc' },
|
|
select: { id: true },
|
|
});
|
|
const supplier = await prisma.supplier.findFirst({
|
|
where: {
|
|
enabled: true,
|
|
archived_at: null,
|
|
OR: [{ legal_entity_id: employee.legal_entity_id }, { legal_entity_id: null }],
|
|
},
|
|
orderBy: { created_at: 'asc' },
|
|
select: { id: true },
|
|
});
|
|
const documentCategory = await prisma.documentCategory.findFirst({
|
|
where: {
|
|
enabled: true,
|
|
archived_at: null,
|
|
entity_type: 'purchase_request',
|
|
},
|
|
orderBy: { created_at: 'asc' },
|
|
select: { id: true },
|
|
});
|
|
const budgetPool = await resolveApplicableBudgetPoolByContext(prisma, {
|
|
departmentId: employee.department_id,
|
|
legalEntityId: employee.legal_entity_id,
|
|
costCenterId: costCenter?.id ?? null,
|
|
effectiveDate: new Date(),
|
|
});
|
|
|
|
process.stdout.write([
|
|
employee.legal_entity_id,
|
|
costCenter?.id ?? '',
|
|
supplier?.id ?? '',
|
|
documentCategory?.id ?? '',
|
|
budgetPool?.id ?? '',
|
|
].join('\n'));
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
assert_procurement_budget_state() {
|
|
local approved_id="$1"
|
|
local withdrawn_id="$2"
|
|
local budget_pool_id="$3"
|
|
local expected_po_no="$4"
|
|
local expected_contract_no="$5"
|
|
|
|
DATABASE_URL="$DATABASE_URL" node - "$approved_id" "$withdrawn_id" "$budget_pool_id" "$expected_po_no" "$expected_contract_no" <<'NODE'
|
|
const [approvedId, withdrawnId, budgetPoolId, expectedPoNo, expectedContractNo] = process.argv.slice(2);
|
|
const { createPrismaClient } = require('./src/prismaClient');
|
|
|
|
(async () => {
|
|
const { prisma, pool } = createPrismaClient();
|
|
await prisma.$connect();
|
|
try {
|
|
const approvedEntries = await prisma.budgetLedgerEntry.findMany({
|
|
where: { entity_type: 'purchase_request', entity_id: approvedId },
|
|
orderBy: { created_at: 'asc' },
|
|
select: { kind: true, amount: true, budget_pool_id: true },
|
|
});
|
|
const withdrawnEntries = await prisma.budgetLedgerEntry.findMany({
|
|
where: { entity_type: 'purchase_request', entity_id: withdrawnId },
|
|
orderBy: { created_at: 'asc' },
|
|
select: { kind: true, amount: true, budget_pool_id: true },
|
|
});
|
|
const archive = await prisma.purchaseOrderArchive.findUnique({
|
|
where: { purchase_request_id: approvedId },
|
|
select: { po_no: true, contract_no: true },
|
|
});
|
|
const poolState = budgetPoolId
|
|
? await prisma.budgetPool.findUnique({
|
|
where: { id: budgetPoolId },
|
|
select: { used_amount: true },
|
|
})
|
|
: null;
|
|
|
|
if (approvedEntries.length !== 2 || approvedEntries[0]?.kind !== 'reserve' || approvedEntries[1]?.kind !== 'confirm') {
|
|
console.error('[ERROR] approved purchase budget ledger mismatch', approvedEntries);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (withdrawnEntries.length !== 2 || withdrawnEntries[0]?.kind !== 'reserve' || withdrawnEntries[1]?.kind !== 'release') {
|
|
console.error('[ERROR] withdrawn purchase budget ledger mismatch', withdrawnEntries);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!archive || archive.po_no !== expectedPoNo || archive.contract_no !== expectedContractNo) {
|
|
console.error('[ERROR] purchase order archive mismatch', archive);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (poolState && Number(poolState.used_amount) < 0) {
|
|
console.error('[ERROR] budget pool used_amount should not be negative', poolState);
|
|
process.exit(1);
|
|
}
|
|
} finally {
|
|
await prisma.$disconnect();
|
|
await pool.end();
|
|
}
|
|
})().catch((error) => {
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
NODE
|
|
}
|
|
|
|
echo "[1/8] login baseline users"
|
|
EMPLOYEE_TOKEN="$(login_user "$EMPLOYEE_IDENTIFIER" "$EMPLOYEE_PASSWORD" "$TMP_DIR/employee-login.json")"
|
|
MANAGER_TOKEN="$(login_user "$MANAGER_IDENTIFIER" "$MANAGER_PASSWORD" "$TMP_DIR/manager-login.json")"
|
|
FINANCE_MGR_TOKEN="$(login_user "$FINANCE_MGR_IDENTIFIER" "$FINANCE_MGR_PASSWORD" "$TMP_DIR/finance-mgr-login.json")"
|
|
|
|
echo "[2/8] employee can read procurement reference master-data"
|
|
master_status="$(api_json_auth GET "$BASE_URL/api/master-data/legal-entities?enabled=true" "$EMPLOYEE_TOKEN" "$TMP_DIR/legal-entities.json")"
|
|
assert_status "$master_status" "200" "employee master-data read" "$TMP_DIR/legal-entities.json"
|
|
assert_jq "$TMP_DIR/legal-entities.json" '.data.items | length > 0' "employee master-data non-empty"
|
|
|
|
mapfile -t FIXTURE_IDS < <(resolve_fixture_ids)
|
|
LEGAL_ENTITY_ID="${FIXTURE_IDS[0]}"
|
|
COST_CENTER_ID="${FIXTURE_IDS[1]}"
|
|
SUPPLIER_ID="${FIXTURE_IDS[2]}"
|
|
DOCUMENT_CATEGORY_ID="${FIXTURE_IDS[3]}"
|
|
BUDGET_POOL_ID="${FIXTURE_IDS[4]}"
|
|
|
|
echo "[3/8] create + submit purchase request => reserve budget"
|
|
create_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests" "$EMPLOYEE_TOKEN" "$TMP_DIR/create.json" '{}')"
|
|
assert_status "$create_status" "200" "create purchase request" "$TMP_DIR/create.json"
|
|
PURCHASE_ID_APPROVED="$(jq -er '.data.purchaseRequest.id' "$TMP_DIR/create.json")"
|
|
PATCH_BODY="$(jq -nc --arg le "$LEGAL_ENTITY_ID" --arg cc "$COST_CENTER_ID" --arg sp "$SUPPLIER_ID" --arg dc "$DOCUMENT_CATEGORY_ID" --arg title "$PROCUREMENT_APPROVED_TITLE" --arg description "$PROCUREMENT_APPROVED_ITEM_DESCRIPTION" '{title:$title, legalEntityId:$le, costCenterId:$cc, supplierId:$sp, documentCategoryId:$dc, items:[{description:$description, specification:"14 inch", quantity:"2.00", unitPrice:"100.00", isAssetCandidate:true}] }')"
|
|
patch_status="$(api_json_auth PATCH "$BASE_URL/api/purchase-requests/$PURCHASE_ID_APPROVED" "$EMPLOYEE_TOKEN" "$TMP_DIR/patch.json" "$PATCH_BODY")"
|
|
assert_status "$patch_status" "200" "patch purchase request" "$TMP_DIR/patch.json"
|
|
submit_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$PURCHASE_ID_APPROVED/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/submit.json" '{}')"
|
|
assert_status "$submit_status" "200" "submit purchase request" "$TMP_DIR/submit.json"
|
|
assert_jq "$TMP_DIR/submit.json" '.data.status == "reviewing" and (.data.budgetReservation.applied == true)' "submit reserve budget"
|
|
TASK_ID="$(jq -er '.data.currentTaskId' "$TMP_DIR/submit.json")"
|
|
|
|
echo "[4/8] manager approve => confirm budget"
|
|
approve_status="$(api_json_auth POST "$BASE_URL/api/workflow/tasks/$TASK_ID/approve" "$MANAGER_TOKEN" "$TMP_DIR/approve.json" '{"comment":"approve procurement regression"}')"
|
|
assert_status "$approve_status" "200" "approve purchase request" "$TMP_DIR/approve.json"
|
|
assert_jq "$TMP_DIR/approve.json" '.data.outcome == "approved" and .data.purchaseRequestStatus == "approved"' "approve purchase request response"
|
|
|
|
echo "[5/8] only procurement.manage can archive PO/contract"
|
|
archive_forbidden_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$EMPLOYEE_TOKEN" "$TMP_DIR/archive-forbidden.json" '{"poNo":"PO-FAIL"}')"
|
|
assert_status "$archive_forbidden_status" "403" "archive order forbidden for employee" "$TMP_DIR/archive-forbidden.json"
|
|
ARCHIVE_BODY="$(jq -nc --arg po "$PROCUREMENT_PO_NO" --arg contract "$PROCUREMENT_CONTRACT_NO" '{poNo:$po, contractNo:$contract, archivedFiles:[]}')"
|
|
archive_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$FINANCE_MGR_TOKEN" "$TMP_DIR/archive.json" "$ARCHIVE_BODY")"
|
|
assert_status "$archive_status" "200" "archive order" "$TMP_DIR/archive.json"
|
|
assert_jq "$TMP_DIR/archive.json" '.data.archiveOrder.poNo == $po and .data.archiveOrder.contractNo == $contract' "archive order response" --arg po "$PROCUREMENT_PO_NO" --arg contract "$PROCUREMENT_CONTRACT_NO"
|
|
|
|
echo "[6/8] archive order readable by purchase participants"
|
|
archive_get_status="$(api_json_auth GET "$BASE_URL/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$EMPLOYEE_TOKEN" "$TMP_DIR/archive-get.json")"
|
|
assert_status "$archive_get_status" "200" "get archive order" "$TMP_DIR/archive-get.json"
|
|
assert_jq "$TMP_DIR/archive-get.json" '.data.archiveOrder.poNo == $po' "get archive order response" --arg po "$PROCUREMENT_PO_NO"
|
|
|
|
echo "[7/8] submit + withdraw purchase request => release budget"
|
|
create2_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests" "$EMPLOYEE_TOKEN" "$TMP_DIR/create2.json" '{}')"
|
|
assert_status "$create2_status" "200" "create purchase request 2" "$TMP_DIR/create2.json"
|
|
PURCHASE_ID_WITHDRAWN="$(jq -er '.data.purchaseRequest.id' "$TMP_DIR/create2.json")"
|
|
PATCH2_BODY="$(jq -nc --arg le "$LEGAL_ENTITY_ID" --arg cc "$COST_CENTER_ID" --arg sp "$SUPPLIER_ID" --arg dc "$DOCUMENT_CATEGORY_ID" --arg title "$PROCUREMENT_WITHDRAWN_TITLE" --arg description "$PROCUREMENT_WITHDRAWN_ITEM_DESCRIPTION" '{title:$title, legalEntityId:$le, costCenterId:$cc, supplierId:$sp, documentCategoryId:$dc, items:[{description:$description, specification:null, quantity:"1.00", unitPrice:"50.00", isAssetCandidate:false}] }')"
|
|
patch2_status="$(api_json_auth PATCH "$BASE_URL/api/purchase-requests/$PURCHASE_ID_WITHDRAWN" "$EMPLOYEE_TOKEN" "$TMP_DIR/patch2.json" "$PATCH2_BODY")"
|
|
assert_status "$patch2_status" "200" "patch purchase request 2" "$TMP_DIR/patch2.json"
|
|
submit2_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$PURCHASE_ID_WITHDRAWN/submit" "$EMPLOYEE_TOKEN" "$TMP_DIR/submit2.json" '{}')"
|
|
assert_status "$submit2_status" "200" "submit purchase request 2" "$TMP_DIR/submit2.json"
|
|
withdraw_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$PURCHASE_ID_WITHDRAWN/withdraw" "$EMPLOYEE_TOKEN" "$TMP_DIR/withdraw2.json" '{}')"
|
|
assert_status "$withdraw_status" "200" "withdraw purchase request" "$TMP_DIR/withdraw2.json"
|
|
assert_jq "$TMP_DIR/withdraw2.json" '.data.status == "draft" and (.data.budgetRelease.applied == true)' "withdraw release budget"
|
|
|
|
echo "[8/8] verify budget ledger + archive DB state"
|
|
assert_procurement_budget_state "$PURCHASE_ID_APPROVED" "$PURCHASE_ID_WITHDRAWN" "$BUDGET_POOL_ID" "$PROCUREMENT_PO_NO" "$PROCUREMENT_CONTRACT_NO"
|
|
|
|
echo "[OK] Procurement regression passed"
|
|
echo "- approved purchaseRequestId: $PURCHASE_ID_APPROVED"
|
|
echo "- withdrawn purchaseRequestId: $PURCHASE_ID_WITHDRAWN"
|