chore(P14-8): 完成运营化与验收收口
这个提交包含在:
@@ -35,3 +35,9 @@ jobs:
|
||||
|
||||
- name: Run minimal checks
|
||||
run: ./scripts/check-minimal.sh
|
||||
|
||||
- name: Inspect vitest coverage
|
||||
working-directory: server
|
||||
run: |
|
||||
npm run test:coverage
|
||||
node ../scripts/coverage_gate.js --mode=ci
|
||||
|
||||
@@ -83,6 +83,34 @@ jobs:
|
||||
mkdir -p "$REGRESSION_REPORT_DIR"
|
||||
RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 ./scripts/regression-all.sh --json-report "$REGRESSION_REPORT_DIR/report.json"
|
||||
|
||||
- name: Generate Job Summary
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f "$REGRESSION_REPORT_DIR/report.json" ]]; then
|
||||
python3 -c "
|
||||
import json, os
|
||||
try:
|
||||
with open('$REGRESSION_REPORT_DIR/report.json') as f:
|
||||
d = json.load(f)
|
||||
ov = d.get('overall', {})
|
||||
summary = f'## Regression Run Status: {ov.get(\"status\")}\\n'
|
||||
summary += f'- Passed: {ov.get(\"passed\")} | Failed: {ov.get(\"failed\")} | Skipped: {ov.get(\"skipped\")}\\n'
|
||||
summary += f'- Duration: {ov.get(\"duration_ms\")} ms\\n\\n'
|
||||
failed = [s for s in d.get('steps', []) if s.get('status') == 'failed']
|
||||
if failed:
|
||||
summary += '### Failed Steps\\n'
|
||||
for s in failed:
|
||||
summary += f'- **{s.get(\"name\")}** (exit {s.get(\"exit_code\")})\\n'
|
||||
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as out:
|
||||
out.write(summary)
|
||||
except Exception as e:
|
||||
with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as out:
|
||||
out.write(f'❌ Failed to parse report: {e}')
|
||||
"
|
||||
else:
|
||||
echo "❌ Regression JSON report not found." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Upload regression report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -91,6 +119,27 @@ jobs:
|
||||
path: /tmp/hyb-oa-regression/report.json
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Generate CFR Report
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p artifacts/regression
|
||||
if [[ -f "$REGRESSION_REPORT_DIR/report.json" ]]; then
|
||||
cp "$REGRESSION_REPORT_DIR/report.json" artifacts/regression/
|
||||
fi
|
||||
python3 scripts/cfr_report.py --input-dir artifacts/regression --window 7d,30d --output-json /tmp/cfr.json --output-md /tmp/cfr.md
|
||||
echo "## Change Failure Rate" >> $GITHUB_STEP_SUMMARY
|
||||
cat /tmp/cfr.md >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Upload CFR report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cfr-report-${{ github.run_id }}-${{ github.sha }}
|
||||
path: |
|
||||
/tmp/cfr.json
|
||||
/tmp/cfr.md
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: |
|
||||
|
||||
+14
-4
@@ -1,8 +1,8 @@
|
||||
# hyb-oa 实现状态矩阵(P5-P13)
|
||||
# hyb-oa 实现状态矩阵(P5-P14)
|
||||
|
||||
**快照日期**:2026-02-12
|
||||
**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8`,共 101 项。
|
||||
**事实源**:`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`(执行合同)
|
||||
**快照日期**:2026-02-13
|
||||
**范围**:`F1-F42`(含 `F19b/F19c/F19d`)+ `R1-S2` + `P9A1-P9D1` + `P10E1-P10G1` + `G0-G6` + `H1` + `M1-M4` + `P13-0-P13-8` + `P14-0-P14-8`,共 110 项。
|
||||
**事实源**:`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/*.csv` 的执行合同职责。
|
||||
@@ -115,3 +115,13 @@
|
||||
| P13-6 | CI回归报告归档 | §16(CI治理) | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12 | DONE | .github/workflows/ci-regression.yml artifact | run_id+sha 可追溯 |
|
||||
| P13-7 | 变更失败率统计脚本 | §16(DORA 指标治理) | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12 | DONE | python3 scripts/cfr_report.py --window 7d,30d | failed_commit=回归失败或24h热修 |
|
||||
| P13-8 | P13验收与矩阵收口 | §16(状态治理) | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12_01-09-41-p13-l5-governance.csv | 2026-02-12 | DONE | check-minimal + regression-all + validate-feature-matrix | none |
|
||||
|
||||
| P14-0 | P14执行合同建档 | §16(执行合同治理) | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13 | DONE | manual | none |
|
||||
| P14-1 | 回归报告Schema固化 | §16(报告Schema) | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13 | DONE | scripts/validate_regression_report.py | none |
|
||||
| P14-2 | CI回归摘要发布 | §16(CI摘要) | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13 | DONE | manual | none |
|
||||
| P14-3 | CFR自动周/月报 | §16(CFR报告) | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13 | DONE | scripts/cfr_report.py | none |
|
||||
| P14-4 | 高风险服务单测扩容 | §16(单测覆盖率) | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13 | DONE | cd server && npm run test:coverage | 仅纯单测 |
|
||||
| P14-5 | 覆盖率门禁渐进 | §16(覆盖率门禁) | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13 | DONE | scripts/coverage_gate.js | none |
|
||||
| P14-6 | OpenAPI覆盖扩展与漂移检查 | §16(OpenAPI覆盖扩展) | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13 | DONE | scripts/openapi_diff_check.py | none |
|
||||
| P14-7 | 分布式追踪最小落地 | §16(链路追踪) | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13 | DONE | manual | none |
|
||||
| P14-8 | P14验收与矩阵收口 | §16(验收与同步) | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13_00-11-56-p14-l5-operationalization.csv | 2026-02-13 | DONE | scripts/check-minimal.sh | none |
|
||||
|
||||
@@ -33,6 +33,7 @@ RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 ./scripts/regression-all.sh --json-r
|
||||
|
||||
## 排障建议
|
||||
|
||||
- 回归失败时优先下载 artifact,检查 `overall.failed` 与 `steps[].status="failed"`。
|
||||
- 若报告未生成,检查回归步骤是否在脚本启动前失败(例如服务未就绪)。
|
||||
- **优先查看 Job Summary**:流水线完成后会自动在 GitHub Actions 运行页面的 Summary 面板输出结构化摘要,可直接看到由于哪些 `steps` 导致阻断失败。
|
||||
- 回归大面积或异常失败时,请优先下载 artifact 中的 `report.json` 进一步比对 `overall.failed` 与具体抛出的退出码。
|
||||
- 若报告未生成,检查回归步骤是否在脚本启动前失败(例如服务未就绪,数据库连不上)。
|
||||
- 若报告字段缺失,优先比对 `server/scripts/lib/regression-report.sh` 与 schema 版本。
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Testing Strategy
|
||||
|
||||
## Coverage Gate Policy
|
||||
为保障核心系统迭代质量,目前系统配备了渐进式的覆盖率门禁(Coverage Gate)。
|
||||
|
||||
- **阈值标准**:
|
||||
核心关键模块代码行覆盖率要求 >= 70% 才能算作合格。关键模块目前指代:
|
||||
- `src/services/featureFlags`
|
||||
- `src/services/verification`
|
||||
- `src/services/integrations/dispatcher.js`
|
||||
- `src/services/integrations/outbox.js`
|
||||
- `src/services/integrations/erp.js`
|
||||
- `src/utils/workflow.js`
|
||||
|
||||
- **阻断策略**:
|
||||
1. 在**Pull Request (PR)**中,覆盖率达不到门禁要求时系统将挂出 Warn 告警,但不会中断构建进度(即不强制干预协作交付)。
|
||||
2. 在**main 分支**中,低于上述覆盖率标准将抛出致命错误并标记部署失败(Error/Fail Block)。此外,全量域仅采取柔性报告并不强制失败。
|
||||
|
||||
## CI Integration
|
||||
流水线集成了 Vitest `npm run test:coverage` 与 node 自定义的 `scripts/coverage_gate.js` 保障上述策略。执行结果会自动附着于 Actions Job Summary。
|
||||
机房脚本将自动解析 coverage-summary.json 从而提取对应业务模块的 `lines.pct` 并在 GITHUB_REF_NAME 为依据判定通过。
|
||||
@@ -0,0 +1,11 @@
|
||||
# Tracing Strategy
|
||||
|
||||
## OpenTelemetry Integration
|
||||
为了未来能进行细粒度的分布式追踪监控,后台程序目前内嵌了 OpenTelemetry (OTel) 的 `sdk-node` 及自动集成框架 `auto-instrumentations-node`。
|
||||
|
||||
- OTel 的启动是 **Fail-Safe** 的。所有链路追踪功能受环境变量硬闸 `OTEL_TRACES_ENABLED` 控制。
|
||||
- 该环境变量默认情况为关闭。开启 `OTEL_TRACES_ENABLED=true` 时,应用将自动在 Console 输出现行 Span。在将来亦可快速配置通过 OTLP 格式投递到远端拓扑分析平台(如 Jaeger、DataDog 等)。
|
||||
|
||||
## 验证与试运行
|
||||
- 可执行 `cd server && node --check src/observability/tracing.js` 验证基础语法挂载。
|
||||
- 在开发态下,如配置 `OTEL_TRACES_ENABLED=true`启动服务,则客户端发出的接口请求均会在服务端控制台打印包含 Trace ID、Span ID 的追踪结果日志。由此表明 HTTP 拦截器正在正确解析和处理 `traceparent` 协议报文头。
|
||||
@@ -1,10 +1,10 @@
|
||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||
P14-0,P14执行合同建档,创建并校验P14执行合同CSV,冻结依赖关系与字段规范,CSV通过校验且字段完整,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-02-13_00-11-56-p14-l5-operationalization.csv,manual,DONE,DONE,DONE,issues/2026-02-13_00-11-56-p14-l5-operationalization.csv,[],none
|
||||
P14-1,回归报告Schema固化,为regression JSON报告补schema校验器并固化schema_version=1.0,报告缺字段或类型不匹配时校验失败,cd server && ./scripts/regression-all.sh --json-report /tmp/r.json && python3 ../scripts/validate_regression_report.py /tmp/r.json,manual,TODO,TODO,TODO,server/scripts/lib/regression-report.sh | server/scripts/regression-all.sh | scripts/validate_regression_report.py | docs/ops/ci-regression-reporting.md,"[""P14-0""]",schema_version固定1.0;回滚: 仅保留基础report字段
|
||||
P14-2,CI回归摘要发布,在ci-regression输出Job Summary并保留artifact定位失败步骤,Actions Summary可直接查看失败步骤与报告下载链接,manual: 触发ci-regression并在Actions页面核验summary与artifact,manual,TODO,TODO,TODO,.github/workflows/ci-regression.yml | docs/ops/ci-regression-reporting.md,"[""P14-1""]",不引入外部SaaS
|
||||
P14-3,CFR自动周/月报,将CFR计算纳入自动化并产出7d/30d报表,可稳定产出JSON与Markdown双格式报表并可复算,"python3 scripts/cfr_report.py --input-dir artifacts/regression --window 7d,30d --output-json /tmp/cfr.json --output-md /tmp/cfr.md",manual,TODO,TODO,TODO,scripts/cfr_report.py | docs/ops/dora-metrics.md | .github/workflows/ci-regression.yml,"[""P14-2""]",热修通过--hotfix-csv合并口径
|
||||
P14-4,高风险服务单测扩容,补齐featureFlags/verification/integrations/outbox/workflow util关键分支单测,新增至少20个测试并提升关键模块覆盖率,cd server && npm test && npm run test:coverage,manual,TODO,TODO,TODO,server/tests/unit/**/*.test.js | server/src/services/featureFlags/index.js | server/src/services/verification/index.js | server/src/services/integrations/dispatcher.js | server/src/services/integrations/outbox.js | server/src/services/integrations/erp.js | server/src/utils/workflow.js,"[""P14-0""]",仅纯单测,不连DB
|
||||
P14-5,覆盖率门禁渐进,建立coverage gate策略:PR告警、main阻断,main分支低于阈值失败;PR仅提示不阻断,cd server && npm run test:coverage && node ../scripts/coverage_gate.js --mode=ci,manual,TODO,TODO,TODO,server/vitest.config.cjs | scripts/coverage_gate.js | .github/workflows/ci-minimal.yml | docs/ops/testing-strategy.md,"[""P14-4""]",初始阈值仅关键模块lines>=70%,全局先告警
|
||||
P14-6,OpenAPI覆盖扩展与漂移检查,扩展高频路由OpenAPI schema并增加导出与对比检查,/api/docs/json包含新增路径且导出结果可做diff,NODE_ENV=development OPENAPI_DOCS_ENABLED=true cd server && node scripts/export_openapi.js --out /tmp/openapi.json && jq '.paths|keys' /tmp/openapi.json,manual,TODO,TODO,TODO,server/src/routes/reimbursements.js | server/src/routes/workflow.js | server/src/routes/invoicePool/read.js | server/scripts/export_openapi.js | scripts/openapi_diff_check.py,"[""P14-0""]",不改业务语义,仅补schema
|
||||
P14-7,分布式追踪最小落地,引入OTel trace基础链路与环境硬闸,默认关闭,开关开启可导出trace,关闭无行为变化,cd server && node --check src/observability/tracing.js && manual: 验证traceparent透传,manual,TODO,TODO,TODO,server/src/observability/tracing.js | server/src/app.js | server/src/index.js | server/src/config.js | server/.env.example | docs/ops/tracing.md,"[""P14-0""]",默认OTEL_TRACES_ENABLED=false,fail-safe
|
||||
P14-8,P14验收与矩阵收口,执行P14全量校验并同步feature-matrix与执行合同状态,check-minimal+regression-all+matrix校验全部通过,./scripts/check-minimal.sh && cd server && ./scripts/regression-all.sh --json-report /tmp/regression-report.json && cd /home/shunli/Projects/hyb-oa && python3 scripts/validate-feature-matrix.py,manual,TODO,TODO,TODO,docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/*.csv,"[""P14-3"",""P14-5"",""P14-6"",""P14-7""]",三状态全DONE后关闭
|
||||
P14-1,回归报告Schema固化,为regression JSON报告补schema校验器并固化schema_version=1.0,报告缺字段或类型不匹配时校验失败,cd server && ./scripts/regression-all.sh --json-report /tmp/r.json && python3 ../scripts/validate_regression_report.py /tmp/r.json,manual,DONE,DONE,DONE,server/scripts/lib/regression-report.sh | server/scripts/regression-all.sh | scripts/validate_regression_report.py | docs/ops/ci-regression-reporting.md,"[""P14-0""]",schema_version固定1.0;回滚: 仅保留基础report字段
|
||||
P14-2,CI回归摘要发布,在ci-regression输出Job Summary并保留artifact定位失败步骤,Actions Summary可直接查看失败步骤与报告下载链接,manual: 触发ci-regression并在Actions页面核验summary与artifact,manual,DONE,DONE,DONE,.github/workflows/ci-regression.yml | docs/ops/ci-regression-reporting.md,"[""P14-1""]",不引入外部SaaS
|
||||
P14-3,CFR自动周/月报,将CFR计算纳入自动化并产出7d/30d报表,可稳定产出JSON与Markdown双格式报表并可复算,"python3 scripts/cfr_report.py --input-dir artifacts/regression --window 7d,30d --output-json /tmp/cfr.json --output-md /tmp/cfr.md",manual,DONE,DONE,DONE,scripts/cfr_report.py | docs/ops/dora-metrics.md | .github/workflows/ci-regression.yml,"[""P14-2""]",热修通过--hotfix-csv合并口径
|
||||
P14-4,高风险服务单测扩容,补齐featureFlags/verification/integrations/outbox/workflow util关键分支单测,新增至少20个测试并提升关键模块覆盖率,cd server && npm test && npm run test:coverage,manual,DONE,DONE,DONE,server/tests/unit/**/*.test.js | server/src/services/featureFlags/index.js | server/src/services/verification/index.js | server/src/services/integrations/dispatcher.js | server/src/services/integrations/outbox.js | server/src/services/integrations/erp.js | server/src/utils/workflow.js,"[""P14-0""]",仅纯单测,不连DB
|
||||
P14-5,覆盖率门禁渐进,建立coverage gate策略:PR告警、main阻断,main分支低于阈值失败;PR仅提示不阻断,cd server && npm run test:coverage && node ../scripts/coverage_gate.js --mode=ci,manual,DONE,DONE,DONE,server/vitest.config.cjs | scripts/coverage_gate.js | .github/workflows/ci-minimal.yml | docs/ops/testing-strategy.md,"[""P14-4""]",初始阈值仅关键模块lines>=70%,全局先告警
|
||||
P14-6,OpenAPI覆盖扩展与漂移检查,扩展高频路由OpenAPI schema并增加导出与对比检查,/api/docs/json包含新增路径且导出结果可做diff,NODE_ENV=development OPENAPI_DOCS_ENABLED=true cd server && node scripts/export_openapi.js --out /tmp/openapi.json && jq '.paths|keys' /tmp/openapi.json,manual,DONE,DONE,DONE,server/src/routes/reimbursements.js | server/src/routes/workflow.js | server/src/routes/invoicePool/read.js | server/scripts/export_openapi.js | scripts/openapi_diff_check.py,"[""P14-0""]",不改业务语义,仅补schema
|
||||
P14-7,分布式追踪最小落地,引入OTel trace基础链路与环境硬闸,默认关闭,开关开启可导出trace,关闭无行为变化,cd server && node --check src/observability/tracing.js && manual: 验证traceparent透传,manual,DONE,DONE,DONE,server/src/observability/tracing.js | server/src/app.js | server/src/index.js | server/src/config.js | server/.env.example | docs/ops/tracing.md,"[""P14-0""]",默认OTEL_TRACES_ENABLED=false,fail-safe
|
||||
P14-8,P14验收与矩阵收口,执行P14全量校验并同步feature-matrix与执行合同状态,check-minimal+regression-all+matrix校验全部通过,./scripts/check-minimal.sh && cd server && ./scripts/regression-all.sh --json-report /tmp/regression-report.json && cd /home/shunli/Projects/hyb-oa && python3 scripts/validate-feature-matrix.py,manual,DONE,DONE,DONE,docs/feature-matrix.md | scripts/validate-feature-matrix.py | issues/*.csv,"[""P14-3"",""P14-5"",""P14-6"",""P14-7""]",三状态全DONE后关闭
|
||||
|
||||
|
@@ -0,0 +1,63 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const summaryPath = path.resolve(__dirname, '../server/coverage/coverage-summary.json');
|
||||
if (!fs.existsSync(summaryPath)) {
|
||||
console.error("No coverage summary found. Run 'npm run test:coverage' first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
|
||||
|
||||
// 初始阈值仅关键模块 lines>=70%,全局先告警
|
||||
const KEY_MODULES = [
|
||||
"src/services/featureFlags",
|
||||
"src/services/verification",
|
||||
"src/services/integrations/dispatcher.js",
|
||||
"src/services/integrations/outbox.js",
|
||||
"src/services/integrations/erp.js",
|
||||
"src/utils/workflow.js"
|
||||
];
|
||||
|
||||
let allPassed = true;
|
||||
const messages = [];
|
||||
|
||||
for (const [file, stats] of Object.entries(summary)) {
|
||||
if (file === "total") continue;
|
||||
|
||||
const isKeyModule = KEY_MODULES.some(key => file.includes(key));
|
||||
if (isKeyModule) {
|
||||
const linesCov = stats.lines.pct;
|
||||
if (linesCov < 70) {
|
||||
allPassed = false;
|
||||
messages.push(`FAIL: ${file} lines coverage is ${linesCov}%, which is below the 70% threshold.`);
|
||||
} else {
|
||||
messages.push(`PASS: ${file} lines coverage is ${linesCov}% (>= 70%).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const globalLines = summary.total?.lines?.pct ?? 0;
|
||||
messages.push(`Global lines coverage is ${globalLines}%. (Warn-only)`);
|
||||
|
||||
console.log("=== Coverage Gate Report ===");
|
||||
console.log(messages.join("\n"));
|
||||
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n## Coverage Gate\n\n\`\`\`text\n${messages.join("\n")}\n\`\`\`\n`);
|
||||
}
|
||||
|
||||
const isPR = process.env.GITHUB_EVENT_NAME === "pull_request";
|
||||
|
||||
if (!allPassed) {
|
||||
if (isPR) {
|
||||
console.log("WARN: Coverage is below threshold. PRs will not be blocked currently, but please fix it.");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error("ERROR: Coverage is below threshold and the branch is NOT a PR. Failing the gate.");
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log("Coverage gate passed successfully.");
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1:
|
||||
openapi_path = sys.argv[1]
|
||||
else:
|
||||
openapi_path = '/tmp/openapi.json'
|
||||
|
||||
try:
|
||||
with open(openapi_path, 'r') as f:
|
||||
spec = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Failed to load {openapi_path}: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
paths = spec.get('paths', {})
|
||||
|
||||
expected_prefixes = ['/api/reimbursements', '/api/workflow/tasks', '/api/invoice-pool']
|
||||
missing_prefixes = []
|
||||
|
||||
for prefix in expected_prefixes:
|
||||
matched = any(p.startswith(prefix) for p in paths.keys())
|
||||
if not matched:
|
||||
missing_prefixes.append(prefix)
|
||||
|
||||
if missing_prefixes:
|
||||
print(f"ERROR: Missing expected root paths in OpenAPI schema: {missing_prefixes}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"SUCCESS: Drift check passed. Found {len(paths)} paths.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -25,6 +25,7 @@ CSV_FACT_SOURCES = [
|
||||
ROOT / "issues" / "2026-02-10_10-35-30-p10-production-hardening.csv",
|
||||
ROOT / "issues" / "2026-02-11-p11-p12-governance-mobile.csv",
|
||||
ROOT / "issues" / "2026-02-12_01-09-41-p13-l5-governance.csv",
|
||||
ROOT / "issues" / "2026-02-13_00-11-56-p14-l5-operationalization.csv",
|
||||
]
|
||||
|
||||
ID_PATTERN = re.compile(r"^[A-Z][0-9][0-9A-Za-z-]*$")
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
|
||||
def validate_step(step, idx):
|
||||
required_keys = {
|
||||
"id": str,
|
||||
"name": str,
|
||||
"enabled": bool,
|
||||
"status": str,
|
||||
"exit_code": int,
|
||||
"duration_ms": int,
|
||||
"started_at": str,
|
||||
"ended_at": str,
|
||||
"command": str
|
||||
}
|
||||
|
||||
for key, expected_type in required_keys.items():
|
||||
if key not in step:
|
||||
raise ValueError(f"Step[{idx}] missing required key: '{key}'")
|
||||
if not isinstance(step[key], expected_type):
|
||||
raise ValueError(f"Step[{idx}] '{key}' must be {expected_type.__name__}, got {type(step[key]).__name__}")
|
||||
|
||||
def validate_report(report_path: Path):
|
||||
if not report_path.exists():
|
||||
raise FileNotFoundError(f"Report file not found: {report_path}")
|
||||
|
||||
try:
|
||||
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON format: {e}")
|
||||
|
||||
# Root Level
|
||||
if "schema_version" not in report or report["schema_version"] != "1.0":
|
||||
raise ValueError("Invalid or missing schema_version. Expected '1.0'")
|
||||
|
||||
if "generated_at" not in report or not isinstance(report["generated_at"], str):
|
||||
raise ValueError("Missing or invalid 'generated_at' string")
|
||||
|
||||
# Git
|
||||
if "git" not in report or not isinstance(report["git"], dict):
|
||||
raise ValueError("Missing or invalid 'git' object")
|
||||
if "sha" not in report["git"] or not isinstance(report["git"]["sha"], str):
|
||||
raise ValueError("Missing or invalid 'git.sha'")
|
||||
if "branch" not in report["git"] or not isinstance(report["git"]["branch"], str):
|
||||
raise ValueError("Missing or invalid 'git.branch'")
|
||||
|
||||
# Overall
|
||||
if "overall" not in report or not isinstance(report["overall"], dict):
|
||||
raise ValueError("Missing or invalid 'overall' object")
|
||||
|
||||
for key in ["duration_ms", "passed", "failed", "skipped", "total"]:
|
||||
if key not in report["overall"] or not isinstance(report["overall"][key], int):
|
||||
raise ValueError(f"Missing or invalid integer 'overall.{key}'")
|
||||
if "status" not in report["overall"] or not isinstance(report["overall"]["status"], str):
|
||||
raise ValueError("Missing or invalid string 'overall.status'")
|
||||
|
||||
# Steps
|
||||
if "steps" not in report or not isinstance(report["steps"], list):
|
||||
raise ValueError("Missing or invalid 'steps' array")
|
||||
|
||||
for idx, step in enumerate(report["steps"]):
|
||||
validate_step(step, idx)
|
||||
|
||||
logging.info(f"Report {report_path} passed schema validation (v1.0).")
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) < 2:
|
||||
logging.error("Usage: validate_regression_report.py <path/to/report.json>")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
validate_report(Path(sys.argv[1]))
|
||||
except Exception as e:
|
||||
logging.error(f"Validation failed: {e}")
|
||||
sys.exit(1)
|
||||
+2
-1
@@ -19,8 +19,9 @@ STORAGE_DIR="generated/storage"
|
||||
SIGNED_URL_TTL_SECONDS="600"
|
||||
MAX_UPLOAD_SIZE_BYTES="10485760"
|
||||
|
||||
# ===== Metrics(Prometheus)=====
|
||||
# ===== Metrics(Prometheus & OpenTelemetry)=====
|
||||
# METRICS_ENABLED="true"
|
||||
# OTEL_TRACES_ENABLED="false"
|
||||
|
||||
# ===== 单域托管 client/dist(试运行/生产建议)=====
|
||||
# SERVE_CLIENT_DIST="true"
|
||||
|
||||
+1986
-10
文件差异内容过多而无法显示
加载差异
+12
-8
@@ -38,28 +38,32 @@
|
||||
"@fastify/multipart": "^9.3.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/swagger": "^9.5.2",
|
||||
"@fastify/swagger-ui": "^5.2.3",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.71.0",
|
||||
"@opentelemetry/sdk-node": "^0.213.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@prisma/adapter-pg": "^7.3.0",
|
||||
"@prisma/client": "^7.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"exceljs": "^4.4.0",
|
||||
"fastify": "^5.7.4",
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^6.10.1",
|
||||
"openid-client": "^6.8.2",
|
||||
"prom-client": "^15.1.0",
|
||||
"pg-boss": "^10.3.0",
|
||||
"pg": "^8.18.0",
|
||||
"pg-boss": "^10.3.0",
|
||||
"prisma": "^7.3.0",
|
||||
"prom-client": "^15.1.0",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||
"zod": "^4.3.6",
|
||||
"zxcvbn": "^4.4.2",
|
||||
"@fastify/swagger": "^9.5.2",
|
||||
"@fastify/swagger-ui": "^5.2.3"
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"dotenv": "^16.4.5",
|
||||
"vitest": "^2.1.8",
|
||||
"@vitest/coverage-v8": "^2.1.8"
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const fs = require("fs");
|
||||
const { buildApp } = require("../src/app");
|
||||
|
||||
async function main() {
|
||||
process.env.NODE_ENV = "development";
|
||||
process.env.OPENAPI_DOCS_ENABLED = "true";
|
||||
|
||||
const app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
const openapiObj = app.swagger();
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const outIndex = args.indexOf("--out");
|
||||
const outPath = outIndex !== -1 ? args[outIndex + 1] : "/tmp/openapi.json";
|
||||
|
||||
fs.writeFileSync(outPath, JSON.stringify(openapiObj, null, 2), "utf8");
|
||||
console.log(`OpenAPI schema exported to ${outPath}`);
|
||||
|
||||
await app.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
+20
-16
@@ -77,9 +77,9 @@ async function buildApp() {
|
||||
|
||||
const allowedOrigins = config.appOrigin
|
||||
? config.appOrigin
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: null;
|
||||
|
||||
await app.register(cors, {
|
||||
@@ -187,6 +187,10 @@ async function buildApp() {
|
||||
app.log.info("OpenAPI docs enabled at /api/docs");
|
||||
}
|
||||
|
||||
if (config.otelTracesEnabled) {
|
||||
app.log.info("OTel tracing enabled");
|
||||
}
|
||||
|
||||
app.get(
|
||||
"/health",
|
||||
{
|
||||
@@ -233,19 +237,19 @@ async function buildApp() {
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
await app.prisma.$queryRaw`SELECT 1`;
|
||||
return reply.send({ requestId: request.id, data: { ok: true } });
|
||||
} catch (error) {
|
||||
app.log.error({ err: error, requestId: request.id }, "Readiness check failed");
|
||||
throw new AppError({
|
||||
code: "SYS_503",
|
||||
message: "Service Unavailable",
|
||||
statusCode: 503,
|
||||
details: { db: "unreachable" },
|
||||
});
|
||||
}
|
||||
},
|
||||
try {
|
||||
await app.prisma.$queryRaw`SELECT 1`;
|
||||
return reply.send({ requestId: request.id, data: { ok: true } });
|
||||
} catch (error) {
|
||||
app.log.error({ err: error, requestId: request.id }, "Readiness check failed");
|
||||
throw new AppError({
|
||||
code: "SYS_503",
|
||||
message: "Service Unavailable",
|
||||
statusCode: 503,
|
||||
details: { db: "unreachable" },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (config.metricsEnabled) {
|
||||
|
||||
@@ -132,6 +132,8 @@ function getConfig() {
|
||||
smtpSecure: process.env.SMTP_SECURE === "true",
|
||||
smtpUser: process.env.SMTP_USER ?? "",
|
||||
smtpPass: process.env.SMTP_PASS ?? "",
|
||||
|
||||
otelTracesEnabled: process.env.OTEL_TRACES_ENABLED === "true",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
const { getConfig } = require("./config");
|
||||
const { initTracing } = require("./observability/tracing");
|
||||
|
||||
const config = getConfig();
|
||||
initTracing(config);
|
||||
|
||||
const { buildApp } = require("./app");
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
const { NodeSDK } = require('@opentelemetry/sdk-node');
|
||||
const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-node');
|
||||
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
|
||||
const { Resource } = require('@opentelemetry/resources');
|
||||
const { SemanticResourceAttributes } = require('@opentelemetry/semantic-conventions');
|
||||
|
||||
let sdk = null;
|
||||
|
||||
function initTracing(config) {
|
||||
if (config.otelTracesEnabled !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exporter = new ConsoleSpanExporter();
|
||||
|
||||
sdk = new NodeSDK({
|
||||
resource: new Resource({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: 'hyb-oa-server',
|
||||
}),
|
||||
traceExporter: exporter,
|
||||
instrumentations: [getNodeAutoInstrumentations({
|
||||
'@opentelemetry/instrumentation-fs': { enabled: false },
|
||||
})]
|
||||
});
|
||||
|
||||
try {
|
||||
sdk.start();
|
||||
console.log('OTel tracing initialized');
|
||||
} catch (error) {
|
||||
console.error('Error initializing OTel tracing', error);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
if (sdk) {
|
||||
sdk.shutdown()
|
||||
.then(() => console.log('OTel tracing terminated'))
|
||||
.catch((error) => console.log('Error terminating OTel tracing', error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { initTracing };
|
||||
@@ -103,6 +103,7 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/invoice-pool",
|
||||
{
|
||||
schema: { tags: ["Invoice Pool"], summary: "List Invoices" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
@@ -134,19 +135,19 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
...(ownerScope === "all"
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ owner_employee_id: request.employee.id },
|
||||
{ uploaded_by_employee_id: request.employee.id },
|
||||
{
|
||||
linked_reimbursement: {
|
||||
OR: [
|
||||
{ owner_id: request.employee.id },
|
||||
{ submitter_id: request.employee.id },
|
||||
],
|
||||
},
|
||||
OR: [
|
||||
{ owner_employee_id: request.employee.id },
|
||||
{ uploaded_by_employee_id: request.employee.id },
|
||||
{
|
||||
linked_reimbursement: {
|
||||
OR: [
|
||||
{ owner_id: request.employee.id },
|
||||
{ submitter_id: request.employee.id },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
@@ -173,6 +174,7 @@ function registerInvoicePoolReadRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/invoice-pool/:id",
|
||||
{
|
||||
schema: { tags: ["Invoice Pool"], summary: "Get Invoice Detail" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
|
||||
@@ -251,17 +251,17 @@ function mapReimbursementSummary(item) {
|
||||
},
|
||||
owner: item.owner
|
||||
? {
|
||||
id: item.owner.id,
|
||||
employeeNo: item.owner.employee_no,
|
||||
name: item.owner.name,
|
||||
}
|
||||
id: item.owner.id,
|
||||
employeeNo: item.owner.employee_no,
|
||||
name: item.owner.name,
|
||||
}
|
||||
: null,
|
||||
submitter: item.submitter
|
||||
? {
|
||||
id: item.submitter.id,
|
||||
employeeNo: item.submitter.employee_no,
|
||||
name: item.submitter.name,
|
||||
}
|
||||
id: item.submitter.id,
|
||||
employeeNo: item.submitter.employee_no,
|
||||
name: item.submitter.name,
|
||||
}
|
||||
: null,
|
||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||
@@ -403,6 +403,7 @@ async function reimbursementRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/reimbursements",
|
||||
{
|
||||
schema: { tags: ["Reimbursements"], summary: "Create Reimbursement" },
|
||||
preHandler: fastify.requirePermission("expense.create"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
@@ -526,6 +527,7 @@ async function reimbursementRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/reimbursements",
|
||||
{
|
||||
schema: { tags: ["Reimbursements"], summary: "List Reimbursements" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
@@ -602,6 +604,7 @@ async function reimbursementRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/reimbursements/:id",
|
||||
{
|
||||
schema: { tags: ["Reimbursements"], summary: "Get Reimbursement Detail" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
@@ -714,12 +717,12 @@ async function reimbursementRoutes(fastify) {
|
||||
description: item.description,
|
||||
expenseType: item.expense_type
|
||||
? {
|
||||
id: item.expense_type.id,
|
||||
code: item.expense_type.code,
|
||||
name: item.expense_type.name,
|
||||
category: item.expense_type.category,
|
||||
enabled: item.expense_type.enabled,
|
||||
}
|
||||
id: item.expense_type.id,
|
||||
code: item.expense_type.code,
|
||||
name: item.expense_type.name,
|
||||
category: item.expense_type.category,
|
||||
enabled: item.expense_type.enabled,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
budgetHint,
|
||||
@@ -732,10 +735,10 @@ async function reimbursementRoutes(fastify) {
|
||||
})),
|
||||
pendingWorkflow: pendingInstance
|
||||
? {
|
||||
instanceId: pendingInstance.id,
|
||||
stepId: pendingInstance.current_step_id,
|
||||
task: pendingTask,
|
||||
}
|
||||
instanceId: pendingInstance.id,
|
||||
stepId: pendingInstance.current_step_id,
|
||||
task: pendingTask,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
},
|
||||
|
||||
+46
-44
@@ -140,10 +140,10 @@ function isoOrNull(value) {
|
||||
function mapTaskSummary(task) {
|
||||
const workflowInstance = task.workflow_instance
|
||||
? {
|
||||
entity_type: task.workflow_instance.entity_type,
|
||||
entity_id: task.workflow_instance.entity_id,
|
||||
current_status: task.workflow_instance.current_status,
|
||||
}
|
||||
entity_type: task.workflow_instance.entity_type,
|
||||
entity_id: task.workflow_instance.entity_id,
|
||||
current_status: task.workflow_instance.current_status,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
@@ -168,10 +168,10 @@ function mapTaskSummary(task) {
|
||||
completedAt: isoOrNull(task.completed_at),
|
||||
workflowInstance: workflowInstance
|
||||
? {
|
||||
entityType: workflowInstance.entity_type,
|
||||
entityId: workflowInstance.entity_id,
|
||||
currentStatus: workflowInstance.current_status,
|
||||
}
|
||||
entityType: workflowInstance.entity_type,
|
||||
entityId: workflowInstance.entity_id,
|
||||
currentStatus: workflowInstance.current_status,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -425,6 +425,7 @@ async function workflowRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/workflow/tasks",
|
||||
{
|
||||
schema: { tags: ["Workflow"], summary: "List Workflow Tasks" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
@@ -479,6 +480,7 @@ async function workflowRoutes(fastify) {
|
||||
fastify.get(
|
||||
"/workflow/tasks/:taskId",
|
||||
{
|
||||
schema: { tags: ["Workflow"], summary: "Get Workflow Task Detail" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
@@ -612,39 +614,39 @@ async function workflowRoutes(fastify) {
|
||||
},
|
||||
workflowInstance: task.workflow_instance
|
||||
? {
|
||||
id: task.workflow_instance.id,
|
||||
currentStatus: task.workflow_instance.current_status,
|
||||
currentStepId: task.workflow_instance.current_step_id,
|
||||
startedAt: isoOrNull(task.workflow_instance.started_at),
|
||||
completedAt: isoOrNull(task.workflow_instance.completed_at),
|
||||
}
|
||||
id: task.workflow_instance.id,
|
||||
currentStatus: task.workflow_instance.current_status,
|
||||
currentStepId: task.workflow_instance.current_step_id,
|
||||
startedAt: isoOrNull(task.workflow_instance.started_at),
|
||||
completedAt: isoOrNull(task.workflow_instance.completed_at),
|
||||
}
|
||||
: null,
|
||||
reimbursement: task.workflow_instance?.reimbursement_form
|
||||
? {
|
||||
id: task.workflow_instance.reimbursement_form.id,
|
||||
formNumber: task.workflow_instance.reimbursement_form.form_number,
|
||||
status: task.workflow_instance.reimbursement_form.status,
|
||||
reimbursementDate:
|
||||
task.workflow_instance.reimbursement_form.reimbursement_date
|
||||
?.toISOString?.()
|
||||
?.slice(0, 10) ?? null,
|
||||
totalAmount: toMoneyString(task.workflow_instance.reimbursement_form.total_amount),
|
||||
description: task.workflow_instance.reimbursement_form.description,
|
||||
owner: task.workflow_instance.reimbursement_form.owner
|
||||
? {
|
||||
id: task.workflow_instance.reimbursement_form.owner.id,
|
||||
employeeNo: task.workflow_instance.reimbursement_form.owner.employee_no,
|
||||
name: task.workflow_instance.reimbursement_form.owner.name,
|
||||
}
|
||||
: null,
|
||||
submitter: task.workflow_instance.reimbursement_form.submitter
|
||||
? {
|
||||
id: task.workflow_instance.reimbursement_form.submitter.id,
|
||||
employeeNo: task.workflow_instance.reimbursement_form.submitter.employee_no,
|
||||
name: task.workflow_instance.reimbursement_form.submitter.name,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
id: task.workflow_instance.reimbursement_form.id,
|
||||
formNumber: task.workflow_instance.reimbursement_form.form_number,
|
||||
status: task.workflow_instance.reimbursement_form.status,
|
||||
reimbursementDate:
|
||||
task.workflow_instance.reimbursement_form.reimbursement_date
|
||||
?.toISOString?.()
|
||||
?.slice(0, 10) ?? null,
|
||||
totalAmount: toMoneyString(task.workflow_instance.reimbursement_form.total_amount),
|
||||
description: task.workflow_instance.reimbursement_form.description,
|
||||
owner: task.workflow_instance.reimbursement_form.owner
|
||||
? {
|
||||
id: task.workflow_instance.reimbursement_form.owner.id,
|
||||
employeeNo: task.workflow_instance.reimbursement_form.owner.employee_no,
|
||||
name: task.workflow_instance.reimbursement_form.owner.name,
|
||||
}
|
||||
: null,
|
||||
submitter: task.workflow_instance.reimbursement_form.submitter
|
||||
? {
|
||||
id: task.workflow_instance.reimbursement_form.submitter.id,
|
||||
employeeNo: task.workflow_instance.reimbursement_form.submitter.employee_no,
|
||||
name: task.workflow_instance.reimbursement_form.submitter.name,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
allowedActions: listAllowedActionsForTask(request, task, actionable),
|
||||
history: historyItems.map((item) => ({
|
||||
@@ -681,12 +683,12 @@ async function workflowRoutes(fastify) {
|
||||
},
|
||||
...(query.keyword
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: query.keyword, mode: "insensitive" } },
|
||||
{ email: { contains: query.keyword, mode: "insensitive" } },
|
||||
{ employee_no: { contains: query.keyword, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
OR: [
|
||||
{ name: { contains: query.keyword, mode: "insensitive" } },
|
||||
{ email: { contains: query.keyword, mode: "insensitive" } },
|
||||
{ employee_no: { contains: query.keyword, mode: "insensitive" } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
|
||||
const featureFlags = require("../../../src/services/featureFlags");
|
||||
|
||||
describe("FeatureFlags Service", () => {
|
||||
describe("normalizeOverrideMode", () => {
|
||||
it("should default to force_off", () => {
|
||||
expect(featureFlags.OVERRIDE_MODE_LIST.includes("force_off")).toBe(true);
|
||||
// internal function tested indirectly or we can just mock config
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateFeatureFlag", () => {
|
||||
it("returns env_hard_off when config disables it globally", async () => {
|
||||
const result = await featureFlags.evaluateFeatureFlag({
|
||||
prisma: {},
|
||||
config: { invoiceVerificationEnabled: false },
|
||||
flagKey: featureFlags.FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
});
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("env_hard_off");
|
||||
});
|
||||
|
||||
it("evaluates using db record when config enabled", async () => {
|
||||
const mockPrisma = {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
flag_key: "invoice_verification",
|
||||
override_mode: "inherit",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await featureFlags.evaluateFeatureFlag({
|
||||
prisma: mockPrisma,
|
||||
config: { invoiceVerificationEnabled: true },
|
||||
flagKey: featureFlags.FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.reason).toBe("env_inherit_global_on");
|
||||
});
|
||||
|
||||
it("blocks if department is not in allowlist", async () => {
|
||||
const mockPrisma = {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
flag_key: "invoice_verification",
|
||||
override_mode: "inherit",
|
||||
rollout_type: "department_allowlist",
|
||||
department_ids: ["dep_A"],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await featureFlags.evaluateFeatureFlag({
|
||||
prisma: mockPrisma,
|
||||
config: { invoiceVerificationEnabled: true },
|
||||
flagKey: featureFlags.FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
departmentId: "dep_B",
|
||||
strictDepartment: true
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(false);
|
||||
expect(result.reason).toBe("department_not_allowed");
|
||||
});
|
||||
|
||||
it("allows if department is in allowlist", async () => {
|
||||
const mockPrisma = {
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
flag_key: "invoice_verification",
|
||||
override_mode: "inherit",
|
||||
rollout_type: "department_allowlist",
|
||||
department_ids: ["dep_A", "dep_B"],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await featureFlags.evaluateFeatureFlag({
|
||||
prisma: mockPrisma,
|
||||
config: { invoiceVerificationEnabled: true },
|
||||
flagKey: featureFlags.FEATURE_FLAG_KEYS.invoiceVerification,
|
||||
departmentId: "dep_B",
|
||||
});
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.reason).toBe("env_inherit_department_allowed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateFeatureFlag", () => {
|
||||
it("returns not_found for unknown flag", async () => {
|
||||
const result = await featureFlags.updateFeatureFlag({}, { flagKey: "unknown" });
|
||||
expect(result.status).toBe("not_found");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const dispatcher = require("../../../../src/services/integrations/dispatcher");
|
||||
|
||||
describe("Integration Dispatcher", () => {
|
||||
it("dispatches erp.reimbursement.paid", async () => {
|
||||
const result = await dispatcher.dispatchOutboxEvent({
|
||||
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload: { reimbursementId: 1 },
|
||||
});
|
||||
expect(result.channel).toBe("erp");
|
||||
expect(result.code).toBe("mock_synced");
|
||||
});
|
||||
|
||||
it("throws IntegrationDispatchError if erp fails", async () => {
|
||||
await expect(
|
||||
dispatcher.dispatchOutboxEvent({
|
||||
config: { erpSyncEnabled: false },
|
||||
eventType: "erp.reimbursement.paid",
|
||||
payload: { reimbursementId: 1 },
|
||||
})
|
||||
).rejects.toThrow(dispatcher.IntegrationDispatchError);
|
||||
});
|
||||
|
||||
it("dispatches notification.im", async () => {
|
||||
const result = await dispatcher.dispatchOutboxEvent({
|
||||
config: {},
|
||||
eventType: "notification.im",
|
||||
payload: { channelType: "slack" },
|
||||
});
|
||||
expect(result.channel).toBe("im");
|
||||
expect(result.response.channelType).toBe("slack");
|
||||
});
|
||||
|
||||
it("throws error for unsupported event", async () => {
|
||||
await expect(
|
||||
dispatcher.dispatchOutboxEvent({
|
||||
config: {},
|
||||
eventType: "unknown.event",
|
||||
})
|
||||
).rejects.toThrow("unsupported_outbox_event:unknown.event");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
|
||||
const erp = require("../../../../src/services/integrations/erp");
|
||||
|
||||
describe("ERP Sync Services", () => {
|
||||
it("builds correct payload map", () => {
|
||||
const payload = erp.buildErpPayload({
|
||||
reimbursement: {
|
||||
id: "r1",
|
||||
form_number: "F001",
|
||||
payment_date: "2026-03-01T12:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(payload.formNumber).toBe("F001");
|
||||
expect(payload.paymentDate).toBe("2026-03-01");
|
||||
});
|
||||
|
||||
it("returns mock synced when provider is mock", async () => {
|
||||
const result = await erp.pushPaidReimbursementToErp(
|
||||
{ erpSyncEnabled: true, erpSyncProvider: "mock" },
|
||||
{ formNumber: "F001" }
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.code).toBe("mock_synced");
|
||||
});
|
||||
|
||||
it("returns invalid provider if unsupported", async () => {
|
||||
const result = await erp.pushPaidReimbursementToErp(
|
||||
{ erpSyncEnabled: true, erpSyncProvider: "invalid_xyz" },
|
||||
{}
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.code).toBe("erp_sync_provider_invalid");
|
||||
});
|
||||
|
||||
it("returns disabled if disabled in config", async () => {
|
||||
const result = await erp.pushPaidReimbursementToErp({ erpSyncEnabled: false }, {});
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.code).toBe("erp_sync_disabled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
|
||||
const outbox = require("../../../../src/services/integrations/outbox");
|
||||
|
||||
describe("Integration Outbox", () => {
|
||||
it("enqueues new event", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({ id: 1, status: "pending" }),
|
||||
},
|
||||
};
|
||||
const result = await outbox.enqueueIntegrationEvent(mockPrisma, { eventType: "test" });
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.duplicated).toBe(false);
|
||||
});
|
||||
|
||||
it("detects duplicated event", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: 2, status: "pending" }),
|
||||
},
|
||||
};
|
||||
const result = await outbox.enqueueIntegrationEvent(mockPrisma, { eventType: "test", dedupeKey: "key" });
|
||||
expect(result.id).toBe(2);
|
||||
expect(result.duplicated).toBe(true);
|
||||
});
|
||||
|
||||
it("marks as processing", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
update: vi.fn().mockResolvedValue({ id: 1, status: "processing" }),
|
||||
},
|
||||
};
|
||||
const res = await outbox.markOutboxProcessing(mockPrisma, 1, "actor");
|
||||
expect(res.status).toBe("processing");
|
||||
});
|
||||
|
||||
it("fetches pending events", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
findMany: vi.fn().mockResolvedValue([{ id: 1 }]),
|
||||
},
|
||||
};
|
||||
const items = await outbox.fetchPendingOutboxEvents(mockPrisma, { batchSize: 5 });
|
||||
expect(items.length).toBe(1);
|
||||
});
|
||||
|
||||
it("marks outbox succeeded", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
update: vi.fn().mockResolvedValue({ id: 1, status: "succeeded" }),
|
||||
},
|
||||
};
|
||||
const res = await outbox.markOutboxSucceeded(mockPrisma, 1, "actor");
|
||||
expect(res.status).toBe("succeeded");
|
||||
});
|
||||
|
||||
it("handles failed outbox and schedules retry", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
update: vi.fn().mockImplementation(async ({ data }) => {
|
||||
return { status: data.status, retry: data.next_retry_at };
|
||||
}),
|
||||
},
|
||||
};
|
||||
const res = await outbox.markOutboxFailed(mockPrisma, {}, { id: 1, attempts: 0 }, { retryable: true }, "actor");
|
||||
expect(res.status).toBe("failed");
|
||||
expect(res.retry).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("moves to dead_letter if max attempts reached", async () => {
|
||||
const mockPrisma = {
|
||||
integrationOutbox: {
|
||||
update: vi.fn().mockImplementation(async ({ data }) => {
|
||||
return { status: data.status, retry: data.next_retry_at };
|
||||
}),
|
||||
},
|
||||
};
|
||||
const res = await outbox.markOutboxFailed(mockPrisma, {}, { id: 1, attempts: 5, max_attempts: 5 }, { retryable: true }, "actor");
|
||||
expect(res.status).toBe("dead_letter");
|
||||
expect(res.retry).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
const verification = require("../../../src/services/verification");
|
||||
|
||||
describe("Verification Service", () => {
|
||||
it("determines if verification is enabled from config", () => {
|
||||
expect(verification.isVerificationEnabled({ invoiceVerificationEnabled: true })).toBe(true);
|
||||
expect(verification.isVerificationEnabled({ invoiceVerificationEnabled: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("selects correct provider from config", () => {
|
||||
expect(verification.getProvider({})).toBe("mock");
|
||||
expect(verification.getProvider({ verificationProvider: "tax" })).toBe("tax");
|
||||
expect(verification.getProvider({ verificationProvider: "UNKNOWN" })).toBe("unknown");
|
||||
});
|
||||
|
||||
it("calls mock provider correctly", async () => {
|
||||
const result = await verification.verifyInvoiceByProvider({ config: {}, invoice: { id: 1 } });
|
||||
expect(result.status).toBe("passed");
|
||||
});
|
||||
|
||||
it("calls tax provider correctly", async () => {
|
||||
const result = await verification.verifyInvoiceByProvider({
|
||||
config: { verificationProvider: "tax" },
|
||||
invoice: { id: 2 }
|
||||
});
|
||||
expect(result.status).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns error for unsupported provider", async () => {
|
||||
const result = await verification.verifyInvoiceByProvider({
|
||||
config: { verificationProvider: "magic" },
|
||||
invoice: { id: 3 }
|
||||
});
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.resultCode).toBe("provider_unsupported");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
|
||||
const workflowUtils = require("../../../src/utils/workflow");
|
||||
|
||||
describe("Workflow Utils", () => {
|
||||
describe("parseWorkflowSteps", () => {
|
||||
it("throws error for invalid steps array", () => {
|
||||
expect(() => workflowUtils.parseWorkflowSteps({})).toThrow(/参数校验失败/);
|
||||
expect(() => workflowUtils.parseWorkflowSteps({ steps: [] })).toThrow(/参数校验失败/);
|
||||
});
|
||||
|
||||
it("parses valid steps array", () => {
|
||||
expect(workflowUtils.parseWorkflowSteps({ steps: [{ id: 1 }] })).toEqual([{ id: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWorkflowStepsFromApprovalRule", () => {
|
||||
it("builds steps correctly for valid rule", () => {
|
||||
const result = workflowUtils.buildWorkflowStepsFromApprovalRule({
|
||||
level1_role: "manager",
|
||||
level2_role: "finance",
|
||||
});
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].assignee_rule.type).toBe("department_manager");
|
||||
expect(result[1].assignee_rule.type).toBe("role");
|
||||
expect(result[1].assignee_rule.value).toBe("finance");
|
||||
});
|
||||
|
||||
it("throws if rule is empty or missing roles", () => {
|
||||
expect(() => workflowUtils.buildWorkflowStepsFromApprovalRule({})).toThrow(/参数校验失败/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAssigneeFromRule", () => {
|
||||
it("resolves role type", async () => {
|
||||
const res = await workflowUtils.resolveAssigneeFromRule({ type: "role", value: "hr" });
|
||||
expect(res.assigneeRole).toBe("hr");
|
||||
expect(res.assigneeId).toBe(null);
|
||||
});
|
||||
|
||||
it("throws for invalid rule", async () => {
|
||||
await expect(workflowUtils.resolveAssigneeFromRule(null)).rejects.toThrow(/参数校验失败/);
|
||||
await expect(workflowUtils.resolveAssigneeFromRule({ type: "unknown" })).rejects.toThrow(/参数校验失败/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toMoneyString", () => {
|
||||
it("converts values to string correctly", () => {
|
||||
expect(workflowUtils.toMoneyString(null)).toBe(null);
|
||||
expect(workflowUtils.toMoneyString("10.0")).toBe("10.0");
|
||||
expect(workflowUtils.toMoneyString(10)).toBe("10");
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户