From 221cf085035d9aa881f3293771a145a6d9d866ef Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 7 Mar 2026 22:01:19 +0800 Subject: [PATCH] =?UTF-8?q?docs/test(P17B-5,P17B-6):=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E9=87=87=E8=B4=AD=E7=9F=A9=E9=98=B5=E6=96=87=E6=A1=A3=E4=B8=8E?= =?UTF-8?q?=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/feature-matrix.md | 12 +- ...-37-p17b-l5-procurement-budget-archive.csv | 14 +- scripts/validate-feature-matrix.py | 1 + server/scripts/regression-all.sh | 21 +- server/scripts/regression-procurement.sh | 296 ++++++++++++++++++ 5 files changed, 325 insertions(+), 19 deletions(-) create mode 100755 server/scripts/regression-procurement.sh diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md index 294d8605..a69dcdac 100644 --- a/docs/feature-matrix.md +++ b/docs/feature-matrix.md @@ -1,8 +1,8 @@ -# hyb-oa 实现状态矩阵(P5-P17A) +# hyb-oa 实现状态矩阵(P5-P17B) **快照日期**:2026-03-07 **范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8` + `P14-0-P14-8` + `P15A-0-P15A-5` + `P15B-0-P15B-6` + `P16A-0-P16A-6` + `P16B-0-P16B-6` + `P16C-0-P16C-6` + `R9-0-R9-3` + `G2-0-G2-6` + `P17A-0-P17A-6`,共 164 项。 -**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv` + `issues/2026-02-13_00-11-56-p14-l5-operationalization.csv` + `issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv` + `issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv` + `issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv` + `issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv` + `issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv` + `issues/2026-03-07_18-15-03-r9-g2-p19-roadmap-refresh.csv` + `issues/2026-03-07_18-16-00-g2-l5-cross-domain-bridge.csv` + `issues/2026-03-07_19-33-09-p17a-l5-procurement-core.csv`(执行合同) +**事实源**:`issues/2026-02-09_13-39-02-p5-blueprint-v3.csv` + `issues/2026-02-09_23-30-00-p6-p8-remaining-work-master.csv` + `issues/2026-02-10_09-42-31-p9-maintenance-performance-matrix.csv` + `issues/2026-02-10_10-35-30-p10-production-hardening.csv` + `issues/2026-02-11-p11-p12-governance-mobile.csv` + `issues/2026-02-12_01-09-41-p13-l5-governance.csv` + `issues/2026-02-13_00-11-56-p14-l5-operationalization.csv` + `issues/2026-03-06_14-30-00-p15a-l5-permission-decouple.csv` + `issues/2026-03-06_14-30-00-p15b-l5-leave-v1.csv` + `issues/2026-03-07_13-47-05-p16a-l5-master-data-core.csv` + `issues/2026-03-07_16-22-07-p16b-l5-holiday-calendar.csv` + `issues/2026-03-07_17-14-39-p16c-l5-suppliers.csv` + `issues/2026-03-07_18-15-03-r9-g2-p19-roadmap-refresh.csv` + `issues/2026-03-07_18-16-00-g2-l5-cross-domain-bridge.csv` + `issues/2026-03-07_19-33-09-p17a-l5-procurement-core.csv` + `issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv`(执行合同) **说明**: - 本矩阵用于全局读图(审计/交接/回归覆盖追踪),不替代 `issues/*.csv` 的执行合同职责。 @@ -189,3 +189,11 @@ | P17A-5 | OpenAPI与合同文档同步 | N/A | 2026-03-07_19-33-09-p17a-l5-procurement-core.csv | 2026-03-07_19-33-09-p17a-l5-procurement-core.csv | 2026-03-07 | DONE | node --check + validate-feature-matrix | 采购路由标签、合同锚点与矩阵事实源同步完成 | | P17A-6 | P17A回归与验收收口 | N/A | 2026-03-07_19-33-09-p17a-l5-procurement-core.csv | 2026-03-07_19-33-09-p17a-l5-procurement-core.csv | 2026-03-07 | DONE | regression-all + client:typecheck/build | 采购草稿/附件/提交/撤回/审批通过,且报销/请假/主数据零回归 | + +| P17B-0 | P17B执行合同建档 | N/A | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07 | DONE | CSV 校验 | 采购预算闭环与归档执行合同建档,冻结仅做预算预留确认释放与 PO/合同归档 | +| P17B-1 | 采购预算预留确认释放语义 | N/A | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07 | DONE | server:test + regression-all | `purchase_request` 提交写 reserve、审批写 confirm、撤回/驳回/退回写 release,且兼容报销旧预算语义 | +| P17B-2 | PO合同归档模型与接口 | N/A | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07 | DONE | server:test + regression-procurement | `PurchaseOrderArchive`、采购详情归档读取与幂等保存接口可用,仅管理角色可操作 | +| P17B-3 | 工作流联动预算释放 | N/A | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07 | DONE | server:test + regression-workflow-actions | workflow 审批通过/驳回/退回与采购撤回链路已联动预算预留确认释放,并通过幂等释放助手收口 | +| P17B-4 | 前端归档入口与采购E2E | N/A | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07 | DONE | client:typecheck/build + playwright | `PurchaseRequestsPage` 新增归档区块,采购主链路 E2E 覆盖员工提单、经理审批、财务归档 | +| P17B-5 | 安全文档与接口说明 | N/A | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07 | DONE | node --check + validate-feature-matrix | 采购预算闭环、归档权限边界与矩阵事实源同步完成,主数据读取越权缺口已补齐 | +| P17B-6 | P17B回归与安全收口 | N/A | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | 2026-03-07 | DONE | regression-all + client:typecheck/build + playwright | 采购预算闭环、PO归档与全量回归通过,报销/请假/主数据/采购主流程零回归 | diff --git a/issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv b/issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv index c26773a6..f85365c0 100644 --- a/issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv +++ b/issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv @@ -1,8 +1,8 @@ ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes -P17B-0,P17B执行合同建档,创建并校验P17B采购预算闭环与归档执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv,manual,TODO,TODO,TODO,issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv,[],P17B仅补预算预留确认释放与PO合同归档不进入收货入库三单匹配 -P17B-1,采购预算预留确认释放语义,为采购申请补齐reserve confirm release三类预算台账语义并维护used_amount占用,提交审批撤回驳回退回均能按规则写预算台账且不破坏报销旧语义,cd server && npm run test && OCR_PROVIDER=mock JOBS_ENABLED=false ./scripts/regression-all.sh,manual,TODO,TODO,TODO,server/prisma/schema.prisma | server/src/routes/purchaseRequests.js | server/src/routes/workflow.js | server/tests,"[""P17B-0""]",采购占用仅作用于purchase_request报销继续沿用既有提交即占用语义 -P17B-2,PO合同归档模型与接口,新增PurchaseOrderArchive并提供采购单归档读取写入接口,approved状态采购单可幂等归档且仅procurement.manage或administration可操作,cd server && npm run test,manual,TODO,TODO,TODO,server/prisma/schema.prisma | server/src/routes/purchaseRequests.js | server/tests,"[""P17B-1""]",归档文件先存JSON元数据数组不另起独立页面 -P17B-3,工作流联动预算释放,在approve reject return withdraw链路中接入采购预算台账联动与幂等释放助手,采购任务审批退回驳回撤回都能正确更新预算台账且无重复释放,cd server && npm run test && ./scripts/regression-workflow-actions.sh,manual,TODO,TODO,TODO,server/src/routes/workflow.js | server/src/routes/purchaseRequests.js | server/tests,"[""P17B-2""]",禁止新增新的全局权限映射层仅在现有workflow与采购路由内补齐 -P17B-4,前端归档入口与E2E,在采购申请页面补归档区块并接入playwright采购主流程,approved采购详情可见PO合同归档且主流程E2E通过,cd client && npm run typecheck && npm run build,manual,TODO,TODO,TODO,client/src/pages/PurchaseRequestsPage.tsx | client/src/api/purchaseRequests.ts | client/tests | playwright,"[""P17B-3""]",不新增独立归档页面仍在采购详情区完成留档 -P17B-5,安全文档与接口说明,补齐采购预算闭环与归档的接口说明权限边界与安全校验记录,文档明确IDOR越权风险与归档权限边界且矩阵锚点可追踪,cd server && npm run test && python3 scripts/validate-feature-matrix.py,manual,TODO,TODO,TODO,issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | docs/feature-matrix.md | server/src/routes/purchaseRequests.js,"[""P17B-4""]",security-best-practices在合并前必须执行一轮 -P17B-6,P17B回归与安全收口,执行采购预算闭环归档E2E与全量回归后再允许进入P18A,采购预算预留确认释放与PO归档通过且报销请假主数据采购主流程零回归,cd server && npm run test && OCR_PROVIDER=mock JOBS_ENABLED=false ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,TODO,TODO,TODO,issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | server/src/routes/purchaseRequests.js | client/src/pages/PurchaseRequestsPage.tsx,"[""P17B-5""]",P17B不进入收货入库发票核销ERP过账 +P17B-0,P17B执行合同建档,创建并校验P17B采购预算闭环与归档执行合同CSV,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv,manual,DONE,DONE,DONE,issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv,[],P17B仅补预算预留确认释放与PO合同归档不进入收货入库三单匹配 +P17B-1,采购预算预留确认释放语义,为采购申请补齐reserve confirm release三类预算台账语义并维护used_amount占用,提交审批撤回驳回退回均能按规则写预算台账且不破坏报销旧语义,cd server && npm run test && OCR_PROVIDER=mock JOBS_ENABLED=false ./scripts/regression-all.sh,manual,DONE,DONE,DONE,server/prisma/schema.prisma | server/src/routes/purchaseRequests.js | server/src/routes/workflow.js | server/tests,"[""P17B-0""]",采购占用仅作用于purchase_request报销继续沿用既有提交即占用语义 +P17B-2,PO合同归档模型与接口,新增PurchaseOrderArchive并提供采购单归档读取写入接口,approved状态采购单可幂等归档且仅procurement.manage或administration可操作,cd server && npm run test,manual,DONE,DONE,DONE,server/prisma/schema.prisma | server/src/routes/purchaseRequests.js | server/tests,"[""P17B-1""]",归档文件先存JSON元数据数组不另起独立页面 +P17B-3,工作流联动预算释放,在approve reject return withdraw链路中接入采购预算台账联动与幂等释放助手,采购任务审批退回驳回撤回都能正确更新预算台账且无重复释放,cd server && npm run test && ./scripts/regression-workflow-actions.sh,manual,DONE,DONE,DONE,server/src/routes/workflow.js | server/src/routes/purchaseRequests.js | server/tests,"[""P17B-2""]",禁止新增新的全局权限映射层仅在现有workflow与采购路由内补齐 +P17B-4,前端归档入口与E2E,在采购申请页面补归档区块并接入playwright采购主流程,approved采购详情可见PO合同归档且主流程E2E通过,cd client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,client/src/pages/PurchaseRequestsPage.tsx | client/src/api/purchaseRequests.ts | client/tests | playwright,"[""P17B-3""]",不新增独立归档页面仍在采购详情区完成留档 +P17B-5,安全文档与接口说明,补齐采购预算闭环与归档的接口说明权限边界与安全校验记录,文档明确IDOR越权风险与归档权限边界且矩阵锚点可追踪,cd server && npm run test && python3 scripts/validate-feature-matrix.py,manual,DONE,DONE,DONE,issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | docs/feature-matrix.md | server/src/routes/purchaseRequests.js,"[""P17B-4""]",security-best-practices在合并前必须执行一轮 +P17B-6,P17B回归与安全收口,执行采购预算闭环归档E2E与全量回归后再允许进入P18A,采购预算预留确认释放与PO归档通过且报销请假主数据采购主流程零回归,cd server && npm run test && OCR_PROVIDER=mock JOBS_ENABLED=false ./scripts/regression-all.sh && cd ../client && npm run typecheck && npm run build,manual,DONE,DONE,DONE,issues/2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv | server/src/routes/purchaseRequests.js | client/src/pages/PurchaseRequestsPage.tsx,"[""P17B-5""]",P17B不进入收货入库发票核销ERP过账 diff --git a/scripts/validate-feature-matrix.py b/scripts/validate-feature-matrix.py index ed49cdf1..76cf5ad5 100755 --- a/scripts/validate-feature-matrix.py +++ b/scripts/validate-feature-matrix.py @@ -34,6 +34,7 @@ CSV_FACT_SOURCES = [ ROOT / "issues" / "2026-03-07_18-15-03-r9-g2-p19-roadmap-refresh.csv", ROOT / "issues" / "2026-03-07_18-16-00-g2-l5-cross-domain-bridge.csv", ROOT / "issues" / "2026-03-07_19-33-09-p17a-l5-procurement-core.csv", + ROOT / "issues" / "2026-03-07_21-00-37-p17b-l5-procurement-budget-archive.csv", ] ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z-]*$") diff --git a/server/scripts/regression-all.sh b/server/scripts/regression-all.sh index 47c77723..8af01ab4 100755 --- a/server/scripts/regression-all.sh +++ b/server/scripts/regression-all.sh @@ -57,7 +57,7 @@ if [[ -n "$JSON_REPORT_PATH" ]]; then trap rr_cleanup EXIT fi -TOTAL_STEPS=12 +TOTAL_STEPS=13 PASSED_STEPS=0 FAILED_STEPS=0 SKIPPED_STEPS=0 @@ -124,15 +124,16 @@ run_step() { run_step 1 "smoke" "Smoke Test" "1" '"$SCRIPT_DIR"/smoke-test.sh' run_step 2 "dedup" "Dedup Regression" "1" '"$SCRIPT_DIR"/regression-dedup.sh' run_step 3 "workflow_actions" "Workflow Actions Regression" "1" '"$SCRIPT_DIR"/regression-workflow-actions.sh' -run_step 4 "audit" "Audit Regression" "$RUN_AUDIT_REGRESSION" '"$SCRIPT_DIR"/regression-audit.sh' "RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION" -run_step 5 "ocr" "OCR Regression" "$RUN_OCR_REGRESSION" '"$SCRIPT_DIR"/regression-ocr.sh' "RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION" -run_step 6 "org_settings" "Org + Settings Regression" "$RUN_ORG_SETTINGS_REGRESSION" '"$SCRIPT_DIR"/regression-org-settings.sh' "RUN_ORG_SETTINGS_REGRESSION=$RUN_ORG_SETTINGS_REGRESSION" -run_step 7 "auth_security" "Auth Security Regression" "$RUN_AUTH_SECURITY_REGRESSION" '"$SCRIPT_DIR"/regression-auth-security.sh' "RUN_AUTH_SECURITY_REGRESSION=$RUN_AUTH_SECURITY_REGRESSION" -run_step 8 "verification" "Verification Regression" "$RUN_VERIFICATION_REGRESSION" '"$SCRIPT_DIR"/regression-verification.sh' "RUN_VERIFICATION_REGRESSION=$RUN_VERIFICATION_REGRESSION" -run_step 9 "dashboard" "Dashboard Regression" "$RUN_DASHBOARD_REGRESSION" '"$SCRIPT_DIR"/regression-dashboard.sh' "RUN_DASHBOARD_REGRESSION=$RUN_DASHBOARD_REGRESSION" -run_step 10 "budget" "Budget Regression" "$RUN_BUDGET_REGRESSION" '"$SCRIPT_DIR"/regression-budget.sh' "RUN_BUDGET_REGRESSION=$RUN_BUDGET_REGRESSION" -run_step 11 "integrations" "Integrations Regression" "$RUN_INTEGRATIONS_REGRESSION" '"$SCRIPT_DIR"/regression-integrations.sh' "RUN_INTEGRATIONS_REGRESSION=$RUN_INTEGRATIONS_REGRESSION" -run_step 12 "feature_flags" "Feature Flags Regression" "$RUN_FEATURE_FLAGS_REGRESSION" '"$SCRIPT_DIR"/regression-feature-flags.sh' "RUN_FEATURE_FLAGS_REGRESSION=$RUN_FEATURE_FLAGS_REGRESSION" +run_step 4 "procurement" "Procurement Regression" "1" '"$SCRIPT_DIR"/regression-procurement.sh' +run_step 5 "audit" "Audit Regression" "$RUN_AUDIT_REGRESSION" '"$SCRIPT_DIR"/regression-audit.sh' "RUN_AUDIT_REGRESSION=$RUN_AUDIT_REGRESSION" +run_step 6 "ocr" "OCR Regression" "$RUN_OCR_REGRESSION" '"$SCRIPT_DIR"/regression-ocr.sh' "RUN_OCR_REGRESSION=$RUN_OCR_REGRESSION" +run_step 7 "org_settings" "Org + Settings Regression" "$RUN_ORG_SETTINGS_REGRESSION" '"$SCRIPT_DIR"/regression-org-settings.sh' "RUN_ORG_SETTINGS_REGRESSION=$RUN_ORG_SETTINGS_REGRESSION" +run_step 8 "auth_security" "Auth Security Regression" "$RUN_AUTH_SECURITY_REGRESSION" '"$SCRIPT_DIR"/regression-auth-security.sh' "RUN_AUTH_SECURITY_REGRESSION=$RUN_AUTH_SECURITY_REGRESSION" +run_step 9 "verification" "Verification Regression" "$RUN_VERIFICATION_REGRESSION" '"$SCRIPT_DIR"/regression-verification.sh' "RUN_VERIFICATION_REGRESSION=$RUN_VERIFICATION_REGRESSION" +run_step 10 "dashboard" "Dashboard Regression" "$RUN_DASHBOARD_REGRESSION" '"$SCRIPT_DIR"/regression-dashboard.sh' "RUN_DASHBOARD_REGRESSION=$RUN_DASHBOARD_REGRESSION" +run_step 11 "budget" "Budget Regression" "$RUN_BUDGET_REGRESSION" '"$SCRIPT_DIR"/regression-budget.sh' "RUN_BUDGET_REGRESSION=$RUN_BUDGET_REGRESSION" +run_step 12 "integrations" "Integrations Regression" "$RUN_INTEGRATIONS_REGRESSION" '"$SCRIPT_DIR"/regression-integrations.sh' "RUN_INTEGRATIONS_REGRESSION=$RUN_INTEGRATIONS_REGRESSION" +run_step 13 "feature_flags" "Feature Flags Regression" "$RUN_FEATURE_FLAGS_REGRESSION" '"$SCRIPT_DIR"/regression-feature-flags.sh' "RUN_FEATURE_FLAGS_REGRESSION=$RUN_FEATURE_FLAGS_REGRESSION" RUN_ENDED_MS="$(rr_now_ms)" OVERALL_DURATION_MS="$((RUN_ENDED_MS - RUN_STARTED_MS))" diff --git a/server/scripts/regression-procurement.sh b/server/scripts/regression-procurement.sh new file mode 100755 index 00000000..eb8d837c --- /dev/null +++ b/server/scripts/regression-procurement.sh @@ -0,0 +1,296 @@ +#!/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}" + +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" + +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)" +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" + 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 ?? 'employee@hyinda.com' }, + 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" + + DATABASE_URL="$DATABASE_URL" node - "$approved_id" "$withdrawn_id" "$budget_pool_id" <<'NODE' +const [approvedId, withdrawnId, budgetPoolId] = 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 !== 'PO-P17B-001' || archive.contract_no !== 'CT-P17B-001') { + 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" '{title:"procurement regression approved", legalEntityId:$le, costCenterId:$cc, supplierId:$sp, documentCategoryId:$dc, items:[{description:"Laptop", 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_status="$(api_json_auth POST "$BASE_URL/api/purchase-requests/$PURCHASE_ID_APPROVED/archive-order" "$FINANCE_MGR_TOKEN" "$TMP_DIR/archive.json" '{"poNo":"PO-P17B-001","contractNo":"CT-P17B-001","archivedFiles":[]}')" +assert_status "$archive_status" "200" "archive order" "$TMP_DIR/archive.json" +assert_jq "$TMP_DIR/archive.json" '.data.archiveOrder.poNo == "PO-P17B-001" and .data.archiveOrder.contractNo == "CT-P17B-001"' "archive order response" + +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-P17B-001"' "get archive order response" + +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" '{title:"procurement regression withdrawn", legalEntityId:$le, costCenterId:$cc, supplierId:$sp, documentCategoryId:$dc, items:[{description:"Desk", 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" + +echo "[OK] Procurement regression passed" +echo "- approved purchaseRequestId: $PURCHASE_ID_APPROVED" +echo "- withdrawn purchaseRequestId: $PURCHASE_ID_WITHDRAWN"