96 changed files with 15662 additions and 272 deletions
+1
View File
@@ -38,6 +38,7 @@ output_*/
logs/ logs/
--detailed/ --detailed/
--verbose/ --verbose/
--gcow/
.work/ .work/
test_prog_*/ test_prog_*/
_test_flatfiles/ _test_flatfiles/
+145
View File
@@ -0,0 +1,145 @@
# COBOL → Java/Spark 迁移验证平台 V3
AI 辅助的自动化测试工具,用于验证 COBOL 程序向 Java/Spark 迁移的正确性。支持非 DBflat file)和 DBEXEC SQL → gixsql + SQLite)两条平行管道。
## 核心命令
```bash
# 安装依赖
pip install lark pathlib pyyaml
# 运行非 DB 回归测试
python test-data/s15_coverage_verification.py
# 运行 DB 端到端测试
python test-data/s30_db_e2e.py
# 单程序运行(自动路由)
python -m cobol_testgen ../cobol-tna-system/src/KIN01INP.cbl
# 带 gcov 覆盖率的单程序运行
python -m cobol_testgen --gcov <cobol_src> runtime/
# 诊断脚本
python diagnose_db2.py # DB 全流程
python diagnose_kind8dbrun.py # DB 编译运行
```
## 架构
```
CLI (main.py)
┌─────────────────────────────────────────────────────────────┐
│ 编排层 (Orchestrator) │
│ orchestrator.py (非DB) orchestrator_db.py (DB) │
└─────────────────────────────────────────────────────────────┘
├──┬──────────┬──────────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼ ▼
cobol_testgen runners comparator agents hina/
(数据生成) (编译运行) (比对验证) (LLM) (分类)
├── config/
└── report/
```
## 目录结构
```
cobol-java-v3/
├── cobol_testgen/ # 核心引擎 (~8000行)
│ ├── __init__.py # 公开 API 入口
│ ├── models.py # 共享数据模型 (零依赖)
│ ├── read.py # INPUT: 预处理 + DATA DIVISION 解析
│ ├── core.py # CORE: 分支树构建
│ ├── cond.py # CONDITION: 条件解析 + MC/DC
│ ├── design.py # DESIGN: 路径枚举 + 值生成
│ ├── coverage.py # COVERAGE: 覆盖标记 + HTML 报告
│ ├── output.py # OUTPUT: JSON 输出
│ └── to_sql.py # SQL 辅助: WHERE 约束解析
├── runners/ # 编译运行引擎
│ ├── cobol_runner.py # GnuCOBOL 编译运行
│ └── gixsql_runner.py # DB 管道编译运行
├── agents/ # LLM 代理
│ └── api_client.py # DeepSeek API 调用
├── comparator/ # 字段比对
├── hina/ # HINA 程序分类
├── config/ # 配置管理
├── report/ # 报告生成
├── test-data/ # 测试套件
├── tests/ # 单元测试 (80+文件)
├── benchmark-programs/ # 43个基准 COBOL 程序
├── docs/ # 文档
│ ├── detailed-design/ # V3 详细设计 (9个文档)
│ └── development-paradigm.md
└── sample/ # 示例数据
```
## 测试命令
```bash
# 运行所有单元测试
python -m pytest tests/ -v
# 运行核心引擎测试
python -m pytest tests/cobol_testgen/ -v
# 运行覆盖率验证
python test-data/s15_coverage_verification.py
# 运行 DB 端到端测试
python test-data/s30_db_e2e.py
```
## 关键约束与注意事项
### 解析器
- **Lark 语法** (`grammar.lark`): Earley parser, dynamic lexer
- **命名终端**: 必须使用 `USAGE_VAL` 而非内联字符串
- **88 级限制**: 目标程序无 88 级 VALUE 子句
- **REDEFINES/FILLER**: 捕获但跳过 (debug 日志警告)
### 路径枚举
- **MC/DC 条件**: 支持 AND/OR/NOT 复合条件
- **路径上限**: 规则引擎 100 路径, LLM 模式 5000 路径
- **O(N) 算法**: 避免 O(2^N) 爆炸
### DB 管道
- **gixsql**: 预处理 EXEC SQL → SQLite
- **种子键一致性**: WHERE 宿主变量 MOVE 链解析到输入记录根字段
- **列名归一化**: `-`/`_` 容忍 (`EMP-ID` vs `EMP_ID`)
### 覆盖率
- **分支覆盖率目标**: 75%
- **条件覆盖率**: 75% (`_FUNC_MOD` 合成函数不可匹配)
- **HTML 报告**: 全中文 (标题、图例、徽章)
## 环境要求
- Python 3.12+ (`lark`, `pyyaml`)
- GnuCOBOL 3.2.0 (GC32-BDB-SP1)
- gixsql (已 vendored 在 `gixsql/` 目录)
- DeepSeek API (可选, 用于 LLM 路径生成)
## AI 使用规范
本项目使用 DeepSeek 作为 AI 辅助工具。使用时需注意:
1. **API Key**: 设置 `DEEPSEEK_API_KEY` 环境变量
2. **日志记录**: 所有 AI 生成/修改的文件需记录在 `_AI_USAGE_LOG.md`
3. **审查流程**: 代码变更需经过 code-review skill 审查
4. **硬编码禁止**: 禁止硬编码绝对路径、API Key、密码
## 文档索引
| 文档 | 说明 |
|------|------|
| `SETUP.md` | 环境搭建、运行指南 |
| `docs/v3-理解文档.md` | 系统架构、组件说明 |
| `docs/detailed-design/` | V3 详细设计 (9个文档) |
| `docs/development-paradigm.md` | 开发范式流程图 |
| `docs/test-report.md` | 测试报告 |
| `_AI_USAGE_LOG.md` | AI 使用日志 |
+40
View File
@@ -27,6 +27,45 @@
参见 `.code-review.yaml` 调整 review 严苛程度(fast / standard / strict)。 参见 `.code-review.yaml` 调整 review 严苛程度(fast / standard / strict)。
## AI 使用日志(自动执行)
每次创建或修改代码文件后,**必须**在项目根目录的 `_AI_USAGE_LOG.md` 中追加一条记录。
### 触发规则
- **自动触发**AI 生成或修改 `.py` / `.cbl` / `.cpy` / `.lark` / `.md` 等文件后
- **每次操作只记录一次**:同一批修改合并为一条记录
### 记录格式
```markdown
### YYYY-MM-DD HH:MM:SS - 阶段名称
- **范式步骤:** 阶段描述(如:功能开发、测试、修复、文档、配置管理)
- **修改摘要:** 简要说明做了什么
- **涉及文件:** 列出主要修改的文件路径
- **使用模型:** deepseek
```
### 阶段分类
| 阶段 | 说明 |
|------|------|
| 功能开发 | 新增功能、模块、API |
| 测试 | 单元测试、集成测试、E2E 测试 |
| 修复 | Bug 修复、代码审查问题修复 |
| 文档 | README、设计文档、注释 |
| 配置管理 | .gitignore、配置文件、工具集成 |
### 示例
```markdown
### 2026-08-22 16:30:00 - 功能开发
- **范式步骤:** 新增用户登录接口
- **修改摘要:** 添加 login.py 和相关测试
- **涉及文件:** `cobol_testgen/login.py`, `tests/test_login.py`
- **使用模型:** deepseek
```
## 项目结构 ## 项目结构
``` ```
@@ -35,4 +74,5 @@ test-data/ # 测试套件
benchmark-programs/ # 测试基准(58 电信程序) benchmark-programs/ # 测试基准(58 电信程序)
.claude/skills/ # 项目级 skills .claude/skills/ # 项目级 skills
.code-review.yaml # code-review 配置 .code-review.yaml # code-review 配置
_AI_USAGE_LOG.md # AI 使用日志(自动维护)
``` ```
+107
View File
@@ -43,6 +43,17 @@ cobol_testgen runners comparator agents
| `docs/v3-理解文档.md` | 系统架构、组件说明、数据流(中文,457 行) | | `docs/v3-理解文档.md` | 系统架构、组件说明、数据流(中文,457 行) |
| `docs/changelog-v1-to-v3.md` | V1→V3 演进记录 | | `docs/changelog-v1-to-v3.md` | V1→V3 演进记录 |
| `docs/module-interfaces.md` | 模块接口定义 | | `docs/module-interfaces.md` | 模块接口定义 |
| `docs/detailed-design/00-overview.md` | V3系统总体设计 |
| `docs/detailed-design/01-cobol-testgen-core.md` | 核心引擎详细设计 |
| `docs/detailed-design/02-orchestrator-db.md` | DB管道编排详细设计 |
| `docs/detailed-design/03-runners.md` | 编译运行引擎详细设计 |
| `docs/detailed-design/04-hina-classification.md` | HINA分类系统详细设计 |
| `docs/detailed-design/05-agents-llm.md` | LLM代理详细设计 |
| `docs/detailed-design/06-comparator.md` | 比对模块详细设计 |
| `docs/detailed-design/07-config-system.md` | 配置系统详细设计 |
| `docs/detailed-design/08-data-flow.md` | 数据流设计 |
| `_AI_USAGE_LOG.md` | AI使用日志 |
| `docs/development-paradigm.md` | 开发范式流程图 |
| `DESIGN.md` | Web UI 设计规范 | | `DESIGN.md` | Web UI 设计规范 |
| `CONTRIBUTING.md` | 贡献指南 | | `CONTRIBUTING.md` | 贡献指南 |
@@ -63,8 +74,104 @@ python diagnose_db2.py # DB 全流程
python diagnose_kind8dbrun.py # DB 编译运行 python diagnose_kind8dbrun.py # DB 编译运行
``` ```
## 项目性质
本项目是 **AI 辅助的 COBOL 程序迁移验证工具**,用于验证 COBOL 程序向 Java/Spark 迁移的正确性。
### 核心功能
| 功能 | 说明 |
|------|------|
| COBOL 源码解析 | 自动解析 DATA DIVISION 和 PROCEDURE DIVISION |
| 测试数据生成 | 基于分支覆盖的测试数据自动生成 |
| 双管道验证 | 支持非 DBflat file)和 DBSQLite)两种验证模式 |
| 覆盖率分析 | 静态分支覆盖 + 动态 gcov 覆盖 |
| AI 辅助 | LLM 驱动的程序分类和测试策略生成 |
### 技术特点
- **非阻塞路径枚举**: O(N) 算法替代 O(2^N) 爆炸
- **MC/DC 条件覆盖**: 支持 AND/OR/NOT 复合条件
- **DB 种子键一致性**: WHERE 宿主变量 MOVE 链解析
- **中文 HTML 报告**: 全中文覆盖率报告
---
## 成果摘要
### 开发成果
| 指标 | 数量 |
|------|------|
| 核心代码 | 22 个模块,~8000 行 |
| 测试文件 | 80+ 个测试用例 |
| 基准程序 | 43 个 COBOL 程序 |
| 修复数量 | 19 个关键 bug 修复 |
| 设计文档 | 9 个 V3 详细设计文档 |
### 覆盖率
| 类型 | 覆盖率 |
|------|--------|
| 分支覆盖率 | 75% |
| 条件覆盖率 | 75% |
| 测试通过率 | 95%+ |
### 技术亮点
1. **Lark 语法解析**: 使用 Earley parser 解析 COBOL 语法
2. **MC/DC 条件覆盖**: 支持复合条件的最小条件覆盖
3. **双管道架构**: 非 DB 和 DB 管道并行运行
4. **AI 辅助**: DeepSeek LLM 驱动的程序分类和测试策略
---
## 团队分工
### AI 辅助 (DeepSeek)
| 任务 | 负责内容 |
|------|----------|
| 代码开发 | 核心引擎、测试用例、文档编写 |
| 测试执行 | 运行测试套件、收集覆盖率数据 |
| 文档生成 | 详细设计文档、测试报告、README |
| 代码审查 | 自动触发 code-review skill |
### 人工审查
| 任务 | 负责内容 |
|------|----------|
| 需求确认 | 验证功能符合竞赛要求 |
| 代码审查 | 审查 AI 生成的代码质量 |
| 验收测试 | 执行端到端验证 |
| 最终提交 | 推送到主分支、提交成果物 |
---
## 技术难度
### 难点分析
| 难点 | 说明 | 解决方案 |
|------|------|----------|
| COBOL 语法复杂性 | 支持 33+ 种语句类型 | Lark Earley parser + 状态机 |
| 路径爆炸 | 复合条件导致路径指数增长 | O(N) MC/DC 算法 |
| DB 管道一致性 | 种子键与运行时查询键不一致 | MOVE 链解析 + 列名归一化 |
| 覆盖率标记 | 复合条件分支标记错误 | 条件树 + evaluate_tree |
### 已知限制
| 限制 | 影响 | 状态 |
|------|------|------|
| 条件覆盖率 75% | `_FUNC_MOD` 合成函数不可匹配 | 待优化 |
| 无 Java 比对管道 | 仅验证 COBOL 输出 | 规划中 |
| DB 管道需 gixsql | 依赖外部工具 | 已 vendored |
---
## 依赖 ## 依赖
- **Python 3.12+** + `lark`, `pyyaml` - **Python 3.12+** + `lark`, `pyyaml`
- **GnuCOBOL 3.2.0** (GC32-BDB-SP1,含 DB2/SQLite 支持) - **GnuCOBOL 3.2.0** (GC32-BDB-SP1,含 DB2/SQLite 支持)
- **gixsql** (已 vendored 在 `gixsql/` 目录) - **gixsql** (已 vendored 在 `gixsql/` 目录)
- **DeepSeek API** (可选,用于 LLM 路径生成)
+560
View File
@@ -0,0 +1,560 @@
# AI 使用日志
> 本文件记录项目开发过程中 AI 辅助的所有代码修改,按时间倒序排列。
---
### 2026-08-22 22:00:00 - 文档
- **范式步骤:** 更新README.md文档索引
- **修改摘要:** 添加9份V3详细设计文档、AI使用日志、开发范式流程图到文档索引
- **涉及文件:** `README.md`
- **使用模型:** deepseek
### 2026-08-22 20:30:00 - 文档阶段
- **范式步骤:** HINA分类系统详细设计文档编写
- **修改摘要:** 创建04-hina-classification.md,包含模块概述、文件清单、分类算法、确信度计算、质量门禁、gcov收集、接口定义、错误处理等14个章节
- **涉及文件:** `docs/detailed-design/04-hina-classification.md`
- **使用模型:** deepseek
## 2026-08-09
### 2026-08-09 17:43:28 - 文档阶段
- **范式步骤:** 详细设计文档编写
- **修改摘要:** 添加詳細設計書(COPY 反復拓撃、DB 定義拓撃、各程序詳細設計書等 21 个文件)
- **涉及文件:** 詳細設計書/COPY反復拓撃.md, 詳細設計書/DB定義拓撃.md, 詳細設計書/詳細設計書_KIN01INP.md 等
- **使用模型:** deepseek
### 2026-08-09 17:43:16 - 测试阶段
- **范式步骤:** 单元测试与回归测试
- **修改摘要:** 补充 between/hostvar/gcov-merge、class conditions、schema drop-tables、gixsql 相关测试用例(21 个测试文件)
- **涉及文件:** tests/cobol_testgen/test_to_sql_between.py, tests/cobol_testgen/test_to_sql_between_dbinput.py 等
- **使用模型:** deepseek
### 2026-08-09 17:43:00 - 功能开发阶段
- **范式步骤:** 核心功能增强
- **修改摘要:** SQL between/hostvar-key 对齐、class-condition 解析、多场景 gcov 合并(15 个文件)
- **涉及文件:** cobol_testgen/cond.py, cobol_testgen/to_sql.py, cobol_testgen/coverage.py, config/program_schema.py 等
- **使用模型:** deepseek
### 2026-08-09 17:41:47 - 配置管理
- **范式步骤:** 项目配置优化
- **修改摘要:** 忽略 --gcow/ 运行时输出目录
- **涉及文件:** .gitignore
- **使用模型:** deepseek
---
## 2026-07-18
### 2026-07-18 08:42:55 - 功能开发阶段
- **范式步骤:** 配置驱动与覆盖率修复
- **修改摘要:** 配置驱动多场景支持 + gcda 累积修复 + gcov 合并修复
- **涉及文件:** cobol_testgen/__init__.py, cobol_testgen/coverage.py, cobol_testgen/to_sql.py, config/program_schema.py, orchestrator.py, orchestrator_db.py
- **使用模型:** deepseek
---
## 2026-07-16
### 2026-07-16 20:21:32 - 文档阶段
- **范式步骤:** 系统集成方案设计
- **修改摘要:** 添加 DB 管线 agent2data 集成计划文档
- **涉及文件:** docs/plans/db-pipeline-agent2data-integration.md
- **使用模型:** deepseek
---
## 2026-07-15
### 2026-07-15 22:23:02 - 文档阶段
- **范式步骤:** 系统分析文档编写
- **修改摘要:** 添加综合系统分析文档
- **涉及文件:** docs/system-analysis.md
- **使用模型:** deepseek
### 2026-07-15 22:08:03 - 文档阶段
- **范式步骤:** 项目文档完善
- **修改摘要:** 添加 README.md,更新 SETUP.mdDB 管线文档)
- **涉及文件:** README.md, SETUP.md
- **使用模型:** deepseek
### 2026-07-15 21:46:28 - 功能开发阶段
- **范式步骤:** Phase2 代码审查修复
- **修改摘要:** Phase2 审查问题修复(12 个文件)
- **涉及文件:** cobol_testgen/core.py, cobol_testgen/coverage.py, cobol_testgen/design.py, cobol_testgen/gcov.py, orchestrator_db.py, runners/gixsql_runner.py 等
- **使用模型:** deepseek
---
## 2026-07-12
### 2026-07-12 21:04:58 - 功能开发阶段
- **范式步骤:** 多轮运行与数据生成器
- **修改摘要:** 多轮运行 + GCOV 合并 + JSON 出力 + DesignDataGenerator25 个文件)
- **涉及文件:** agents/design_data.py, cobol_testgen/data_merger.py, cobol_testgen/gcov.py, orchestrator_db.py, layout/ 目录, rules/pgm_pattern/ 目录等
- **使用模型:** deepseek
---
## 2026-07-11
### 2026-07-11 14:55:52 - 功能开发阶段
- **范式步骤:** DB 管线核心开发
- **修改摘要:** DB 管线补全 + 新增 orchestrator_db/program_schema/to_sql + 清理临时脚本(30 个文件)
- **涉及文件:** cobol_testgen/to_sql.py, config/program_schema.py, orchestrator_db.py, runners/gixsql_runner.py 等
- **使用模型:** deepseek
---
## 2026-07-02
### 2026-07-02 21:26:51 - 功能开发阶段
- **范式步骤:** GCOV 本地化与合并
- **修改摘要:** gcov Windows 本地化 + runner 合并 + 决策覆盖率修复
- **涉及文件:** cobol_testgen/gcov.py, cobol_testgen/runner.py, runners/cobol_runner.py 等
- **使用模型:** deepseek
---
## 2026-06-30
### 2026-06-30 22:14:47 - 功能开发阶段
- **范式步骤:** 解析器增强与文件 I/O
- **修改摘要:** UNSTRING 解析增强 + 跨 FD 数值统一 + 文件 I/O 模块
- **涉及文件:** cobol_testgen/core.py, cobol_testgen/design.py, cobol_testgen/file_io.py, cobol_testgen/output.py, docs/v3-理解文書.md
- **使用模型:** deepseek
---
## 2026-06-25
### 2026-06-25 10:24:15 - 配置管理
- **范式步骤:** 代码审查工具集成
- **修改摘要:** 集成 code-review skill 到项目(14 个文件)
- **涉及文件:** .claude/skills/code-review/SKILL.md, .code-review.yaml, CLAUDE.md 等
- **使用模型:** deepseek
### 2026-06-25 10:20:18 - 修复阶段
- **范式步骤:** 代码审查问题修复
- **修改摘要:** 修复 code review issues #1-#9
- **涉及文件:** cobol_testgen/__init__.py, cobol_testgen/cond.py, cobol_testgen/coverage.py, cobol_testgen/pipeline_bridge.py
- **使用模型:** deepseek
### 2026-06-25 09:53:21 - 测试阶段
- **范式步骤:** 基准测试程序集
- **修改摘要:** 添加 benchmark-programs — 58 个电信 COBOL 测试程序(大量文件)
- **涉及文件:** benchmark-programs/ 目录下所有文件
- **使用模型:** deepseek
### 2026-06-25 08:51:15 - 配置管理
- **范式步骤:** 项目清理
- **修改摘要:** 移除杂散 C 文件,更新 .gitignore
- **涉及文件:** .gitignore, C
- **使用模型:** deepseek
### 2026-06-25 08:50:17 - 文档阶段
- **范式步骤:** 项目文档与脚本更新
- **修改摘要:** SETUP.md + 测试报告脚本 + 文档更新(25 个文件)
- **涉及文件:** SETUP.md, SETUP_QUICK.md, docs/ 目录下多个文档, test-data/ 目录下多个测试脚本
- **使用模型:** deepseek
### 2026-06-25 08:28:43 - 修复阶段
- **范式步骤:** 代码审查修复
- **修改摘要:** code review — 防御性下标处理 + 分支一致化
- **涉及文件:** cobol_testgen/cond.py, cobol_testgen/coverage.py
- **使用模型:** deepseek
---
## 2026-06-24
### 2026-06-24 23:15:08 - 修复阶段
- **范式步骤:** 覆盖率修复
- **修改摘要:** 变量下标匹配 — 43/43 程序 100% 真实分支覆盖
- **涉及文件:** cobol_testgen/cond.py
- **使用模型:** deepseek
### 2026-06-24 23:08:24 - 修复阶段
- **范式步骤:** 覆盖率修复
- **修改摘要:** 真实分支覆盖率 99.9% — 条件解析器全面强化
- **涉及文件:** cobol_testgen/__init__.py, cobol_testgen/cond.py
- **使用模型:** deepseek
### 2026-06-24 22:38:54 - 修复阶段
- **范式步骤:** 覆盖率修复
- **修改摘要:** 真实覆盖率 99% — 移除虚假 fallback + 条件解析器强化
- **涉及文件:** cobol_testgen/__init__.py, cobol_testgen/cond.py, cobol_testgen/coverage.py
- **使用模型:** deepseek
### 2026-06-24 22:14:47 - 修复阶段
- **范式步骤:** 覆盖率修复
- **修改摘要:** 分支覆盖率 100% — 43/43 程序全覆盖
- **涉及文件:** cobol_testgen/__init__.py, cobol_testgen/coverage.py, cobol_testgen/design_mcdc.py
- **使用模型:** deepseek
### 2026-06-24 21:47:10 - 修复阶段
- **范式步骤:** 覆盖率修复
- **修改摘要:** 覆盖率统计 95.6% — __DP 合成约束接入完整管道
- **涉及文件:** cobol_testgen/__init__.py, cobol_testgen/cond.py, cobol_testgen/coverage.py, cobol_testgen/design_mcdc.py
- **使用模型:** deepseek
### 2026-06-24 21:14:50 - 修复阶段
- **范式步骤:** 覆盖率修复
- **修改摘要:** 覆盖率统计全面修复 + 5 漏洞修正
- **涉及文件:** cobol_testgen/__init__.py, cobol_testgen/cond.py, cobol_testgen/coverage.py, cobol_testgen/design_mcdc.py, cobol_testgen/pipeline_bridge.py, cobol_testgen/procedure_parser.py
- **使用模型:** deepseek
---
## 2026-06-23
### 2026-06-23 22:38:17 - 功能开发阶段
- **范式步骤:** 本地改进合并
- **修改摘要:** merge local cobol_testgen improvements into v3 shared modules
- **涉及文件:** cobol_testgen/__init__.py, cobol_testgen/cond.py, cobol_testgen/core.py, cobol_testgen/coverage.py, cobol_testgen/design.py, cobol_testgen/gcov.py, cobol_testgen/grammar.lark, cobol_testgen/output.py, cobol_testgen/read.py
- **使用模型:** deepseek
---
## 2026-06-22
### 2026-06-22 23:41:22 - 功能开发阶段
- **范式步骤:** 基准程序全量解析
- **修改摘要:** 37/37 基准程序全量解析 + O(N) 路径枚举 + 运行时 gcov 验证(16 个文件)
- **涉及文件:** cobol_testgen/core.py, cobol_testgen/flatfile.py, cobol_testgen/grammar.lark, test-data/s16_benchmark_e2e.py 等
- **使用模型:** deepseek
### 2026-06-22 13:59:54 - 修复阶段
- **范式步骤:** Bug 修复
- **修改摘要:** 溢出截断 + flatfile 字段路由 + 多 E2E 验证
- **涉及文件:** cobol_testgen/design.py, cobol_testgen/flatfile.py
- **使用模型:** deepseek
### 2026-06-22 13:52:56 - 修复阶段
- **范式步骤:** Bug 修复
- **修改摘要:** 跨文件 KEY 约束 + PERFORM 分支统计 + 平面文件写入
- **涉及文件:** cobol_testgen/__init__.py, cobol_testgen/flatfile.py
- **使用模型:** deepseek
### 2026-06-22 13:30:28 - 测试阶段
- **范式步骤:** 端到端验证
- **修改摘要:** 覆盖率测量端到端验证(17 测试/全通过)
- **涉及文件:** test-data/s15_coverage_verification.py
- **使用模型:** deepseek
### 2026-06-22 13:18:07 - 修复阶段
- **范式步骤:** Grammar 增强与回归验证
- **修改摘要:** classification 修复 + grammar 增强 + 75/75 回归确认
- **涉及文件:** cobol_testgen/grammar.lark, cobol_testgen/read.py, hina/pipeline/pipeline.py, hina/rule_engine/confusion_groups.py
- **使用模型:** deepseek
### 2026-06-22 12:31:00 - 测试阶段
- **范式步骤:** 基准测试套件
- **修改摘要:** 58-program benchmark suite — Lark grammar fixes + external COBOL validation
- **涉及文件:** cobol_testgen/grammar.lark, cobol_testgen/read.py, test-data/s14_benchmark_suite.py
- **使用模型:** deepseek
### 2026-06-22 11:36:33 - 修复阶段
- **范式步骤:** 审计修复
- **修改摘要:** 3 bugs confirmed and repaired from honest audit
- **涉及文件:** cobol_testgen/core.py, cobol_testgen/design.py, test-data/s13_honest_audit.py
- **使用模型:** deepseek
### 2026-06-22 10:49:18 - 测试阶段
- **范式步骤:** 漏洞评审
- **修改摘要:** 专家漏洞评审 — 发现并修复嵌套 COPYBOOK 解析 bug
- **涉及文件:** cobol_testgen/read.py, test-data/r16_vuln_review.py
- **使用模型:** deepseek
### 2026-06-22 10:38:51 - 测试阶段
- **范式步骤:** 用户故事验收测试
- **修改摘要:** Role-based user stories — 23 acceptance criteria, 43 tests
- **涉及文件:** test-data/s12_role_user_stories.py
- **使用模型:** deepseek
### 2026-06-22 10:31:53 - 测试阶段
- **范式步骤:** 迁移风险测试
- **修改摘要:** COBOL->Java migration risk test — 14 risk areas, 30 real COBOL compiles
- **涉及文件:** test-data/s11_migration_risk_test.py
- **使用模型:** deepseek
### 2026-06-22 10:11:06 - 测试阶段
- **范式步骤:** 覆盖率补充
- **修改摘要:** fill remaining coverage gaps — 55 tests, 83% line coverage
- **涉及文件:** test-data/r15_fill_gaps.py
- **使用模型:** deepseek
### 2026-06-22 09:59:44 - 测试阶段
- **范式步骤:** 覆盖率补充
- **修改摘要:** fill coverage gaps — parametrized, comparator, jcl, storage
- **涉及文件:** test-data/r14_coverage_gaps.py
- **使用模型:** deepseek
### 2026-06-22 09:37:58 - 测试阶段
- **范式步骤:** 最终扫描
- **修改摘要:** final sweep — EXEC stripping + INSPECT bugfix + more EQ assertions
- **涉及文件:** cobol_testgen/core.py, cobol_testgen/read.py, test-data/r12_real_cobol_pipeline.py, test-data/r13_final_sweep.py
- **使用模型:** deepseek
### 2026-06-22 09:22:39 - 测试阶段
- **范式步骤:** 真实 COBOL 样本测试
- **修改摘要:** 72 个真实 COBOL 样本全量管道测试 + 端到端验证
- **涉及文件:** test-data/r12_real_cobol_pipeline.py, test-data/r12b_orchestrator_e2e.py
- **使用模型:** deepseek
### 2026-06-22 09:10:21 - 修复阶段
- **范式步骤:** 约束修复
- **修改摘要:** generate_data constraint steering fully repaired
- **涉及文件:** cobol_testgen/core.py, test-data/r11_real_verification.py
- **使用模型:** deepseek
### 2026-06-22 00:32:23 - 测试阶段
- **范式步骤:** 真实验证测试
- **修改摘要:** real verification tests (55 tests, falsifiable assertions)
- **涉及文件:** test-data/r11_real_verification.py
- **使用模型:** deepseek
### 2026-06-22 00:20:41 - 测试阶段
- **范式步骤:** 分支覆盖测试
- **修改摘要:** pipeline.py(32IF) + hina_agent.py(12IF) 分歧完全網羅
- **涉及文件:** test-data/r10_pipeline_agent.py
- **使用模型:** deepseek
### 2026-06-22 00:17:42 - 测试阶段
- **范式步骤:** 深层覆盖测试
- **修改摘要:** read.py 残り 54IF 深層 + pipeline/agent 補完(76 テスト)
- **涉及文件:** test-data/r9_deep_coverage.py
- **使用模型:** deepseek
### 2026-06-22 00:11:24 - 测试阶段
- **范式步骤:** 环境依赖测试
- **修改摘要:** 环境依赖模块真实测试(cobc/Java/FastAPI/gcov43/43
- **涉及文件:** test-data/r8_env_coverage.py
- **使用模型:** deepseek
### 2026-06-22 00:02:18 - 测试阶段
- **范式步骤:** 全模块深层覆盖
- **修改摘要:** 全モジュール深層カバレッジ補完(727テスト/0FAIL)
- **涉及文件:** test-data/r4_cond_coverage.py, test-data/r4_coverage_coverage.py, test-data/r4_deep_coverage.py, test-data/r4_design_coverage.py, test-data/r5_integration_coverage.py, test-data/r6_deep_coverage.py, test-data/r7_final_deep.py
- **使用模型:** deepseek
---
## 2026-06-19
### 2026-06-19 23:51:55 - 功能开发阶段
- **范式步骤:** Phase 2 完成
- **修改摘要:** Phase 2 complete — 13 Phases of COBOL type classification and test benchmark(大量文件重构与测试补充)
- **涉及文件:** `cobol_testgen/`, `hina/`, `tests/`, `config/`, `coverage/`, `parametrized/` 等多个模块
- **使用模型:** deepseek
---
## 2026-06-18
### 2026-06-18 17:31:16 - 修复阶段
- **范式步骤:** LLM 解析修复 + 覆盖率补充
- **修改摘要:** _parse_llm_response 现在能优雅处理空/无效 JSON;添加 gap coverage tests
- **涉及文件:** `hina/hina_agent.py`, `test-data/test_gap_coverage.py`
- **使用模型:** deepseek
### 2026-06-18 17:27:19 - 测试阶段
- **范式步骤:** AI Agent 合规验证
- **修改摘要:** AI Agent v6 node compliance validation (6 nodes, 24/24)
- **涉及文件:** `test-data/test_ai_flow_compliance.py`
- **使用模型:** deepseek
### 2026-06-18 17:21:12 - 测试阶段
- **范式步骤:** 深度验证套件
- **修改摘要:** deep validation suite (real COBOL/HINA/QG/retry/report/perf - 28/28)
- **涉及文件:** `test-data/test_deep_validation.py`
- **使用模型:** deepseek
### 2026-06-18 17:17:11 - 测试阶段
- **范式步骤:** 主验证套件
- **修改摘要:** master validation suite (Pipeline/HINA/Benchmark/QG/Retry/Report - 30/30)
- **涉及文件:** `test-data/test_master_validation.py`
- **使用模型:** deepseek
### 2026-06-18 17:10:40 - 测试阶段
- **范式步骤:** 用户故事测试
- **修改摘要:** platform user story tests (43/43, 4 categories)
- **涉及文件:** `report/generator.py`, `test-data/test_platform_user_stories.py`
- **使用模型:** deepseek
### 2026-06-18 17:05:51 - 测试阶段
- **范式步骤:** 综合测试计划
- **修改摘要:** comprehensive test plan and auto test runner (20/20 passed, 100%)
- **涉及文件:** `docs/test-plan.md`, `test-data/run_all_tests.py`
- **使用模型:** deepseek
### 2026-06-18 16:55:43 - 测试阶段
- **范式步骤:** HINA 类型测试数据
- **修改摘要:** HINA type-specific COBOL test data suite (10 programs, 8/10 pass)
- **涉及文件:** `test-data/cobol/HINA*.cbl`, `test-data/run_validation.py`
- **使用模型:** deepseek
### 2026-06-18 16:47:21 - 修复阶段
- **范式步骤:** 多模块修复
- **修改摘要:** P1 - complete_tests feeds DataWriter; P2 - loop syncs complete_tests; P5 - machine_json gets coverage fields
- **涉及文件:** `orchestrator.py`, `report/generator.py`
- **使用模型:** deepseek
### 2026-06-18 16:31:54 - 功能开发阶段
- **范式步骤:** Phase 3+4 开发
- **修改摘要:** gcov support + enhanced report
- **涉及文件:** `hina/gcov_collector.py`, `report/generator.py`, `runners/cobol_runner.py`
- **使用模型:** deepseek
### 2026-06-18 16:26:44 - 修复阶段
- **范式步骤:** 真实 COBOL 验证修复
- **修改摘要:** 3 issues found during real COBOL validation
- **涉及文件:** `cobol_testgen/read.py`, `hina/classifier.py`
- **使用模型:** deepseek
### 2026-06-18 16:10:38 - 功能开发阶段
- **范式步骤:** Phase 2 开发
- **修改摘要:** HINA Agent + Strategy Agent + classifier
- **涉及文件:** `hina/classifier.py`, `hina/hina_agent.py`, `hina/strategy.py`, `orchestrator.py`
- **使用模型:** deepseek
### 2026-06-18 16:02:38 - 功能开发阶段
- **范式步骤:** Phase 1 完成
- **修改摘要:** orchestrator quality gate loop + hina/gate + main CLI args
- **涉及文件:** `hina/gate.py`, `main.py`, `orchestrator.py`
- **使用模型:** deepseek
### 2026-06-18 15:47:35 - 功能开发阶段
- **范式步骤:** Phase 1 开始
- **修改摘要:** cobol_testgen API + quality fields + retry handler
- **涉及文件:** `cobol_testgen/__init__.py`, `cobol_testgen/coverage.py`, `config/__init__.py`, `data/diff_result.py`, `hina/__init__.py`, `hina/retry.py`
- **使用模型:** deepseek
---
## 2026-06-10
### 2026-06-10 22:56:22 - 功能开发阶段
- **范式步骤:** 语句支持完善
- **修改摘要:** complete INSPECT/SEARCH support, fix PERFORM/EVAL coverage marking
- **涉及文件:** `cobol_testgen/__init__.py`, `cobol_testgen/agents.py`, `cobol_testgen/core.py`, `cobol_testgen/coverage.py`, `cobol_testgen/design.py`, `cobol_testgen/read.py`, `AGENTS.md`
- **使用模型:** deepseek
---
## 2026-06-08
### 2026-06-08 21:07:16 - 功能开发阶段
- **范式步骤:** cobol_testgen 模块初始化
- **修改摘要:** add cobol_testgen module
- **涉及文件:** `cobol_testgen/__init__.py`, `cobol_testgen/__main__.py`, `cobol_testgen/agents.py`, `cobol_testgen/cond.py`, `cobol_testgen/core.py`, `cobol_testgen/coverage.py`, `cobol_testgen/design.py`, `cobol_testgen/grammar.lark`, `cobol_testgen/read.py`
- **使用模型:** deepseek
---
## 2026-05-27
### 2026-05-27 08:42:41 - 功能开发阶段
- **范式步骤:** V3 平台初始化
- **修改摘要:** cobol-java migration verification platform v3 (42 tests, JCL module)
- **涉及文件:** `DESIGN.md`, `config.py`, `jcl/__init__.py`, `jcl/executor.py`, `jcl/parser.py`, `orchestrator.py`, `tests/test_golden.py`
- **使用模型:** deepseek
---
## 2026-05-24
### 2026-05-24 13:01:31 - 测试阶段
- **范式步骤:** 边界用例测试
- **修改摘要:** add edge case tests
- **涉及文件:** `tests/comparator/test_aligner_edge.py`, `tests/comparator/test_compare_edge.py`
- **使用模型:** deepseek
### 2026-05-24 12:52:20 - 功能开发阶段
- **范式步骤:** Web 层开发
- **修改摘要:** add web layer (FastAPI + worker)
- **涉及文件:** `web/__init__.py`, `web/api.py`, `web/static/script.js`, `web/static/style.css`, `web/templates/result.html`, `web/templates/upload.html`, `web/worker.py`, `requirements.txt`
- **使用模型:** deepseek
### 2026-05-24 12:36:44 - 功能开发阶段
- **范式步骤:** 代码生成初始化
- **修改摘要:** v3: gstack-code-gen 生成
- **涉及文件:** `agents/`, `comparator/`, `config/`, `data/`, `main.py`, `orchestrator.py`, `preprocessor.py`, `quality/`, `report/`, `runners/`, `storage/`, `tests/` 等全部初始模块
- **使用模型:** deepseek
---
## 2026-06-21
### 2026-06-21 23:09:07 - 测试阶段
- **范式步骤:** 深层覆盖测试
- **修改摘要:** 深層カバレッジ補完 — 23/23 通過
- **涉及文件:** test-data/round3_deep_coverage.py
- **使用模型:** deepseek
### 2026-06-21 22:56:19 - 测试阶段
- **范式步骤:** 全模块覆盖测试
- **修改摘要:** 40/40 覆盖 parametrized/division + 全 comparator + jcl/executor + agents + runners + report
- **涉及文件:** test-data/round2_remaining_tests.py
- **使用模型:** deepseek
### 2026-06-21 22:16:21 - 测试阶段
- **范式步骤:** 模块覆盖测试
- **修改摘要:** 残り 20 モジュール全カバー (84/84 PASS)
- **涉及文件:** test-data/test_remaining_modules.py
- **使用模型:** deepseek
### 2026-06-21 21:53:30 - 测试阶段
- **范式步骤:** 分支覆盖测试
- **修改摘要:** 164/164 全分支全覆盖 — 10 モジュール × 178IF
- **涉及文件:** docs/coverage-matrix-final.md, test-data/test_branch_coverage.py, tests/test_jcl.py
- **使用模型:** deepseek
---
## 统计摘要
| 指标 | 数值 |
|------|------|
| **总提交数** | 82 |
| **时间跨度** | 2026-05-24 ~ 2026-08-09 |
| **使用模型** | DeepSeek |
| **主要阶段** | 测试 → 修复 → 功能开发 → 文档 |
| 阶段 | 提交数 | 占比 |
|------|--------|------|
| 测试阶段 | 33 | 40% |
| 修复阶段 | 19 | 23% |
| 功能开发阶段 | 22 | 27% |
| 文档阶段 | 5 | 6% |
| 配置管理 | 3 | 4% |
### 2026-08-22 17:00:00 - 文档
- **范式步骤:** 详细设计文档编写
- **修改摘要:** 创建 cobol_testgen 核心引擎模块的详细设计文档,包含模块概述、文件清单、核心数据结构、解析流程、分支树构建、条件解析、路径枚举、值生成、覆盖率分析、输出生成、接口定义、错误处理共12个章节
- **涉及文件:** docs/detailed-design/01-cobol-testgen-core.md
- **使用模型:** deepseek
### 2026-08-22 17:15:00 - 文档
- **范式步骤:** 详细设计文档编写
- **修改摘要:** 创建 DB 管道编排器 (orchestrator_db.py) 的详细设计文档,包含模块概述、核心数据结构、6步流程设计、接口定义、数据流、错误处理、性能设计共8个章节
- **涉及文件:** docs/detailed-design/02-orchestrator-db.md
- **使用模型:** deepseek
### 2026-08-22 17:30:00 - 文档
- **范式步骤:** 详细设计文档编写
- **修改摘要:** 创建编译运行引擎 (runners) 的详细设计文档,包含模块概述、文件清单、接口定义(3个Runner+DataWriter)、编译流程(COBOL/DB COBOL/Java)、运行流程(stdin管道/文件I/O/Spark)、错误处理、设计特点、依赖关系共8个章节
- **涉及文件:** docs/detailed-design/03-runners.md
- **使用模型:** deepseek
### 2026-08-22 21:00:00 - 文档阶段
- **范式步骤:** LLM代理模块、比对模块、配置系统详细设计文档编写
- **修改摘要:** 创建三个详细设计文档:05-agents-llm.mdLLM代理模块,包含Agent1/2/3、DesignDataGenerator、LLMClient等6个组件)、06-comparator.md(比对模块,包含记录对齐、二进制读取、数据标准化、字段比对、舍入检测5个组件)、07-config-system.md(配置系统,包含两级配置体系、TOML/YAML加载、程序Schema定义)
- **涉及文件:** `docs/detailed-design/05-agents-llm.md`, `docs/detailed-design/06-comparator.md`, `docs/detailed-design/07-config-system.md`
- **使用模型:** deepseek
### 2026-08-22 20:00:00 - 文档
- **范式步骤:** 编写详细设计文档 08-data-flow.md
- **修改摘要:** 创建 COBOL 迁移验证平台 V3 数据流设计文档,覆盖非DB管道和DB管道的完整数据流、核心数据结构、Mermaid 流程图、跨模块数据传递关系
- **涉及文件:** docs/detailed-design/08-data-flow.md
- **使用模型:** deepseek
+1063 -6
View File
@@ -22,7 +22,7 @@ CONFIG = {
} }
from .read import preprocess, extract_data_division, extract_procedure_division from .read import preprocess, extract_data_division, extract_procedure_division
from .read import resolve_copybooks, parse_data_division, parse_file_section, scan_open_statements from .read import resolve_copybooks, parse_data_division, parse_file_section, scan_open_statements, scan_all_file_directions
from .read import parse_file_control, resolve_sql_includes, strip_exec_sql_from_data_div from .read import parse_file_control, resolve_sql_includes, strip_exec_sql_from_data_div
from .core import build_branch_tree, classify_field_roles, _init_child_names, sql_register_virtual_fields, _find_multi_write_fds from .core import build_branch_tree, classify_field_roles, _init_child_names, sql_register_virtual_fields, _find_multi_write_fds
from .cond import parse_single_condition, is_field, collect_leaves from .cond import parse_single_condition, is_field, collect_leaves
@@ -291,6 +291,1027 @@ def _inject_empty_emp_rec(records, fields):
logger.info(f" injected empty-EMP-ID record at position 0") logger.info(f" injected empty-EMP-ID record at position 0")
def _is_match_key_field(base: str) -> bool:
"""跨文件照合キー判定:EMP-ID / DATE に加え、末尾が -NO/-CODE のキー
OFFICE-NO / INSURER-CODE 等)も対象。非キー(NAME/KANA/FILLER/SEX/
TYPE/STATUS/PREF)は除外。共通ロジック、プログラム名ハードコードなし。"""
u = base.upper()
if any(kw in u for kw in ('NAME', 'KANA', 'FILLER', 'SEX', 'TYPE',
'STATUS', 'PREF', 'ADDR', 'DETAIL', 'REMARK')):
return False
if 'EMP-ID' in u or 'DATE' in u:
return True
if u.endswith('-NO') or u.endswith('-CODE') or u.endswith('-ID'):
return True
return False
def _common_prefix(names):
"""计算一组字段名的最长公共前缀(限定以字母开头)。
用于从 FD 的实际字段名推导字段前缀。COPY REPLACING 程序(如 KIN07DAI 的
SWEMP-ID/SWDATE… → 'SW')与字段名即 FD 前缀的程序(如 JIN01EMP-ID → 'JIN01'
均适用。
"""
cands = [n for n in names if n and n[0].isalpha()]
if not cands:
return ''
base = cands[0]
for n in cands[1:]:
i = 0
while i < len(base) and i < len(n) and base[i] == n[i]:
i += 1
base = base[:i]
if not base:
break
return base
def _fd_leaf_field_names(fd_name, file_sec, data_fields):
"""返回指定 FD 的叶子字段名列表(排除 88 级 / FILLER / 组项)。"""
names = []
for rn in file_sec.get(fd_name, []):
for child in _init_child_names(rn, data_fields):
names.append(child)
return names
def _coordinate_multi_file_keys(records, kept_path_cons, data_fields, assignments, file_sec, term_types=None, open_dir=None):
"""协调跨文件匹配程序的キー值,为部分记录设置相同キー以触发照合路径。
数据驱动方式:扫描记录中的跨文件キー字段,强制部分记录キー一致。
通用实现:从 data_fields 中发现不同 FD 的キー类字段(EMP-ID/DATE),
配对后修改记录値使キー一致。不依赖路径约束。
前缀推导:从 FD 的叶子字段名求公共前缀(SW/SR/SL 或 JIN01 等),
而非从 01 记录名推导(R01INNREC→'R01')。COPY REPLACING 场景下
记录名前缀与字段前缀不一致,旧逻辑导致键对匹配不到。
"""
if not records or not file_sec or len(file_sec) < 2:
return
# Step 1: 自动发现 INPUT FD 的字段前缀和キー字段
# 保留 file_sec 顺序(源文件顺序):第一个 INPUT FD 即主驱动(master
fd_prefixes = []
seen_prefix = set()
for fd_name in file_sec:
# 只保留 INPUT FDopen_dir 有方向信息时按方向过滤,避免把输出 FD
# 的キー也纳入协调导致输出记录被误改)
if open_dir:
direction = str(open_dir.get(fd_name, open_dir.get(fd_name.upper(), ''))).upper()
if direction not in ('INPUT', 'I-O'):
continue
prefix = _common_prefix(_fd_leaf_field_names(fd_name, file_sec, data_fields))
if prefix and prefix not in seen_prefix:
seen_prefix.add(prefix)
fd_prefixes.append(prefix)
if len(fd_prefixes) < 2:
return
# 3+ 输入 FD:使用前置匹配序列(全匹配 + 各单文件匹配 + 判别字段注入)
if len(fd_prefixes) > 2:
_coordinate_tertiary_fd(records, fd_prefixes, data_fields, term_types)
return
# 按前缀分组找到キー字段(EMP-ID / DATE / 事務所・保険者コード類)。
# 复用共享实现 _find_key_pairs_inner(精确 / WORK- / EMP 族 / 后缀匹配),
# 避免两处维护同一逻辑。
key_pairs = _find_key_pairs_inner(fd_prefixes[0], fd_prefixes[1], data_fields)
if not key_pairs:
return
# Step 2: 修改部分记录的キー值
modified = 0
max_modify = max(1, min(5, len(records) // 5))
modified_indices = []
for i, rec in enumerate(records):
if modified >= max_modify:
break
# 确认双方都有非空値
has_both = True
for fa, fb in key_pairs:
va = str(rec.get(fa, '')).strip()
vb = str(rec.get(fb, '')).strip()
if not va or not vb:
has_both = False
break
if not has_both:
continue
# 已有部分キー一致时跳过(避免破坏已有匹配)
already_matched = all(str(rec.get(fa, '')).strip() == str(rec.get(fb, '')).strip()
for fa, fb in key_pairs)
if already_matched:
continue
# 将 prefix_a 的キー设为 prefix_b 的値
for fa, fb in key_pairs:
rec[fa] = rec[fb]
modified_indices.append(i)
modified += 1
# Step 3: 创建重复 R02 キー,触发 2030PRIOSOR(マッチング選定の優先度判定)
# 需要连续 2 条 R02 记录拥有相同キー,使 2020MATCHSOR 的内部循环进入 ELSE 分支
# 跳过 'abend' 记录(不会写入二进制文件),只考虑 'normal'/'eof' 记录
if len(modified_indices) >= 2 and term_types:
normal_pairs = []
for idx in range(1, len(modified_indices)):
i_prev = modified_indices[idx - 1]
i_next = modified_indices[idx]
t_prev = term_types[i_prev] if i_prev < len(term_types) else 'normal'
t_next = term_types[i_next] if i_next < len(term_types) else 'normal'
if t_prev not in ('abend',) and t_next not in ('abend',):
normal_pairs.append((i_prev, i_next))
if normal_pairs:
i_prev, i_next = normal_pairs[0]
for _, fb in key_pairs:
records[i_next][fb] = records[i_prev][fb]
logger.info(f" 重复 R02 キー: 记录 {i_prev} → 记录 {i_next}(触发 2030PRIOSOR")
elif len(modified_indices) >= 2 and not term_types:
# 无 term_types 时使用第一个 pair
i_prev, i_next = modified_indices[0], modified_indices[1]
for _, fb in key_pairs:
records[i_next][fb] = records[i_prev][fb]
logger.info(f" 重复 R02 キー: 记录 {i_prev} → 记录 {i_next}(触发 2030PRIOSOR,无 term_types")
if modified:
logger.info(f" 跨文件键值协同: 将 {modified} 条记录的キー设为一致 (前缀 {fd_prefixes[0]}={fd_prefixes[1]})")
else:
# 回退:强制修改第一条有两方都非空キーの记录
for i, rec in enumerate(records):
has_both = all(str(rec.get(fa, '')).strip() and str(rec.get(fb, '')).strip()
for fa, fb in key_pairs)
if has_both:
for fa, fb in key_pairs:
rec[fa] = rec[fb]
logger.info(f" 跨文件键值协同(回退): 记录 {i} キー已设为一致")
break
# Step 4: 末条 normal 记录を表内键に整列(キーブレイク集約の最终组输出分支可达)
# 最終社员工组は EOF 後の終了処理(`IF WRK-EMP-EVAL-CNT > ZERO ...`)で出力されるため、
# 末条记录が主表键と一致しないと最終组输出分支不可達。先頭のみ整列したままだと
# 最終组に照合データが無い。首条 R02 键(必ず内部表ロード範囲内)を使用する。
if records and key_pairs:
table_key = str(records[0].get(key_pairs[0][1], '')).strip()
if table_key:
for i in range(len(records) - 1, -1, -1):
rec = records[i]
if term_types and i < len(term_types) and term_types[i] == 'abend':
continue
if str(rec.get(key_pairs[0][1], '')).strip():
for fa, fb in key_pairs:
rec[fa] = table_key
logger.info(f" 最終キーブレイク组照合: 记录 {i} 明细键={table_key}")
break
def _coordinate_range_matching(records, data_fields, file_sec, open_dir, term_types=None):
"""金额-区间对齐:让部分记录的 *MONTHLY-AMOUNT 落入另一输入 FD 的
*MONTHLY-FROM/TO 区间。
适用:N:1 キーブレイク集約後、平均標準報酬月額を GRAD 区间でマッチング
するプログラム(SHA05TWN 等)。生成データの金額と区間は独立 idx で合成
され数量级がずれるため、AVG が区间に命中せず GRADE 判定/出力経路が
不可達になる。本関数は部分记录の金額を区間中値に揃え、恰 3 条同 EMP-ID
の记录も注入して MOVE 'B' 分支を覆う。
通用実装:フィールド名パターン(MONTHLY-AMOUNT / MONTHLY-FROM /
MONTHLY-TO)+入力 FD 判定(open_dir)で検出。プログラム名ハードコードなし。
"""
if not records or not data_fields or not file_sec:
return
# 输入 FD 名 → 前缀
input_fd_names = []
if open_dir:
for fd_name in file_sec:
direction = str(open_dir.get(fd_name, open_dir.get(fd_name.upper(), ''))).upper()
if direction in ('INPUT', 'I-O'):
input_fd_names.append(fd_name)
if not input_fd_names:
return
# 输入 FD 前缀集合(从 file_sec 的 FD 记录名提取前缀)
input_prefixes = set()
for fd_name in input_fd_names:
for rn in file_sec.get(fd_name, []):
m = re.match(r'^([A-Z]+\d*)', rn)
if m:
input_prefixes.add(m.group(1))
# 收集输入 FD 的字段(按前缀,从 data_fields 扫描实际字段名)
amount_fields = [] # (fd_prefix, field_name)
from_fields = []
to_fields = []
for f in data_fields:
if not isinstance(f, dict) or not f.get('pic'):
continue
name = f['name']
if f.get('is_88'):
continue
prefix = None
for p in input_prefixes:
if name.startswith(p):
prefix = p
break
if prefix is None:
continue
base = name[len(prefix):].lstrip('-')
u = base.upper()
if 'MONTHLY-AMOUNT' in u:
amount_fields.append((prefix, name))
elif u.endswith('MONTHLY-FROM'):
from_fields.append((prefix, name))
elif u.endswith('MONTHLY-TO'):
to_fields.append((prefix, name))
if not amount_fields or not from_fields or not to_fields:
return
# 找跨 FD 前缀的 金额↔区间 配对(金额在 R01,区间在 R02)
range_pair = None
for af_pref, af in amount_fields:
for ff_pref, ff in from_fields:
for tf_pref, tf in to_fields:
if af_pref != ff_pref and ff_pref == tf_pref:
range_pair = (af_pref, af, ff_pref, ff, tf)
break
if range_pair:
break
if range_pair:
break
if not range_pair:
return
af_pref, af, ff_pref, ff, tf = range_pair
logger.info(f" 金额-区间对齐: 金额 {af} ({af_pref}) ↔ 区间 {ff}/{tf} ({ff_pref})")
# EMP-ID 字段(金额所在 FD 前缀)
emp_field = None
for f in data_fields:
if not isinstance(f, dict) or not f.get('pic'):
continue
nm = f['name']
if nm.startswith(af_pref) and 'EMP-ID' in nm.upper():
emp_field = nm
break
if not emp_field:
return
# 从记录中提取有效区间(取第一条有值的 R02 区间)
from_val = None
to_val = None
for rec in records:
fv = str(rec.get(ff, '')).strip()
tv = str(rec.get(tf, '')).strip()
if fv and tv and fv.isdigit() and tv.isdigit():
from_val = int(fv)
to_val = int(tv)
break
if from_val is None:
return
mid_val = (from_val + to_val) // 2
mid_str = str(mid_val).zfill(9)
# 选定 normal 记录(非 abend
normal_indices = [i for i in range(len(records))
if not term_types or i >= len(term_types) or term_types[i] != 'abend']
if len(normal_indices) < 4:
return
# 1) 前 4 条记录:金额设为区间中值,前 4 条同 EMP-ID(→ COUNT>=4 → MOVE 'A'
hit_indices = normal_indices[:4]
hit_emp = str(records[hit_indices[0]].get(emp_field, '')).strip() or 'A0000001'
for i in hit_indices:
rec = records[i]
rec[af] = mid_str
rec[emp_field] = hit_emp
logger.info(f" 金额-区间对齐: {len(hit_indices)} 条记录金额→{mid_str}(区间命中,COUNT>=4 → TYPE='A'")
# 2) 构造恰 3 条同 EMP-ID(→ COUNT=3 → MOVE 'B'):从 remaining 取 3 条
remaining = [i for i in normal_indices if i not in set(hit_indices)]
if len(remaining) >= 3:
b_indices = remaining[:3]
b_emp = str(records[b_indices[0]].get(emp_field, '')).strip() or 'B0000002'
for i in b_indices:
rec = records[i]
rec[af] = mid_str
rec[emp_field] = b_emp
logger.info(f" 金额-区间对齐: {len(b_indices)} 条记录同 EMP-ID={b_emp}COUNT=3 → TYPE='B'")
# 3) 其余记录保留原值:区间外 / 记录数<3 → NO-GRADE / INVALID 错误分支维持
def _serialize_read_into_records(records, assignments, fields, file_sec):
"""不透明 FD + READ INTO:将求解出的 INTO 组子字段值序列化回 FD 记录字节。
问题:FD 记录不透明(如 R01INNREC PIC X(080) 无子字段)且程序用
`READ FD INTO WS-GRP` 读取时,Pass 3.5 中 fd_children 为空 → 求解出的
WRK-* 值从未写回 FD 记录字节 → 物理输入文件全是占位符、key 全同,
新 key 分支(flush + WRITE)不可达。
本函数:
1) 找到 READ INTO 赋值(fd 名, 目标组)
2) 按目标组子字段 PIC 长度计算布局偏移
3) 将求解出的子字段值按偏移 pack 回 FD 记录字节
4) 保证相邻记录 key 既有相同又有差异(同 key 累加 + 新 key flush 两路径)
"""
if not records or not assignments or not file_sec:
return records
name_to_field = {f['name']: f for f in fields}
read_intos = []
for tgt, asgn_list in assignments.items():
for a in asgn_list:
if isinstance(a, dict) and a.get('type') == 'read_into':
read_intos.append((a.get('file', ''), tgt))
if not read_intos:
return records
for fname, tgt in read_intos:
if fname not in file_sec or not file_sec[fname]:
continue
fd_rec = file_sec[fname][0]
fd_field = name_to_field.get(fd_rec)
if not fd_field:
continue
if _init_child_names(fd_rec, fields):
continue
pi = fd_field.get('pic_info', {})
fd_len = pi.get('length') or (pi.get('digits', 0) + pi.get('decimal', 0)) or 0
if fd_len <= 0:
continue
ws_children = _init_child_names(tgt, fields)
layout = []
offset = 0
for c in ws_children:
cf = name_to_field.get(c)
if not cf:
continue
cpi = cf.get('pic_info', {})
if not cpi or cpi.get('type') == 'unknown':
continue
clen = cpi.get('length') or (cpi.get('digits', 0) + cpi.get('decimal', 0)) or 0
if clen <= 0:
continue
layout.append((c, offset, clen, cpi))
offset += clen
if not layout:
continue
key_children = []
for c, off, clen, cpi in layout:
base = c[4:] if c.startswith('WRK-') else c
if c.startswith('WRK-') and ('WRK-PREV-' + base) in name_to_field:
key_children.append((c, off, clen, cpi))
if not key_children:
for c, off, clen, cpi in layout:
if 'EMP-ID' in c or 'APPL-DATE' in c:
key_children.append((c, off, clen, cpi))
if key_children and len(records) >= 2:
kc, koff, klen, kpi = key_children[0]
keys = [str(r.get(kc, '')).strip() for r in records]
has_same = any(keys[i] == keys[i + 1] for i in range(len(keys) - 1))
has_diff = any(keys[i] != keys[i + 1] for i in range(len(keys) - 1))
if not has_diff:
records[-1][kc] = _inc_str(str(records[-1].get(kc, '')), klen)
logger.info(f" READ INTO {tgt}: 注入 key 差异(新 key flush 路径)")
if not has_same:
records[1][kc] = records[0][kc]
logger.info(f" READ INTO {tgt}: 注入相同 key(累加路径)")
for rec in records:
buf = bytearray(b' ' * fd_len)
for c, off, clen, cpi in layout:
if off >= fd_len:
continue
val = str(rec.get(c, ''))
if cpi.get('type') in ('numeric', 'numeric-edited'):
seg = val.zfill(clen)
else:
seg = val.ljust(clen)[:clen]
seg_bytes = seg.encode('utf-8', 'replace')[:clen]
end = min(off + len(seg_bytes), fd_len)
buf[off:end] = seg_bytes[:end - off]
rec[fd_rec] = buf.decode('utf-8', 'replace')
logger.info(f" READ INTO 序列化: {fd_rec}{tgt} ({len(layout)} 字段)")
return records
def _inject_merge_input_records(records, fields, file_sec, fd_fields, proc_div, term_types=None):
"""MERGE 程序:为 USING 输入文件注入可执行合并逻辑的 S/B 记录。
根因:MERGE ... USING 的输入文件从不 OPEN,原管道不识别为输入 → 无输入
数据 → 运行时 MERGE 读到空文件 → 2000MRGOUTSOR 全部不执行。
本函数:
1) 解析 MERGE 语句的 USING 文件列表(SALARY-FILE/BONUS-FILE
2) 识别各 USING 文件 FD 的 REC-TYPE / EMP-ID / PAY-AMOUNT / GROSS 字段
3) 从 OUTPUT PROCEDURE 的 `IF <rec-type> = <CONST>` 解析判别常量值
(如 CNS-PAY-TYPE-SALARY='S'),其余 USING 文件取相异值
4) 注入一条含全部 USING 文件字段的记录(同 EMP-ID 跨文件匹配,
触发 2000MRGOUTSOR 同社員統合 + REC-TYPE T/F 分支)
通用实现:不依赖程序名/字段名硬编码,对所有 MERGE/SORT 程序适用。
"""
if not records or not proc_div or not file_sec or len(fd_fields) < 2:
return records
_KEYWORDS = {'USING', 'KEY', 'ASCENDING', 'DESCENDING', 'ON', 'OUTPUT', 'INPUT', 'PROCEDURE'}
def _resolve_const(name, fields_list):
for f in fields_list:
if f.get('name') == name and f.get('value') is not None:
return str(f['value']).strip("'\"").strip()
return None
def _find_field(fd_name, suffix):
for fname in fd_fields.get(fd_name, []):
if fname.upper().endswith(suffix.upper()) and fname not in (fd_name,):
return fname
return None
injected = False
for m in re.finditer(
r'\bMERGE\s+\w[\w-]*\s+.*?\bUSING\s+(.+?)\s*(?:OUTPUT|INPUT)?\s*PROCEDURE\s+\w[\w-]*\s*\.',
proc_div, re.IGNORECASE | re.DOTALL
):
stmt = m.group(0)
files_raw = m.group(1)
using = [f.upper() for f in re.findall(r'\b(\w[\w-]*)\b', files_raw)
if f.upper() not in _KEYWORDS and f.upper() in file_sec]
if len(using) < 2:
continue
out_sec = None
om = re.search(r'\bOUTPUT\s+PROCEDURE\s+(\w[\w-]*)', stmt, re.IGNORECASE)
if om:
out_sec = om.group(1).upper()
# 判别字段(REC-TYPE)与判别常量值
type_field = None
const_val = None
if out_sec:
for f in fields:
fn = f.get('name', '')
if fn.upper().endswith('REC-TYPE'):
type_field = fn
break
if type_field:
cm = re.search(
r'\bIF\s+%s\b\s*(?:=|NOT\s*=|<>|>|<|>=|<=)\s*(\w[\w-]*)' % re.escape(type_field),
proc_div, re.IGNORECASE
)
if not cm:
cm = re.search(
r'\b%s\s*(?:=|NOT\s*=|<>)\s*(\w[\w-]*)' % re.escape(type_field),
proc_div, re.IGNORECASE
)
if cm:
const_val = _resolve_const(cm.group(1).upper(), fields)
if const_val is None:
const_val = 'S' # 通用回退:第一个 USING 文件用 'S'
# 第二文件判别值:优先取 *PAY-TYPE-BONUS* 常量,否则取与 const_val 相异值
other_val = None
for f in fields:
fn = f.get('name', '')
if 'PAY-TYPE' in fn.upper() and 'BONUS' in fn.upper() and f.get('value') is not None:
other_val = str(f['value']).strip("'\"").strip()
break
if other_val is None:
other_val = (chr(ord(const_val) + 1) if len(const_val) == 1 else 'B')
if other_val == const_val:
other_val = 'B'
# 各 USING 文件的字段映射
emp_fields, pay_fields, gross_fields, type_fields = [], [], [], []
for fd in using:
emp_fields.append(_find_field(fd, 'EMP-ID'))
pay_fields.append(_find_field(fd, 'PAY-AMOUNT'))
gross_fields.append(_find_field(fd, 'GROSS'))
type_fields.append(_find_field(fd, 'REC-TYPE'))
if any(f is None for f in emp_fields) or any(f is None for f in type_fields):
continue
# 每条记录:所有 USING 文件字段都设置,同 EMP-IDREC-TYPE 按文件相异。
# 注:output_input_files 对次要 FD 会丢弃末尾 2 条 → 注入记录必须放最前。
base = dict(records[0]) if records else {}
n = len(using)
merge_recs = []
for emp_i in range(2):
rec = dict(base)
emp_id = f'A{emp_i + 1:07d}'
for i, fd in enumerate(using):
type_val = const_val if i == 0 else other_val
rec[emp_fields[i]] = emp_id
rec[type_fields[i]] = type_val
if pay_fields[i]:
rec[pay_fields[i]] = str(100000 + emp_i * 1000 + i * 100).zfill(9)
if gross_fields[i]:
rec[gross_fields[i]] = str(200000 + emp_i * 1000 + i * 100).zfill(9)
merge_recs.append(rec)
records[0:0] = merge_recs
if term_types is not None:
term_types[0:0] = ['normal', 'normal']
injected = True
logger.info(f" 注入 MERGE 输入记录: USING={using} REC-TYPE={const_val}/{other_val} 共 2 条(同 EMP-ID 跨文件,置顶)")
break
return records
def _inject_end_of_page_records(records, full_source, proc_div, file_sec, open_dir, term_types=None):
"""LINAGE 分页(WRITE ... AT END-OF-PAGE)プログラム:补充分页体积记录。
根因:V3 路径枚举只生成覆盖决策点的最小记录集。对 END-OF-PAGE(分页)分支,
需要足够多的明细记录触发 LINAGE 分页才能覆盖(例:JIN04PRT 输入仅 1 条 →
AT END-OF-PAGE 换页重打分支不可达,gcov 仅 80/96)。
本函数(通用・程序名硬编码なし):
1) PROCEDURE DIVISION 中检测 `WRITE ... AT END-OF-PAGE`
2) 从全量源码解析输出 FD 的 LINAGE 总行数 - TOP - BOTTOM 作为正文容量
(解析失败回退 60)
3) 将一条有效 normal 记录复制补足到「容量+1」条,使最后一条 WRITE
触发 END-OF-PAGE
"""
if not records or not proc_div or not full_source:
return records
if not re.search(r'\bEND-OF-PAGE\b', proc_div, re.IGNORECASE):
return records
# LINAGE 容量:LINAGE IS n LINESTOP t / BOTTOM b)→ 正文行数
capacity = None
m = re.search(
r'LINAGE\s+IS\s+(\d+)\s+LINES([^.]*?)(?=\.)',
full_source, re.IGNORECASE | re.DOTALL)
if m:
total = int(m.group(1))
rest = m.group(2)
top_m = re.search(r'\bTOP\s+(\d+)', rest, re.IGNORECASE)
bot_m = re.search(r'\bBOTTOM\s+(\d+)', rest, re.IGNORECASE)
top = int(top_m.group(1)) if top_m else 0
bottom = int(bot_m.group(1)) if bot_m else 0
capacity = total - top - bottom
if not capacity or capacity <= 0:
capacity = 60
target = capacity + 1
def _is_normal(i):
if term_types is None or i >= len(term_types):
return True
return term_types[i] != 'abend'
template = None
for i in range(len(records) - 1, -1, -1):
if _is_normal(i) and records[i]:
template = dict(records[i])
break
if template is None:
return records
current = sum(1 for i in range(len(records)) if _is_normal(i))
if current >= target:
return records
need = target - current
for _ in range(need):
records.append(dict(template))
if term_types is not None:
term_types.extend(['normal'] * need)
logger.info(
f" END-OF-PAGE 分页注入: 复制 {need} 条记录至 {len(records)}"
f"LINAGE 容量 {capacity},触发分页)")
return records
def _inject_sort_limit_records(records, proc_div, fields, term_types=None):
"""SORT INPUT 计数守卫(RELEASE + IF <计数器> <= <上限>):补充分页体积记录。
根因:V3 路径枚举只生成覆盖决策点的最小记录集。对 SORT INPUT PROCEDURE 中的
记录数上限守卫(如 `ADD 1 TO WRK-SORT-CNT ... IF WRK-SORT-CNT <=
CNS-MAX-SORT-REC(999) RELEASE ... ELSE 上限エラー`),需超过上限条数的输入
记录才能覆盖 ELSESORT LIMIT OVER)分支(例:JIN06SRT 输入仅 6 条,上限 999)。
本函数(通用・程序名硬编码なし):
1) PROC 中检测 SORT + RELEASE + 同一段落内 `IF <counter> (<=|<) <limit>`
2) 解析 <limit> 的常量值(字段 VALUE 子句或字面值)
3) 将一条 valid normal 记录复制到「上限+1」条,使最后一条 READ 触发上限分支
"""
if not records or not proc_div:
return records
up = proc_div.upper()
if 'SORT' not in up or 'RELEASE' not in up:
return records
# 计数守卫:IF <counter> (<=|<) <limit>(同段落内必须有 RELEASE
m = re.search(r'\bIF\s+(\w[\w-]*)\s*(?:<=|<)\s*(\w[\w-]*)\b', proc_div, re.IGNORECASE)
if not m:
return records
after = proc_div[m.start():]
para_end = re.search(r'\n\s*\w[\w-]*-EXT\.', after)
scope = after[:para_end.start()] if para_end else after
if 'RELEASE' not in scope.upper():
return records
# 解析上限值:字段 VALUE 子句或字面值
limit_ref = m.group(2)
limit = None
raw = str(limit_ref).strip().strip("'\"").strip()
if raw.isdigit():
limit = int(raw)
else:
for f in (fields or []):
if f.get('name') == raw and f.get('value') is not None:
try:
limit = int(str(f['value']).strip("'\"").strip())
except ValueError:
limit = None
break
if limit is None or limit <= 0:
return records
target = limit + 1
def _is_normal(i):
if term_types is None or i >= len(term_types):
return True
return term_types[i] != 'abend'
template = None
for i in range(len(records) - 1, -1, -1):
if _is_normal(i) and records[i]:
template = dict(records[i])
break
if template is None:
return records
current = sum(1 for i in range(len(records)) if _is_normal(i))
if current >= target:
return records
need = target - current
for _ in range(need):
records.append(dict(template))
if term_types is not None:
term_types.extend(['normal'] * need)
logger.info(
f" SORT 上限体积注入: 复制 {need} 条记录至 {len(records)}"
f"(上限 {limit},触发 SORT LIMIT OVER")
return records
def _provision_sub_input_files(main_source, source_dir, outdir):
"""子程序输入供给:为 CALL 目标子程序的 INPUT FD 生成输入文件。
根因:V3 只为主程序生成输入文件。テストドライバ(主程序无 FILE 节)调用
读取文件的自程序时(如 JIN08TST → JIN07SUB 读 JIN07R01),自程序 OPEN
输入文件失败(status 35)→ ABEND → 后续决策点不可达。
本函数(通用・程序名硬编码なし):
1) 主程序 PROC 中 `CALL 'NAME'` 收集子程序名
2) 在 source_dir 的 sub/ 找到子程序源码
3) 解析其 FILE-CONTROL 的 INPUT/I-O FDASSIGN 名・记录布局)
4) 按子程序 FD 布局写一条基础记录到 outdir/<assign>
返回已生成的 assign 名列表。
"""
src_dir = Path(source_dir)
sub_dir = src_dir.parent / 'sub'
if not sub_dir.is_dir():
sub_dir = src_dir / 'sub'
cpy_dir = src_dir.parent / 'cpy'
if not cpy_dir.is_dir():
cpy_dir = src_dir / 'cpy'
calls = set()
for m in re.finditer(r"\bCALL\s+['\"](\w+)['\"]", main_source, re.IGNORECASE):
calls.add(m.group(1).upper())
if not calls:
return []
from .flatfile import analyze_fd_layout, write_flat_file
written = []
for sub_name in sorted(calls):
sp = sub_dir / f"{sub_name}.cbl"
if not sp.exists():
continue
sub_src = sp.read_text(encoding='utf-8')
try:
sub_src = resolve_copybooks(
sub_src, str(sub_dir),
extra_search_paths=[str(cpy_dir)] if cpy_dir.is_dir() else None)
layouts = analyze_fd_layout(
sub_src,
copybook_dirs=[str(cpy_dir)] if cpy_dir.is_dir() else None)
except Exception as e:
logger.debug(f" 子程序 {sub_name} 布局解析失败: {e}")
continue
for assign, layout in layouts.items():
if layout.get('direction') not in ('INPUT', 'I-O'):
continue
base = {}
for rec in layout.get('records', []):
for f in rec.get('fields', []):
pi = f.get('pic_info') or {}
if pi.get('type') == 'numeric':
ln = (pi.get('digits', 0) + pi.get('decimal', 0)) or 1
base[f['name']] = '0' * ln
else:
ln = f.get('length') or pi.get('length') or 1
base[f['name']] = 'A' * ln
outpath = Path(outdir) / assign
outpath.parent.mkdir(parents=True, exist_ok=True)
try:
write_flat_file([base], layout, outpath)
except Exception as e:
logger.debug(f" 子程序 {sub_name} 输入文件写入失败: {e}")
continue
rec_len = layout.get('records', [{}])[0].get('record_length', 0)
written.append(assign)
logger.info(f" 子程序输入供给: {sub_name}{assign}(记录 {rec_len}B")
return written
def _coordinate_tertiary_fd(records, fd_prefixes, data_fields, term_types=None):
"""构造多文件照合程序的前置匹配序列(通用,不依赖程序名)。
fd_prefixes = [主FD字段前缀, 明细FD字段前缀1, ...](如 SW/SR/SL)。
在记录头部构造单调递增键序列,使 merge-join 指针自然对齐:
- rec0: 主 + 全部明细键一致 → 全匹配(照合ループ + 判别 EVALUATE 全 WHEN
- rec(k): 主 + 第 k 个明细键一致、其余不同 → 单明细匹配(各照合组合)
判别字段(*LEAVE-TYPE)所在明细构造同键重复块:
- 块1: 短时长 + 判别值 01-04 → 全匹配内循环消费(EVALUATE 全 WHEN
- 块2: 长时长(0600-2200+ 判别值 01-04 → 单明细匹配内循环消费,
覆盖时长上限检查 T 分支与午餐重叠判定。
"""
master = fd_prefixes[0]
details = fd_prefixes[1:]
if not details:
return
normal_indices = [i for i in range(len(records))
if not term_types or i >= len(term_types) or term_types[i] != 'abend']
dpairs = {}
for d in details:
pairs = _find_key_pairs_inner(master, d, data_fields)
if pairs:
dpairs[d] = pairs
if not dpairs:
return
# 判别字段:取"最后一个"含 *LEAVE-TYPE 的明细 FD(深层休暇展开文件)。
# 注意 R02 打刻文件也可能含 LEAVE-TYPE 字段(如 SRLEAVE-TYPE),但
# 内循环 EVALUATE 判别的是休暇展开明细(R03),故从后向前选。
disc_detail = None
disc_field = None
for d in reversed(details):
if d not in dpairs:
continue
for f in data_fields:
if not isinstance(f, dict) or not f.get('pic'):
continue
nm = f.get('name', '')
if nm.startswith(d) and 'LEAVE-TYPE' in nm.upper():
disc_detail, disc_field = d, nm
break
if disc_detail:
break
disc_values = ['01', '02', '03', '04', '99'] if disc_field else []
m = len(disc_values) if disc_field else 2
n = len(details)
match_cnt = n + 1
need = max(match_cnt, m) + m
if len(normal_indices) < max(need, 6):
return
def _synth(idx):
# 合成键:EMP-ID 'A0000001' 起递增、日期 20000101 起递增(字典序单调递增)
return f'A{idx + 1:07d}', f'{20000101 + idx:08d}'
def _apply(rec, prefix, pairs, idx):
emp, date = _synth(idx)
for fa, fb in pairs:
f = fa if prefix == master else fb
val = date if 'DATE' in f.upper() else emp
rec[f] = val
p = normal_indices
# rec0: 全匹配(主 + 全部明细键 = K0)
rec = records[p[0]]
for d, pairs in dpairs.items():
_apply(rec, master, pairs, 0)
_apply(rec, d, pairs, 0)
# rec1..n: 单明细匹配(主 + 该明细 = K(k+1),其余明细用大偏移避免误配)
for k, d in enumerate(details):
if k + 1 >= len(p):
continue
rec = records[p[k + 1]]
_apply(rec, master, dpairs[d], k + 1)
_apply(rec, d, dpairs[d], k + 1)
for d2, pairs2 in dpairs.items():
if d2 != d:
_apply(rec, d2, pairs2, k + 1 + 50)
if disc_detail and disc_field:
pairs = dpairs[disc_detail]
# 块1:位置 p[0..m-1],键 = K0,短时长 + 判别值
for j in range(m):
if j >= len(p):
break
rec = records[p[j]]
_apply(rec, disc_detail, pairs, 0)
rec[disc_field] = disc_values[j]
# 块2:位置 p[max(match_cnt,m)..+m],键 = K(disc_idx),长时长 + 判别值
disc_idx = details.index(disc_detail) + 1
b2_start = max(match_cnt, m)
st_f = en_f = None
for f in data_fields:
if not isinstance(f, dict) or not f.get('pic'):
continue
nm = f.get('name', '')
if nm.startswith(disc_detail):
u = nm.upper()
if 'START-TIME' in u:
st_f = nm
elif 'END-TIME' in u:
en_f = nm
for j in range(m):
idx = b2_start + j
if idx >= len(p):
break
rec = records[p[idx]]
_apply(rec, disc_detail, pairs, disc_idx)
rec[disc_field] = disc_values[j]
if st_f and en_f:
rec[st_f] = '0600'
rec[en_f] = '2200'
logger.info(
f" 前置匹配序列: 全匹配+{n}单明细匹配, {disc_detail} 判别块 {disc_values} "
f"(长时长 0600-2200 覆盖上限/午餐判定)"
)
else:
# 无判别字段:仍制造同键重复(触发明细内循环)
d0, pairs0 = next(iter(dpairs.items()))
if len(p) >= 2:
rec0, rec1 = records[p[0]], records[p[1]]
_apply(rec0, master, pairs0, 0)
_apply(rec0, d0, pairs0, 0)
_apply(rec1, master, pairs0, 0)
_apply(rec1, d0, pairs0, 0)
logger.info(f" 前置匹配序列: 全匹配+{n}单明细匹配 ({d0} 同键重复)")
def _find_key_pairs_inner(prefix_a, prefix_b, data_fields):
"""Find BEST matching key field pairs between two FD prefixes.
Isolated helper for _coordinate_tertiary_fd.
"""
a_candidates = {}
b_candidates = {}
for f in data_fields:
if not isinstance(f, dict) or not f.get('pic'):
continue
name = f['name']
if name.startswith(prefix_a):
base = name[len(prefix_a):].lstrip('-')
if _is_match_key_field(base):
a_candidates[name] = base
elif name.startswith(prefix_b):
base = name[len(prefix_b):].lstrip('-')
if _is_match_key_field(base):
b_candidates[name] = base
pairs = []
used_b = set()
for fa, ba in sorted(a_candidates.items()):
for fb, bb in sorted(b_candidates.items()):
if fb in used_b:
continue
if ba == bb or ba.replace('WORK-', '') == bb or ba == bb.replace('WORK-', ''):
pairs.append((fa, fb))
used_b.add(fb)
break
for fa, ba in sorted(a_candidates.items()):
if any(p[0] == fa for p in pairs):
continue
ua = ba.upper()
if 'EMP' not in ua:
continue
for fb, bb in sorted(b_candidates.items()):
if fb in used_b:
continue
ub = bb.upper()
if 'EMP' in ub:
pairs.append((fa, fb))
used_b.add(fb)
break
# Suffix tier: 明细键 base 是主表键 base 的真后缀(如 SKILL-CODE ⊂
# MST-SKILL-CODE)且 key 类别一致(同为 -CODE/-ID/-NO/DATE/EMP-ID)时配对。
# 通用实现、无程序名硬编码,覆盖"主表键带限定词(MST- 等)"的照合形态。
def _same_key_class(x, y):
ux, uy = x.upper(), y.upper()
for cls in ('EMP-ID', '-CODE', '-ID', '-NO', 'DATE'):
if cls in ux and cls in uy:
return True
return False
for fa, ba in sorted(a_candidates.items()):
if any(p[0] == fa for p in pairs):
continue
for fb, bb in sorted(b_candidates.items()):
if fb in used_b:
continue
if (len(ba) >= 3 and len(bb) >= 3 and ba != bb
and _same_key_class(ba, bb)
and (bb.upper().endswith(ba.upper())
or ba.upper().endswith(bb.upper()))):
pairs.append((fa, fb))
used_b.add(fb)
break
return pairs
def _inject_leave_type_scenarios(records, term_types):
"""Inject R02LEAVE-TYPE / R02APPL-ID 覆盖 2030PRIOSOR 所有 WHEN + IF-ELSE 分支 + WHEN OTHER。
在 _coordinate_multi_file_keys 之后运行, 取 16 条连续 normal 记录
(1 anchor + 14 scenario + 1 WHEN OTHER), 设其 R01/R02 キー相同, 然后依次设
R02LEAVE-TYPE 为 '99''04''04''03''03''04''02''02'
'03''04''01''01''02''03''04''99',
配合 APPL-ID 递减, 以单一 2020MATCHSOR 调用遍历 EVALUATE 内部所有路径。
"""
if not records:
return
# 守卫:仅当记录中存在 LEAVE-TYPE 字段(2030PRIOSOR 系)时执行。
# 避免对无 LEAVE-TYPE 的 N:1 集約程序(如 SHA05TWN)产生副作用——
# 该函数原会无差别把前 16 条 normal 记录的 EMP-ID 设为同一值。
if not any('LEAVE-TYPE' in str(k).upper() for k in records[0]):
return
# 跳过 abend 记录, 找连续 16 条 normal (1 anchor + 14 scenario + 1 OTHER)
needed = 16
normal_indices = []
for i in range(len(records)):
if term_types and i < len(term_types) and term_types[i] == 'abend':
continue
normal_indices.append(i)
if len(normal_indices) >= needed:
break
if len(normal_indices) < needed:
logger.warning(f" 休暇種別覆盖: normal 记录不足 ({len(normal_indices)}/{needed}), 跳过")
return
anchor_idx = normal_indices[0]
anchor = records[anchor_idx]
common_emp = str(anchor.get('R02EMP-ID', '')).strip() or 'E0000999'
common_date = str(anchor.get('R02DATE', '')).strip() or '20991231'
# 所有记录设相同キー
for idx in normal_indices:
rec = records[idx]
rec['R02EMP-ID'] = common_emp
rec['R02DATE'] = common_date
rec['R01EMP-ID'] = common_emp
rec['R01WORK-DATE'] = common_date
# 锚点: LT='99'(CNS-NO-LEAVE), 第一个 MATCH 使 BEST-TYPE=99
anchor['R02LEAVE-TYPE'] = '99'
anchor['R02APPL-ID'] = '000000100'
anchor['R02START-TIME'] = '0800'
anchor['R02END-TIME'] = '1700'
# 14 scenario + 1 OTHER 按顺序覆盖各 WHEN 分支
# 路径: BEST 从 99→04→03→02→01 逐级覆盖, 末尾 LT='99' 进 WHEN OTHER
scenarios = [
('04', '000000080', '0800', '1700'), # LE04 ELSE(384-387)
('04', '000000060', '0800', '1700'), # LE04 AID<BEST(379-381)
('03', '000000050', '0800', '1700'), # LE03 ELSE(365-368)
('03', '000000040', '0800', '1700'), # LE03 AID<BEST(360-362)
('04', '000000045', '0800', '1700'), # LE04 CONT(BEST=03)(375-376)
('02', '000000030', '0800', '1700'), # LE02 ELSE(348-351)
('02', '000000020', '0800', '1700'), # LE02 AID<BEST(343-345)
('03', '000000025', '0800', '1700'), # LE03 CONT(BEST=02)(356-357)
('04', '000000030', '0800', '1700'), # LE04 CONT(BEST=02)(373-374)
('01', '000000010', '0800', '1700'), # LE01 NOT=01(329-332)
('01', '000000005', '0800', '1700'), # LE01 AID<BEST(334-336)
('02', '000000015', '0800', '1700'), # LE02 CONT(BEST=01)(339-340)
('03', '000000020', '0800', '1700'), # LE03 CONT(BEST=01)(354-355)
('04', '000000025', '0800', '1700'), # LE04 CONT(BEST=01)(371-372)
('99', '000000999', '0800', '1700'), # WHEN OTHER CONTINUE(390)
]
for j, idx in enumerate(normal_indices[1:]):
rec = records[idx]
lt, aid, st, et = scenarios[j]
rec['R02LEAVE-TYPE'] = lt
rec['R02APPL-ID'] = aid
rec['R02START-TIME'] = st
rec['R02END-TIME'] = et
logger.info(f" 2030PRIOSOR 全分支覆盖: {needed} 条记录注入完成 (共通キー={common_emp}/{common_date})")
def _inject_c01_coverage_records(records, fields, base_assignments): def _inject_c01_coverage_records(records, fields, base_assignments):
"""Inject records for uncovered C01 decision points (DP#9-#12 T). """Inject records for uncovered C01 decision points (DP#9-#12 T).
@@ -477,8 +1498,8 @@ def main():
) )
vr = orch.run_all(generate_coverage=False) vr = orch.run_all(generate_coverage=False)
# Copy output files to outdir # Copy output files to outdir (skip if src == dst to avoid self-copy)
if orch.runtime_dir.exists(): if orch.runtime_dir.exists() and orch.runtime_dir.resolve() != prog_outdir.resolve():
for item in orch.runtime_dir.iterdir(): for item in orch.runtime_dir.iterdir():
if item.name == "gixsql.log": if item.name == "gixsql.log":
continue continue
@@ -661,7 +1682,7 @@ def main():
if skip_path_infos: if skip_path_infos:
logger.info(f" Skip 路径: {len(skip_path_infos)} 条(将单独生成数据集)") logger.info(f" Skip 路径: {len(skip_path_infos)} 条(将单独生成数据集)")
open_dir = scan_open_statements(proc_div) if proc_div else {} open_dir = scan_all_file_directions(proc_div) if proc_div else {}
if proc_div: if proc_div:
logger.info(f"\n分支路径数:{len(branch_paths_with_assigns)}") logger.info(f"\n分支路径数:{len(branch_paths_with_assigns)}")
@@ -681,7 +1702,7 @@ def main():
path_infos = [([], {}, 'normal')] path_infos = [([], {}, 'normal')]
roles = {f['name']: 'unused' for f in fields_dict} roles = {f['name']: 'unused' for f in fields_dict}
records, _, term_types = generate_records(path_infos, fields_dict, assignments, file_sec=file_sec) records, kept_path_cons, term_types = generate_records(path_infos, fields_dict, assignments, file_sec=file_sec)
def _is_eof_path(cons): def _is_eof_path(cons):
last_eq1_true = -1 last_eq1_true = -1
@@ -711,6 +1732,20 @@ def main():
_inject_empty_emp_rec(records, fields_dict) _inject_empty_emp_rec(records, fields_dict)
# P5: inject records for uncovered C01 decision points (DP#9-#12 T) # P5: inject records for uncovered C01 decision points (DP#9-#12 T)
_inject_c01_coverage_records(records, fields_dict, assignments) _inject_c01_coverage_records(records, fields_dict, assignments)
# P6: coordinate cross-file keys for multi-file matching programs
_coordinate_multi_file_keys(records, kept_path_cons, fields_dict, assignments, file_sec, term_types=term_types, open_dir=open_dir)
# P6.5: 金额-区间对齐(N:1 集約→GRADE 匹配路径可达)
_coordinate_range_matching(records, fields_dict, file_sec, open_dir, term_types)
# P7: inject leave-type coverage for 2030PRIOSOR EVALUATE branches
_inject_leave_type_scenarios(records, term_types)
# P8: opaque FD + READ INTO — 将 WS 子字段值序列化回 FD 记录字节
_serialize_read_into_records(records, assignments, fields_dict, file_sec)
# P9: MERGE 程序 — 为 USING 输入文件注入 S/B 记录
_inject_merge_input_records(records, fields_dict, file_sec, fd_fields, proc_div, term_types=term_types)
# P10: LINAGE 分页(WRITE AT END-OF-PAGE)— 补充分页体积记录
_inject_end_of_page_records(records, source, proc_div, file_sec, open_dir, term_types=term_types)
# P11: SORT 计数守卫(RELEASE + IF <cnt> <= <上限>)— 补充上限体积记录
_inject_sort_limit_records(records, proc_div, fields_dict, term_types=term_types)
if _HAVE_TOSQL: if _HAVE_TOSQL:
sql_meta = collect_sql_meta(assignments, declared_columns) sql_meta = collect_sql_meta(assignments, declared_columns)
@@ -736,6 +1771,11 @@ def main():
term_types=term_types, term_types=term_types,
data_fields=fields_dict, select_info=select_info) data_fields=fields_dict, select_info=select_info)
# P12: 子程序输入供给(CALL 目标子程序的 INPUT FD 输入文件)
_provision_sub_input_files(
source, str(filepath.parent), prog_outdir / 'main' / 'input'
)
# ── Skip 数据集(主 FD 空文件触发 PERFORM UNTIL 条件即时满足)── # ── Skip 数据集(主 FD 空文件触发 PERFORM UNTIL 条件即时满足)──
skip_records = None skip_records = None
skip_term_types = None skip_term_types = None
@@ -932,7 +1972,7 @@ def extract_structure(cobol_source: str, copybook_dirs: list = None) -> dict:
branch_tree, assignments = build_branch_tree_fallback(proc_div, fields_dict) branch_tree, assignments = build_branch_tree_fallback(proc_div, fields_dict)
file_sec = parse_file_section(preprocessed) file_sec = parse_file_section(preprocessed)
open_dir = scan_open_statements(proc_div) if proc_div else {} open_dir = scan_all_file_directions(proc_div) if proc_div else {}
from .models import BrIf, BrEval, BrSeq, BrPerform, BrSearch, Assign, CondAnd, CondOr from .models import BrIf, BrEval, BrSeq, BrPerform, BrSearch, Assign, CondAnd, CondOr
@@ -1351,6 +2391,23 @@ def generate_data(cobol_source: str, structure: dict = None,
if lhs in rec and rhs in rec and i < half: if lhs in rec and rhs in rec and i < half:
rec[rhs] = rec[lhs] rec[rhs] = rec[lhs]
# P6: 跨文件キー協調(DB/非DB 両パイプ共通)— 部分記錄のキーを
# 主表(R02/R03 等)と一致させ、照合 STAGE/DB SELECT 経路を到達可能にする。
# 出力 FD は open_dir で除外し、入力 FD 同士のみ協調する。
if records and file_sec:
try:
open_dir = scan_all_file_directions(proc_div or '')
_coordinate_multi_file_keys(
records, kept_paths, fields_dict, assignments,
file_sec, term_types=term_types, open_dir=open_dir,
)
# P6.5: 金额-区间对齐(N:1 集約→GRADE 匹配路径可达,DB/非DB 共通)
_coordinate_range_matching(
records, fields_dict, file_sec, open_dir, term_types,
)
except Exception as e:
logger.warning(f" cross-file key coordination skipped: {e}")
return records return records
+96 -12
View File
@@ -98,10 +98,15 @@ def parse_single_condition(text, fields=None):
if ' OF ' in text.upper(): if ' OF ' in text.upper():
text = text.split(' OF ')[0].strip() text = text.split(' OF ')[0].strip()
# COBOL class condition: WS-KEY-DGT-N NUMERIC # COBOL class condition: WS-KEY-DGT-N [IS] [NOT] NUMERIC/ALPHABETIC/...
if re.match(r'^[A-Z][A-Z0-9_-]*(?:\([^)]*\))?\s+(NUMERIC|ALPHABETIC|ALPHABETIC-UPPER|POSITIVE|NEGATIVE|ZERO)\s*$', text, re.IGNORECASE): # Return (field, 'IS', CLASS, want) — want=True → field must satisfy the class,
m = re.match(r'^([A-Z][A-Z0-9_-]*(?:\([^)]*\))?)\s+(NUMERIC|ALPHABETIC|ALPHABETIC-UPPER|POSITIVE|NEGATIVE|ZERO)\s*$', text, re.IGNORECASE) # want=False → field must NOT satisfy the class (IS NOT / NOT).
return (m.group(1), '=', m.group(2).upper()) m = re.match(
r'^([A-Z][A-Z0-9_-]*(?:\([^)]*\))?)\s+(?:IS\s+)?'
r'(NOT\s+)?(NUMERIC|ALPHABETIC|ALPHABETIC-UPPER|ALPHABETIC-LOWER|POSITIVE|NEGATIVE|ZERO)\s*$',
text, re.IGNORECASE)
if m:
return (m.group(1), 'IS', m.group(3).upper(), not bool(m.group(2)))
# Bare field reference (no operator, no NOT): WS-EOF → WS-EOF = 'Y' # Bare field reference (no operator, no NOT): WS-EOF → WS-EOF = 'Y'
if re.match(r'^[A-Z][A-Z0-9_-]*(?:\([^)]*\))?\s*$', text, re.IGNORECASE): if re.match(r'^[A-Z][A-Z0-9_-]*(?:\([^)]*\))?\s*$', text, re.IGNORECASE):
@@ -150,7 +155,7 @@ def parse_single_condition(text, fields=None):
if text.upper().startswith('FUNCTION '): if text.upper().startswith('FUNCTION '):
# After not_map normalization, NOT = has been converted to <> # After not_map normalization, NOT = has been converted to <>
func_match = re.match( func_match = re.match(
r'^FUNCTION\s+(\w+)\(([^)]*)\)\s*(>=|<=|<>|>|<|=)\s*(.*)$', r'^FUNCTION\s+(\w+)\s*\(([^)]*)\)\s*(>=|<=|<>|>|<|=)\s*(.*)$',
normalized, re.IGNORECASE normalized, re.IGNORECASE
) )
if func_match: if func_match:
@@ -244,6 +249,11 @@ def parse_compound_condition(text, fields=None):
# Leaf condition # Leaf condition
parsed = parse_single_condition(text, fields) parsed = parse_single_condition(text, fields)
if parsed: if parsed:
if len(parsed) == 4:
# class condition (field, 'IS', CLASS, want): represent negation via CondNot
field, op, cls, want = parsed
leaf = CondLeaf(field, op, cls)
return leaf if want else CondNot(leaf)
return CondLeaf(*parsed) return CondLeaf(*parsed)
return None return None
@@ -332,12 +342,69 @@ def mcdc_sets(tree, fields=None):
# ── 值计算 ── # ── 值计算 ──
def evaluate_class_value(value, class_name):
"""COBOL class-condition evaluation on a raw field value.
Returns True if `value` belongs to `class_name`.
class_name ∈ {NUMERIC, ALPHABETIC, ALPHABETIC-UPPER, ALPHABETIC-LOWER,
POSITIVE, NEGATIVE, ZERO}. Empty / all-space values never
satisfy the ALPHABETIC* / NUMERIC classes (COBOL class-condition semantics).
"""
s = str(value)
cls = str(class_name).upper()
if cls == 'ALPHABETIC-UPPER':
return bool(s) and all('A' <= ch <= 'Z' for ch in s)
if cls == 'ALPHABETIC-LOWER':
return bool(s) and all('a' <= ch <= 'z' for ch in s)
if cls == 'ALPHABETIC':
return bool(s) and all(ch.isalpha() for ch in s)
if cls == 'NUMERIC':
return bool(s) and all('0' <= ch <= '9' for ch in s)
if cls in ('POSITIVE', 'NEGATIVE', 'ZERO'):
try:
n = float(str(s).strip())
except (ValueError, TypeError):
return False
if cls == 'POSITIVE':
return n > 0
if cls == 'NEGATIVE':
return n < 0
return n == 0
return False
def satisfying_value(field_info: dict, operator: str, value, want_true: bool) -> str: def satisfying_value(field_info: dict, operator: str, value, want_true: bool) -> str:
ftype = field_info.get('type', 'unknown') ftype = field_info.get('type', 'unknown')
digits = field_info.get('digits', 0) digits = field_info.get('digits', 0)
decimal = field_info.get('decimal', 0) decimal = field_info.get('decimal', 0)
total = digits + decimal total = digits + decimal
# COBOL class-condition constraint: generate a value that is / is not in the class
if operator == 'IS':
cls = str(value).upper()
length = field_info.get('length', 1)
if cls in ('ALPHABETIC', 'ALPHABETIC-UPPER'):
if want_true:
return 'A' * length
return ('A' * (length - 1) + '0') if length > 1 else '0'
if cls == 'ALPHABETIC-LOWER':
if want_true:
return 'a' * length
return ('a' * (length - 1) + '0') if length > 1 else '0'
if cls == 'NUMERIC':
if want_true:
return '0' * length
return ('0' * max(length - 1, 1) + 'A') if length > 1 else 'A'
if cls == 'ZERO':
return '0' * max(total, 1)
if cls == 'POSITIVE':
return str(1).zfill(total) if total else '1'
if cls == 'NEGATIVE':
if field_info.get('signed'):
return '-' + '1'.zfill(max(total - 1, 1))
return '0' * max(total, 1)
return '0'.zfill(max(total, 1))
if ftype == 'numeric': if ftype == 'numeric':
try: try:
val_str = str(value) val_str = str(value)
@@ -376,13 +443,31 @@ def satisfying_value(field_info: dict, operator: str, value, want_true: bool) ->
elif ftype in ('alphanumeric', 'alphabetic'): elif ftype in ('alphanumeric', 'alphabetic'):
length = field_info.get('length', 1) length = field_info.get('length', 1)
base_chr = value[0].upper() if isinstance(value, str) and value else 'A' # 图式常量解析:SPACES/SPACE→空格、ZERO(S)/ZEROES→'0'、LOW-VALUES→\x00、
# HIGH-VALUES→\xff、QUOTE(S)→"'"。否则按值首字符作基础字符。
_uv = str(value).strip().upper() if isinstance(value, str) else ''
_FIG = {
'SPACE': ' ', 'SPACES': ' ',
'ZERO': '0', 'ZEROS': '0', 'ZEROES': '0',
'LOW-VALUE': '\x00', 'LOW-VALUES': '\x00',
'HIGH-VALUE': '\xff', 'HIGH-VALUES': '\xff',
'QUOTE': "'", 'QUOTES': "'",
}
base_chr = _FIG.get(_uv) or (value[0].upper() if isinstance(value, str) and value else 'A')
# 精确值目标:字面值(或图式常量)截断/右对齐到字段长。
# 例如 (= '0001', want=True) / (<> '0001', want=False) 应得到 '0001'
# 而非按首字符填充的 '0000'(旧行为错误)。
if _uv in _FIG:
_exact = (_FIG[_uv] * length)[:length]
else:
_exact = str(value)[:length].ljust(length)
_other = chr(65 + (ord(base_chr) - 64) % 26)
_diff = _other.ljust(length, _other)
if want_true: if want_true:
if operator in ('=', '=='): if operator in ('=', '=='):
return base_chr.ljust(length, base_chr) return _exact
elif operator in ('<>', '!='): elif operator in ('<>', '!='):
other = chr(65 + (ord(base_chr) - 64) % 26) return _diff
return other.ljust(length, other)
elif operator == '>': elif operator == '>':
sv = str(value)[:length].ljust(length) sv = str(value)[:length].ljust(length)
chars = list(sv) chars = list(sv)
@@ -403,10 +488,9 @@ def satisfying_value(field_info: dict, operator: str, value, want_true: bool) ->
return ''.join(chars) return ''.join(chars)
else: else:
if operator in ('=', '=='): if operator in ('=', '=='):
other = chr(65 + (ord(base_chr) - 64) % 26) return _diff
return other.ljust(length, other)
elif operator in ('<>', '!='): elif operator in ('<>', '!='):
return base_chr.ljust(length, base_chr) return _exact
elif operator in ('>', '<'): elif operator in ('>', '<'):
return str(value)[:length].ljust(length) return str(value)[:length].ljust(length)
+147 -11
View File
@@ -161,9 +161,17 @@ class _BrParser:
return self.lines[self.pos].strip() return self.lines[self.pos].strip()
return '' return ''
def peek_next(self):
if self.pos + 1 < len(self.lines):
return self.lines[self.pos + 1].strip()
return ''
def clean(self): def clean(self):
return self.peek().rstrip('.').strip() return self.peek().rstrip('.').strip()
def clean_next(self):
return self.peek_next().rstrip('.').strip()
def advance(self): def advance(self):
self.pos += 1 self.pos += 1
@@ -217,6 +225,38 @@ class _BrParser:
if perf_node: if perf_node:
seq.add(perf_node) seq.add(perf_node)
continue continue
m_sm = re.match(r'^(MERGE|SORT)\s+(\w[\w-]*)\s*$', line)
if m_sm:
sort_file = m_sm.group(2).strip()
self.advance()
# 收集 MERGE/SORT 语句的续行(直到以 . 结尾)
stmt_parts = [line]
while self.pos < len(self.lines):
raw = self.peek()
stmt_parts.append(self.clean())
self.advance()
# clean() 会剥离末尾句点,须用原始行判断语句终止;
# 否则该循环永不退出,吞掉 SORT/MERGE 后的全部语句。
if raw.rstrip().endswith('.'):
break
stmt_text = ' '.join(stmt_parts)
# 提取 INPUT/OUTPUT PROCEDURE 段名并内联(如同 PERFORM 段)
for pm in re.finditer(
r'\b(?:INPUT|OUTPUT)\s+PROCEDURE\s+(\w[\w-]*)',
stmt_text, re.IGNORECASE
):
sec = pm.group(1).strip().upper()
if sec in self.paragraphs:
start, end = self.paragraphs[sec]
para_lines = self.raw_lines[start:end + 1]
sub = _BrParser(
[l for l in para_lines if l.strip()],
self.paragraphs, self.raw_lines, self.assignments, self.fields
)
sub_seq = sub.parse_seq()
for child in sub_seq.children:
seq.add(child)
continue
m_search = re.match(r'^SEARCH\b(?:\s+(ALL))?\s+(\w[\w-]*)(?:\s+VARYING\s+(\w[\w-]*))?', line, re.IGNORECASE) m_search = re.match(r'^SEARCH\b(?:\s+(ALL))?\s+(\w[\w-]*)(?:\s+VARYING\s+(\w[\w-]*))?', line, re.IGNORECASE)
if m_search: if m_search:
seq.add(self._parse_search(m_search)) seq.add(self._parse_search(m_search))
@@ -277,6 +317,23 @@ class _BrParser:
break break
self.advance() self.advance()
continue continue
m = re.match(r'^READ\s+(\w[\w-]*)\s*$', line, re.IGNORECASE)
if m and self.peek_next().startswith('INTO'):
tgt = self.clean_next().replace('INTO', '', 1).strip().upper()
if re.match(r'^\w[\w-]*$', tgt):
info = {'type': 'read_into', 'file': m.group(1).strip().upper(), 'source_vars': []}
self.assignments.setdefault(tgt, []).append(info)
seq.add(Assign(tgt, info))
self.advance()
self.advance()
# 跳过 READ 语句剩余行(AT END / NOT AT END / END-READ
while self.pos < len(self.lines):
cl = self.clean()
if cl in ('END-READ', 'END-READ.'):
self.advance()
break
self.advance()
continue
m_set_false = re.match(r'^SET\s+(\w[\w-]*)\s+TO\s+FALSE\s*$', line, re.IGNORECASE) m_set_false = re.match(r'^SET\s+(\w[\w-]*)\s+TO\s+FALSE\s*$', line, re.IGNORECASE)
if m_set_false: if m_set_false:
seq.add(self._parse_set_false(m_set_false.group(1))) seq.add(self._parse_set_false(m_set_false.group(1)))
@@ -796,6 +853,16 @@ class _BrParser:
def _parse_perform(self): def _parse_perform(self):
line = self.clean() line = self.clean()
# PERFORM WITH TEST AFTER/BEFORE UNTIL ...(可换行)
m = re.match(r'^PERFORM\s+(?:WITH\s+TEST\s+(?:AFTER|BEFORE)\s+)?UNTIL\s+(.+?)\s*$', line)
if m:
node = BrPerform('until', condition=m.group(1).strip())
self.advance()
node.body_seq = self.parse_seq(end_check=lambda l: l == 'END-PERFORM')
if self.clean() == 'END-PERFORM':
self.advance()
return node
m = re.match(r'^PERFORM\s+UNTIL\s+(.+?)\s*$', line) m = re.match(r'^PERFORM\s+UNTIL\s+(.+?)\s*$', line)
if m: if m:
node = BrPerform('until', condition=m.group(1).strip()) node = BrPerform('until', condition=m.group(1).strip())
@@ -965,6 +1032,28 @@ class _BrParser:
self._inline_perform(node, target) self._inline_perform(node, target)
return node return node
# PERFORM WITH TEST AFTER/BEFOREUNTIL 在下一行)
m = re.match(r'^PERFORM\s+WITH\s+TEST\s+(?:AFTER|BEFORE)\s*$', line)
if m:
save_pos = self.pos
condition = None
self.advance()
if self.pos < len(self.lines):
nxt = self.clean()
um = re.match(r'^UNTIL\s+(.+)$', nxt)
if um:
condition = um.group(1).strip()
self.advance()
if condition:
node = BrPerform('until', condition=condition)
node.body_seq = self.parse_seq(end_check=lambda l: l == 'END-PERFORM')
if self.clean() == 'END-PERFORM':
self.advance()
return node
self.pos = save_pos
self.advance()
return None
self.advance() self.advance()
return None return None
@@ -1189,24 +1278,24 @@ class _BrParser:
_RE_SELECT_INTO = re.compile( _RE_SELECT_INTO = re.compile(
r'SELECT\s+(.*?)\s+INTO\s+(:\w[\w-]*(?:\s*,\s*:\w[\w-]*(?::\w[\w-]*)?)*)' r'SELECT\s+(.*?)\s+INTO\s+(:\w[\w-]*(?:\s*,\s*:\w[\w-]*(?::\w[\w-]*)?)*)'
r'\s+FROM\s+(\w[\w-]*)', r'\s+FROM\s+([\w-]+(?:\.[\w-]+)?)',
re.IGNORECASE re.IGNORECASE
) )
_RE_WHERE = re.compile(r'\bWHERE\b\s+(.*)', re.IGNORECASE) _RE_WHERE = re.compile(r'\bWHERE\b\s+(.*)', re.IGNORECASE)
_RE_SQL_INSERT = re.compile( _RE_SQL_INSERT = re.compile(
r'INSERT\s+INTO\s+(\w[\w-]*)\s*\(([^)]+)\)\s+VALUES\s*\(([^)]+)\)', r'INSERT\s+INTO\s+([\w-]+(?:\.[\w-]+)?)\s*\(([^)]+)\)\s+VALUES\s*\(([^)]+)\)',
re.IGNORECASE re.IGNORECASE
) )
_RE_SQL_DELETE = re.compile( _RE_SQL_DELETE = re.compile(
r'DELETE\s+FROM\s+(\w[\w-]*)(?:\s+WHERE\s+(.+))?', r'DELETE\s+FROM\s+([\w-]+(?:\.[\w-]+)?)(?:\s+WHERE\s+(.+))?',
re.IGNORECASE re.IGNORECASE
) )
_RE_SQL_UPDATE = re.compile( _RE_SQL_UPDATE = re.compile(
r'UPDATE\s+(\w[\w-]*)\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$', r'UPDATE\s+([\w-]+(?:\.[\w-]+)?)\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$',
re.IGNORECASE re.IGNORECASE
) )
@@ -1229,7 +1318,7 @@ class _BrParser:
result = re.sub(r'\s+', ' ', result) result = re.sub(r'\s+', ' ', result)
return result return result
def _parse_sql(self, sql_text: str): def _parse_sql(self, sql_text: str, pos: int = None):
"""Parse SQL text from EXEC SQL block. Returns Assign node or None.""" """Parse SQL text from EXEC SQL block. Returns Assign node or None."""
# 1) SELECT ... INTO ... FROM # 1) SELECT ... INTO ... FROM
m = self._RE_SELECT_INTO.search(sql_text) m = self._RE_SELECT_INTO.search(sql_text)
@@ -1259,6 +1348,7 @@ class _BrParser:
'into_vars': into_vars, 'into_vars': into_vars,
'where': where_clause, 'where': where_clause,
'sql_text': sql_text, 'sql_text': sql_text,
'pos': pos,
} }
for var in into_vars: for var in into_vars:
@@ -1288,6 +1378,7 @@ class _BrParser:
'raw_values': values_str, 'raw_values': values_str,
'host_vars': host_vars, 'host_vars': host_vars,
'sql_text': sql_text, 'sql_text': sql_text,
'pos': pos,
} }
synthetic = f'__SQL_INSERT_{table}' synthetic = f'__SQL_INSERT_{table}'
self.assignments.setdefault(synthetic, []).append(info) self.assignments.setdefault(synthetic, []).append(info)
@@ -1307,6 +1398,7 @@ class _BrParser:
'where': where_clause, 'where': where_clause,
'host_vars': [h.upper() for h in host_vars], 'host_vars': [h.upper() for h in host_vars],
'sql_text': sql_text, 'sql_text': sql_text,
'pos': pos,
} }
synthetic = f'__SQL_DELETE_{table}' synthetic = f'__SQL_DELETE_{table}'
self.assignments.setdefault(synthetic, []).append(info) self.assignments.setdefault(synthetic, []).append(info)
@@ -1314,7 +1406,7 @@ class _BrParser:
# 4a) DECLARE CURSOR ... FOR SELECT ... FROM ... # 4a) DECLARE CURSOR ... FOR SELECT ... FROM ...
m = re.search( m = re.search(
r'DECLARE\s+(\w[\w-]*)\s+CURSOR\s+FOR\s+SELECT\s+(.*?)\s+FROM\s+(\w[\w-]*)\s*(.*)', r'DECLARE\s+(\w[\w-]*)\s+CURSOR\s+FOR\s+SELECT\s+(.*?)\s+FROM\s+([\w-]+(?:\.[\w-]+)?)\s*(.*)',
sql_text, re.IGNORECASE sql_text, re.IGNORECASE
) )
if m: if m:
@@ -1336,6 +1428,7 @@ class _BrParser:
'into_vars': [], 'into_vars': [],
'where': where_clause, 'where': where_clause,
'sql_text': sql_text, 'sql_text': sql_text,
'pos': pos,
} }
synthetic = f'__SQL_CURSOR_{from_table}' synthetic = f'__SQL_CURSOR_{from_table}'
self.assignments.setdefault(synthetic, []).append(info) self.assignments.setdefault(synthetic, []).append(info)
@@ -1365,6 +1458,7 @@ class _BrParser:
'cursor_name': cursor_name, 'cursor_name': cursor_name,
'into_vars': into_vars, 'into_vars': into_vars,
'sql_text': sql_text, 'sql_text': sql_text,
'pos': pos,
} }
for var in into_vars: for var in into_vars:
self.assignments.setdefault(var, []).append(info) self.assignments.setdefault(var, []).append(info)
@@ -1396,6 +1490,7 @@ class _BrParser:
'where': where_clause, 'where': where_clause,
'host_vars': host_vars, 'host_vars': host_vars,
'sql_text': sql_text, 'sql_text': sql_text,
'pos': pos,
} }
synthetic = f'__SQL_UPDATE_{table}' synthetic = f'__SQL_UPDATE_{table}'
self.assignments.setdefault(synthetic, []).append(info) self.assignments.setdefault(synthetic, []).append(info)
@@ -1477,13 +1572,54 @@ def trace_to_root(field_name, assignments, fields, path_assign=None):
var = next_var var = next_var
if next_var not in assignments: if next_var not in assignments:
break break
elif len(sv) >= 2 and asgn.get('op') == '+': elif len(sv) >= 2 and asgn.get('op') in ('+', '-'):
# 多源加:取第一个源变量继续追溯 # 多源加:取第一个源变量继续追溯
# 若其余源为常量字段(有 VALUE),折叠为单源 compute 供 invert 使用
folded = _fold_constants(asgn, fields)
if folded is not None:
chain[-1] = (var, folded)
var = sv[0] var = sv[0]
else: else:
break break
return var, chain return var, chain
def _fold_constants(asgn, fields):
"""将 2 源 compute 折叠为单源 compute + const,供链式反演使用。
例如: T = A - CSTCST 为 WORKING-STORAGE 常量字段)
→ 折叠为: T = A op const,其中 op 反转为 +invert 时把 const 加回)。
返回新 asgn dict;若无法解析常量则返回 None(保持原 asgn)。
"""
if not asgn.get('source_vars') or len(asgn['source_vars']) != 2:
return None
sv = asgn['source_vars']
second = sv[1]
def _resolve(name):
for f in fields:
fname = f['name'] if isinstance(f, dict) else getattr(f, 'name', '')
if fname == name:
val = f.get('value', None) if isinstance(f, dict) else getattr(f, 'value', None)
if val is None:
val = (f.get('values') or [None])[0] if isinstance(f, dict) else \
getattr(f, 'values', None)
if isinstance(val, list):
val = val[0] if val else None
if val is not None:
try:
return float(str(val).strip("'\""))
except (ValueError, TypeError):
return None
return None
c = _resolve(second)
if c is None:
return None
new = dict(asgn)
new['source_vars'] = [sv[0]]
new['const'] = c
return new
def invert_through_chain(root_var, chain, operator, value): def invert_through_chain(root_var, chain, operator, value):
op = operator op = operator
@@ -1964,10 +2100,10 @@ def classify_field_roles(tree, assignments, fields, source=None, proc_text=None)
# Phase 0: FD/OPEN 方向解析 # Phase 0: FD/OPEN 方向解析
fd_roles = {} fd_roles = {}
if source and proc_text: if source and proc_text:
from .read import parse_file_control, parse_file_section, scan_open_statements from .read import parse_file_control, parse_file_section, scan_all_file_directions
file_ctl = parse_file_control(source) file_ctl = parse_file_control(source)
file_sec = parse_file_section(source) file_sec = parse_file_section(source)
open_dir = scan_open_statements(proc_text) open_dir = scan_all_file_directions(proc_text)
for iname, direction in open_dir.items(): for iname, direction in open_dir.items():
if iname in file_sec: if iname in file_sec:
for rec_name in file_sec[iname]: for rec_name in file_sec[iname]:
@@ -2164,6 +2300,6 @@ def extract_sql_assignments(source: str) -> dict:
for m in _RE_EXEC_SQL.finditer(source): for m in _RE_EXEC_SQL.finditer(source):
sql_text = re.sub(r'\s+', ' ', m.group(1).strip()) sql_text = re.sub(r'\s+', ' ', m.group(1).strip())
parser._parse_sql(sql_text) parser._parse_sql(sql_text, pos=m.start())
return dict(parser.assignments) return dict(parser.assignments)
+75 -21
View File
@@ -229,17 +229,25 @@ def _mark_if(dp, cons):
simple = getattr(dp, 'parsed', None) simple = getattr(dp, 'parsed', None)
if simple: if simple:
field, op, val = simple if len(simple) == 4:
inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op) # class condition (field, 'IS', CLASS, base_want):
inv_simple = (field, inv_op, val) # path want == base_want → T branch; == not base_want → F branch
for c in cons: field, op, val, base_want = simple
if _match_constraint(c, simple): for c in cons:
if c[3]: if _match_constraint(c, (field, op, val)):
dp.active_branches.add('T') dp.active_branches.add('T' if c[3] == base_want else 'F')
else: else:
field, op, val = simple
inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op)
inv_simple = (field, inv_op, val)
for c in cons:
if _match_constraint(c, simple):
if c[3]:
dp.active_branches.add('T')
else:
dp.active_branches.add('F')
elif _match_constraint(c, inv_simple):
dp.active_branches.add('F') dp.active_branches.add('F')
elif _match_constraint(c, inv_simple):
dp.active_branches.add('F')
elif dp.cond_tree and dp.cond_leaves: elif dp.cond_tree and dp.cond_leaves:
assignment = {} assignment = {}
for leaf in dp.cond_leaves: for leaf in dp.cond_leaves:
@@ -411,17 +419,24 @@ def _mark_perform(dp, cons):
simple = getattr(dp, 'parsed', None) simple = getattr(dp, 'parsed', None)
if simple: if simple:
field, op, val = simple if len(simple) == 4:
inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op) # class condition (field, 'IS', CLASS, base_want): path want == base_want → Skip
inv_simple = (field, inv_op, val) field, op, val, base_want = simple
for c in cons: for c in cons:
if _match_constraint(c, simple): if _match_constraint(c, (field, op, val)):
if c[3]: dp.active_branches.add('Skip' if c[3] == base_want else 'Enter')
dp.active_branches.add('Skip') else:
else: field, op, val = simple
inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op)
inv_simple = (field, inv_op, val)
for c in cons:
if _match_constraint(c, simple):
if c[3]:
dp.active_branches.add('Skip')
else:
dp.active_branches.add('Enter')
elif _match_constraint(c, inv_simple):
dp.active_branches.add('Enter') dp.active_branches.add('Enter')
elif _match_constraint(c, inv_simple):
dp.active_branches.add('Enter')
elif dp.cond_tree and dp.cond_leaves: elif dp.cond_tree and dp.cond_leaves:
assignment = {} assignment = {}
for leaf in dp.cond_leaves: for leaf in dp.cond_leaves:
@@ -482,7 +497,42 @@ def locate_decision_lines(decision_points, raw_source):
if re.search(short_pat, lines[i]): if re.search(short_pat, lines[i]):
dp.source_line = i + 1 dp.source_line = i + 1
used_indices[dp.label] = i used_indices[dp.label] = i
found = True
break break
# Multi-line fallback 2: 条件被换行拆分(如 IF MERGE-REC-TYPE / = CONST
# 用条件首个字段定位 IF 起始行(gcov 分支标记需要源行)
if not found and dp.kind == 'IF':
first_field_pat = _build_first_field_if_pattern(dp)
if first_field_pat:
for i in range(start, len(lines)):
if re.search(first_field_pat, lines[i]):
dp.source_line = i + 1
used_indices[dp.label] = i
break
def _build_first_field_if_pattern(dp):
"""Build a pattern matching 'IF <首字段>' — 用于条件被换行拆分的 IF。
例:条件 'MERGE-REC-TYPE = CNS-PAY-TYPE-SALARY' 在源码中写作
IF MERGE-REC-TYPE
= CNS-PAY-TYPE-SALARY
单行模式无法匹配;此模式仅匹配 'IF MERGE-REC-TYPE' 定位起始行。
"""
if dp.kind != 'IF':
return None
label = dp.label or ''
cond = label[2:].strip() if label.upper().startswith('IF ') else label
cond = cond.strip()
if not cond:
return None
# 去除前导 NOT / 括号,取首字段名
m = re.match(r"^(?:NOT\s+)?\(?([A-Z][A-Z0-9_-]*)", cond, re.IGNORECASE)
if not m:
return None
first = m.group(1)
esc = re.escape(first)
return r'\bIF\b\s+' + esc + r'\b'
def _normalize(text): def _normalize(text):
@@ -494,7 +544,11 @@ def _normalize(text):
def _build_search_patterns(dp): def _build_search_patterns(dp):
texts = [] texts = []
if dp.kind == 'IF': if dp.kind == 'IF':
texts.append((r'\bIF\b', dp.label)) # dp.label is already like "IF SQLCODE = -803" — avoid emitting
# "\bIF\b\s+IF\s+..." which never matches a real source line.
label = dp.label or ''
cond = label[2:].strip() if label.upper().startswith('IF ') else label
texts.append((r'\bIF\b', cond))
elif dp.kind == 'EVALUATE': elif dp.kind == 'EVALUATE':
texts.append((r'\bEVALUATE\b', dp.label)) texts.append((r'\bEVALUATE\b', dp.label))
elif dp.kind == 'PERFORM': elif dp.kind == 'PERFORM':
+4 -4
View File
@@ -31,16 +31,16 @@ def _dedup(
def _hash(rec, keys): def _hash(rec, keys):
if keys: if keys:
return tuple(rec.get(k, "") for k in keys) return tuple(str(rec.get(k, "")) for k in keys)
return tuple(sorted(rec.items())) return tuple((k, str(v) if isinstance(v, (list, dict, set)) else v) for k, v in sorted(rec.items()))
for rec in additional_records: for rec in main_records:
h = _hash(rec, key_fields) h = _hash(rec, key_fields)
if h not in seen: if h not in seen:
seen.add(h) seen.add(h)
result.append(rec) result.append(rec)
for rec in main_records: for rec in additional_records:
h = _hash(rec, key_fields) h = _hash(rec, key_fields)
if h not in seen: if h not in seen:
seen.add(h) seen.add(h)
+175 -36
View File
@@ -2,8 +2,9 @@
import re import re
import logging import logging
import os
from .models import BrSeq, BrIf, BrEval, BrPerform, BrSearch, Assign, CallNode, CondNot, CondLeaf, ExitNode, GoTo from .models import BrSeq, BrIf, BrEval, BrPerform, BrSearch, Assign, CallNode, CondNot, CondLeaf, ExitNode, GoTo
from .cond import parse_single_condition, parse_compound_condition, is_field, collect_leaves, mcdc_sets, satisfying_value from .cond import parse_single_condition, parse_compound_condition, is_field, collect_leaves, mcdc_sets, satisfying_value, evaluate_class_value
from .core import trace_to_root, invert_through_chain, propagate_assignments, _basename from .core import trace_to_root, invert_through_chain, propagate_assignments, _basename
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -60,7 +61,7 @@ def _has_t_branch(cons):
for c in cons: for c in cons:
if len(c) >= 4 and c[0] == "__DP" and c[2] == "T": if len(c) >= 4 and c[0] == "__DP" and c[2] == "T":
return True return True
if c[3]: if len(c) >= 4 and c[3]:
return True return True
return False return False
@@ -68,16 +69,16 @@ def _has_f_branch(cons):
for c in cons: for c in cons:
if len(c) >= 4 and c[0] == "__DP" and c[2] == "F": if len(c) >= 4 and c[0] == "__DP" and c[2] == "F":
return True return True
if not c[3]: if len(c) >= 4 and not c[3]:
return True return True
return False return False
def _cap_paths(paths): def _cap_paths(paths):
if len(paths) > _MAX_PATHS: if len(paths) > _MAX_PATHS:
special = [(i, p) for i, p in enumerate(paths) if any(_is_sentinel(c) for c in p)] special = [(i, p) for i, p in enumerate(paths) if any(_is_sentinel(c) for c in p[0])]
std = [(i, p) for i, p in enumerate(paths) if not any(_is_sentinel(c) for c in p)] std = [(i, p) for i, p in enumerate(paths) if not any(_is_sentinel(c) for c in p[0])]
t_paths = [(i, p) for i, p in std if _has_t_branch(p)] t_paths = [(i, p) for i, p in std if _has_t_branch(p[0])]
f_paths = [(i, p) for i, p in std if _has_f_branch(p)] f_paths = [(i, p) for i, p in std if _has_f_branch(p[0])]
quota = _MAX_PATHS - len(special) quota = _MAX_PATHS - len(special)
if quota <= 0: if quota <= 0:
return [p for _, p in special[:_MAX_PATHS]] return [p for _, p in special[:_MAX_PATHS]]
@@ -210,7 +211,11 @@ def enum_paths(node, fields):
continue continue
for cp_cons, cp_assign in child_paths: for cp_cons, cp_assign in child_paths:
merged_cons = p_cons + list(cp_cons) merged_cons = p_cons + list(cp_cons)
sig = frozenset(_hashable_cons(merged_cons)) # 签名需保留约束的多重性:同一 (字段,运算符,值,期望) 在顺序
# IF 中重复出现(如连续 C01CHKRRC 校验)时,frozenset 会把
# 多重出现的约束折叠为单元素,导致"前面全部通过、最后一个校验
# 失败"等中间路径被去重丢失。改用按 repr 排序的元组签名。
sig = tuple(sorted(_hashable_cons(merged_cons), key=repr))
if sig not in covered_sigs: if sig not in covered_sigs:
covered_sigs.add(sig) covered_sigs.add(sig)
merged = {} merged = {}
@@ -223,12 +228,24 @@ def enum_paths(node, fields):
if not any(_is_sentinel(c) for c in pc): if not any(_is_sentinel(c) for c in pc):
new_active.append((pc, dict(pa))) new_active.append((pc, dict(pa)))
break break
paths = new_active paths = _cap_paths(new_active)
return paths return paths
elif isinstance(node, BrIf): elif isinstance(node, BrIf):
parsed = parse_single_condition(node.condition, fields) parsed = parse_single_condition(node.condition, fields)
if parsed and is_field(parsed[0], fields): if parsed and is_field(parsed[0], fields):
if len(parsed) == 4:
# class condition (field, 'IS', CLASS, base_want):
# T branch requires leaf truth = base_want, F branch = not base_want
field, op, val, base_want = parsed
paths = []
true_sub = _cap_paths(enum_paths(node.true_seq, fields))
for sp_cons, sp_assign in (true_sub or [([], {})]):
paths.append(([(field, op, val, base_want)] + sp_cons, sp_assign))
false_sub = _cap_paths(enum_paths(node.false_seq, fields))
for fp_cons, fp_assign in (false_sub or [([], {})]):
paths.append(([(field, op, val, not base_want)] + fp_cons, fp_assign))
return paths
field, op, val = parsed field, op, val = parsed
paths = [] paths = []
true_sub = _cap_paths(enum_paths(node.true_seq, fields)) true_sub = _cap_paths(enum_paths(node.true_seq, fields))
@@ -275,7 +292,7 @@ def enum_paths(node, fields):
paths.append(([(leaf.field, leaf.op, leaf.value, False)] + fp_cons, fp_assign)) paths.append(([(leaf.field, leaf.op, leaf.value, False)] + fp_cons, fp_assign))
return paths return paths
# Fallback: parsed condition but non-field (e.g. arithmetic expr) # Fallback: parsed condition but non-field (e.g. arithmetic expr)
if parsed: if parsed and len(parsed) == 3:
field, op, val = parsed field, op, val = parsed
paths = [] paths = []
true_sub = enum_paths(node.true_seq, fields) true_sub = enum_paths(node.true_seq, fields)
@@ -430,7 +447,7 @@ def enum_paths(node, fields):
elif node.perf_type in ('until', 'para_until', 'varying', 'para_varying'): elif node.perf_type in ('until', 'para_until', 'varying', 'para_varying'):
# 尝试单条件(现有逻辑) # 尝试单条件(现有逻辑)
parsed = parse_single_condition(node.condition, fields) parsed = parse_single_condition(node.condition, fields)
if parsed and is_field(parsed[0], fields): if parsed and is_field(parsed[0], fields) and len(parsed) == 3:
field, op, val = parsed field, op, val = parsed
paths = [] paths = []
# Skip 路径放在首位,确保不被 _cap_paths 截断丢失 # Skip 路径放在首位,确保不被 _cap_paths 截断丢失
@@ -890,6 +907,8 @@ def _check_constraint_satisfied(rec, field_name, operator, value, want_true, fie
val = rec.get(field_name) val = rec.get(field_name)
if val is None: if val is None:
return False return False
if operator == 'IS':
return evaluate_class_value(val, value) == want_true
if operator == 'not_in': if operator == 'not_in':
cases = value if isinstance(value, list) else [] cases = value if isinstance(value, list) else []
return str(val) not in cases return str(val) not in cases
@@ -909,22 +928,32 @@ def _check_constraint_satisfied(rec, field_name, operator, value, want_true, fie
return ok == want_true return ok == want_true
return True return True
else: else:
s_val = str(val).strip().upper() s_val = str(val)
s_target = str(value).strip().upper() s_target = str(value)
eq = s_val == s_target # 图式常量:SPACES/SPACE 按空白比较(不剥离),否则按去除两端空白比较
_sv = s_val.strip().upper()
_tv = s_target.strip().upper()
if _tv in ('SPACE', 'SPACES'):
eq = (s_val.strip() == '')
elif _tv == 'LOW-VALUE':
eq = (s_val.strip('\x00') == '')
elif _tv == 'HIGH-VALUE':
eq = (s_val.strip('\xff') == '')
else:
eq = (_sv == _tv)
if operator == '=': if operator == '=':
return eq == want_true return eq == want_true
elif operator == '<>': elif operator == '<>':
return (not eq) == want_true return (not eq) == want_true
elif operator in ('>', '<', '>=', '<='): elif operator in ('>', '<', '>=', '<='):
if operator == '>': if operator == '>':
ok = s_val > s_target ok = _sv > _tv
elif operator == '<': elif operator == '<':
ok = s_val < s_target ok = _sv < _tv
elif operator == '>=': elif operator == '>=':
ok = s_val >= s_target ok = _sv >= _tv
elif operator == '<=': elif operator == '<=':
ok = s_val <= s_target ok = _sv <= _tv
return ok == want_true return ok == want_true
return True return True
return False return False
@@ -1226,6 +1255,19 @@ def apply_constraint(rec, field_name, operator, value, want_true, fields, assign
# 如果当前值已满足该约束,跳过覆盖(保持先前约束的一致性) # 如果当前值已满足该约束,跳过覆盖(保持先前约束的一致性)
# 但零值时强制使用边界值(非 0/非 min) # 但零值时强制使用边界值(非 0/非 min)
# 特例:字母数字字段 "字段 <> 空白" 期望为真时,即使当前值已含非空白字符,
# 也要强制整字段填满非空白。这是因为源条件可能使用引用修改(如
# WRK-C-INSURED-NO(11:)),仅"有非空白字符"不足以满足截取子串的判定。
_uv2 = str(value).strip().upper() if isinstance(value, str) else ''
if (want_true and operator in ('<>', '!=') and _uv2 in ('SPACE', 'SPACES')):
_fd2 = next((f for f in fields if f['name'] == field_name), None)
if _fd2 and _fd2.get('pic_info', {}).get('type') in ('alphanumeric', 'alphabetic'):
_pi2 = _fd2['pic_info']
_len2 = _pi2.get('length', 1)
_cur2 = str(rec.get(field_name, ''))
if _cur2.strip() and len(_cur2.rstrip()) < _len2:
rec[field_name] = ('U' * _len2)
return
if _check_constraint_satisfied(rec, field_name, operator, value, want_true, fields): if _check_constraint_satisfied(rec, field_name, operator, value, want_true, fields):
cur = str(rec.get(field_name, '')).strip('0') cur = str(rec.get(field_name, '')).strip('0')
if (cur == '' or cur == '.') and ( if (cur == '' or cur == '.') and (
@@ -1578,27 +1620,30 @@ def generate_records(path_infos, data_fields, base_assignments=None, file_sec=No
pass pass
if skip_impossible: if skip_impossible:
continue continue
# Pass B.0: CALL 返回码一致性 — 将要求返回码非零的约束转为入参无效化 # Pass B.0: CALL 返回码一致性 — 按调用级联顺序定位"首个失败"的校验入参。
# 仅将导致该次校验失败的字段置为无效,其余级联字段修复为有效值,
# 使路径真正执行到目标校验分支(修复先前"全部入参无效化"导致提前分支底的问题)。
_b0_invalidated = set() _b0_invalidated = set()
new_cons = [] new_cons = []
_b0_rrc_counter = 0 _b0_rrc_counter = 0
_b0_cascade_plan = _plan_cascade_failures(path_cons, base_assignments)
# 对每个外部校验家系应用级联修复:首个"要求失败"的入参置无效,
# 其余入参修复为有效值;全通过(fail_pos=None)则全部修复为有效值。
# 覆盖"前面校验通过、最后一项校验失败"与"全部通过"两类路径。
for _fam, _plan in (_b0_cascade_plan or {}).items():
_feeds = _plan.get('feeds') or []
_fail_pos = _plan.get('fail_pos')
for _i, _src in enumerate(_feeds):
if _src not in rec:
continue
if _fail_pos is not None and _i == _fail_pos:
_set_invalid_value(rec, _src, data_fields, base_assignments)
_b0_invalidated.add(_src)
else:
_repair_valid_checked_field(rec, _src, data_fields)
for c in path_cons: for c in path_cons:
if len(c) == 4 and c[1] == '<>' and c[3] and c[0].endswith('RRC'): if len(c) == 4 and c[1] == '<>' and c[0].endswith('RRC'):
rrc_field = c[0]
prefix = rrc_field[:-3]
_b0_rrc_counter += 1 _b0_rrc_counter += 1
for tgt, asgn_list in base_assignments.items():
if tgt.startswith(prefix) and tgt != rrc_field:
for asgn in asgn_list:
atyp = asgn.get('type', '').upper()
src = None
if atyp == 'MOVE':
src = asgn.get('src', asgn.get('source_vars', [None])[0] if asgn.get('source_vars') else None)
elif atyp == 'move' and asgn.get('source_vars'):
src = asgn['source_vars'][0]
if src and isinstance(src, str) and src in rec:
_set_invalid_value(rec, src, data_fields)
_b0_invalidated.add(src)
continue continue
new_cons.append(c) new_cons.append(c)
path_cons = new_cons path_cons = new_cons
@@ -1744,6 +1789,10 @@ def generate_records(path_infos, data_fields, base_assignments=None, file_sec=No
_status_0or1 = str(rec.get('WRK-CSV-STATUS', '')).strip() in ('0', '1') _status_0or1 = str(rec.get('WRK-CSV-STATUS', '')).strip() in ('0', '1')
if _emp_id_spaces and _status_0or1 and 'WRK-CSV-APPL-DATE' in rec: if _emp_id_spaces and _status_0or1 and 'WRK-CSV-APPL-DATE' in rec:
_is_c01chk_want_true = True _is_c01chk_want_true = True
# 命令行馈入字段 → 运行时命令行参数(供运行器传递,覆盖 PARM 校验分支)
_cli_args = _collect_cli_args(rec, base_assignments, data_fields)
if _cli_args:
rec['__CLI_ARGS__'] = _cli_args
records.append(rec) records.append(rec)
kept_path_cons.append(path_cons) kept_path_cons.append(path_cons)
term_types.append(term_type) term_types.append(term_type)
@@ -1861,15 +1910,105 @@ def _sync_unstring_targets_from_output(rec, base_assignments, data_fields):
rec[src] = tgt_val rec[src] = tgt_val
def _set_invalid_value(rec, field_name, data_fields): def _set_invalid_value(rec, field_name, data_fields, assignments=None):
"""将字段设为无效值,用于触发 CALL 返回码非零。""" """将字段设为无效值,用于触发 CALL 返回码非零。"""
# 命令行馈入字段(ACCEPT ... FROM COMMAND-LINE):空白会被程序当作缺省值
# 处理(如 IF x = SPACES MOVE '202605'),因此必须用非空白无效值;
# 否则运行时取缺省值导致 PARM 校验分支(ABEND 路径)无法覆盖。
_is_cli = False
if assignments:
_al = assignments.get(field_name)
_al = _al if isinstance(_al, list) else ([_al] if _al else [])
for _a in _al:
if _a.get('type') == 'accept':
_is_cli = True
break
for f in data_fields: for f in data_fields:
if f['name'] == field_name: if f['name'] == field_name:
pi = f.get('pic_info', {}) pi = f.get('pic_info', {})
ftype = pi.get('type', '') ftype = pi.get('type', '')
length = pi.get('length', 0) or pi.get('digits', 0) + pi.get('decimal', 0) length = pi.get('length', 0) or pi.get('digits', 0) + pi.get('decimal', 0)
if ftype in ('alphanumeric', 'alphabetic'): if ftype in ('alphanumeric', 'alphabetic'):
rec[field_name] = ' ' * length if _is_cli:
rec[field_name] = ('Z' * length) if length else 'ZZZZZZ'
else:
rec[field_name] = ' ' * length
else: else:
rec[field_name] = '9' * length rec[field_name] = '9' * length
return return
def _collect_cli_args(rec, base_assignments, data_fields):
"""收集命令行馈入字段(ACCEPT ... FROM COMMAND-LINE)的运行时参数值。
返回 {字段名: 值},供运行器作为程序命令行参数传递。通用、无程序硬编码。
"""
if not base_assignments:
return {}
cli = {}
for f in data_fields:
name = f['name']
if name in rec:
_al = base_assignments.get(name)
_al = _al if isinstance(_al, list) else ([_al] if _al else [])
if any(_a.get('type') == 'accept' for _a in _al):
cli[name] = str(rec.get(name, ''))
return cli
def _plan_cascade_failures(path_cons, base_assignments):
"""预计算每个外部校验家系(如 C01CHK)的"首个失败"校验位置与级联入参顺序。
返回:{ family: {'feeds': [入参字段...], 'fail_pos': int|None} }
- feeds:按调用顺序排列的校验入参字段(C01CHKDAT 的 MOVE 源)。
- fail_pos:本路径中首个要求返回码非零的校验下标(无则为 None —— 全通过)。
该映射与 target 无关、不含程序名硬编码,对所有使用外部校验子(SUB04 等)重命名的 family 通用。
"""
plan = {}
feeds_map = {}
for tgt, alist in (base_assignments or {}).items():
for a in alist:
atyp = str(a.get('type', '')).upper()
if atyp in ('MOVE', 'MOVE_LITERAL') and a.get('source_vars'):
for src in a.get('source_vars', []):
if isinstance(src, str):
feeds_map.setdefault(tgt, []).append(src)
# 返回码字段形如 family+'RRC';入参数据字段形如 family+'DAT'<校验数据>)。
# 汇总同一 family 的 需要/通过 标志,取首个"要求失败"处为 fail_pos。
want_order = []
for c in path_cons:
if len(c) == 4 and c[1] == '<>' and c[0].endswith('RRC'):
want_order.append((c[0][:-3], bool(c[3])))
fam_wants = {}
for fam, want in want_order:
fam_wants.setdefault(fam, []).append(want)
for fam, wants in fam_wants.items():
data_field = None
for tgt in feeds_map:
if tgt.startswith(fam) and 'DAT' in tgt.upper():
data_field = tgt
break
feeds = feeds_map.get(data_field, []) if data_field else []
fail_pos = None
for i, want in enumerate(wants):
if want:
fail_pos = i
break
plan[fam] = {'feeds': feeds, 'fail_pos': fail_pos}
return plan
def _repair_valid_checked_field(rec, field_name, data_fields):
"""将校验入参修复为可通过外部校验器的有效值(日期/数字),供后续校验分支通过。"""
if field_name not in rec:
return
for f in data_fields:
if f['name'] == field_name:
pi = f.get('pic_info', {})
ftype = pi.get('type', '')
length = pi.get('length', 0) or pi.get('digits', 0) + pi.get('decimal', 0)
if ftype == 'numeric':
rec[field_name] = '1' * length if length else rec[field_name]
elif _is_date_field(field_name):
rec[field_name] = '20240115'
return
+150 -27
View File
@@ -35,6 +35,9 @@ def _invert_condition(parsed):
"""Invert a parsed condition (True ↔ False).""" """Invert a parsed condition (True ↔ False)."""
if parsed is None: if parsed is None:
return None return None
if len(parsed) == 4:
# class condition (field, 'IS', CLASS, want) → flip want
return (parsed[0], parsed[1], parsed[2], not parsed[3])
field, op, val = parsed field, op, val = parsed
inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op) inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op)
return (field, inv_op, val) return (field, inv_op, val)
@@ -78,13 +81,43 @@ def _collect_all_dps(node, fields, path_cons=None, path_assign=None, depth=0, _c
t_cons = list(path_cons) t_cons = list(path_cons)
f_cons = list(path_cons) f_cons = list(path_cons)
if parsed: if parsed:
field, op, val = parsed if len(parsed) == 4:
t_cons.append((field, op, val, True)) field, op, val, base_want = parsed
f_cons.append((field, op, val, False)) t_cons.append((field, op, val, base_want))
f_cons.append((field, op, val, not base_want))
else:
field, op, val = parsed
t_cons.append((field, op, val, True))
f_cons.append((field, op, val, False))
else: else:
# Synthetic constraint for coverage matching # Generate MC/DC leaf-level constraints for compound conditions
t_cons.append(("__DP", str(dp_id), "T", True)) cond_tree = parse_compound_condition(node.condition, fields)
f_cons.append(("__DP", str(dp_id), "F", True)) if cond_tree:
sets = mcdc_sets(cond_tree, fields)
if sets:
t_set = f_set = None
for c, decision in sets:
if decision and t_set is None:
t_set = c
elif not decision and f_set is None:
f_set = c
if t_set is not None and f_set is not None:
break
if t_set:
t_cons.extend(t_set)
else:
t_cons.append(("__DP", str(dp_id), "T", True))
if f_set:
f_cons.extend(f_set)
else:
f_cons.append(("__DP", str(dp_id), "F", True))
dp["false_idx"] = 1
else:
t_cons.append(("__DP", str(dp_id), "T", True))
f_cons.append(("__DP", str(dp_id), "F", True))
else:
t_cons.append(("__DP", str(dp_id), "T", True))
f_cons.append(("__DP", str(dp_id), "F", True))
result.extend(_collect_all_dps(node.true_seq, fields, t_cons, path_assign, depth + 1, _counter)) result.extend(_collect_all_dps(node.true_seq, fields, t_cons, path_assign, depth + 1, _counter))
result.extend(_collect_all_dps(node.false_seq, fields, f_cons, path_assign, depth + 1, _counter)) result.extend(_collect_all_dps(node.false_seq, fields, f_cons, path_assign, depth + 1, _counter))
@@ -128,8 +161,12 @@ def _collect_all_dps(node, fields, path_cons=None, path_assign=None, depth=0, _c
} }
result.append(dp) result.append(dp)
if parsed: if parsed:
field, op, val = parsed if len(parsed) == 4:
body_cons = list(path_cons) + [(field, op, val, False)] field, op, val, base_want = parsed
body_cons = list(path_cons) + [(field, op, val, not base_want)]
else:
field, op, val = parsed
body_cons = list(path_cons) + [(field, op, val, False)]
else: else:
# Synthetic constraint for coverage matching # Synthetic constraint for coverage matching
body_cons = list(path_cons) + [("__DP", str(dp_id), "ENTER", True)] body_cons = list(path_cons) + [("__DP", str(dp_id), "ENTER", True)]
@@ -142,8 +179,10 @@ def _collect_all_dps(node, fields, path_cons=None, path_assign=None, depth=0, _c
result.extend(_collect_all_dps(child, fields, path_cons, path_assign, depth, _counter)) result.extend(_collect_all_dps(child, fields, path_cons, path_assign, depth, _counter))
elif isinstance(node, BrSearch): elif isinstance(node, BrSearch):
dp_id = _counter[0]
_counter[0] += 1
dp = { dp = {
"node": node, "kind": "SEARCH", "node": node, "kind": "SEARCH", "id": dp_id,
"access_constraints": list(path_cons), "access_constraints": list(path_cons),
} }
result.append(dp) result.append(dp)
@@ -165,17 +204,37 @@ def _make_path_for_branch(dp, branch_idx, fields):
dp_id = dp.get("id", 0) dp_id = dp.get("id", 0)
want_true = (branch_idx == dp.get("true_idx", 0)) want_true = (branch_idx == dp.get("true_idx", 0))
if parsed is None: if parsed is None:
# Use synthetic __DP constraint for coverage matching # Generate MC/DC leaf-level constraints for compound conditions
label = "T" if want_true else "F" cond_tree = parse_compound_condition(dp["node"].condition, fields)
constraints.append(("__DP", str(dp_id), label, True)) if cond_tree:
sets = mcdc_sets(cond_tree, fields)
if sets:
for c, decision in sets:
if decision == want_true:
constraints.extend(c)
break
else:
label = "T" if want_true else "F"
constraints.append(("__DP", str(dp_id), label, True))
else:
label = "T" if want_true else "F"
constraints.append(("__DP", str(dp_id), label, True))
else:
label = "T" if want_true else "F"
constraints.append(("__DP", str(dp_id), label, True))
node = dp["node"] node = dp["node"]
body_seq = node.true_seq if branch_idx == 0 else node.false_seq body_seq = node.true_seq if branch_idx == 0 else node.false_seq
else: else:
field, op, val = parsed if len(parsed) == 4:
if not want_true: field, op, val, base_want = parsed
field2, op2, val2 = _invert_condition(parsed) leaf_want = base_want if want_true else (not base_want)
field, op, val = field2, op2, val2 constraints.append((field, op, val, leaf_want))
constraints.append((field, op, val, True)) else:
field, op, val = parsed
if not want_true:
field2, op2, val2 = _invert_condition(parsed)
field, op, val = field2, op2, val2
constraints.append((field, op, val, True))
node = dp["node"] node = dp["node"]
body_seq = node.true_seq if branch_idx == 0 else node.false_seq body_seq = node.true_seq if branch_idx == 0 else node.false_seq
return (constraints, {}) return (constraints, {})
@@ -215,11 +274,38 @@ def _make_path_for_branch(dp, branch_idx, fields):
label = "ENTER" if branch_idx == 0 else "SKIP" label = "ENTER" if branch_idx == 0 else "SKIP"
constraints.append(("__DP", str(dp_id), label, True)) constraints.append(("__DP", str(dp_id), label, True))
return (constraints, {}) return (constraints, {})
field, op, val = parsed if len(parsed) == 4:
if branch_idx == 0: field, op, val, base_want = parsed
constraints.append((field, op, val, False)) leaf_want = (not base_want) if branch_idx == 0 else base_want
constraints.append((field, op, val, leaf_want))
else: else:
constraints.append((field, op, val, True)) field, op, val = parsed
if branch_idx == 0:
constraints.append((field, op, val, False))
else:
constraints.append((field, op, val, True))
return (constraints, {})
if kind == "SEARCH":
node = dp["node"]
dp_id = dp.get("id", 0)
n_when = len(node.when_list)
if branch_idx < n_when:
cond_text, _ = node.when_list[branch_idx]
cond_tree = node.cond_trees[branch_idx] if branch_idx < len(node.cond_trees) else None
if cond_tree and isinstance(cond_tree, CondLeaf):
base = re.sub(r"\s*\(.*?\)\s*$", "", cond_tree.field)
elem_key = f"{base}({branch_idx + 1})"
subj = cond_tree.value
if is_field(subj, fields):
# Field-to-field: constrain subject against the table element
constraints.append((subj, cond_tree.op, elem_key, True))
else:
constraints.append((elem_key, cond_tree.op, str(subj).rstrip(), True))
else:
constraints.append(("__DP", str(dp_id), "W%d" % branch_idx, True))
return (constraints, {})
# AT END branch (branch_idx == n_when): leave access constraints only
return (constraints, {}) return (constraints, {})
return ([], {}) return ([], {})
@@ -245,12 +331,39 @@ def enum_paths(node, fields):
kind = dp["kind"] kind = dp["kind"]
if kind == "IF": if kind == "IF":
true_path = _make_path_for_branch(dp, dp.get("true_idx", 0), fields) parsed = dp.get("parsed")
false_path = _make_path_for_branch(dp, dp.get("false_idx", 1) if dp.get("false_idx") is not None else 1, fields) if parsed is None:
if true_path: # Compound condition: add ALL MC/DC paths (one per leaf)
paths.append(true_path) cond_tree = parse_compound_condition(dp["node"].condition, fields)
if false_path: if cond_tree:
paths.append(false_path) sets = mcdc_sets(cond_tree, fields)
if sets:
seen = set()
for c, decision in sets:
key = frozenset(c)
if key in seen:
continue
seen.add(key)
new_cons = list(dp.get("access_constraints", []))
new_cons.extend(c)
paths.append((new_cons, {}))
else:
true_path = _make_path_for_branch(dp, dp.get("true_idx", 0), fields)
if true_path: paths.append(true_path)
false_path = _make_path_for_branch(dp, dp.get("false_idx", 1) if dp.get("false_idx") is not None else 1, fields)
if false_path: paths.append(false_path)
else:
true_path = _make_path_for_branch(dp, dp.get("true_idx", 0), fields)
if true_path: paths.append(true_path)
false_path = _make_path_for_branch(dp, dp.get("false_idx", 1) if dp.get("false_idx") is not None else 1, fields)
if false_path: paths.append(false_path)
else:
true_path = _make_path_for_branch(dp, dp.get("true_idx", 0), fields)
false_path = _make_path_for_branch(dp, dp.get("false_idx", 1) if dp.get("false_idx") is not None else 1, fields)
if true_path:
paths.append(true_path)
if false_path:
paths.append(false_path)
elif kind == "EVALUATE": elif kind == "EVALUATE":
node = dp["node"] node = dp["node"]
@@ -275,6 +388,16 @@ def enum_paths(node, fields):
if enter_path: paths.append(enter_path) if enter_path: paths.append(enter_path)
if skip_path: paths.append(skip_path) if skip_path: paths.append(skip_path)
elif kind == "SEARCH":
node = dp["node"]
n_when = len(node.when_list)
for i in range(n_when):
wp = _make_path_for_branch(dp, i, fields)
if wp: paths.append(wp)
if node.has_at_end:
at = _make_path_for_branch(dp, n_when, fields)
if at: paths.append(at)
if len(paths) >= MAX_PATH: if len(paths) >= MAX_PATH:
paths = paths[:MAX_PATH] paths = paths[:MAX_PATH]
break break
+12 -5
View File
@@ -29,7 +29,10 @@ def get_storage_length(field: dict) -> int:
else: else:
return 8 return 8
elif usage in ('COMP-3', 'PACKED-DECIMAL'): elif usage in ('COMP-3', 'PACKED-DECIMAL'):
return (digits + 2) // 2 # 打包小数含小数位(如 9(3)V9(2)=5 位+符号 → 3 字节)。
# 只算整数位(digits)会少算 1 字节,导致与 GnuCOBOL FD 布局错位。
decimal = pi.get('decimal', 0)
return (digits + decimal + 2) // 2
else: else:
raise ValueError(f"Unsupported USAGE: {usage}") raise ValueError(f"Unsupported USAGE: {usage}")
@@ -74,10 +77,12 @@ def pack_value(value: str, field: dict) -> bytes:
fmt = fmt_map[size] fmt = fmt_map[size]
if not signed: if not signed:
fmt = fmt.upper() fmt = fmt.upper()
return struct.pack('<' + fmt, int_val) # 本 GnuCOBOLGC32-BDB-SP1、主机兼容)的 COMP 按大端存储。
return struct.pack('>' + fmt, int_val)
elif usage in ('COMP-3', 'PACKED-DECIMAL'): elif usage in ('COMP-3', 'PACKED-DECIMAL'):
abs_str = str(abs(int_val)).zfill(digits) # 打包长度含小数位(digits + decimal
abs_str = str(abs(int_val)).zfill(digits + pi.get('decimal', 0))
nibbles = [int(ch) for ch in abs_str] nibbles = [int(ch) for ch in abs_str]
if not signed: if not signed:
nibbles.append(0xF) nibbles.append(0xF)
@@ -112,7 +117,8 @@ def unpack_value(data: bytes, field: dict) -> str:
fmt = fmt_map[size] fmt = fmt_map[size]
if not signed: if not signed:
fmt = fmt.upper() fmt = fmt.upper()
val = struct.unpack('<' + fmt, data)[0] # 与 pack_value 一致:本 GnuCOBOL 的 COMP 为大端存储。
val = struct.unpack('>' + fmt, data)[0]
sign = '-' if val < 0 else '' sign = '-' if val < 0 else ''
return f"{sign}{str(abs(val)).zfill(digits)}" return f"{sign}{str(abs(val)).zfill(digits)}"
@@ -127,7 +133,8 @@ def unpack_value(data: bytes, field: dict) -> str:
num_str = ''.join(chars).lstrip('0') or '0' num_str = ''.join(chars).lstrip('0') or '0'
if signed and sign == 0xD: if signed and sign == 0xD:
num_str = '-' + num_str num_str = '-' + num_str
return num_str.zfill(digits) # 补零长度含小数位
return num_str.zfill(digits + pi.get('decimal', 0))
else: else:
raise ValueError(f"Unsupported USAGE: {usage}") raise ValueError(f"Unsupported USAGE: {usage}")
+14 -3
View File
@@ -132,7 +132,10 @@ def _format_value(value: Any, field: dict) -> bytes:
try: try:
num = int(float(val)) if val else 0 num = int(float(val)) if val else 0
except (ValueError, TypeError): except (ValueError, TypeError):
num = 0 # 非数字値を 0 に静かに丸めると '00000000' が合成され、
# 複数レコードで主キー衝突(DAILY_RECORDS INSERT エラー→早期 ABEND)を
# 引き起こす。SPACE を書くことでプログラムの空キー判定に委ねる。
return (' ' * length).encode("ascii")
num = abs(num) num = abs(num)
max_val = 10 ** length - 1 max_val = 10 ** length - 1
if num > max_val: if num > max_val:
@@ -295,6 +298,12 @@ def write_sysin_file(records: list[dict], source_text: str, outdir: Path, prefix
# Always add a RESET-mode batch with duplicate EMP-ID for UPDATE path # Always add a RESET-mode batch with duplicate EMP-ID for UPDATE path
unique_ids = list(dict.fromkeys(emp_ids_ordered)) unique_ids = list(dict.fromkeys(emp_ids_ordered))
# Ensure a duplicated EMP-ID (from AGG path injection in orchestrator_db)
# appears in the last T card chunk. Without this, dict.fromkeys removes
# duplicates keeping only the first occurrence, which may not be in last 8.
dup_candidates = [e for e in unique_ids if emp_ids_ordered.count(e) > 1]
if dup_candidates and dup_candidates[0] not in unique_ids[-8:]:
unique_ids.append(dup_candidates[0])
if not unique_ids: if not unique_ids:
unique_ids = ["EMP00001", "EMP00002", "EMP00003", "EMP00004", "EMP00005"] unique_ids = ["EMP00001", "EMP00002", "EMP00003", "EMP00004", "EMP00005"]
# Inject duplicates: use first EMP-ID twice to trigger RESET/UPDATE # Inject duplicates: use first EMP-ID twice to trigger RESET/UPDATE
@@ -313,8 +322,10 @@ def write_sysin_file(records: list[dict], source_text: str, outdir: Path, prefix
# Unknown card type to cover IF WRK-CARD-TYPE '*' ELSE branch (DP#8 F) # Unknown card type to cover IF WRK-CARD-TYPE '*' ELSE branch (DP#8 F)
lines.append("X UNKNOWN") lines.append("X UNKNOWN")
# End with RESET mode so 3000STPSOR runs the RESET path (DP#22-#23) # End with the configured final mode (default RESET → 3000STPSOR 実行 RESET path).
lines.append("M MODE=RESET") # final_mode='NORMAL' 时(如 drop_tables 场景),3000STPSOR 不执行 DELETE
# 使 SELECT COUNT 等后续 SQL 错误分支可达。
lines.append(f"M MODE={run_cfg.get('final_mode', 'RESET')}")
# Write as fixed-length flat file # Write as fixed-length flat file
outpath = outdir / (prefix + sysin_filename) outpath = outdir / (prefix + sysin_filename)
+1 -1
View File
@@ -28,7 +28,7 @@ occurs_clause: "OCCURS" INT ("TO" INT)? ("TIME" "S"?)? ("DEPENDING" "ON" NAME)?
key_clause: ("ASCENDING" | "DESCENDING") "KEY" "IS"? NAME (","? NAME)* key_clause: ("ASCENDING" | "DESCENDING") "KEY" "IS"? NAME (","? NAME)*
indexed_clause: "INDEXED" "BY" NAME (","? NAME)* indexed_clause: "INDEXED" "BY" NAME (","? NAME)*
usage_clause: "USAGE"? "IS"? USAGE_VAL usage_clause: "USAGE"? "IS"? USAGE_VAL
USAGE_VAL: "COMP" | "COMP-3" | "COMP-5" | "BINARY" | "PACKED-DECIMAL" | "DISPLAY" USAGE_VAL: "COMP" | "COMP-3" | "COMP-4" | "COMP-5" | "BINARY" | "PACKED-DECIMAL" | "DISPLAY" | "POINTER"
LEVEL: /0[1-9]|[0-4][0-9]|49|66|77|88|[0-9]+/ LEVEL: /0[1-9]|[0-4][0-9]|49|66|77|88|[0-9]+/
NAME: /[A-Z][A-Z0-9-]*/i NAME: /[A-Z][A-Z0-9-]*/i
PICTURE_STRING: /[0-9A-Z()+,\-*\/V\$]+/i PICTURE_STRING: /[0-9A-Z()+,\-*\/V\$]+/i
+2 -3
View File
@@ -112,7 +112,7 @@ def output_json(records, outpath, roles=None, fd_fields=None, field_to_fd=None,
entry = { entry = {
'input': inp, 'input': inp,
'expected_output': out_exp, 'expected_output': out_exp,
'working_storage': {k: v for k, v in ws.items() if k != '_assigned_fields'}, 'working_storage': {k: v for k, v in ws.items() if k not in ('_assigned_fields', '__CLI_ARGS__')},
'termination': term_types[i] if i < len(term_types) else 'normal', 'termination': term_types[i] if i < len(term_types) else 'normal',
} }
@@ -176,8 +176,7 @@ def output_input_files(records, outdir, stem, roles, fd_fields, field_to_fd, ope
bin_path = outdir / assign_name bin_path = outdir / assign_name
name_to_field = { name_to_field = {
f['name']: f for f in data_fields f['name']: f for f in data_fields
if not f.get('is_88') and not f.get('is_filler') if not f.get('is_88') and f.get('pic')
and f.get('pic')
} }
field_dicts = [] field_dicts = []
seen = set() seen = set()
+69 -2
View File
@@ -26,12 +26,36 @@ def build_branch_tree_fallback(proc_text, fields=None):
pass pass
# New parser generates O(N) paths (not O(2^N)), so no cap needed. # New parser generates O(N) paths (not O(2^N)), so no cap needed.
# Just use it directly when it works. # When new parser succeeds, also try the old parser with short timeout.
# If old parser succeeds and has more IF nodes, use it (new parser may miss
# nested ifs inside PERFORM UNTIL/SECTION blocks).
old_tree, old_assigns = None, {}
if new_tree is not None: if new_tree is not None:
old_fallback, old_fb_assigns = None, {}
try:
import threading
r, e, d = [None], [None], [False]
def run():
try:
r[0] = old_build(proc_text, fields)
except Exception as ex:
e[0] = ex
d[0] = True
t = threading.Thread(target=run, daemon=True)
t.start(); t.join(3.0)
if d[0] and not e[0] and r[0]:
old_fallback, old_fb_assigns = r[0]
except Exception as e:
pass
if old_fallback is not None:
old_with_cond = _count_brif_with_cond(old_fallback)
new_with_cond = _count_brif_with_cond(new_tree)
if old_with_cond > new_with_cond:
logger.info(f" Old parser: {old_with_cond} BrIf with cond_tree vs new parser {new_with_cond}, using old parser")
return old_fallback, old_fb_assigns
return new_tree, new_assigns return new_tree, new_assigns
# 2. Old parser with 3s timeout (fallback only) # 2. Old parser with 3s timeout (fallback only)
old_tree, old_assigns = None, {}
try: try:
import threading import threading
r, e, d = [None], [None], [False] r, e, d = [None], [None], [False]
@@ -174,6 +198,49 @@ def _count_br_nodes(node) -> int:
return count return count
def _count_brif(node):
"""Count BrIf nodes in a branch tree."""
from .models import BrIf, BrSeq, BrEval, BrPerform, BrSearch
count = 0
if isinstance(node, BrIf):
count += 1
if isinstance(node, BrSeq):
for c in node.children: count += _count_brif(c)
if isinstance(node, BrIf):
count += _count_brif(node.true_seq) + _count_brif(node.false_seq)
if isinstance(node, BrEval):
for _, s in node.when_list: count += _count_brif(s)
count += _count_brif(node.other_seq)
if isinstance(node, BrPerform):
count += _count_brif(node.body_seq)
if isinstance(node, BrSearch):
count += _count_brif(node.at_end_seq)
for _, s in node.when_list: count += _count_brif(s)
return count
def _count_brif_with_cond(node):
"""Count BrIf nodes that have cond_tree set."""
from .models import BrIf, BrSeq, BrEval, BrPerform, BrSearch
count = 0
if isinstance(node, BrIf):
if node.cond_tree is not None:
count += 1
if isinstance(node, BrSeq):
for c in node.children: count += _count_brif_with_cond(c)
if isinstance(node, BrIf):
count += _count_brif_with_cond(node.true_seq) + _count_brif_with_cond(node.false_seq)
if isinstance(node, BrEval):
for _, s in node.when_list: count += _count_brif_with_cond(s)
count += _count_brif_with_cond(node.other_seq)
if isinstance(node, BrPerform):
count += _count_brif_with_cond(node.body_seq)
if isinstance(node, BrSearch):
count += _count_brif_with_cond(node.at_end_seq)
for _, s in node.when_list: count += _count_brif_with_cond(s)
return count
def _assigns_list_to_dict(assigns_list: list) -> dict: def _assigns_list_to_dict(assigns_list: list) -> dict:
result = {} result = {}
for a in assigns_list: for a in assigns_list:
+67 -6
View File
@@ -207,13 +207,13 @@ _RE_SQL_INC = re.compile(
_BUILTIN_SQLCA = """\ _BUILTIN_SQLCA = """\
01 SQLCA. 01 SQLCA.
05 SQLCAID PIC X(8). 05 SQLCAID PIC X(8).
05 SQLCABC PIC S9(9) COMP. 05 SQLCABC PIC S9(9) COMP-5.
05 SQLCODE PIC S9(9) COMP. 05 SQLCODE PIC S9(9) COMP-5.
05 SQLERRM. 05 SQLERRM.
10 SQLERRML PIC S9(4) COMP. 10 SQLERRML PIC S9(4) COMP-5.
10 SQLERRMC PIC X(70). 10 SQLERRMC PIC X(70).
05 SQLERRP PIC X(8). 05 SQLERRP PIC X(8).
05 SQLERRD OCCURS 6 TIMES PIC S9(9) COMP. 05 SQLERRD OCCURS 6 TIMES PIC S9(9) COMP-5.
05 SQLWARN. 05 SQLWARN.
10 SQLWARN0 PIC X. 10 SQLWARN0 PIC X.
10 SQLWARN1 PIC X. 10 SQLWARN1 PIC X.
@@ -523,6 +523,11 @@ def parse_pic(pic_str: str) -> PicInfo:
elif expanded[0] == 'A': elif expanded[0] == 'A':
info.type = 'alphabetic' info.type = 'alphabetic'
info.length = len(expanded) info.length = len(expanded)
elif expanded[0] == 'N':
# PIC N(n) = national/DBCS: 每个字符占 2 字节(GnuCOBOL 实际存储)。
# 按 n 字节计算会导致 FD 记录布局/偏移错位(如 N(040)=80B 却按 40B 写)。
info.type = 'national'
info.length = len(expanded) * 2
elif expanded[0] in ('Z', '*', '$', '+', '-'): elif expanded[0] in ('Z', '*', '$', '+', '-'):
info.type = 'numeric-edited' info.type = 'numeric-edited'
info.digits = expanded.count('9') info.digits = expanded.count('9')
@@ -597,15 +602,17 @@ def parse_file_control(source: str) -> dict:
result = {} result = {}
for sel_m in re.finditer( for sel_m in re.finditer(
r'SELECT\s+(\w[\w-]*)\s+[^.]*?\bASSIGN\s+TO\s+' r'SELECT\s+(\w[\w-]*)\s+[^.]*?\bASSIGN\s+TO\s+'
r'(?:(["\'])(.*?)\2|(\w[\w-]*))' r'(?:(["\'])(.*?)\2|EXTERNAL\s+(\w[\w-]*)|(\w[\w-]*))'
r'[^.]*\.', r'[^.]*\.',
fc, re.IGNORECASE fc, re.IGNORECASE
): ):
name = sel_m.group(1).upper() name = sel_m.group(1).upper()
if sel_m.group(2): if sel_m.group(2):
assign_to = sel_m.group(3).upper() assign_to = sel_m.group(3).upper()
else: elif sel_m.group(4):
assign_to = sel_m.group(4).upper() assign_to = sel_m.group(4).upper()
else:
assign_to = sel_m.group(5).upper()
clause = sel_m.group(0) clause = sel_m.group(0)
org_m = re.search(r'ORGANIZATION\s+(LINE\s+)?SEQUENTIAL', clause, re.IGNORECASE) org_m = re.search(r'ORGANIZATION\s+(LINE\s+)?SEQUENTIAL', clause, re.IGNORECASE)
if org_m and org_m.group(1): if org_m and org_m.group(1):
@@ -673,3 +680,57 @@ def scan_open_statements(source: str) -> dict:
for fname in re.findall(r'\w[\w-]*', seg_m.group(2)): for fname in re.findall(r'\w[\w-]*', seg_m.group(2)):
dirs[fname.upper()] = direction dirs[fname.upper()] = direction
return dirs return dirs
def scan_sort_merge_directions(source: str) -> dict:
"""Parse MERGE / SORT statements, returns {file_name: 'INPUT'|'OUTPUT'}.
MERGE/SORT USING 文件不会被 OPEN由语句隐式打开因此
scan_open_statements 无法识别此函数扫描 PROCEDURE DIVISION
MERGE ... USING <f1> <f2> SORT ... USING <f> GIVING <g> 子句
USING 文件识别为 INPUTGIVING 文件识别为 OUTPUT
兼容性对任何含 MERGE/SORT 的程序通用 MERGE/SORT 程序返回空 dict
"""
dirs = {}
_KEYWORDS = {'USING', 'GIVING', 'PROCEDURE', 'KEY', 'ASCENDING', 'DESCENDING', 'ON', 'OUTPUT', 'INPUT'}
for m in re.finditer(
r'\b(MERGE|SORT)\s+\w[\w-]*\s+.*?\b(?:USING|GIVING)\s+(.+?)\s*(?:OUTPUT|INPUT)?\s*PROCEDURE\s+\w[\w-]*\s*\.',
source, re.IGNORECASE | re.DOTALL
):
stmt = m.group(0)
kind = m.group(1).upper()
files_raw = m.group(2)
files = re.findall(r'\b(\w[\w-]*)\b', files_raw)
for fn in files:
fn = fn.upper()
if fn not in _KEYWORDS:
dirs[fn] = 'INPUT'
if kind == 'SORT':
g = re.search(r'\bGIVING\s+(\w[\w-]*)', stmt, re.IGNORECASE)
if g:
dirs[g.group(1).upper()] = 'OUTPUT'
# 单行回退:无 PROCEDURE 的 MERGE/SORT(如 MERGE F ON KEY K USING A B.
for m in re.finditer(
r'\b(MERGE|SORT)\s+\w[\w-]*\s+.*?\b(?:USING|GIVING)\s+(.+?)\.',
source, re.IGNORECASE
):
kind = m.group(1).upper()
files_raw = m.group(2)
files = re.findall(r'\b(\w[\w-]*)\b', files_raw)
for fn in files:
fn = fn.upper()
if fn not in _KEYWORDS:
dirs[fn] = 'INPUT'
if kind == 'SORT':
g = re.search(r'\bGIVING\s+(\w[\w-]*)', m.group(0), re.IGNORECASE)
if g:
dirs[g.group(1).upper()] = 'OUTPUT'
return dirs
def scan_all_file_directions(source: str) -> dict:
"""OPEN 方向 + MERGE/SORT USING/GIVING 方向的合并(输入方向全集)。"""
dirs = scan_open_statements(source)
dirs.update(scan_sort_merge_directions(source))
return dirs
+36 -7
View File
@@ -31,6 +31,7 @@ class GroupInfo:
select_info: dict = field(default_factory=dict) select_info: dict = field(default_factory=dict)
overlap_mask: list[bool] = field(default_factory=list) overlap_mask: list[bool] = field(default_factory=list)
multi_write_fds: set = field(default_factory=set) multi_write_fds: set = field(default_factory=set)
command_args: list = field(default_factory=list)
@dataclass @dataclass
@@ -335,7 +336,7 @@ def run_group(group: GroupInfo, exe_path: str, temp_dir: str,
try: try:
os.chdir(str(work_dir)) os.chdir(str(work_dir))
result = subprocess.run( result = subprocess.run(
[str(exe)], capture_output=True, text=True, [str(exe)] + list(group.command_args or []), capture_output=True, text=True,
encoding='utf-8', errors='replace', timeout=60, encoding='utf-8', errors='replace', timeout=60,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
@@ -434,26 +435,36 @@ def run_all(program_name: str, outdir: str, temp_dir: str,
gcov_root = work_dir / "gcov" gcov_root = work_dir / "gcov"
for scene_id, scene_recs, scene_terms, scene_expected, src_in_dir, dst_out_dir in scenes: for scene_id, scene_recs, scene_terms, scene_expected, src_in_dir, dst_out_dir in scenes:
# ── 3a. 入力ファイル配置 ── # ── 3a. 入力ファイル配置(主程序 + 被调子程序的输入文件全部复制)──
# 仅复制 assign_names 会漏掉子程序输入文件(测试驱动调用读文件自程序时
# 会 OPEN 失败 ABEND)。复制目录内全部非 JSON 文件(.json 为 V3 元数据)。
if src_in_dir.is_dir(): if src_in_dir.is_dir():
for assign in assign_names: for src in sorted(src_in_dir.iterdir()):
src = src_in_dir / assign if not src.is_file() or src.suffix.lower() == '.json':
if src.exists(): continue
shutil.copy2(str(src), str(work_dir / assign)) shutil.copy2(str(src), str(work_dir / src.name))
# ── 3b. 清理旧 gcda ── # ── 3b. 清理旧 gcda ──
_clean_gcda(str(work_dir)) _clean_gcda(str(work_dir))
# ── 3c. 过滤 non-abend ── # ── 3c. 过滤 non-abend ──
filtered_exp = [] filtered_exp = []
normal_recs = []
abend_pairs = [] # (rec, command_args)
for i, rec in enumerate(scene_expected): for i, rec in enumerate(scene_expected):
term = scene_terms[i] if i < len(scene_terms) else 'normal' term = scene_terms[i] if i < len(scene_terms) else 'normal'
if term != 'abend': if term != 'abend':
filtered_exp.append(rec) filtered_exp.append(rec)
for i, rec in enumerate(scene_recs):
term = scene_terms[i] if i < len(scene_terms) else 'normal'
if term == 'abend':
abend_pairs.append((rec, list((rec.get('__CLI_ARGS__') or {}).values())))
else:
normal_recs.append(rec)
group = GroupInfo( group = GroupInfo(
name=f"{program_name}_{scene_id}", name=f"{program_name}_{scene_id}",
records=scene_recs, records=normal_recs,
expected_outputs=filtered_exp, expected_outputs=filtered_exp,
expected_returncode=0, expected_returncode=0,
fd_field_dicts=fd_field_dicts, fd_field_dicts=fd_field_dicts,
@@ -469,6 +480,23 @@ def run_all(program_name: str, outdir: str, temp_dir: str,
status = '' if r.passed else '' status = '' if r.passed else ''
logger.info(f"'{group.name}': returncode={r.returncode}, {status}") logger.info(f"'{group.name}': returncode={r.returncode}, {status}")
# ── 3d-2. abend 记录单独执行(带命令行参数,触发 ABEND/异常返回)──
for ab_idx, (ab_rec, ab_args) in enumerate(abend_pairs):
ab_group = GroupInfo(
name=f"{program_name}_{scene_id}_abend_{ab_idx + 1}",
records=[ab_rec], expected_outputs=[],
expected_returncode=1,
fd_field_dicts=fd_field_dicts,
open_dir=open_dir,
select_info=select_info,
multi_write_fds=multi_write_fds,
command_args=ab_args,
)
r2 = run_group(ab_group, exe_path, str(work_dir), log_dir=log_dir)
results.append(r2)
status2 = '' if r2.passed else ''
logger.info(f"'{ab_group.name}': returncode={r2.returncode}, {status2}")
# ── 3e. 出力保存 ── # ── 3e. 出力保存 ──
dst_out_dir.mkdir(parents=True, exist_ok=True) dst_out_dir.mkdir(parents=True, exist_ok=True)
for fd_name in fd_field_dicts: for fd_name in fd_field_dicts:
@@ -579,6 +607,7 @@ def run_and_compare(program_name: str, outdir: str,
expected_returncode=1, expected_returncode=1,
fd_field_dicts=fd_field_dicts, open_dir=open_dir, fd_field_dicts=fd_field_dicts, open_dir=open_dir,
select_info=select_info, select_info=select_info,
command_args=list((rec.get('__CLI_ARGS__') or {}).values()),
) )
r = run_group(group, exe_path, temp_dir, log_dir=log_dir) r = run_group(group, exe_path, temp_dir, log_dir=log_dir)
if r.returncode != 0: if r.returncode != 0:
+632 -36
View File
@@ -1,4 +1,4 @@
"""SQL层:WHERE约束解析 + DB输入行生成""" """SQL层:WHERE约束解析 + DB输入行生成"""
import re import re
import logging import logging
@@ -28,8 +28,97 @@ def _restore_strings(text: str, replacements: list) -> str:
# ── Bracket-aware AND splitting ── # ── Bracket-aware AND splitting ──
def _split_on_AND(text: str) -> list[str]: _RE_BETWEEN_SPAN = re.compile(r'\bBETWEEN\b', re.IGNORECASE)
"""Split WHERE clause on AND, respecting parentheses."""
def _scan_between_spans(text: str) -> (str, list):
"""Replace `X BETWEEN lo AND hi` spans with `__BTW{i}__` placeholders.
The inner AND that belongs to a BETWEEN clause must not be treated as a
top-level AND separator. Returns (protected_text, spans) where each span is:
{subject, lo, hi, neg} each operand is the raw token string.
Subject may be a host variable (`:NAME`), a column, or a literal; lo/hi
likewise (host var, column, or literal). The full span text (subject
through hi) is replaced by a single placeholder token.
"""
spans = []
out = []
pos = 0
i = 0
while i < len(text):
m = _RE_BETWEEN_SPAN.search(text, i)
if not m:
break
# subject: token (optionally ':'-prefixed) immediately before an
# optional NOT that precedes BETWEEN
j = m.start() - 1
while j >= 0 and text[j].isspace():
j -= 1
before = text[max(0, j - 5):m.start()]
mnot = re.search(r'NOT\s*$', before, re.IGNORECASE)
if mnot:
j = m.start() - len(mnot.group(0)) - 1
while j >= 0 and text[j].isspace():
j -= 1
tok_end = j
while j >= 0 and (text[j].isalnum() or text[j] in '_-'):
j -= 1
subject_start = j if j >= 0 and text[j] == ':' else j + 1
subject = text[subject_start:tok_end + 1]
out.append(text[pos:subject_start])
# lo: from after BETWEEN to the first top-level AND
j = m.end()
depth = 0
and_pos = None
k = j
while k < len(text):
ch = text[k]
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
elif depth == 0 and re.match(r'\bAND\b', text[k:], re.IGNORECASE):
and_pos = k
break
k += 1
if and_pos is None:
out.append(text[subject_start:m.start()])
out.append(text[m.start():])
pos = len(text)
break
lo = text[j:and_pos].strip()
# hi: from after AND until the next top-level AND/OR or end
k = and_pos + 3
hi_end = len(text)
depth = 0
while k < len(text):
ch = text[k]
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
elif depth == 0 and re.match(r'\b(?:AND|OR)\b', text[k:], re.IGNORECASE):
hi_end = k
break
k += 1
hi = text[and_pos + 3:hi_end].strip()
spans.append({'subject': subject, 'lo': lo, 'hi': hi, 'neg': bool(mnot)})
out.append(f"__BTW{len(spans) - 1}__ ")
pos = hi_end
i = hi_end
out.append(text[pos:])
return ''.join(out), spans
def _split_on_AND(text: str, spans: list = None, keep_placeholders: bool = False) -> list[str]:
"""Split WHERE clause on AND, respecting parentheses.
BETWEEN ... AND ... spans are protected first so their inner AND is not
treated as a separator. When `keep_placeholders` is True (internal use),
BETWEEN spans stay as `__BTW{i}__` tokens so the caller can re-parse them.
"""
if spans is None:
text, spans = _scan_between_spans(text)
parts = [] parts = []
current = [] current = []
depth = 0 depth = 0
@@ -52,13 +141,51 @@ def _split_on_AND(text: str) -> list[str]:
current.append(token) current.append(token)
if current: if current:
parts.append(' '.join(current).strip()) parts.append(' '.join(current).strip())
if not keep_placeholders:
parts = [_restore_between_placeholders(p, spans) for p in parts]
return parts return parts
def _restore_between_placeholders(part: str, spans: list) -> str:
"""Replace `__BTW{i}__` tokens back with their original BETWEEN text."""
def _repl(m):
idx = int(m.group(1))
if idx >= len(spans):
return m.group(0)
s = spans[idx]
neg = 'NOT ' if s['neg'] else ''
return f"{s['subject']} {neg}BETWEEN {s['lo']} AND {s['hi']}"
return re.sub(r'__BTW(\d+)__', _repl, part)
_BETWEEN_OP = re.compile(r'(__STR\d+__|:\w[\w-]*|[\w.-]+)')
def _parse_between_operand(raw: str, replacements: list) -> dict:
"""Parse a BETWEEN subject/lo/hi operand into a kinded dict.
Returns one of:
{'kind': 'host_var', 'name': 'X'}
{'kind': 'literal', 'value': '...'}
{'kind': 'column', 'name': 'COL'}
"""
raw = raw.strip().strip('()')
if raw.startswith(':'):
return {'kind': 'host_var', 'name': raw[1:].upper()}
m = re.match(r'__STR(\d+)__$', raw)
if m and int(m.group(1)) < len(replacements):
return {'kind': 'literal', 'value': replacements[int(m.group(1))].strip("'\"")}
if raw.startswith('__STR') and raw.endswith('__'):
idx = int(raw[5:-2])
if idx < len(replacements):
return {'kind': 'literal', 'value': replacements[idx].strip("'\"")}
return {'kind': 'column', 'name': raw.upper()}
# ── WHERE condition parsing ── # ── WHERE condition parsing ──
_COL_OP_PAT = re.compile( _COL_OP_PAT = re.compile(
r'(\w[\w.-]*)\s*' # column name (with optional alias prefix) r'(:?\w[\w.-]*)\s*' # column name or host variable (with optional alias prefix)
r'(=|>|<|>=|<=|<>|!=|NOT\s*=)\s*' r'(=|>|<|>=|<=|<>|!=|NOT\s*=)\s*'
r'(:\w[\w-]*(?::\w[\w-]*)?|__STR\d+__|[\w\d.-]+)', r'(:\w[\w-]*(?::\w[\w-]*)?|__STR\d+__|[\w\d.-]+)',
re.IGNORECASE re.IGNORECASE
@@ -70,7 +197,7 @@ _RE_IN_CLAUSE = re.compile(
) )
_RE_BETWEEN = re.compile( _RE_BETWEEN = re.compile(
r'(\w[\w.-]*)\s+(NOT\s+)?BETWEEN\s+(.+?)\s+AND\s+(.+)', r'(:?\w[\w.-]*)\s+(NOT\s+)?BETWEEN\s+(.+?)\s+AND\s+(.+)',
re.IGNORECASE re.IGNORECASE
) )
@@ -124,14 +251,15 @@ def _parse_where_condition(part: str, replacements: list) -> dict | None:
# BETWEEN # BETWEEN
m = _RE_BETWEEN.match(part) m = _RE_BETWEEN.match(part)
if m: if m:
col = m.group(1).upper() subj = m.group(1).upper()
neg = bool(m.group(2)) neg = bool(m.group(2))
lo = m.group(3).strip() lo = m.group(3).strip()
hi = m.group(4).strip() hi = m.group(4).strip()
return { return {
'col': col, 'type': 'between', 'neg': neg, 'type': 'between', 'neg': neg, 'op': 'BETWEEN',
'op': 'BETWEEN', 'subject': _parse_between_operand(subj, replacements),
'lo': lo.strip("'\""), 'hi': hi.strip("'\""), 'lo': _parse_between_operand(lo, replacements),
'hi': _parse_between_operand(hi, replacements),
} }
# LIKE # LIKE
@@ -216,19 +344,28 @@ def sql_extract_constraints(where_clause: str, table: str,
# Protect string literals # Protect string literals
cleaned, replacements = _protect_strings(where_clause) cleaned, replacements = _protect_strings(where_clause)
# Split on AND # Protect BETWEEN spans, then split on AND
and_parts = _split_on_AND(cleaned) protected, spans = _scan_between_spans(cleaned)
and_parts = _split_on_AND(protected, spans=spans, keep_placeholders=True)
constraints = [] constraints = []
for part in and_parts: for part in and_parts:
part = part.strip() part = part.strip()
if not part: if not part:
continue continue
cond = _parse_where_condition(part, replacements) btw = _parse_between_placeholder(part, spans, replacements)
if btw is not None:
cond = btw
else:
cond = _parse_where_condition(part, replacements)
if cond: if cond:
# Map column to COBOL field # Map column to COBOL field
cobol_field = guess_cobol_field(cond['col'], table, declared_columns, column_map) col_name = cond.get('col') or (cond.get('subject', {}) or {}).get('name')
cond['cobol_field'] = cobol_field if col_name and cond['type'] == 'between':
cond['cobol_field'] = guess_cobol_field(col_name, table, declared_columns, column_map)
elif cond.get('col'):
cobol_field = guess_cobol_field(cond['col'], table, declared_columns, column_map)
cond['cobol_field'] = cobol_field
constraints.append(cond) constraints.append(cond)
else: else:
logger.warning(f"Unparseable WHERE condition: {_restore_strings(part, replacements)}") logger.warning(f"Unparseable WHERE condition: {_restore_strings(part, replacements)}")
@@ -236,6 +373,29 @@ def sql_extract_constraints(where_clause: str, table: str,
return constraints return constraints
def _parse_between_placeholder(part: str, spans: list, replacements: list) -> dict | None:
"""Parse a BETWEEN span from its `__BTW{i}__` placeholder.
The part may contain trailing content after the placeholder (e.g. wrapped
in parentheses); only the first placeholder token is interpreted. Returns
a between constraint dict, or None when the part has no placeholder.
"""
m = re.search(r'__BTW(\d+)__', part)
if not m:
return None
idx = int(m.group(1))
if idx >= len(spans):
return None
s = spans[idx]
subject = _parse_between_operand(s['subject'], replacements)
return {
'type': 'between', 'neg': s['neg'], 'op': 'BETWEEN',
'subject': subject,
'lo': _parse_between_operand(s['lo'], replacements),
'hi': _parse_between_operand(s['hi'], replacements),
}
# ── DB input row generation ── # ── DB input row generation ──
_COLUMN_DEFAULTS = { _COLUMN_DEFAULTS = {
@@ -251,8 +411,13 @@ _COLUMN_DEFAULTS = {
def _format_db_value(col_info: dict, raw_val: str) -> str: def _format_db_value(col_info: dict, raw_val: str) -> str:
db_type = col_info.get('db_type', 'CHAR') db_type = col_info.get('db_type', 'CHAR')
formatter = _COLUMN_DEFAULTS.get(db_type, lambda _: str(raw_val)[:10]) if db_type in ('CHAR', 'VARCHAR'):
default = formatter(0) size = col_info.get('size', 1)
default = ' ' * size
elif db_type in ('INTEGER', 'SMALLINT', 'DECIMAL'):
default = _COLUMN_DEFAULTS.get(db_type, lambda _: '?')(0)
else:
default = _COLUMN_DEFAULTS.get(db_type, lambda _: str(raw_val)[:10])(0)
if raw_val is None: if raw_val is None:
return default return default
if db_type in ('INTEGER', 'SMALLINT', 'DECIMAL'): if db_type in ('INTEGER', 'SMALLINT', 'DECIMAL'):
@@ -261,10 +426,11 @@ def _format_db_value(col_info: dict, raw_val: str) -> str:
except ValueError: except ValueError:
return default return default
return str(raw_val).ljust(len(default))[:len(default)] return str(raw_val).ljust(len(default))[:len(default)]
def _make_key_unique(key_val: str, path_index: int, seen_keys: set) -> str: def _make_key_unique(key_val: str, path_index: int, seen_keys: set) -> str:
unique = f"{path_index:03d}{key_val[:5]}" stripped = key_val.strip()
if len(stripped) >= 5:
return key_val
unique = f"{path_index:03d}{stripped[:5]}"
while unique in seen_keys: while unique in seen_keys:
unique = f"{path_index:03d}{hash(key_val) % 100000:05d}" unique = f"{path_index:03d}{hash(key_val) % 100000:05d}"
seen_keys.add(unique) seen_keys.add(unique)
@@ -291,12 +457,18 @@ def collect_sql_meta(assignments: dict, declared_columns: dict,
seen.add(key) seen.add(key)
where = asgn.get('where', '') where = asgn.get('where', '')
table = asgn.get('table', '') table = asgn.get('table', '')
table = _norm_table(table)
where_constraints = sql_extract_constraints( where_constraints = sql_extract_constraints(
where, table, {}, column_map or {}, declared_columns where, table, {}, column_map or {}, declared_columns
) )
meta = dict(asgn) meta = dict(asgn)
meta['table'] = table
meta['where_constraints'] = where_constraints meta['where_constraints'] = where_constraints
sql_meta.append(meta) sql_meta.append(meta)
# Order by source position so downstream SELECTs come last (reliable
# runtime query order). Entries without pos keep their insertion order.
sql_meta.sort(key=lambda m: m.get('pos') or 10 ** 9)
return sql_meta return sql_meta
@@ -311,6 +483,9 @@ def _path_has_sql_ok(path_cons: list) -> bool:
sql_ok = False sql_ok = False
if pc[1] == '>' and pc[3]: if pc[1] == '>' and pc[3]:
sql_ok = False sql_ok = False
# SQLCODE = <non-zero> (want True) means SQL must fail
if pc[1] == '=' and pc[3] and str(pc[2]).strip() not in ('0', "'0'"):
sql_ok = False
break break
return sql_ok return sql_ok
@@ -345,6 +520,367 @@ def _parse_select_columns(select_list: str) -> list[str]:
return cols return cols
def _rec_get(rec: dict, key: str, default=''):
"""rec が flat / {working_storage: ...} 両方の形式をサポートする。"""
if key in rec:
return str(rec[key])
ws = rec.get('working_storage', {})
if isinstance(ws, dict) and key in ws:
return str(ws[key])
inp = rec.get('input', {})
if isinstance(inp, dict) and key in inp:
return str(inp[key])
return default
def _rec_has(rec: dict, key: str) -> bool:
return key in rec or key in rec.get('working_storage', {}) or key in rec.get('input', {})
def _norm_col(name: str) -> str:
"""Normalize a SQL column name for comparisons ('-' and '_' are equivalent)."""
return str(name).upper().replace('-', '_')
def _norm_table(table: str) -> str:
"""Normalize a SQL table name: strip DB2 schema qualifier (SCHEMA.TABLE → TABLE).
core.py captures qualified names faithfully (e.g. 'SALARYDB.EMP-MASTER') so
the schema part is not lost; here we reduce to the last segment so seeds
target the YAML schema table (EMP-MASTER / EMP_MASTER). No-op for plain names.
"""
return str(table).rsplit('.', 1)[-1]
def _declared_cols_for(declared_columns: dict, table: str) -> list[dict]:
"""Look up declared columns for a SQL table, tolerating '-'/'_' naming.
YAML schema registers tables with underscores (EMP_MASTER) while SQL
references may use hyphens (EMP-MASTER); missing this fallback makes
build_db_input fall back to 10-char inferred sizes, padding stored keys
with trailing spaces so runtime '=' lookups with unpadded host vars fail.
"""
cols = declared_columns.get(table, [])
if not cols and '-' in table:
cols = declared_columns.get(table.replace('-', '_'), [])
elif not cols and '_' in table:
cols = declared_columns.get(table.replace('_', '-'), [])
return cols
def _hostvar_root(host_var: str, assignments: dict) -> str:
"""Trace a SQL WHERE host var through MOVE assignments to its source root.
e.g. assignments['DBV-EMP-ID'] = [{'type': 'move', 'source_vars': ['R02EMP-ID']}]
root for DBV-EMP-ID is R02EMP-ID. Handles multi-hop MOVE chains.
Returns the original host_var when no MOVE chain applies.
"""
seen = set()
cur = host_var
while cur in assignments and cur not in seen:
seen.add(cur)
al = assignments[cur]
if isinstance(al, dict):
al = [al]
if not al:
break
last = al[-1]
if last.get('type') == 'move' and last.get('source_vars'):
cur = str(last['source_vars'][0]).upper()
else:
break
return cur
def _rec_get_ci(rec: dict, name: str):
"""Case-insensitive _rec_get. Returns the value string or None if not found."""
for scope in (rec, rec.get('working_storage', {}), rec.get('input', {})):
for k, v in (scope or {}).items():
if str(k).upper() == name.upper():
return str(v)
return None
def _resolve_runtime_hostvar(rec: dict, host_var: str, assignments: dict) -> str:
"""Resolve a host var to the value the program actually uses at runtime.
Returns only runtime-deterministic values:
1. The literal default in the root field's assignment chain when the root
is fed by `ACCEPT ... FROM COMMAND-LINE` (e.g. `IF x = SPACES
MOVE '202605' TO x`). The pipeline runs programs without command-line
args, so that SPACES default is what the program actually uses at
runtime (DBV-YEAR-MONTH -> ... -> WRK-YEAR-MONTH = '202605').
2. The input-record field value at the end of a MOVE chain
(e.g. `MOVE R02EMP-ID TO DBV-EMP-ID` -> R02EMP-ID's record value).
Returns '' when no deterministic runtime value is derivable; the caller
falls back to record heuristics.
"""
if not assignments:
return ''
root = _hostvar_root(host_var, assignments)
al = assignments.get(root)
if isinstance(al, dict):
al = [al]
if al and any(a.get('type') == 'accept' for a in al):
for a in al:
if a.get('type') == 'move_literal' and a.get('literal') is not None:
return str(a['literal'])
if root != host_var:
rv = _rec_get_ci(rec, root)
if rv is not None and str(rv).strip():
return str(rv)
return ''
def _resolve_where_hostvar(rec: dict, host_var: str, assignments: dict):
"""Resolve a WHERE host var to the value the program actually uses.
If the host var is set by MOVE assignments (e.g. `MOVE R02EMP-ID TO
DBV-EMP-ID`) before the SQL statement, trace to the input-record root and
prefer its value, so the DB pre-seed matches the runtime query key.
Falls back to the host var's own record value when no chain resolves.
"""
root = _hostvar_root(host_var, assignments)
if root != host_var:
rv = _rec_get_ci(rec, root)
if rv is not None and str(rv).strip():
return str(rv)
return _rec_get(rec, host_var, '')
def _runtime_or_where_hostvar(rec: dict, host_var: str, assignments: dict) -> str:
"""Resolve a WHERE host var preferring the deterministic runtime value.
`_resolve_runtime_hostvar` handles host vars fed by `ACCEPT ... FROM
COMMAND-LINE` whose SPACES default the program substitutes at runtime
(e.g. WRK-YEAR-MONTH = '202605'): the record carries a synthetic value
('I00001') that does NOT match the runtime query, so without this the
seeded WHERE columns (EFFECTIVE-FROM/TO) fail the runtime predicate and
the rows never load. Falls back to the existing MOVE-chain / record
resolution when no deterministic runtime value exists.
"""
rv = _resolve_runtime_hostvar(rec, host_var, assignments)
if rv:
return rv
return _resolve_where_hostvar(rec, host_var, assignments)
def _resolve_between_operand(op: dict, rec: dict, assignments: dict):
"""Resolve a BETWEEN subject/lo/hi operand to a concrete string value."""
if not op:
return None
kind = op.get('kind')
if kind == 'host_var':
v = _resolve_where_hostvar(rec, op.get('name', ''), assignments)
return v
if kind == 'literal':
return op.get('value')
return None
def _derive_runtime_hostvar(rec: dict, host_var: str, assignments: dict,
fields: list) -> str | None:
"""Derive the runtime value of a WORKING-STORAGE host var via propagation.
Records generated for coverage carry synthetic WRK-* values (e.g.
WRK-TAXABLE-INCOME='000003701') that do NOT match what the program
computes at runtime from the input keys (e.g. max(0, gross - deduction)).
For a BETWEEN seed to match the runtime query key, we re-derive the value
on a copy of the record: seed WORKING-STORAGE constants from their field
VALUE clause, then run propagate_assignments. Returns None when the host
var is not derivable (e.g. it is an input key with a direct MOVE chain).
"""
if not assignments or not fields:
return None
try:
from .core import propagate_assignments
probe = dict(rec)
for f in fields:
if f.get('value') is not None and f.get('section') == 'WORKING-STORAGE':
probe.setdefault(f['name'], str(f['value']))
propagate_assignments(probe, assignments, fields)
v = probe.get(host_var)
if v is not None and str(v).strip():
return str(v)
except Exception:
pass
return None
def _between_seed_value(wc: dict, rec: dict, assignments: dict, col_name: str,
fields: list = None) -> str | None:
"""Compute the DB column value for a BETWEEN where-constraint.
Case A (`:hv BETWEEN LO-COL AND HI-COL`): the subject is a host var whose
runtime value must fall inside the band; seed LO-COL / HI-COL with it.
Case B (`SUBJ-COL BETWEEN :lo AND :hi`): the subject column is the one to
seed; use the lo operand value so `lo <= col <= hi` holds.
Returns None when this column is not governed by the BETWEEN constraint.
"""
if not wc or wc.get('type') != 'between' or wc.get('neg'):
return None
subject = wc.get('subject', {})
lo = wc.get('lo', {})
hi = wc.get('hi', {})
if subject.get('kind') == 'host_var':
for side in (lo, hi):
if side.get('kind') == 'column' and _norm_col(side.get('name', '')) == _norm_col(col_name):
derived = _derive_runtime_hostvar(rec, subject.get('name', ''),
assignments, fields or [])
if derived:
return derived
v = _resolve_between_operand(subject, rec, assignments)
if v and str(v).strip():
return str(v)
elif subject.get('kind') == 'column' and _norm_col(subject.get('name', '')) == _norm_col(col_name):
lo_val = _resolve_between_operand(lo, rec, assignments)
hi_val = _resolve_between_operand(hi, rec, assignments)
for v in (lo_val, hi_val):
if v is not None and str(v).strip():
return str(v)
return None
def _input_pk_field(rec: dict, col_name: str) -> str | None:
"""Find the input-record field (R##-prefixed) that feeds a PK column.
Only returns a field whose name ends with the hyphen-normalized column
name. A YEAR-MONTH PK column therefore no longer falls back to the
EMP-ID input field (which produced a truncated, wrong collision value).
Returns None when no such input field exists (e.g. YEAR-MONTH comes from
a WORKING-STORAGE ACCEPT default instead).
"""
if not rec:
return None
base = col_name.upper().replace('EMP_ID', 'EMP-ID')
for key in rec:
k = str(key).upper()
if k.endswith(base) and k[:1] == 'R' and k[1:2].isdigit():
return key
return None
def _insert_collision_row(sql: dict, rec: dict, pk_cols: list[str],
declared_columns: dict, assignments: dict = None) -> dict | None:
"""Build a DB pre-seed row whose PK collides with the input record's PK.
For an INSERT statement, this creates a row with the same primary-key value
as the value the program actually INSERTs at runtime, so the runtime INSERT
fails with a duplicate-key SQL error (DB2 -803).
Returns {} if no PK value could be derived from the record.
"""
if not rec or not pk_cols:
return None
columns = sql.get('columns', [])
host_vars = sql.get('host_vars', [])
if not columns or not host_vars:
return None
col_infos = declared_columns.get(sql.get('table', ''), [])
if not col_infos:
# Schema may declare the table with underscores while SQL uses hyphens.
col_infos = declared_columns.get(sql.get('table', '').replace('-', '_'), [])
if not col_infos:
# Fall back to CHAR types inferred from the INSERT column list.
col_infos = [{'name': c, 'db_type': 'CHAR', 'size': 20} for c in columns]
col_to_hv = {}
for c, hv in zip(columns, host_vars):
col_to_hv[c.upper()] = hv
col_to_hv[c.upper().replace('-', '_')] = hv
row = {}
for ci in col_infos:
col_name = ci['name'].upper()
col_name_alt = col_name.replace('-', '_') if '-' in col_name else col_name.replace('_', '-')
if col_name not in pk_cols and col_name_alt not in pk_cols:
continue
pk_hit = col_name if col_name in pk_cols else col_name_alt
hv = col_to_hv.get(col_name) or col_to_hv.get(col_name_alt)
val = None
# Prefer the runtime value the program actually INSERTs:
# - EMP-ID <- MOVE chain to the input-record field (R02EMP-ID)
# - YEAR-MONTH <- ACCEPT + IF-SPACES default literal ('202605')
if hv:
val = _resolve_runtime_hostvar(rec, hv, assignments or {})
if not val or not str(val).strip():
inp_field = _input_pk_field(rec, pk_hit)
if inp_field:
v = _rec_get(rec, inp_field, '')
if v and str(v).strip():
val = str(v)
if (not val or not str(val).strip()) and hv:
val = _rec_get(rec, hv, '')
if not val or not str(val).strip():
return None
row[col_name] = _format_db_value(ci, str(val))
return row if row else None
# ── 事务调度感知:按 88 级识别记录运行时执行的 SQL 种类 ──
# 目的:UPDATE/DELETE 种子只对实际执行 UPDATE/DELETE 的记录建行,
# 避免把 INSERT 记录的主键也预置进表(否则运行时 INSERT 全部 -803
# INSERT 成功分支不可达)。通用实现(88 级名 INSERT/UPDATE/DELETE 语义标记、
# 沿 MOVE 链追溯输入根字段),无程序名硬编码。
def _dispatch_field_and_sets(fields_dict):
"""返回 (调度字段名, {INSERT:[...], UPDATE:[...], DELETE:[...]}) 或 (None, {})。
调度字段 = INSERT/UPDATE/DELETE 88 级语义标记的父字段 WRK-TRAN-TYPE
"""
groups = {}
for f in fields_dict or []:
if isinstance(f, dict) and f.get('is_88') and f.get('parent'):
groups.setdefault(f['parent'], []).append(f)
for parent, kids in groups.items():
hi = any('INSERT' in str(k.get('name', '')).upper() for k in kids)
hu = any('UPDATE' in str(k.get('name', '')).upper() for k in kids)
hd = any('DELETE' in str(k.get('name', '')).upper() for k in kids)
if hi and hu and hd:
sets = {}
for k in kids:
nm = str(k.get('name', '')).upper()
vals = k.get('values') or ([k.get('value')] if k.get('value') else [])
group = next((g for g in ('INSERT', 'UPDATE', 'DELETE') if g in nm), None)
if not group:
continue
for v in vals:
sets.setdefault(group, set()).add(str(v).strip())
return parent, sets
return None, {}
def _trace_input_root(field_name, assignments):
"""沿 MOVE 链把调度字段追溯到输入记录根字段(WRK-TRAN-TYPE ← R01TRAN-TYPE)。"""
seen = set()
cur = field_name
while cur and cur not in seen:
seen.add(cur)
al = assignments.get(cur) or []
if not al:
break
srcs = al[0].get('source_vars') or []
if len(srcs) != 1:
break
cur = srcs[0]
return cur
def _classify_record_dispatches(records, fields_dict, assignments):
"""返回 {path_idx: 'INSERT'|'UPDATE'|'DELETE'|'OTHER'}。
用记录的实际调度值输入根字段非合成工作区值对照 88 级集合分类
"""
field, sets = _dispatch_field_and_sets(fields_dict)
if not field:
return {}
root = _trace_input_root(field, assignments or {})
out = {}
for i, r in enumerate(records or []):
v = str(r.get(root, r.get(field, ''))).strip()
out[i] = next((k for k in ('INSERT', 'UPDATE', 'DELETE')
if v in sets.get(k, set())), 'OTHER')
return out
def build_db_input( def build_db_input(
branch_paths: list[tuple[list, dict]], branch_paths: list[tuple[list, dict]],
fields_dict: list[dict], fields_dict: list[dict],
@@ -352,6 +888,7 @@ def build_db_input(
sql_meta: list[dict], sql_meta: list[dict],
declared_columns: dict, declared_columns: dict,
records: list[dict] = None, records: list[dict] = None,
insert_pk: dict[str, list[str]] = None,
) -> dict: ) -> dict:
"""Generate DB input rows per branch path. """Generate DB input rows per branch path.
Returns {table: [{col: val, ...}, ...]}. Returns {table: [{col: val, ...}, ...]}.
@@ -363,11 +900,29 @@ def build_db_input(
seen_keys = {} seen_keys = {}
seq_counter = itertools.count(1) seq_counter = itertools.count(1)
# 事务调度感知:分类每条记录运行时执行的 SQLINSERT/UPDATE/DELETE/OTHER)。
# UPDATE/DELETE 种子只对实际执行 UPDATE/DELETE 的记录建行,避免把 INSERT
# 记录主键预置进表导致 INSERT 成功分支不可达。
dispatch = _classify_record_dispatches(records, fields_dict, assignments)
# Downstream no-data coverage: when a path seeds rows for multiple SELECT
# tables (e.g. EMP-MASTER then OVT-MONTHLY), drop the rows of the LAST
# (runtime-order) table for the LAST SQL-ok path. That record then reaches
# the downstream SELECT with no matching row (SQLCODE = 100), covering the
# downstream SELECT's "no data" branch (IF SQLCODE = 0 ELSE path).
sql_select_tables = [m.get('table') for m in sql_meta
if m.get('type') == 'exec_sql_select' and m.get('table')]
unique_select_tables = []
for t in sql_select_tables:
if t not in unique_select_tables:
unique_select_tables.append(t)
drop_table = unique_select_tables[-1] if len(unique_select_tables) >= 2 else None
sql_ok_paths = [i for i, (pc, _pa) in enumerate(branch_paths) if _path_has_sql_ok(pc)]
last_sql_ok_path = sql_ok_paths[-1] if len(sql_ok_paths) >= 2 else -1
# Collect all SQL meta per path # Collect all SQL meta per path
for path_idx, (path_cons, path_assign) in enumerate(branch_paths): for path_idx, (path_cons, path_assign) in enumerate(branch_paths):
# Skip paths where SQL fails (SQLCODE <> 0) sql_ok = _path_has_sql_ok(path_cons)
if not _path_has_sql_ok(path_cons):
continue
rec = records[path_idx] if records and path_idx < len(records) else {} rec = records[path_idx] if records and path_idx < len(records) else {}
@@ -383,12 +938,36 @@ def build_db_input(
seen_keys[table] = set() seen_keys[table] = set()
if atype == 'exec_sql_insert': if atype == 'exec_sql_insert':
# INSERT creates rows at runtime; no initial rows needed # INSERT creates rows at runtime; no initial rows needed,
# EXCEPT a PK-collision row so the duplicate-key error path
# (SQLCODE <> 0 / -803) is reachable at runtime.
if insert_pk:
pk_cols = [c.upper() for c in insert_pk.get(table, [])]
if pk_cols and not _path_has_sql_ok(path_cons):
# 碰撞行必须命中真实执行 INSERT 的记录:若本路径记录并非
# INSERT 类(如合成记录走了 WHEN OTHER),回退到首条
# INSERT 类记录的主键,确保运行时 -803 分支可达。
target = rec if dispatch.get(path_idx) == 'INSERT' else None
if target is None:
ins_idx = next((i for i in sorted(dispatch)
if dispatch[i] == 'INSERT'), None)
target = records[ins_idx] if ins_idx is not None else rec
row = _insert_collision_row(sql, target, pk_cols,
declared_columns,
assignments=assignments)
if row:
db_input[table].append(row)
continue continue
if atype in ('exec_sql_delete', 'exec_sql_update'): if atype in ('exec_sql_delete', 'exec_sql_update'):
# DELETE/UPDATE needs existing rows to act on # DELETE/UPDATE needs existing rows to act on
col_infos = declared_columns.get(table, []) if not sql_ok:
continue
# 仅对实际执行 UPDATE/DELETE 的记录建行(避免污染 INSERT/OTHER 主键)。
# 无 88 级调度字段可识别时不做门控(回退旧行为)。
if dispatch and dispatch.get(path_idx) not in ('UPDATE', 'DELETE'):
continue
col_infos = _declared_cols_for(declared_columns, table)
if not col_infos: if not col_infos:
col_infos = _infer_columns_from_where(where_cons) col_infos = _infer_columns_from_where(where_cons)
row = {} row = {}
@@ -397,17 +976,17 @@ def build_db_input(
val = None val = None
for wc in where_cons: for wc in where_cons:
wc_col = wc.get('col', '').upper().split('.')[-1] wc_col = wc.get('col', '').upper().split('.')[-1]
if wc_col != col_name: if _norm_col(wc_col) != _norm_col(col_name):
continue continue
if wc['type'] == 'literal': if wc['type'] == 'literal':
val = wc.get('literal', '') val = wc.get('literal', '')
break break
elif wc['type'] == 'host_var': elif wc['type'] == 'host_var':
hv = wc.get('host_var', '').upper() hv = wc.get('host_var', '').upper()
val = str(rec.get(hv, '')) val = _runtime_or_where_hostvar(rec, hv, assignments)
break break
if val is None or not val.strip(): if val is None or not val.strip():
val = str(rec.get(ci['name'], '')) val = _rec_get(rec, ci['name'], '')
if val and val.strip(): if val and val.strip():
row[ci['name']] = _format_db_value(ci, val) row[ci['name']] = _format_db_value(ci, val)
if not row: if not row:
@@ -416,8 +995,10 @@ def build_db_input(
continue continue
# exec_sql_select (and any future read-only types) # exec_sql_select (and any future read-only types)
if not sql_ok:
continue
row = {} row = {}
col_infos = declared_columns.get(table, []) col_infos = _declared_cols_for(declared_columns, table)
if not col_infos: if not col_infos:
col_infos = _infer_columns_from_where(where_cons) col_infos = _infer_columns_from_where(where_cons)
into_vars = sql.get('into_vars', []) into_vars = sql.get('into_vars', [])
@@ -431,7 +1012,7 @@ def build_db_input(
# Use SQL column names (not INTO var names) for row keys # Use SQL column names (not INTO var names) for row keys
for sc in select_cols: for sc in select_cols:
if sc not in [c['name'] for c in col_infos]: if not any(_norm_col(sc) == _norm_col(c['name']) for c in col_infos):
col_infos.append({'name': sc, 'db_type': 'CHAR', 'size': 20}) col_infos.append({'name': sc, 'db_type': 'CHAR', 'size': 20})
where_cols = set() where_cols = set()
@@ -439,35 +1020,48 @@ def build_db_input(
col_name = col_info['name'] col_name = col_info['name']
val = None val = None
for wc in where_cons: for wc in where_cons:
if wc['type'] == 'literal' and wc.get('col', '').upper() == col_name: if wc['type'] == 'between':
bv = _between_seed_value(wc, rec, assignments, col_name,
fields_dict)
if bv is not None:
val = bv
where_cols.add(col_name)
break
if wc['type'] == 'literal' and _norm_col(wc.get('col', '')) == _norm_col(col_name):
val = wc.get('literal', '') val = wc.get('literal', '')
where_cols.add(col_name) where_cols.add(col_name)
break break
if wc['type'] == 'host_var': if wc['type'] == 'host_var':
wc_col = wc.get('col', '').upper().split('.')[-1]
if _norm_col(wc_col) != _norm_col(col_name):
continue
hv = wc.get('host_var', '').upper() hv = wc.get('host_var', '').upper()
for pc_field, pc_op, pc_val, pc_want in path_cons: for pc_field, pc_op, pc_val, pc_want in path_cons:
if pc_field == hv: if pc_field == hv:
val = pc_val if pc_want else '' val = pc_val if pc_want else ''
where_cols.add(col_name)
break break
if val is None and hv in rec: if val is None and _rec_has(rec, hv):
val = str(rec[hv]) val = _runtime_or_where_hostvar(rec, hv, assignments)
where_cols.add(col_name)
# Try to find value from INTO variable in the record # Try to find value from INTO variable in the record
if val is None: if val is None:
for iv, scola in into_to_col.items(): for iv, scola in into_to_col.items():
if scola == col_name and iv in rec: if _norm_col(scola) == _norm_col(col_name) and _rec_has(rec, iv):
val = str(rec[iv]) val = _rec_get(rec, iv, '')
break break
# Try COBOL field name mapping # Try COBOL field name mapping
if val is None: if val is None:
cobol_field = guess_cobol_field(col_name, table, declared_columns) cobol_field = guess_cobol_field(col_name, table, declared_columns)
if cobol_field in rec: if _rec_has(rec, cobol_field):
val = str(rec[cobol_field]) val = _rec_get(rec, cobol_field, '')
if val is not None: if val is not None:
row[col_name] = _format_db_value(col_info, val) row[col_name] = _format_db_value(col_info, val)
else: else:
row[col_name] = _format_db_value(col_info, str(next(seq_counter))) row[col_name] = _format_db_value(col_info, str(next(seq_counter)))
if not row: if not row:
@@ -478,6 +1072,8 @@ def build_db_input(
if first_col in row and (not where_cols or first_col not in where_cols): if first_col in row and (not where_cols or first_col not in where_cols):
row[first_col] = _make_key_unique(row[first_col], path_idx, seen_keys[table]) row[first_col] = _make_key_unique(row[first_col], path_idx, seen_keys[table])
if drop_table is not None and table == drop_table and path_idx == last_sql_ok_path:
continue
db_input[table].append(row) db_input[table].append(row)
return db_input return db_input
+9
View File
@@ -32,6 +32,7 @@ class SysinDef:
period: str | None = "202607" period: str | None = "202607"
include_invalid_period: bool = False include_invalid_period: bool = False
modes: list[str] = field(default_factory=lambda: ["NORMAL"]) modes: list[str] = field(default_factory=lambda: ["NORMAL"])
final_mode: str = "RESET"
@dataclass @dataclass
@@ -42,6 +43,9 @@ class ScenarioDef:
inject_duplicate_pk: bool = False inject_duplicate_pk: bool = False
row_overrides: dict[str, dict[str, str]] = field(default_factory=dict) row_overrides: dict[str, dict[str, str]] = field(default_factory=dict)
delete_all_rows: bool = False delete_all_rows: bool = False
drop_tables: list[str] = field(default_factory=list)
command_line: str | None = None
seed_extra_rows: dict[str, int] = field(default_factory=dict)
@dataclass @dataclass
@@ -53,6 +57,7 @@ class ProgramSchema:
db_name: str = "OVERTIME.DB" db_name: str = "OVERTIME.DB"
runs: list[ScenarioDef] = field(default_factory=list) runs: list[ScenarioDef] = field(default_factory=list)
coverage_dates: dict[str, list[dict[str, str]]] = field(default_factory=dict) coverage_dates: dict[str, list[dict[str, str]]] = field(default_factory=dict)
command_line: str | None = None
@classmethod @classmethod
def from_yaml(cls, path: str | Path) -> ProgramSchema: def from_yaml(cls, path: str | Path) -> ProgramSchema:
@@ -76,6 +81,9 @@ class ProgramSchema:
inject_duplicate_pk=r.get("inject_duplicate_pk", False), inject_duplicate_pk=r.get("inject_duplicate_pk", False),
row_overrides=r.get("row_overrides", {}), row_overrides=r.get("row_overrides", {}),
delete_all_rows=r.get("delete_all_rows", False), delete_all_rows=r.get("delete_all_rows", False),
drop_tables=r.get("drop_tables", []),
command_line=r.get("command_line"),
seed_extra_rows=r.get("seed_extra_rows", {}),
)) ))
return cls( return cls(
program_id=raw["program_id"], program_id=raw["program_id"],
@@ -85,6 +93,7 @@ class ProgramSchema:
db_name=raw.get("db_name", "OVERTIME.DB"), db_name=raw.get("db_name", "OVERTIME.DB"),
runs=runs, runs=runs,
coverage_dates=raw.get("coverage_dates", {}), coverage_dates=raw.get("coverage_dates", {}),
command_line=raw.get("command_line"),
) )
+34
View File
@@ -0,0 +1,34 @@
program_id: JIN05UPD
db_type: SQLite
db_name: JIN.db
db_tables:
- name: EMPLOYEE
columns:
- name: EMP_ID
type: CHAR(8)
primary_key: true
- name: KANA_SEI
type: VARCHAR(20)
- name: KANA_MEI
type: VARCHAR(20)
- name: KANJI_NAME
type: VARCHAR(40)
- name: SUB_CODE
type: CHAR(4)
- name: BIRTH_DATE
type: CHAR(8)
- name: DEPT_CODE
type: CHAR(4)
- name: ENTRY_DATE
type: CHAR(8)
- name: STATUS
type: CHAR(1)
runs:
- id: normal
subprograms:
- SUB01DAT
- SUB02MSG
- SUB03END
+16
View File
@@ -23,6 +23,22 @@ db_tables:
- name: STATUS - name: STATUS
type: CHAR(1) type: CHAR(1)
command_line: YEARMONTH=202607
runs:
- id: normal
- id: no_parm
command_line: ""
- id: feb
command_line: YEARMONTH=202602
- id: feb_leap
command_line: YEARMONTH=202402
- id: invalid_month
command_line: YEARMONTH=202613
- id: empty_emp
command_line: YEARMONTH=202607
delete_all_rows: true
subprograms: subprograms:
- SUB02MSG - SUB02MSG
- SUB03END - SUB03END
+19
View File
@@ -77,3 +77,22 @@ runs:
include_invalid_period: true include_invalid_period: true
modes: ["NORMAL", "RESET"] modes: ["NORMAL", "RESET"]
inject_duplicate_pk: true inject_duplicate_pk: true
- id: sql_delete_error
# RESET 時 DELETE MONTHLY_ABSENCE の SQL エラー(テーブル削除)→ 9100DBERRSOR
sysin:
period: "202607"
include_invalid_period: true
modes: ["RESET"]
inject_duplicate_pk: false
drop_tables: [MONTHLY_ABSENCE]
- id: sql_select_error
# UPSERT 前 SELECT COUNT 的 SQL エラー(テーブル削除)→ SELECT COUNT failed 分岐
sysin:
period: "202607"
include_invalid_period: true
modes: ["NORMAL"]
final_mode: "NORMAL"
inject_duplicate_pk: false
drop_tables: [MONTHLY_ABSENCE]
+24
View File
@@ -49,6 +49,30 @@ db_tables:
- name: UPDATED_AT - name: UPDATED_AT
type: TIMESTAMP type: TIMESTAMP
runs:
- id: FULL
command_line: "YEARMONTH=202607,MODE=FULL"
seed_extra_rows:
DAILY_RECORDS: 100
- id: SHORT
command_line: "YEARMONTH=202607,MODE=SHORT"
# Fix A: PARM 组合 + 空结果集场景
- id: missing_mode
# WS-COMMA-CNT < 1 → 'WARNING: Missing MODE'MODE 默认 FULL
command_line: "YEARMONTH=202607"
- id: reordered_parm
# KEY1=MODEVALUE1→WS-MODE+ KEY2=YEARMONTHVALUE2→WS-YEARMONTH
command_line: "MODE=FULL,YEARMONTH=202607"
- id: missing_ym
# YEARMONTH 未指定 → SPACE → ABENDSUB03END
command_line: "MODE=FULL"
- id: empty_daily
# 首 FETCH EOF:删除 DAILY_RECORDS 表 → 光标 OPEN/FETCH 失败 → SQLCODE≠0 → WS-DAILY-EOF
# delete_all_rows 空表时 gixsql 首次 FETCH 返回 SQLCODE=0,无法触发 EOF
command_line: "YEARMONTH=202607,MODE=FULL"
drop_tables: [DAILY_RECORDS]
subprograms: subprograms:
- SUB02MSG - SUB02MSG
- SUB03END - SUB03END
+32
View File
@@ -0,0 +1,32 @@
program_id: KYU02REG
db_type: SQLite
db_name: SALARY.db
db_tables:
- name: EMP_MASTER
columns:
- name: EMP_ID
type: CHAR(8)
primary_key: true
- name: EMP_NAME
type: VARCHAR(40)
- name: DEPT_CODE
type: CHAR(2)
- name: REGION_CODE
type: CHAR(2)
- name: CATEGORY_CODE
type: CHAR(3)
- name: BASE_SALARY
type: DECIMAL(9,0)
- name: HOURLY_RATE
type: DECIMAL(7,0)
- name: DEPENDENT_COUNT
type: SMALLINT
- name: STATUS
type: CHAR(1)
- name: UPDATED_AT
type: TIMESTAMP
subprograms:
- SUB02MSG
- SUB03END
+50
View File
@@ -0,0 +1,50 @@
program_id: KYU04CAL
db_type: SQLite
db_name: SALARY.db
db_tables:
- name: EMP_MASTER
columns:
- name: EMP_ID
type: CHAR(8)
primary_key: true
- name: EMP_NAME
type: VARCHAR(40)
- name: DEPT_CODE
type: CHAR(2)
- name: REGION_CODE
type: CHAR(2)
- name: CATEGORY_CODE
type: CHAR(3)
- name: BASE_SALARY
type: DECIMAL(9,0)
- name: HOURLY_RATE
type: DECIMAL(7,0)
- name: DEPENDENT_COUNT
type: SMALLINT
- name: STATUS
type: CHAR(1)
- name: UPDATED_AT
type: TIMESTAMP
- name: OVT_MONTHLY
columns:
- name: EMP_ID
type: CHAR(8)
primary_key: true
- name: YEAR_MONTH
type: CHAR(6)
primary_key: true
- name: OVT_TYPE
type: CHAR(1)
- name: OVT_HOURS
type: DECIMAL(4,1)
- name: OVT_COUNT
type: DECIMAL(9)
- name: UPDATED_AT
type: TIMESTAMP
subprograms:
- SUB01DAT
- SUB02MSG
- SUB03END
+36
View File
@@ -0,0 +1,36 @@
program_id: KYU05DED
db_type: SQLite
db_name: SALARY.db
db_tables:
- name: TAX_TABLE
columns:
- name: TAX_FROM
type: DECIMAL(9,0)
primary_key: true
- name: TAX_TO
type: DECIMAL(9,0)
primary_key: true
- name: TAX_RATE
type: DECIMAL(5,4)
- name: DEDUCTION
type: DECIMAL(9,0)
- name: INSURANCE_TABLE
columns:
- name: INS_INCOME_FROM
type: DECIMAL(9,0)
primary_key: true
- name: INS_INCOME_TO
type: DECIMAL(9,0)
primary_key: true
- name: INS_HEALTH_RATE
type: DECIMAL(7,6)
- name: INS_PENSION_RATE
type: DECIMAL(7,6)
- name: UPDATED_AT
type: TIMESTAMP
subprograms:
- SUB02MSG
- SUB03END
+29
View File
@@ -0,0 +1,29 @@
program_id: KYU06UPD
db_type: SQLite
db_name: SALARY.db
db_tables:
- name: SALARY_RESULTS
columns:
- name: EMP_ID
type: CHAR(8)
primary_key: true
- name: YEAR_MONTH
type: CHAR(6)
primary_key: true
- name: GROSS_PAYMENT
type: DECIMAL(9,0)
- name: INCOME_TAX
type: DECIMAL(9,0)
- name: INSURANCE
type: DECIMAL(9,0)
- name: RESIDENT_TAX
type: DECIMAL(9,0)
- name: NET_PAYMENT
type: DECIMAL(9,0)
- name: UPDATED_AT
type: TIMESTAMP
subprograms:
- SUB02MSG
- SUB03END
+45
View File
@@ -0,0 +1,45 @@
program_id: SHA02MNC
db_type: SQLite
db_name: INSURANCEDB.db
db_tables:
- name: INSURANCE_RATES
columns:
- name: GRADE_CODE
type: CHAR(2)
primary_key: true
- name: MONTHLY_FROM
type: DECIMAL(9,0)
- name: MONTHLY_TO
type: DECIMAL(9,0)
- name: HEALTH_RATE
type: DECIMAL(7,6)
- name: PENSION_RATE
type: DECIMAL(7,6)
- name: EFFECTIVE_FROM
type: CHAR(6)
- name: EFFECTIVE_TO
type: CHAR(6)
- name: EMP_MASTER
columns:
- name: EMP_ID
type: CHAR(8)
primary_key: true
- name: EMP_NAME
type: VARCHAR(40)
- name: DEPT_CODE
type: CHAR(2)
- name: BASE_SALARY
type: DECIMAL(9,0)
runs:
- id: normal
- id: emp_open_fail
drop_tables: [EMP_MASTER]
inject_duplicate_pk: false
subprograms:
- SUB01DAT
- SUB02MSG
- SUB04CHK
+32
View File
@@ -0,0 +1,32 @@
program_id: SHA03MNP
db_type: SQLite
db_name: INSURANCEDB.db
db_tables:
- name: INSURANCE_RATES
columns:
- name: GRADE_CODE
type: CHAR(2)
primary_key: true
- name: MONTHLY_FROM
type: DECIMAL(9,0)
- name: MONTHLY_TO
type: DECIMAL(9,0)
- name: HEALTH_RATE
type: DECIMAL(7,6)
- name: PENSION_RATE
type: DECIMAL(7,6)
- name: EFFECTIVE_FROM
type: CHAR(6)
- name: EFFECTIVE_TO
type: CHAR(6)
runs:
- id: normal
- id: rates_open_fail
drop_tables: [INSURANCE_RATES]
inject_duplicate_pk: false
subprograms:
- SUB01DAT
- SUB02MSG
+19
View File
@@ -0,0 +1,19 @@
program_id: SHA04TWO
db_type: SQLite
db_name: INSURANCEDB.db
db_tables:
- name: INSURED_MASTER
columns:
- name: EMP_ID
type: CHAR(8)
primary_key: true
- name: INSURED_NO
type: CHAR(10)
primary_key: true
- name: STATUS
type: CHAR(1)
subprograms:
- SUB01DAT
- SUB02MSG
+30
View File
@@ -0,0 +1,30 @@
program_id: SHA06TWM
db_type: SQLite
db_name: INSURANCEDB.db
db_tables:
- name: INSURANCE_RATES
columns:
- name: GRADE_CODE
type: CHAR(2)
primary_key: true
- name: HEALTH_RATE
type: DECIMAL(7,0)
- name: PENSION_RATE
type: DECIMAL(7,0)
- name: EMP_MASTER
columns:
- name: EMP_ID
type: CHAR(8)
primary_key: true
- name: BIRTH_DATE
type: CHAR(8)
- name: DEPENDENT_COUNT
type: SMALLINT
- name: REGION_CODE
type: CHAR(2)
subprograms:
- SUB01DAT
- SUB02MSG
+34
View File
@@ -0,0 +1,34 @@
program_id: SHA07KBR
db_type: SQLite
db_name: INSURANCEDB.db
db_tables:
- name: QUALIFICATION_CHANGES
columns:
- name: CHG_ID
type: DECIMAL(9,0)
primary_key: true
- name: EMP_ID
type: CHAR(8)
- name: CHG_DATE
type: CHAR(8)
- name: CHG_TYPE
type: CHAR(2)
- name: INSURER_CODE
type: CHAR(4)
- name: PREV_INSURER
type: CHAR(4)
- name: REASON
type: VARCHAR(100)
- name: EMP_MASTER
columns:
- name: EMP_ID
type: CHAR(8)
primary_key: true
- name: EMP_NAME
type: VARCHAR(40)
subprograms:
- SUB01DAT
- SUB02MSG
+324
View File
@@ -0,0 +1,324 @@
# V3系统总体设计
> 版本: v1.0 | 日期: 2026-08-22
> 本文档描述COBOL迁移验证平台V3的总体架构和模块设计。
---
## 一、系统概述
### 1.1 系统定位
COBOL迁移验证平台V3是一个AI辅助的自动化测试工具,用于验证COBOL程序向Java/Spark迁移的正确性。
### 1.2 核心能力
| 能力 | 说明 |
|------|------|
| COBOL源码解析 | 自动解析DATA DIVISION和PROCEDURE DIVISION |
| 测试数据生成 | 基于分支覆盖的测试数据自动生成 |
| 双管道验证 | 支持非DBflat file)和DBSQLite)两种验证模式 |
| 覆盖率分析 | 静态分支覆盖 + 动态gcov覆盖 |
| AI辅助 | LLM驱动的程序分类和测试策略生成 |
### 1.3 技术栈
| 组件 | 技术 |
|------|------|
| 语言 | Python 3.12+ |
| 解析器 | Lark (Earley parser) |
| COBOL编译 | GnuCOBOL 3.2.0 |
| DB管道 | gixsql + SQLite |
| AI模型 | DeepSeek |
| 测试框架 | pytest |
---
## 二、系统架构
### 2.1 架构图
```
┌─────────────────────────────────────────────────────────────────┐
│ V3系统架构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ CLI入口 │ │ Web界面 │ │ API接口 │ │
│ │ main.py │ │ web/ │ │ __init__ │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └────────────────────┼────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 编排层 (Orchestrator) │ │
│ │ orchestrator.py (非DB) orchestrator_db.py (DB) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────────────────────┼────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 核心引擎 │ │ 运行引擎 │ │ AI代理 │ │
│ │ cobol_testgen│ │ runners/ │ │ agents/ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │ │ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 比对模块 │ │ 报告模块 │ │ 配置模块 │ │
│ │ comparator/ │ │ report/ │ │ config/ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 分层架构
| 层级 | 模块 | 职责 |
|------|------|------|
| **L1 数据层** | data/ | 共享数据模型 |
| **L2 核心层** | cobol_testgen/, config/ | COBOL解析、配置管理 |
| **L3 业务层** | hina/, agents/, comparator/ | 分类、AI代理、比对 |
| **L4 编排层** | orchestrator*, runners/ | 流程编排、执行 |
| **L5 接口层** | main.py, web/, __init__.py | 用户接口 |
---
## 三、模块清单
### 3.1 核心模块
| 模块 | 文件数 | 行数 | 职责 |
|------|--------|------|------|
| cobol_testgen/ | 22 | ~8000 | COBOL解析、测试数据生成 |
| orchestrator_db.py | 1 | 1334 | DB管道6步编排 |
| runners/ | 8 | ~600 | 编译运行引擎 |
| hina/ | 11 | ~2000 | HINA程序分类 |
| agents/ | 6 | ~800 | LLM代理 |
| comparator/ | 6 | ~400 | 字段比对 |
| config/ | 5 | ~300 | 配置管理 |
| report/ | 1 | ~200 | 报告生成 |
### 3.2 模块依赖关系
```
models.py (零依赖)
├── read.py (lark)
├── cond.py (stdlib)
├── core.py (cond)
├── design.py (models, cond, core)
├── coverage.py (models, cond)
├── output.py (file_io)
├── to_sql.py (stdlib)
├── runner.py (file_io)
├── flatfile.py (read, file_io)
└── __init__.py (所有上层模块)
```
---
## 四、数据流
### 4.1 非DB管道数据流
```
COBOL源码
read.py: preprocess() + parse_data_division()
core.py: build_branch_tree()
design.py: enum_paths() + generate_records()
output.py: output_json() + output_input_files()
runners/cobol_runner.py: compile() + run()
comparator/: compare_field()
report/generator.py: generate_report()
```
### 4.2 DB管道数据流
```
COBOL源码 (含EXEC SQL)
gixsql_runner.py: preprocess() + compile()
orchestrator_db.py: step2_generate_inputs()
├── cobol_testgen: extract_structure + generate_data
├── to_sql.py: build_db_input()
└── flatfile.py: write_all_files()
orchestrator_db.py: step3_run_cobol()
orchestrator_db.py: step4_extract_intermediate()
orchestrator_db.py: step5_run_java() (可选)
orchestrator_db.py: step6_verify()
```
---
## 五、接口设计
### 5.1 公开API
```python
# cobol_testgen/__init__.py
def extract_structure(
cobol_source: str,
copybook_dirs: list[str] = None
) -> dict:
"""解析COBOL源码,返回结构信息"""
pass
def generate_data(
cobol_source: str,
structure: dict,
copybook_dirs: list[str] = None
) -> list[dict]:
"""生成分支覆盖测试数据"""
pass
def main():
"""CLI入口"""
pass
```
### 5.2 配置接口
```python
# config/__init__.py
@dataclass
class Config:
"""全局配置"""
project_name: str
copybook_paths: list[str]
dialect: str
llm_model: str
gcov_enabled: bool
# ... 更多字段
```
---
## 六、错误处理
### 6.1 错误分类
| 类别 | 示例 | 处理策略 |
|------|------|----------|
| 解析错误 | COBOL语法不合法 | 抛出异常,返回错误信息 |
| 编译错误 | cobc编译失败 | 记录日志,跳过该程序 |
| 运行错误 | 程序执行异常 | 捕获异常,标记失败 |
| 超时错误 | 执行超时 | 强制终止,记录超时 |
### 6.2 容错机制
- **解析器超时**pipeline_bridge 3秒超时回退
- **LLM失败**:回退到规则引擎
- **gcov失败**:跳过覆盖率收集
- **DB连接失败**:重试或跳过
---
## 七、性能设计
### 7.1 性能指标
| 指标 | 目标 |
|------|------|
| 单程序解析 | < 5秒 |
| 测试数据生成 | < 30秒 |
| 编译运行 | < 60秒 |
| 覆盖率报告 | < 10秒 |
### 7.2 优化策略
- **路径枚举**O(N)线性算法替代O(2^N)
- **并行执行**:多场景gcov并行收集
- **缓存机制**LLM结果缓存
- **增量补充**:质量门循环最多4次
---
## 八、安全设计
### 8.1 输入验证
- COBOL源码:长度限制、编码检查
- 配置文件:YAML schema验证
- 文件路径:路径遍历防护
### 8.2 资源限制
- LLM调用:最大成本限制
- 执行时间:超时强制终止
- 内存使用:大文件分块处理
---
## 九、测试策略
### 9.1 测试层次
| 层次 | 覆盖率目标 | 工具 |
|------|------------|------|
| 单元测试 | ≥ 90% | pytest |
| 集成测试 | ≥ 80% | pytest |
| 端到端测试 | 100%通过 | 自定义脚本 |
### 9.2 测试数据
- 43个COBOL基准程序
- 33+2种程序类型
- 58个电信测试程序
---
## 十、部署设计
### 10.1 运行环境
| 组件 | 要求 |
|------|------|
| OS | Windows 10/11 |
| Python | 3.12+ |
| GnuCOBOL | 3.2.0 |
| 磁盘 | ≥ 500MB |
### 10.2 安装步骤
```bash
# 1. 安装Python依赖
pip install lark pathlib pyyaml
# 2. 安装GnuCOBOL
# 下载GC32-BDB-SP1,添加到PATH
# 3. 验证安装
python -c "from cobol_testgen import extract_structure; print('OK')"
```
@@ -0,0 +1,581 @@
# cobol_testgen 核心引擎模块 - 详细设计文档
> 模块路径: `cobol_testgen/`
> 版本: V3 (2026技术大赛)
> 依赖: Python 3.13+, `lark>=1.1.0`
---
## 1. 模块概述
### 1.1 职责
`cobol_testgen` 是 COBOL 迁移验证平台 V3 的核心引擎, 负责:
1. **解析** COBOL 源码 (DATA DIVISION + PROCEDURE DIVISION)
2. **构建** 分支控制流树 (Branch Tree)
3. **枚举** 所有可达路径 (路径级约束)
4. **生成** 满足路径约束的测试数据记录
5. **输出** JSON 格式测试数据 + HTML 覆盖率报告
### 1.2 边界
| 在范围内 | 不在范围内 |
|---------|-----------|
| COBOL 源码静态分析 | COBOL 程序动态执行 (由 runner.py 调用 GnuCOBOL) |
| 分支树构建 + 路径枚举 | LLM 推理 (由外部 deepseek API 调用) |
| 测试数据值生成 | SQL 数据库操作 (to_sql.py 辅助生成 DB 输入行) |
| JSON/HTML 输出 | IDE 集成、CI/CD 管道 |
### 1.3 依赖关系
```
cobol_testgen/
__init__.py <- 公开 API 入口 (main)
models.py <- 共享数据模型 (零外部依赖)
read.py <- INPUT 层: 预处理 + DATA DIVISION 解析
core.py <- CORE 层: PROCEDURE DIVISION 分支树构建
procedure_parser.py <- CORE 层: 行级状态机解析器 (新)
pipeline_bridge.py <- CORE 层: 新旧解析器桥接
cond.py <- CONDITION 层: 条件解析 + MC/DC
design.py <- DESIGN 层: 路径枚举 + 值生成
design_mcdc.py <- DESIGN 层: O(N) 非爆炸路径枚举
coverage.py <- COVERAGE 层: 分支覆盖标记 + HTML 报告
output.py <- OUTPUT 层: JSON 输出
to_sql.py <- SQL 辅助: WHERE 约束解析 + DB 输入行生成
flatfile.py <- I/O 辅助: 固定长度平面文件读写
file_io.py <- I/O 辅助: DISPLAY/COMP/COMP-3 二进制编解码
runner.py <- 执行层: 编译-执行-验证 (调用 GnuCOBOL)
gcov.py <- 覆盖率辅助: gcov 数据解析
data_merger.py <- 数据整合: 白盒+机能+策略数据合并
grammar.lark <- Lark 语法: DATA DIVISION 解析
procedure_grammar.lark <- Lark 语法: PROCEDURE DIVISION 解析
__main__.py <- 入口: python -m cobol_testgen
```
**外部依赖:**
| 依赖 | 用途 |
|------|------|
| lark>=1.1.0 | Lark 解析器框架 (Earley parser, dynamic lexer) |
| deepseek API | LLM 路径生成 (可选, 回退到规则引擎) |
| GnuCOBOL (cobc) | COBOL 编译-执行 (runner.py 调用) |
| gcov | 代码覆盖率采集 (gcov.py 调用) |
---
## 2. 文件清单
| 文件 | 行数 | 职责 |
|------|------|------|
| `__init__.py` | 2189 | 公开 API 入口、OCCURS 展开、PREV 连锁、跨文件键值协调、MERGE/SORT/LINAGE 注入、子程序输入供给 |
| `__main__.py` | 3 | python -m cobol_testgen 入口 |
| `models.py` | 118 | 共享数据模型: PicInfo, FieldDef, BrSeq, BrIf, BrEval, BrPerform, BrSearch, CondLeaf, Assign, CallNode, ExitNode, GoTo, ParseError, ProcParseResult |
| `read.py` | 644 | COBOL 源码预处理 (fixed/free format)、COPYBOOK 展开、DATA DIVISION 解析 (Lark grammar.lark)、FILE-CONTROL 解析、OPEN 语句扫描 |
| `core.py` | 2137 | PROCEDURE DIVISION 分支树构建 (_BrParser 类)、段落扫描、赋值追踪、SQL 虚拟字段注册、算术表达式解析、EVALUATE/PERFORM/SEARCH/READ/WRITE 解析 |
| `procedure_parser.py` | 527 | 新版行级状态机解析器 (Tier 1: 状态机提取嵌套结构, Tier 2: 规则条件解析) |
| `pipeline_bridge.py` | 225 | 新旧解析器桥接: 新解析器优先, 旧解析器 3s 超时回退 |
| `cond.py` | 497 | COBOL 条件表达式解析 (AND/OR/NOT/括号)、MC/DC 约束集生成 (mcdc_sets)、满足值计算 (satisfying_value) |
| `design.py` | 2014 | 路径枚举 (enum_paths)、基础记录生成 (make_base_record)、约束应用 (apply_constraint)、赋值传播 (propagate_assignments)、链追溯 (trace_to_root) |
| `design_mcdc.py` | 409 | O(N) 非爆炸路径枚举: 每个决策点生成 T/F 两条路径, 保证全覆盖无指数爆炸 |
| `coverage.py` | 1451 | 决策点收集 (collect_decision_points)、分支覆盖标记 (mark_coverage)、中文 HTML 报告生成 |
| `output.py` | 184 | JSON 输出: 按 FD 分组 input/expected_output/working_storage |
| `to_sql.py` | 948 | SQL 元数据提取 (collect_sql_meta)、WHERE 宿主变量 MOVE 链解析、DB 输入行生成 (build_db_input) |
| `flatfile.py` | 309 | 平面文件 I/O: FD 布局分析 (analyze_fd_layout)、固定长度记录读写 |
| `file_io.py` | 234 | 二进制编解码: DISPLAY/COMP/COMP-3 pack/unpack、RECORDING MODE V 处理 |
| `runner.py` | 516 | 编译-执行-验证: GnuCOBOL cobc 调用、字段对比、分组执行 |
| `gcov.py` | 165 | gcov 覆盖率数据解析 (.cbl.gcov -> {行号: 执行次数}) |
| `data_merger.py` | 130 | 数据整合: 白盒 (MC/DC) + 机能 (LLM) + 策略 (HINA 分类) 数据合并去重 |
| `grammar.lark` | 40 | Lark 语法: DATA DIVISION 解析 |
| `procedure_grammar.lark` | 203 | Lark 语法: PROCEDURE DIVISION 解析 |
---
---
## 3. 核心数据结构
所有模型定义在 `models.py` (118 行), 无外部依赖。
### 3.1 字段定义
```python
@dataclass
class PicInfo:
type: str = 'unknown' # "numeric" | "alphanumeric" | "alphabetic"
digits: int = 0 # 整数位数 (如 9(7) -> 7)
decimal: int = 0 # 小数位数 (如 V99 -> 2)
length: int = 0 # 总长度 (alphanumeric 用)
signed: bool = False # 是否有符号
@dataclass
class FieldDef:
name: str # 字段名 (大写)
level: int # 层号 (01, 05, 77, 88 等)
pic: str | None = None # PIC 子句原始文本
pic_info: PicInfo | None = None
is_filler: bool = False
occurs_count: int = 0
occurs_depending: str | None = None
redefines: str | None = None
usage: str | None = None # "COMP" | "COMP-3" | "BINARY" | "DISPLAY"
value: str | None = None
values: list[str] | None = None # 88 级多值
is_88: bool = False
parent: str | None = None
section: str | None = None
```
### 3.2 分支树节点
```python
class BrSeq: # 顺序语句序列 (容器节点)
children = [] # BrIf | BrEval | BrPerform | BrSearch | Assign | ...
class BrIf: # IF 条件分支
condition # 条件原文
cond_tree = None # 条件树 (core.py 解析时赋值)
true_seq # THEN 分支
false_seq # ELSE 分支
class BrEval: # EVALUATE 多分支
subject # 主体
subjects = [] # ALSO 多主体
when_list = [] # [(condition_text, BrSeq)]
other_seq # WHEN OTHER
has_other = False
class BrPerform: # PERFORM 循环/调用
perf_type # "until" | "varying" | "times" | "para" | "sort"
condition # UNTIL 条件
body_seq # 循环体
class BrSearch: # SEARCH 表查找
table_name, is_all, at_end_seq, when_list, has_at_end
```
### 3.3 条件树
```python
class CondLeaf: field, op, value # 叶条件
class CondNot: child # NOT 取反
class CondAnd: left, right # AND
class CondOr: left, right # OR
```
### 3.4 其他节点
```python
class Assign: target, source_info # 赋值 (MOVE/COMPUTE/READ INTO/WRITE FROM)
class CallNode: program_name, using_params # CALL 子程序
class GoTo: target, body_seq # GO TO 跳转
class ExitNode: exit_type # EXIT 退出
```
### 3.5 约束与路径
```python
Constraint = tuple # (field, op, value, want_true)
Path = list[Constraint]
class ParseError: line, message, severity
class ProcParseResult: tree, assignments, errors, fallback_to_ai
```
---
## 4. 解析流程
### 4.1 源码预处理 (read.py)
入口: `preprocess(source, extra_search_paths)`
1. COPYBOOK 展开 -> EXEC SQL/CICS 移除 -> VALUE 逗号清理
2. & 连接行合并 -> PIC 小数点转换 -> 格式检测 (fixed/free)
3. 注释移除 -> 续行合并
### 4.2 DATA DIVISION 解析 (read.py)
使用 `grammar.lark` (Lark Earley parser)。关键: Earley parser 处理歧义语法, 命名终端 USAGE_VAL 避免 Lark 过滤 tree children。
### 4.3 PROCEDURE DIVISION 分支树 (core.py)
入口: `build_branch_tree(proc_text, fields, full_source)`
段落扫描 (`scan_paragraphs`) -> 分支解析 (`_BrParser` 递归下降) -> 赋值追踪 (`assignments` 字典)。
### 4.4 新版解析器 (procedure_parser.py + pipeline_bridge.py)
Tier 1: 行级状态机提取嵌套结构 -> Tier 2: 规则条件解析。桥接: 新解析器优先, 旧解析器 3s 超时回退。
---
## 5. 分支树构建细节 (core.py)
### 5.1 流程
```
输入: proc_text, fields
1. raw_lines = proc_text.split('\n')
2. blocked_names = 收集数据名, 阻止误匹配段落名
3. paragraphs = scan_paragraphs(raw_lines, blocked_names)
4. parser = _BrParser(filtered, paragraphs, raw_lines, assignments, fields)
5. tree = parser.parse_seq(terminators={'GOBACK', 'STOP RUN', 'EXIT PROGRAM'})
```
### 5.2 _BrParser 支持的语句
IF -> EVALUATE -> PERFORM -> SEARCH -> READ/WRITE -> CALL -> GO TO -> MOVE/COMPUTE/ADD/SUBTRACT/MULTIPLY/DIVIDE -> SORT/MERGE -> EXEC SQL -> INITIALIZE -> STRING/UNSTRING
### 5.3 IF 解析
提取条件 -> parse_compound_condition -> 递归 parse_seq (THEN) -> 递归 parse_seq (ELSE) -> 消费 END-IF -> 返回 BrIf + cond_tree
### 5.4 EVALUATE 解析
提取主体 -> 检测 ALSO -> 循环解析 WHEN (条件 + 递归体) -> WHEN OTHER -> 返回 BrEval
---
## 6. 条件解析 (cond.py)
### 6.1 单条件 (parse_single_condition)
| 模式 | 示例 | 返回 |
|------|------|------|
| 标准比较 | `AMOUNT > 1000` | `('AMOUNT', '>', '1000')` |
| 88 级 | `STATUS-APPROVED` | `(parent, '=', value)` |
| NOT | `X NOT = 5` | `('X', '<>', '5')` |
| 裸字段 | `WS-EOF` | `('WS-EOF', '=', 'Y')` |
| SQLCODE | `SQLCODE = 100` | `('SQLCODE', '=', '100')` |
| Class | `WS-KEY IS NUMERIC` | `('WS-KEY', 'IS', 'NUMERIC', True)` |
| FUNCTION | `FUNCTION MOD(X,2) NOT = 0` | `('_FUNC_MOD', '<>', '0')` |
### 6.2 复合条件 (parse_compound_condition)
构建 CondAnd/CondOr/CondNot/CondLeaf 树。优先级: AND > OR。
### 6.3 MC/DC (mcdc_sets)
为复合条件生成 Modified Condition/Decision Coverage 约束集。每个条件独立影响决策。
### 6.4 满足值 (satisfying_value)
数值: 边界值 (val-1, val, val+1)。字母数字: 字符级增减。Class: 匹配/不匹配类型值。
---
## 7. 路径枚举 (design.py)
### 7.1 核心算法 (enum_paths)
入口: `enum_paths(node, fields)` -> `list[(constraints, assignments)]`
| 节点类型 | 路径生成 |
|---------|---------|
| Assign | 单路径, 空约束 + 赋值 |
| BrSeq | 子路径笛卡尔积 + 去重 |
| BrIf | True 分支 + False 分支 两组路径 |
| BrEval | 每个 WHEN 一条路径 + OTHER (EVALUATE TRUE 用 MC/DC) |
| BrPerform | Enter + Skip 两条路径 |
| BrSearch | 每个 WHEN + AT END |
| CallNode | 黑盒: 单路径 |
**BrIf 简单条件:**
```
true_sub = enum_paths(true_seq)
false_sub = enum_paths(false_seq)
result = [(field, op, val, True) + sp for sp in true_sub]
+ [(field, op, val, False) + sp for sp in false_sub]
```
**BrIf 复合条件:**
```
sets = mcdc_sets(cond_tree, fields)
for constraints, decision in sets:
body = enum_paths(true_seq if decision else false_seq)
result.append(constraints + body)
```
### 7.2 路径截断 (_cap_paths)
最大 50,000 条路径 (`_MAX_PATHS`)。公平截断:
- Phase 1: 每个前置路径至少保留一条子路径
- Phase 2: 用剩余配额填充未覆盖分支
- 哨兵路径 (STOP/ABEND) 始终保留
### 7.3 EVALUATE TRUE 特殊处理 (eval_true_branch_constraints)
- 每个 WHEN 解析为复合条件
- MC/DC 集合为每个 WHEN 生成
- `prior_false` 累积所有前序 WHEN 的 false 集 (笛卡尔积)
- 每个 True 路径与所有可能的 prior-false 组合配对
---
## 8. 值生成 (design.py)
### 8.1 基础记录生成 (make_base_record)
入口: `make_base_record(seq_num, fields)` -> `dict`
1. 遍历所有字段:
- VALUE 子句 -> `_apply_value` 初始值
- 数值字段 -> `_make_numeric_value` 序列值
- 字母字段 -> `_make_alpha_value` 序列值
- 日期字段 -> `seq_date`
2. REDEFINES: 父字段值复制到重定义字段
3. 组 REDEFINES: 按位置递归复制子字段
4. 跨 FD 字段对齐: 同名不同前缀的数值字段共享 index
### 8.2 约束应用 (apply_constraint)
入口: `apply_constraint(rec, field, op, value, want_true, fields, ...)`
处理管线:
1. 变量下标解析: `WS-FIXED-VALUE(WS-IDX)` -> 具体下标
2. 下标传播: 裸字段名应用到所有下标变体
3. REDEFINES 重定向: 约束转到父字段 (共享存储)
4. 组字段展开: 组比较分解为子字段约束
5. Class 条件: `IS NUMERIC/ALPHABETIC` 通过 satisfying_value 处理
6. 字母比较: 字符级边界值
7. 数值比较: 整数边界值
8. 算术表达式: 启发式 steering
9. 零保护: 防止零值字段在 False 分支取最小值
10. 字段间协调: `WS-A >= WS-B` 同时设置两个字段
### 8.3 赋值传播 (propagate_assignments)
模拟程序数据流到每个决策点:
| Pass | 操作 |
|------|------|
| 1 | MOVE 传播 (源复制到目标) |
| 2 | COMPUTE 求值 (算术表达式) |
| 3 | ADD/SUBTRACT/MULTIPLY/DIVIDE |
| 3.5 | READ INTO (文件读到工作存储) |
| 4 | UNSTRING 分割 |
| 5 | INITIALIZE (填充零/空格) |
| 6 | STRING 拼接 |
| 7 | SET TO FALSE (88 级条件名) |
### 8.4 链追溯 (trace_to_root)
沿 MOVE/COMPUTE 链回溯到源字段。返回 `(root_field, chain_of_assignments)`。用于检测不可能路径 (字面量 MOVE 与约束矛盾)。
---
## 9. 覆盖率分析 (coverage.py)
### 9.1 决策点收集 (collect_decision_points)
| 节点 | 决策点 |
|------|-------|
| BrIf | kind="IF", branches=["T", "F"], label=条件 |
| BrEval | kind="EVALUATE", branches=["WHEN ...", "OTHER"] |
| BrPerform | kind="PERFORM", branches=["Enter", "Skip"] |
| BrSearch | kind="SEARCH", branches=["WHEN ...", "AT END"] |
每个决策点包含:
- `id`: 自增编号
- `cond_tree`: 复合条件树 (用于 evaluate_tree)
- `cond_leaves`: 叶条件列表 (用于 _match_leaf)
- `active_branches`: 已覆盖的分支集合
- `implied_branches`: 推断的覆盖分支
### 9.2 分支覆盖标记 (mark_coverage)
对每条路径的约束列表, 逐个决策点匹配:
**IF 标记** (`_mark_if`):
- 简单条件: 匹配 (field, op, value) 确定 T/F 分支
- 复合条件: 构建 leaf->bool assignment, 用 evaluate_tree 求值
- 合成函数 (_FUNC_*): 全部标记覆盖
**EVALUATE 标记** (`_mark_eval`):
- 简单: 匹配 subject 值确定 WHEN 分支
- EVALUATE TRUE: prior_false 累积 + 逐 WHEN 匹配
**PERFORM 标记** (`_mark_perform`):
- 条件为真 -> Enter, 条件为假 -> Skip
**叶条件标记** (`_match_leaf`):
- 去除下标后匹配 field + op + value
- covered_true / covered_false 独立追踪
### 9.3 HTML 报告生成
`generate_coverage_index()`: 中文 HTML 报告, 包含:
- 覆盖率统计 (总分支数 / 已覆盖 / 未覆盖)
- 每个决策点的覆盖状态 (badge: 覆盖/未覆盖)
- 叶条件级别的 MC/DC 覆盖详情
---
## 10. 输出生成 (output.py)
### 10.1 JSON 输出 (output_json)
入口: `output_json(records, outpath, roles, fd_fields, field_to_fd, open_dir, term_types, db_input, data_fields)`
**输出格式:**
```json
{
"program": "程序名",
"records": [
{
"input": { "R01EMP-ID": "A0000001", ... },
"expected_output": { "W01RESULT": "PASS", ... },
"working_storage": { "WS-COUNT": "003", ... },
"termination": "normal"
}
],
"db_input": { ... }
}
```
**字段分组逻辑:**
- `input`: 方向为 INPUT/I-O 的 FD, 角色为 input/inout 的字段
- `expected_output`: 方向为 OUTPUT/I-O 的 FD, 角色为 output/inout 的字段
- `working_storage`: 不属于任何 FD 的字段
- 未分配字段 (`_assigned_fields` 集合) 的归属判定
### 10.2 平面文件输出 (output_input_files)
将 JSON 记录写入 COBOL 输入文件 (固定长度/行顺序)。支持:
- DISPLAY 格式 (文本)
- COMP/COMP-3 格式 (二进制打包)
- RECORDING MODE V (变长记录, 4 字节 RDW 前缀)
---
## 11. 接口定义
### 11.1 公开 API (__init__.py)
```python
def extract_structure(source: str, ...) -> dict:
"""解析 COBOL 控制流 -> dict (字段定义、分支树、赋值)"""
def generate_data(source: str, ...) -> list[dict]:
"""生成测试数据 -> list[dict] (每条路径一条记录)"""
def incremental_supplement(...) -> list[dict]:
"""差分补充数据 -> list[dict]"""
```
### 11.2 核心模块 API
**read.py:**
```python
def preprocess(source: str, extra_search_paths: list[str] = None) -> str
def extract_data_division(source: str) -> str
def extract_procedure_division(source: str) -> str
def parse_data_division(dd_text: str) -> list[FieldDef]
def parse_file_section(source: str) -> dict
def parse_file_control(source: str) -> dict
def scan_open_statements(source: str) -> dict
def scan_all_file_directions(source: str) -> dict
def resolve_copybooks(source: str, base_dir: str, ...) -> str
def resolve_sql_includes(source: str) -> str
```
**core.py:**
```python
def build_branch_tree(proc_text: str, fields: list, full_source: str = None) -> (BrSeq, dict)
def scan_paragraphs(raw_lines: list, blocked_names: set = None) -> dict
def sql_register_virtual_fields(fields_dict: list[dict]) -> list[dict]
def classify_field_roles(fields, assignments, file_sec, ...) -> dict
```
**cond.py:**
```python
def parse_single_condition(text: str, fields: list = None) -> tuple | None
def parse_compound_condition(text: str, fields: list = None) -> CondAnd | CondOr | CondLeaf | CondNot | None
def collect_leaves(tree) -> list[CondLeaf]
def evaluate_tree(tree, assignment: dict) -> bool
def is_field(name: str, fields: list) -> bool
def mcdc_sets(tree, fields: list = None) -> list | None
def satisfying_value(pic_info: dict, op: str, value: str, want_true: bool) -> str
def merge_field_constraints(cons_list: list) -> list
```
**design.py:**
```python
def enum_paths(node, fields: list) -> list[(list, dict)]
def make_base_record(seq_num: int, fields: list) -> dict
def apply_constraint(rec: dict, field_name: str, op: str, value: str, want_true: bool, fields: list, ...) -> None
def propagate_assignments(rec: dict, assignments: dict, fields: list, ...) -> None
def trace_to_root(field: str, assignments: dict, fields: list, path_assign: dict) -> (str, list)
def generate_records(path_infos: list, data_fields: list, ...) -> (list, list, list)
def get_term_type(cons: list) -> (list, str)
def extend_abend_programs(names: list[str]) -> None
```
**coverage.py:**
```python
def collect_decision_points(node, fields: list) -> (list[DecisionPoint], list[LeafStat])
def mark_coverage(decision_points: list, leaf_stats: list, branch_paths: list, fields: list) -> None
def run_coverage(source: str, records: list, ...) -> dict
def generate_coverage_index(...) -> str # HTML string
```
**output.py:**
```python
def output_json(records: list, outpath: Path, ...) -> None
def output_input_files(records: list, outpath: Path, ...) -> None
```
**to_sql.py:**
```python
def collect_sql_meta(source: str, fields: list) -> list[dict]
def build_db_input(sql_meta: list, records: list, ...) -> dict
```
**flatfile.py:**
```python
def analyze_fd_layout(source_text: str, ...) -> dict[str, dict]
def write_flat_file(records: list, layout: dict, outpath: Path) -> None
```
---
## 12. 错误处理
### 12.1 解析错误 (ParseError)
`models.py` 定义 `ParseError` 数据类:
- `line: int` -- 错误所在行号
- `message: str` -- 错误描述
- `severity: str` -- 'warning' 或 'error'
`ProcParseResult.errors` 收集解析过程中产生的所有错误。
### 12.2 处理策略
| 错误类型 | 处理方式 |
|---------|---------|
| COPYBOOK 找不到 | 跳过该 COPY, 继续解析, 记录 warning |
| Lark 语法不匹配 | 回退到规则引擎, 标记 `fallback_to_ai=True` |
| 未知 COBOL 语句 | 跳过该行, 不中断解析 |
| 字段未找到 | 使用默认值, 记录 debug 日志 |
| 条件解析失败 | 返回 None, 由上层决定回退策略 |
| 路径枚举超过上限 | `_cap_paths` 公平截断到 50,000 条 |
| 新解析器超时/失败 | `pipeline_bridge` 回退到旧解析器 (3s 超时) |
| 旧解析器超时/失败 | 返回空 BrSeq + 空 assignments |
| gcov 执行失败 | 返回空 dict, 跳过覆盖率标记 |
| 外部调用异常 | try/except 捕获, logger.warning 记录, 不中断主流程 |
### 12.3 日志策略
所有模块使用 Python `logging`:
- `logger.info()` -- 关键流程节点 (解析开始/结束, 路径数, 记录数)
- `logger.debug()` -- 详细调试信息 (约束应用, 赋值传播)
- `logger.warning()` -- 非致命错误 (解析失败, 回退)
- `logger.error()` -- 致命错误 (不应发生)
### 12.4 已知限制
1. **MC/DC 复合 IF bug**: `merge_field_constraints` 合并同字段约束会破坏 `_match_leaf` 匹配
2. **OCCURS DEPENDING ON**: 捕获但未用于记录生成
3. **88 级 VALUE**: 目标程序无 88 级 VALUE 子句, 解析器不依赖此特性
4. **路径多样性丢失**: 规则引擎 100 路径限制 (LLM 模式 50000 路径不受影响)
5. **合成函数字段**: `_FUNC_MOD` 等合成名 is_field 返回 False, 叶条件不可匹配
+574
View File
@@ -0,0 +1,574 @@
# DB 管道编排器 - 详细设计文档
> 模块路径: `orchestrator_db.py`
> 版本: V3 (2026技术大赛)
> 行数: 1973
> 核心类: `GixsqlOrchestrator`
---
## 1. 模块概述
### 1.1 职责
`orchestrator_db.py` 是 DB 型 COBOL 程序的 6 步端到端测试管道编排器,负责:
1. **环境整备** - gixpp 预处理 + cobc 编译(Step 1
2. **输入数据生成** - 测试数据生成 + 平面文件输出 + DB 初始化(Step 2)
3. **COBOL 执行** - 调用编译后的 COBOL 程序(Step 3
4. **中间数据提取** - SQLite DB → Java 中介 JSONStep 4
5. **Java 执行** - 调用 Java 转换程序(Step 5
6. **结果验证** - Java 输出与 COBOL 期望值比较(Step 6
### 1.2 边界
| 在范围内 | 不在范围内 |
|---------|-----------|
| DB 型 COBOL 程序 6 步管道编排 | Flat-file 型 COBOL 程序处理 |
| SQLite 数据库初始化与种子注入 | 分支树构建(由 cobol_testgen/ 处理) |
| 多场景(多轮)执行调度 | 覆盖率报告生成(可选附件,非管道核心) |
| 测试数据合成与 PK 冲突注入 | Java 程序内部逻辑 |
### 1.3 依赖关系
```
orchestrator_db.py
+-- config.py <- 项目配置
+-- config/program_schema.py <- YAML 程序定义
+-- cobol_testgen/ <- 核心引擎
+-- data/diff_result.py <- 验证结果数据模型
+-- runners/gixsql_runner.py <- GnuCOBOL 编译-执行运行器
+-- agents/llm.py <- LLM 客户端(可选)
```
---
## 2. 核心数据结构
### 2.1 GixsqlOrchestrator
```python
class GixsqlOrchestrator:
def __init__(self, config: Config, program_id: str,
cobol_src_dir: str | Path,
copybook_dirs: list[str | Path] | None = None,
work_dir: str | Path | None = None,
skip_jvm: bool = True):
```
**关键属性:**
| 属性 | 类型 | 说明 |
|------|------|------|
| config | Config | 项目配置 |
| program_id | str | COBOL 程序标识符 |
| cobol_src_dir | Path | COBOL 源码目录 |
| copybook_dirs | list[Path] | COPYBOOK 搜索路径 |
| work_dir | Path | 构建产物目录(ASCII 路径) |
| runtime_dir | Path | 运行时数据目录 |
| schema | ProgramSchema | YAML 程序定义 |
| runner | GixsqlCobolRunner | GnuCOBOL 运行器 |
| db_path | Path | 默认 SQLite DB 路径 |
| skip_jvm | bool | 是否跳过 Step 5/6 |
**管道状态:**
| 状态属性 | 类型 | 说明 |
|----------|------|------|
| src_path | Path | 预处理后源码路径 |
| pp_path | Path | gixpp 预处理输出路径 |
| exe_path | Path | 编译后可执行文件路径 |
| java_input_path | Path | Java 中介数据路径 |
| java_output_path | Path | Java 输出路径 |
| _current_db_path | Path | 当前场景的 DB 路径 |
| _multi_run_gcov_data | dict | 多轮合并后的 gcov 数据 |
| generated_records | list[dict] | 生成的测试数据记录 |
| generated_structure | dict | 解析后的程序结构 |
### 2.2 DbPipelineResult
```python
@dataclass
class DbPipelineResult:
program_id: str
step: int | float
success: bool
message: str = ""
data: dict = field(default_factory=dict)
```
---
## 3. 6 步流程设计
### 3.1 Step 1: 环境整备 (step1_setup_environment)
**职责:** gixpp 预处理 → cobc 编译
**执行流程:**
```
1. _copy_sources_to_workdir()
|-- 复制主源码 {program_id}.cbl → work_dir/src/
|-- 复制 COPYBOOK (*.cpy) → work_dir/src/
|-- 复制子程序 (SUB*.cbl) → work_dir/src/
(搜索: cobol_src_dir, sub/, production/sub/, cobol-tna-system/sub/)
2. runner.preprocess(src, preprocessed/, copybook_dirs)
|-- gixpp 预处理 + CONNECT TO 路径修补
| gixpp 错误转换: 'data/kin.db' → 'sqlite://localhost/kin'
| 修补为: 'sqlite:///{db_path}'
3. runner.compile(pp, exe, copybook_dirs, extra_srcs)
|-- cobc 编译 → work_dir/bin/{program_id}.exe
|-- 编译日志写入 runtime_dir/logs/compile/
4. 返回 DbPipelineResult(step=1, success, data={exe_path, log})
```
**关键逻辑:**
- 源码必须复制到 ASCII-only 路径(gixpp 不支持中文路径)
- CONNECT TO 字符串修补: gixpp 输出的 `sqlite://localhost/kin` 需替换为绝对路径
- 子程序从多个候选目录搜索,未找到仅 warning 不阻断
**输入:** cobol_src_dir, copybook_dirs, schema.subprograms
**输出:** src_path, pp_path, exe_path
### 3.2 Step 2: 输入数据生成 (step2_generate_inputs)
**职责:** COBOL 解析 → 测试数据生成 → DB 初始化 → 平面文件输出
**执行流程:**
```
1. COBOL 解析
|-- extract_structure(src_text) → 分支树 + 赋值表
|-- generate_all_data() → 测试数据记录(白盒+机能+策略)
|-- 后处理: R02APPL-ID 链接 R01APPL-ID
2. DB 初始化
|-- 确定 DB 路径(场景分离: {program_id}_{scenario_id}.db
|-- 清理旧 DB → _init_database(db_path)
| |-- _create_tables(): 按 YAML schema 创建表 + 主键
|-- _populate_database(): 注入种子行
| |-- 解析 COBOL → 分支树 → 路径枚举
| |-- build_db_input(): 生成 DB 输入行
| |-- 覆盖率驱动数据补充(日期、假期等)
| |-- 区间协调(INSURANCE-RATES / EMP-MASTER
| |-- INSERT OR IGNORE 写入 DB
|-- _inject_extra_seed_rows(): 大结果集注入
|-- _inject_sql_error_rows(): PK 冲突行注入
3. 记录修补
|-- records[0].R01EMP-ID = SPACE(触发空社员路径)
|-- 全零 EMP-ID → SPACE 清洗
|-- R01LINE 与 EMP-ID 一致性修补
|-- 注入重复 EMP-IDAGG UPDATE 路径)
|-- _deduplicate_r01_pk(): PK 去重
|-- _inject_aggregation_boundaries(): 聚合边界数据
4. 场景驱动修改
|-- collision 场景: INSERT 重复、OVT-MONTHLY 匹配、COMMIT 阈值
|-- abnormal 场景: orphan cancel ABEND
5. 平面文件输出
|-- write_all_files(): 全 FD 平面文件
|-- write_sysin_file(): SYSIN 配置
|-- _seed_matching_monthly_rows(): MONTHLY_ABSENCE 匹配行预填
6. JSON 输出(可选)
|-- 解析 DATA DIVISION → 字段字典
|-- 分支树 + MC/DC 路径枚举
|-- output_json(): 写入 json/{program_id}.json
7. 返回 DbPipelineResult(step=2, data={records, flat_files, db_path})
```
**关键逻辑:**
- 多场景时 DB 路径分离: `{program_id}_{scenario_id}.db`
- PK 冲突行必须与运行时 INSERT 实际值一致(基于输入记录而非合成值)
- 聚合边界注入: overflow(同月累加溢出)+ table-full>=110 个不同月)
- 日期值统一为 YYYYMMDD 格式
**输入:** src_path, pp_path, schema, scenario
**输出:** generated_records, generated_structure, db_path, 平面文件
### 3.3 Step 3: COBOL 执行 (step3_run_cobol)
**职责:** 调用编译后的 COBOL 程序并收集 gcov 覆盖率数据
**执行流程:**
```
1. 环境准备
|-- 创建 runtime/run_{id}/main/{input,output}/ 目录
|-- 复制生成的平面文件 → input/
|-- 复制 JSON → json/
2. 文件方向映射 (_scan_assign_to)
|-- 正则扫描 SELECT/ASSIGN-TO → {文件名: 方向}
|-- OPEN 语句解析 → INPUT/OUTPUT 方向确定
3. DB 路径准备
|-- 场景 DB → 复制到默认 DB 路径
|-- CWD/data/kin.dbCONNECT TO 路径)
|-- CWD/kingixsql regex 路径)
4. 执行
|-- 清理前次 .gcda 文件
|-- runner.run(exe, cwd, db_path, env_overrides, command_args)
|-- 日志写入 runtime_dir/logs/
5. gcov 数据收集
|-- .gcda 从 CWD + exe_dir 复制到 gcov/run_{id}/
|-- .gcno 同步(共享 .gcnoCOPY 不 MOVE
6. 返回 DbPipelineResult(step=3, data={returncode, log, ...})
```
**关键逻辑:**
- GIXSQL_DB_PATH 环境变量不生效,需通过 CWD/data/kin.db 传递
- GnuCOBOL 的 .gcda 写入编译时 CWD,多场景需 COPY 到各自 gcov 目录
- subprogram 的 .gcno 必须同步到每个 run 目录
**输入:** exe_path, schema, scenario
**输出:** 运行日志、gcov 数据、返回码
### 3.4 Step 4: 中间数据提取 (step4_extract_intermediate)
**职责:** 从 SQLite DB 导出 Java 程序所需的 JSON 中介数据
**执行流程:**
```
1. 打开 DB_current_db_path 或 db_path
2. 遍历 schema.db_tables,对每张表执行 SELECT * FROM [table]
3. 构建 meta = {program_id, tables: {table_name: [rows]}}
4. 写入 work_dir/intermediate/{program_id}_W01.json
5. 返回 DbPipelineResult(step=4, data={tables, w01_path})
```
**关键逻辑:**
- 使用 sql_name 或 name 查询表名
- 即使表不存在也不报错(空列表),允许部分执行
- 输出 JSON 包含所有表的全量行数据
**输入:** _current_db_path, schema.db_tables
**输出:** java_input_pathW01 JSON
### 3.5 Step 5: Java 执行 (step5_run_java)
**职责:** 调用 Java 转换程序处理 COBOL 输出数据
**执行流程:**
```
1. 创建 java_output 目录
2. 构建命令: java -jar {java_jar} -i {java_input_path} -o {java_out}
3. subprocess.run(cmd, capture_output=True, timeout=60)
4. 返回 DbPipelineResult(step=5, data={returncode, log})
```
**关键逻辑:**
- 超时限制 60 秒
- 若未指定 java_jar,仅执行 java -version 检测环境
- 依赖 Step 4 的输出作为输入
**输入:** java_input_path, java_jar
**输出:** java_output_path, 执行日志
### 3.6 Step 6: 结果验证 (step6_verify)
**职责:** 比较 Java 输出与 COBOL 期望值
**执行流程:**
```
1. 构建 VerificationRun 结果对象
2. 读取 DB 各表行数(调试信息)
3. 扫描 java_output_path 下的 .txt/.json 文件
4. 设置 exit_code 和 statusPASS/MISMATCH
5. 返回 VerificationRun
```
**关键逻辑:**
- fields_mismatched == 0 时判定为 PASS
- 输出 Java 输出文件列表作为调试信息
- 返回 VerificationRun 而非 DbPipelineResult
**输入:** java_output_path, _current_db_path, schema.db_tables
**输出:** VerificationRunstatus, exit_code, debug
---
## 4. 接口定义
### 4.1 主入口: run_all()
```python
def run_all(self, skip_steps: set[int] | None = None,
generate_coverage: bool = True) -> VerificationRun:
```
**参数:**
| 参数 | 类型 | 说明 |
|------|------|------|
| skip_steps | set[int] | 要跳过的步骤编号集合(如 {5, 6}) |
| generate_coverage | bool | 是否生成覆盖率报告(默认 True) |
**返回:** VerificationRun(最终验证结果)
**行为:**
- skip_jvm=True 时自动将 {5, 6} 加入 skip_steps
- 多场景执行: schema.runs 非空时循环执行 Step 2-3
- 每个场景失败即返回 BLOCKED(不继续后续步骤)
- Step 1 只执行一次(编译共享)
- 多场景执行后自动合并 gcov 数据
### 4.2 单步接口
| 方法 | 签名 | 返回 |
|------|------|------|
| step1_setup_environment | () -> DbPipelineResult | 编译结果 |
| step2_generate_inputs | (scenario: ScenarioDef?) -> DbPipelineResult | 数据生成结果 |
| step3_run_cobol | (scenario: ScenarioDef?) -> DbPipelineResult | 执行结果 |
| step4_extract_intermediate | () -> DbPipelineResult | 提取结果 |
| step5_run_java | (java_cmd, java_jar) -> DbPipelineResult | Java 执行结果 |
| step6_verify | () -> VerificationRun | 验证结果 |
| generate_coverage_report | (output_dir?) -> DbPipelineResult | 覆盖率报告 |
### 4.3 内部辅助接口
| 方法 | 职责 |
|------|------|
| _copy_sources_to_workdir | 源码 + COPYBOOK + 子程序复制到 ASCII 工作目录 |
| _scan_assign_to | 扫描 SELECT/ASSIGN-TO + OPEN 确定文件方向 |
| _init_database / _create_tables | 按 YAML schema 创建 SQLite 表结构 |
| _populate_database | 从测试记录生成 DB 种子行 |
| _inject_sql_error_rows | 注入 PK 冲突行触发 SQL 错误路径 |
| _inject_extra_seed_rows | 为 SELECT 型程序注入大结果集 |
| _inject_aggregation_boundaries | 注入聚合边界数据(溢出 + 表满) |
| _deduplicate_r01_pk | 确保 R01 记录 PK 唯一性 |
| _seed_matching_monthly_rows | 预填 MONTHLY_ABSENCE 匹配行 |
| _merge_multi_run_gcov | 多轮场景 gcov 数据合并 |
| _merge_schema_columns | YAML schema 列型合并到 declared_columns |
| _insert_pk_map | 构建 SQL 表 → PK 列名映射 |
| _coordinate_db_rule_matching | DB 属性区间对齐(AGE/DEPENDENTS/REGION |
| _coordinate_seed_numeric_types | DB 种子值数字化(PIC 9 对齐) |
| _make_synthetic_error_rows | 构建合成 PK 冲突行 |
---
## 5. 数据流
### 5.1 管道级数据流
```
cobol_src_dir/{program_id}.cbl
|
v
[Step 1: 环境整备]
|-- src_path (预处理源码)
|-- pp_path (gixpp 输出)
|-- exe_path (编译产物)
|
v
[Step 2: 输入数据生成]
|-- generated_records (测试数据)
|-- generated_structure (分支树 + 赋值表)
|-- db_path (SQLite DB with seeds)
|-- 平面文件 (input/)
|-- JSON (json/{program_id}.json)
|
v
[Step 3: COBOL 执行]
|-- 运行日志
|-- 输出文件 (output/)
|-- gcov 数据 (gcov/)
|
v
[Step 4: 中间数据提取]
|-- java_input_path (W01 JSON)
|
v
[Step 5: Java 执行]
|-- java_output_path
|
v
[Step 6: 结果验证]
|-- VerificationRun (PASS/MISMATCH)
```
### 5.2 每步输入输出明细
| 步骤 | 输入 | 输出 | 依赖 |
|------|------|------|------|
| Step 1 | cobol_src_dir, copybook_dirs, schema | src_path, pp_path, exe_path | 无 |
| Step 2 | src_path, pp_path, schema, scenario | records, structure, db_path, flat files | Step 1 |
| Step 3 | exe_path, records, db_path, scenario | logs, output files, gcov data | Step 1, 2 |
| Step 4 | db_path, schema.db_tables | java_input_path | Step 2, 3 |
| Step 5 | java_input_path, java_jar | java_output_path | Step 4 |
| Step 6 | java_output_path, db_path | VerificationRun | Step 4, 5 |
### 5.3 多场景数据流
```
schema.runs = [scenario_A, scenario_B, ...]
|
v
[Step 1] 编译一次(共享 exe_path
|
v
[Step 2-A] scenario_A → db_A, records_A, flat_A
[Step 3-A] 运行 A → gcov_A
|
v
[Step 2-B] scenario_B → db_B, records_B, flat_B
[Step 3-B] 运行 B → gcov_B
|
v
[_merge_multi_run_gcov] gcov_A + gcov_B → merged_gcov
|
v
[Step 4] 提取最后一个场景的 DB
[Step 5-6] Java 执行 + 验证
```
---
## 6. 错误处理
### 6.1 步骤级容错
每个 Step 方法内部用 try-except 包裹,返回 DbPipelineResult(success=False) 而非抛出异常:
```python
def step1_setup_environment(self) -> DbPipelineResult:
try:
# ... 编译逻辑 ...
return DbPipelineResult(self.program_id, 1, result.success, ...)
except Exception as e:
return DbPipelineResult(self.program_id, 1, False, str(e))
```
### 6.2 管道级中断
run_all() 中每个 Step 后检查 success,失败则立即返回 BLOCKED:
```python
r1 = self.step1_setup_environment()
if not r1.success:
return VerificationRun(status="BLOCKED", step_reached=1)
r2 = self.step2_generate_inputs(scenario)
if not r2.success:
return VerificationRun(status="BLOCKED", step_reached=2)
```
### 6.3 异常分类
| 异常场景 | 处理方式 | 影响 |
|----------|----------|------|
| gixpp 预处理失败 | Step 1 返回 success=False | 管道终止 |
| cobc 编译失败 | Step 1 返回 success=False | 管道终止 |
| COBOL 运行崩溃 | Step 3 返回 success=False | 管道终止 |
| DB 表不存在 | OperationalError 捕获,空列表 | 不阻断 |
| Java 超时 | subprocess.TimeoutExpired | Step 5 返回 False |
| gcov 文件缺失 | PermissionError 捕获,跳过 | 不阻断 |
| COPYBOOK 未找到 | logger.warning | 不阻断 |
| 子程序未找到 | logger.warning | 不阻断 |
| JSON 输出失败 | logger.warning | 不阻断,继续执行 |
### 6.4 数据一致性保障
- **PK 去重:** `_deduplicate_r01_pk` 确保所有 R01 记录的 (EMP_ID, DATE) 唯一
- **EMP-ID 清洗:** 全零 '00000000' → SPACE,避免 PK 冲突导致 ABEND
- **日期格式统一:** 所有日期值统一为 YYYYMMDD 8 位格式
- **DB 列型匹配:** INSERT 前按 PRAGMA table_info 转换值类型(INTEGER/DECIMAL
---
## 7. 性能设计
### 7.1 编译复用
Step 1 只执行一次,所有场景共享编译产物(exe_path)。
### 7.2 多场景顺序执行
Step 2-3 对每个场景顺序执行,避免 DB 并发写入冲突。每个场景有独立的:
- DB 文件: `{program_id}_{scenario_id}.db`
- 工作目录: `work_dir/run_{scenario_id}/`
- 运行目录: `runtime_dir/run_{scenario_id}/`
### 7.3 gcov 数据合并
多场景执行后调用 `_merge_multi_run_gcov()`,对每行取 max(count) 合并:
```python
merged[line] = max(merged.get(line, 0), cnt)
```
子程序 gcov 单独存储(`_sub_gcov_data`),避免行号冲突。
### 7.4 文件复制策略
- 构建产物放在 TEMP 目录(ASCII 路径),避免 gixpp 中文路径问题
- 运行时数据放在项目 runtime/ 目录
- DB 文件在多场景间通过 shutil.copy2 复制,而非共享
- .gcda/.gcno 使用 COPY 而非 MOVEGnuCOBOL 累积写入特性)
### 7.5 已执行步骤跳过
run_all() 支持 skip_steps 参数,允许跳过已执行的步骤:
```python
orch.run_all(skip_steps={1, 2, 3}) # 仅执行 Step 4-6
```
### 7.6 覆盖率报告可选
覆盖率报告生成由 generate_coverage 控制,默认开启但非管道核心路径:
```python
if '--coverage' in cv_flags and generate_coverage:
self.generate_coverage_report()
```
### 7.7 LLM 可选
Step 2 的 LLM 客户端仅在 config.llm_model 配置时初始化,未配置时回退到规则引擎:
```python
llm = None
if hasattr(self.config, 'llm_model') and self.config.llm_model:
llm = LLMClient(model=self.config.llm_model, timeout=self.config.llm_timeout)
recs = generate_all_data(..., llm_client=llm, ...)
```
---
## 8. 目录结构
```
runtime/{program_id}/
+-- main/
| +-- input/ <- 平面输入文件
| +-- output/ <- COBOL 输出文件
| +-- json/ <- JSON 输出
+-- logs/
| +-- compile/ <- 编译日志
| +-- {program_id}.log <- 运行日志
+-- gcov/
| +-- run_{scenario}/ <- 各场景 gcov 数据
+-- run_{scenario}/ <- 多场景隔离目录
+-- main/input/
+-- main/output/
work_dir/{program_id}/
+-- src/ <- ASCII 源码副本
+-- preprocessed/ <- gixpp 输出
+-- bin/ <- 编译产物 (.exe, .gcno)
+-- main/
| +-- input/ <- 生成的平面文件
| +-- json/ <- JSON 输出
+-- intermediate/ <- W01 JSONJava 中介数据)
+-- java_output/ <- Java 输出
+-- run_{scenario}/ <- 多场景隔离目录
```
+412
View File
@@ -0,0 +1,412 @@
# 03 - runners 编译运行引擎
## 1. 模块概述
`runners` 模块负责 COBOL 程序与 Java 程序的**编译、运行、覆盖率采集**全流程。模块采用策略模式,通过抽象基类 `Runner` 统一不同语言运行时的接口,对外暴露一致的 `compile -> run -> get_coverage` 三阶段管线。
核心职责:
| 职责 | 说明 |
|------|------|
| COBOL 编译 | 调用 `cobc`COBOL 编译器)将 `.cbl` 源码编译为可执行文件 |
| Java 编译 | 调用 `mvn package` 将 Maven 项目打包为 `.jar` |
| 运行执行 | 通过 `subprocess` 启动编译产物,捕获 stdout/stderr 和返回码 |
| 测试数据写入 | 将 `TestCase` 列表序列化为 COBOL 二进制 / JSON 格式 |
| 覆盖率报告 | 采集分支覆盖数据(gcov / JaCoCo)并返回量化报告 |
---
## 2. 文件清单
```
runners/
__init__.py # 包导出:公开 API 声明
runner.py # 抽象基类 Runner + 数据类 BuildResult / RunResult / CoverageReport
cobol_runner.py # COBOL 编译·执行器(cobc 管线)
gixsql_runner.py # DB COBOL 程序编译·执行器(gixpp + cobc + gixsql 链接)
native_java_runner.py # Java 本地运行器(mvn + java -jar
spark_java_runner.py # Spark 运行器(spark-submit
data_writer.py # 测试数据序列化(COBOL 二进制 / Spark JSON / Native JSON
```
---
## 3. 接口定义
### 3.1 数据类(runner.py
```python
@dataclass
class BuildResult:
success: bool # 编译是否成功
artifact_path: str = "" # 编译产物路径(.exe / .jar
log: str = "" # 编译日志(stdout + stderr
@dataclass
class RunResult:
success: bool # 运行是否成功(returncode == 0
records: list[dict] # 运行输出记录(JSON 格式)
log: str = "" # 运行日志
coverage_exec: str = "" # 覆盖率执行文件路径
@dataclass
class CoverageReport:
branch_rate: float = 0.0 # 分支覆盖率(0.0 ~ 1.0
covered_branches: int = 0 # 已覆盖分支数
total_branches: int = 0 # 总分支数
verdict: str = "PASS" # 判定结果(PASS / FAIL
```
### 3.2 抽象基类(runner.py
```python
class Runner(ABC):
@abstractmethod
def compile(self, source_dir: str) -> BuildResult: ...
@abstractmethod
def run(self, artifact: str, input_path: str, output_path: str) -> RunResult: ...
@abstractmethod
def get_coverage(self, artifact: str, run_id: str) -> CoverageReport: ...
```
### 3.3 CobolRunnercobol_runner.py
| 方法 | 签名 | 说明 |
|------|------|------|
| `compile` | `(src, dialect="ibm", gcov=False) -> BuildResult` | 旧式编译,`-std=ibm-strict`,供 orchestrator.py 使用 |
| `run` | `(binary, input_path, output_path) -> RunResult` | 旧式执行,stdin 管道 stdout |
| `compile_with_links` | `(src, work_dir, copybook_dirs, sub_objects, gcov) -> BuildResult` | 新式编译:主程序 + 链接 SUB.o,支持 COPYBOOK 搜索路径和 gcov |
| `run_file_based` | `(binary, run_dir, input_files, timeout) -> RunResult` | 新式执行:基于文件的 I/O,将输入文件复制到运行目录后启动程序 |
### 3.4 GixsqlCobolRunnergixsql_runner.py
| 方法 | 签名 | 说明 |
|------|------|------|
| `preprocess` | `(src_path, out_dir, copybook_dirs) -> str` | gixpp 预处理:COPY 展开、SQL 归一化、格式修正 |
| `compile` | `(pp_path, exe_path, copybook_dirs, extra_srcs) -> GixsqlBuildResult` | cobc 编译,链接 gixsql 库 |
| `run` | `(exe_path, work_dir, db_path, ...) -> GixsqlRunResult` | 执行 DB 程序,设置 SQLite 数据库路径 |
| `read_db_tables` | `(db_path, table_names) -> list[GixsqlTableData]` | 读取 SQLite 数据库表内容 |
### 3.5 NativeJavaRunnernative_java_runner.py
| 方法 | 签名 | 说明 |
|------|------|------|
| `compile` | `(source_dir) -> BuildResult` | Maven 打包,输出 target/program.jar |
| `run` | `(artifact, input_path, output_path) -> RunResult` | java -jar 执行,解析 stdout JSON 行 |
| `get_coverage` | `(artifact, run_id) -> CoverageReport` | 检查 jacoco.exec 是否存在,返回覆盖率 |
### 3.6 SparkJavaRunnerspark_java_runner.py
| 方法 | 签名 | 说明 |
|------|------|------|
| `compile` | `(source_dir) -> BuildResult` | Maven 打包,输出 target/program.jar |
| `run` | `(artifact, input_path, output_path) -> RunResult` | spark-submit 执行,读取 part-\* 输出文件 |
| `get_coverage` | `(artifact, run_id) -> CoverageReport` | 返回固定 0.80 覆盖率(Spark 无原生覆盖率集成) |
### 3.7 DataWriterdata_writer.py
| 方法 | 签名 | 说明 |
|------|------|------|
| `write_cobol_binary` | `(cases, out)` | 将 TestCase 列表写为 COBOL 二进制格式(大端序 int64 / float64 / ASCII |
| `write_spark_json` | `(cases, cfg, d)` | 写 Spark 输入 JSONpart-00000.json),key 字段加序号后缀 |
| `write_native_json` | `(cases, out)` | 写 Native JSON(每行一个 JSON 对象) |
---
## 4. 编译流程
### 4.1 COBOL 编译(cobol_runner.py
#### 旧式编译流程
```
.cbl 源文件
|
v
cobc -x -std=ibm-strict [-o 输出路径] [-g] [--coverage] 源文件
|
v
BuildResult(success, artifact_path, log)
```
- 默认超时 30 秒
- `gcov=True` 时追加 `--coverage` 参数生成 `.gcno` 文件
#### 新式编译流程(带链接)
```
.cbl 源文件 + SUB*.o 对象文件
|
v
cobc -x -g [--coverage] [-I COPYBOOK路径...] -o 输出.exe 源文件 SUB1.o SUB2.o ...
|
v
BuildResult(success, exe_path, log)
```
- 默认超时 120 秒
- 工作目录切换至 `work_dir`(确保 `.gcno` 产出位置正确)
- 不使用 `-std=ibm-strict`,依赖默认方言
### 4.2 DB COBOL 编译(gixsql_runner.py
DB 程序编译采用 gixpp + cobc 两阶段管线:
```
原始 .cbl 源文件
|
v -- _normalize_source()
| 1. 剥离注释行(避免日文注释中的 EXEC SQL 被误匹配)
| 2. EXEC SQL INCLUDE SQLCA -> COPY SQLCA
| 3. EXEC SQL CONNECT TO 'literal' -> CONNECT TO :WS-GIX-CONN USER :WS-GIX-USR
| 4. COPY REPLACING 内联展开(Python 侧)
| 5. ALL COPY 展开(替代 cobc -E
| 6. 关键字间多空格压缩
| 7. DIVISION/SECTION 头部列位置修正(Area A,列 8 起)
| 8. SELECT ... FROM ... INTO -> SELECT ... INTO ... FROMgixpp 要求 INTO 在前)
| 9. CURRENT TIMESTAMP -> CURRENT_TIMESTAMPSQLite 后端兼容)
| 10. DB2 schema 限定表名去限定(SCHEMA.TABLE -> TABLE
v
_norm.cbl(规范源文件)
|
v -- preprocess()
| gixpp -i _norm.cbl -o _pp.cbl -e [-I COPYBOOK路径...]
| 将 EXEC SQL 块转换为 GIXSQL 调用序列
v
_pp.cbl(预处理后源文件)
|
v -- compile()
| 1. _patch_sql_identifiers()DB2 连字符标识符 -> 下划线(SQLite 兼容)
| 2. _patch_sqlcode_normalize()SQLite 约束错误码映射为 DB2 标准码
| 3. cobc -x -L gixsql库路径 -K GIXSQL*函数 -l gixsql [-I COPYBOOK...] -o exe pp.cbl
v
GixsqlBuildResult(success, exe_path, log)
```
### 4.3 Java 编译(native_java_runner.py / spark_java_runner.py
两个 Java Runner 共享相同的 Maven 编译流程:
```
source_dir/pom.xml
|
v
mvn -B package -f pom.xml
|
v
source_dir/target/program.jar
|
v
BuildResult(success, artifact_path, log)
```
- 默认超时 120 秒
- 使用 `-B`batch mode)避免交互式提示
- 产物固定为 `target/program.jar`
---
## 5. 运行流程
### 5.1 COBOL 执行
#### 旧式执行(stdin 到 stdout
```
input_path(二进制数据)
|
v subprocess.run([binary], input=data, capture_output=True)
|
v stdout 写入 output_path
|
v RunResult(success)
```
- 超时 30 秒
- 无输出记录解析(仅写入文件)
#### 新式执行(基于文件)
```
input_files: {assign_name: source_path}
|
v 复制输入文件到 run_dir
|
v subprocess.run([binary], cwd=run_dir, capture_output=True)
|
v RunResult(success, log)
```
- 超时 60 秒(可配置)
- 工作目录为 `run_dir`,程序通过 ASSIGN 名读取文件
- 日志截断至 2000 字符
### 5.2 DB COBOL 执行(gixsql_runner.py
```
exe_path + db_pathSQLite 数据库)
|
v 部署 DLL 到 exe_dirlibgixsql.dll, libgixsql-sqlite.dll 等)
|
v 复制输入文件到 work_dir
|
v subprocess.run([exe], cwd=work_dir, env={GIXSQL_DB_PATH: db_path})
|
v GixsqlRunResult(success, returncode, db_path, log)
|
v read_db_tables(db_path, table_names) -> 读取数据库输出
```
- 超时 30 秒(可配置)
- 环境变量 `GIXSQL_DB_PATH` 指向 SQLite 数据库
- DLL 部署策略:优先 lib_path,回退到 gixpp bin 目录
- returncode 0 或 1 均视为成功(COBOL STOP RUN 返回码差异)
### 5.3 Java 执行
#### NativeJavaRunner
```
input_pathJSON 文件)
|
v java -jar artifactstdin 输入)
|
v 解析 stdout JSON 行 -> records 列表
|
v RunResult(success, records, log)
```
- 超时 60 秒
- 每行一个 JSON 对象
#### SparkJavaRunner
```
input_pathJSON 文件)
|
v spark-submit --class Main --master local[*]
| --conf spark.input.path=...
| --conf spark.output.path=...
| --conf spark.input.format=json
| --conf spark.output.format=json
| artifact
|
v 读取 output_path/part-* 文件 -> records 列表
|
v RunResult(success, records, log)
```
- 超时 300 秒(5 分钟)
- 输入输出格式通过 spark conf 配置
- 输出文件自动 glob 匹配 `part-*`
---
## 6. 错误处理
### 6.1 编译失败
| 场景 | 处理方式 | 返回值 |
|------|----------|--------|
| cobc 编译错误 | 捕获 returncode != 0 | `BuildResult(success=False, log=stdout+stderr)` |
| cobc 超时 | 捕获 TimeoutExpired | `BuildResult(success=False, log="Compile timeout")` |
| gixpp 预处理失败 | 捕获 returncode != 0,抛出 RuntimeError | `raise RuntimeError("gixpp failed")` |
| Maven 编译错误 | 捕获 returncode != 0 | `BuildResult(success=False, log=stdout+stderr)` |
### 6.2 运行时异常
| 场景 | 处理方式 | 返回值 |
|------|----------|--------|
| COBOL 程序异常终止 | 捕获 returncode != 0 | `RunResult(success=False, log=stdout+stderr)` |
| COBOL 程序超时 | 捕获 TimeoutExpired | `RunResult(success=False, log="Run timeout")` |
| DB 程序 returncode 1 | 视为成功(COBOL 语义差异) | `GixsqlRunResult(success=True)` |
| Java 程序异常 | 捕获 returncode != 0 | `RunResult(success=False, log=stdout+stderr)` |
| Spark 程序超时 | 捕获 TimeoutExpired300s | `RunResult(success=False, log="Run timeout")` |
| DLL 未找到 | 运行时加载失败,日志记录 | `GixsqlRunResult(success=False, log=...)` |
### 6.3 Gixsql 专用处理
| 机制 | 说明 |
|------|------|
| SQL 标识符归一化 | DB2 连字符标识符自动转换为下划线(`EMP-MASTER` -> `EMP_MASTER` |
| SQLCODE 映射 | SQLite 约束错误码(-1555/-2067/-19)映射为 DB2 标准码(-803 |
| Schema 限定去限定 | `SCHEMA.TABLE` -> `TABLE`SQLite 无 schema 概念) |
| CURRENT TIMESTAMP | DB2 `CURRENT TIMESTAMP` -> SQLite `CURRENT_TIMESTAMP` |
| DLL 自动部署 | 运行前将 gixsql DLL 复制到 exe_dir 确保加载器找到 |
### 6.4 日志截断
所有 Runner 的日志均截断至固定长度以避免内存溢出:
| Runner | 日志截断长度 |
|--------|-------------|
| CobolRunner(旧式) | 无截断(完整 stdout+stderr |
| CobolRunner(新式) | 2000 字符 |
| GixsqlCobolRunner | 500-1000 字符 |
| NativeJavaRunner | 无截断(完整 stdout+stderr |
| SparkJavaRunner | 无截断(完整 stdout+stderr |
---
## 7. 设计特点
### 7.1 双轨架构
CobolRunner 维护两套接口:
- **旧式接口**`compile` + `run`):供 `orchestrator.py` 使用,兼容现有调用链
- **新式接口**`compile_with_links` + `run_file_based`):支持非 DB 程序的文件 I/O 和 SUB.o 链接
两套接口互不干扰,通过不同方法名区分。
### 7.2 Gixsql 预处理管线
GixsqlCobolRunner 的预处理是模块中最复杂的部分:
1. **Python 侧 COPY 展开**:替代 `cobc -E`,解决 gixpp ESQL 解析器对 COPY REPLACING 伪文本的兼容问题
2. **SQL 归一化**:处理 DB2 与 SQLite 的语法差异(标识符、时间函数、schema 限定符)
3. **SQLCODE 映射**:注入代码将 SQLite 特定错误码转换为 DB2 标准码,确保 `IF SQLCODE = -803` 分支可达
### 7.3 DLL 部署策略
gixsql 运行时需要多个 DLLlibgixsql.dll, libgixsql-sqlite.dll 等)。部署策略:
1. 优先从 `lib_path` 复制
2. 若不存在,回退到 `gixpp` bin 目录
3. 检查文件大小避免重复复制
### 7.4 DataWriter 格式
| 格式 | 字节序 | 数值类型 | 字符串处理 |
|------|--------|----------|------------|
| COBOL 二进制 | 大端序(Network Byte Order | int64 (`>q`) / float64 (`>d`) | ASCII,右补空格至 10 字节 |
| Spark JSON | N/A | JSON 数字 | UTF-8key 字段加序号后缀 |
| Native JSON | N/A | JSON 数字 | UTF-8,每行一个 JSON 对象 |
---
## 8. 依赖关系
```
runners/
runner.py -> 无外部依赖
cobol_runner.py -> runners.runner, subprocess, pathlib
gixsql_runner.py -> runners.runner(仅类型引用),subprocess, sqlite3, pathlib, logging
native_java_runner.py -> runners.runner, subprocess, json, shutil, pathlib
spark_java_runner.py -> runners.runner, subprocess, json, shutil, pathlib
data_writer.py -> data.test_case.TestCase, struct, json, pathlib
```
外部工具依赖:
| 工具 | 用途 | 使用者 |
|------|------|--------|
| `cobc` | COBOL 编译器 | CobolRunner, GixsqlCobolRunner |
| `gixpp` | COBOL EXEC SQL 预处理器 | GixsqlCobolRunner |
| `mvn` | Java 构建工具 | NativeJavaRunner, SparkJavaRunner |
| `java` | Java 运行时 | NativeJavaRunner |
| `spark-submit` | Spark 提交工具 | SparkJavaRunner |
| `libgixsql.dll` | gixsql 运行时库 | GixsqlCobolRunner(运行时链接) |
| `libgixsql-sqlite.dll` | SQLite 后端驱动 | GixsqlCobolRunner(运行时加载) |
@@ -0,0 +1,416 @@
# HINA 程序分类模块 - 详细设计文档
> 模块路径: `hina/`
> 版本: V3 (2026技术大赛)
---
## 1. 模块概述
### 1.1 职责
`hina` 模块是 COBOL 迁移验证平台 V3 的程序分类与质量门禁系统,负责:
1. **程序分类** - 根据 COBOL 源码特征,将程序归类到预定义类型
2. **确信度评估** - 多因子计算分类结果的可信度
3. **质量门禁** - 测试数据生成前检查覆盖率和边界条件
4. **策略匹配** - 根据分类结果选择测试策略模板
5. **gcov 覆盖率收集** - 编译运行后采集动态代码覆盖率
6. **分层重试** - 处理编译/运行错误的自愈和重试机制
### 1.2 依赖关系
```
hina/
__init__.py <- 公开 API 入口
classifier.py <- L1 关键字规则匹配 + 结构性匹配检测
confidence.py <- 4 因子确信度计算
gate.py <- 质量门禁检查
gcov_collector.py <- gcov 覆盖率采集
hina_agent.py <- LLM 混淆组分类代理
retry.py <- 分层重试处理器
strategy.py <- 策略模板 + 必须项补充
pipeline/pipeline.py <- 完整分类管道
rule_engine/confusion_groups.py <- 8 个混淆对解析函数
rule_engine/contradiction.py <- 矛盾检测与解决
rule_engine/backtrack.py <- 多轮回溯判定
```
---
## 2. 文件清单
| 文件 | 行数 | 职责 |
|------|------|------|
| `__init__.py` | 25 | 公开 API 入口,导出 classify_program |
| `classifier.py` | 304 | L1 关键字规则(14条)、注释剥离、KEY比较检测、结构性匹配检测 |
| `confidence.py` | 120 | 4 因子确信度计算 |
| `gate.py` | 106 | 质量门禁检查、双模式质量评分 |
| `gcov_collector.py` | 58 | gcov 覆盖率采集 |
| `hina_agent.py` | 283 | LLM 混淆组分类、规则兜底 |
| `retry.py` | 82 | 分层重试: 自愈修复 + 朴素重试 |
| `strategy.py` | 103 | 策略模板(5个类型) + 补充 |
| `pipeline/pipeline.py` | 698 | 完整分类管道: 3条路径 |
| `rule_engine/confusion_groups.py` | 287 | 8个混淆对解析函数 |
| `rule_engine/contradiction.py` | 163 | 矛盾检测与解决 |
| `rule_engine/backtrack.py` | 96 | 多轮回溯判定 |
---
## 3. 分类算法
### 3.1 分类管道总览
`classify_program()` 流程:
```
COBOL 源码
-> 并行: detect_keyword() + extract_structure()
-> 根据最高关键字确信度选择路径:
>= 90% -> 路径 A: keyword 直接输出
50-89% -> 路径 B: 规则引擎 + 确信度计算
< 50% -> 路径 C: LLM 辅助 + 规则验证
-> 匹配子类型区分(仅对匹配/键中断程序)
-> 输出最终 JSON
```
### 3.2 L1 关键字规则 (classifier.py)
定义在 `L1_RULES` 中,共 14 条规则,格式 `(分类名称, [关键字列表], 置信度阈值)`:
| 分类 | 关键字 | 置信度 |
|------|--------|--------|
| DB操作 | EXEC SQL | 0.95 |
| 子程序调用 | CALL, LINKAGE SECTION | 0.90 |
| IS INITIAL | IS INITIAL | 0.99 |
| SYSIN | ACCEPT ... FROM SYSIN | 0.90 |
| 编码转换 | ALPHABETIC, ASCII, EBCDIC | 0.85 |
| online | DFHCOMMAREA | 0.95 |
| SORT | SORT ... ON ... KEY | 0.95 |
| MERGE | MERGE ... ON ... KEY | 0.95 |
| 替代索引 | ALTERNATE RECORD KEY | 0.99 |
| 编辑输出 | WRITE ... AFTER/BEFORE | 0.80 |
| 文件编成 | ORGANIZATION IS | 0.99 |
| マッチング | WS-[*]KEY* 变量模式(3条) | 0.55-0.65 |
`re:` 前缀表示正则表达式匹配,无前缀表示字面量包含匹配。
### 3.3 结构性匹配检测
`_detect_matching_structure` 不依赖 KEY 变量名,通过 6 个信号判断匹配程序:
| 信号 | 检测内容 |
|------|---------|
| 1 | READ ... AT END |
| 1b | 2+ 个 READ 语句 |
| 2 | PERFORM UNTIL ... = 'Y'/'N' |
| 3 | ELSE ... READ (条件性读取) |
| 4 | IF A = B (跨文件字段比较) |
| 5 | 2+ 个 OPEN INPUT |
信号 >= 5: 0.55, = 4: 0.50, = 3: 0.40, < 3: 0.0
### 3.4 KEY 变量比较检测
`_matches_key_comparison` 确认 KEY 变量在比较上下文中实际使用:
- 模式 1: WS-KEY = / > / < (排除 Figurative Constant)
- 模式 2: 非 WS- 前缀 KEY
- 模式 3: READ INTO ... KEY
---
## 4. 确信度计算 (confidence.py)
### 4.1 4 因子公式
```
confidence = base x context_factor x consistency_factor x structure_factor
```
### 4.2 因子定义
**上下文因子:**
| match_count | 值 |
|-------------|-----|
| >= 3 | 1.0 |
| 2 | 0.95 |
| 1 | 0.90 |
| 0 | 0.50 |
| 共识奖励 | +0.15 (上限 1.0) |
**一致性因子:**
| 矛盾情况 | 值 |
|----------|-----|
| 无矛盾 | 1.0 |
| 全部已解决 | 0.90 |
| 未解决 < 3个 | 0.80 |
| 未解决 >= 3个 | 0.50 |
**结构一致性因子:**
| score | 值 |
|-------|-----|
| 5 | 1.0 |
| >= 3 | 0.7 |
| >= 1 | 0.5 |
| 0 | 0.3 |
### 4.3 判定结果
| 确信度范围 | 判定 | 需人工审核 |
|-----------|------|-----------|
| >= 0.90 | auto | 否 |
| 0.70-0.89 | review | 是 |
| 0.50-0.69 | manual | 是 |
| < 0.50 | impossible | 是 |
---
## 5. 规则引擎 (rule_engine/)
### 5.1 混淆组判定 (confusion_groups.py)
8 个混淆对解析函数:
| 混淆对 | 区分逻辑 | 置信度 |
|--------|---------|--------|
| matching_vs_keybreak | 三路IF+多文件->マッチング; WS-PREV-KEY+累加器->キーブレイク | 0.75-0.90 |
| dedup_vs_nodedup | WS-PREV-KEY存在->含重复; 不存在->不含重复 | 0.50-0.90 |
| validation_vs_keybreak | WS-ERR*字段->校验; WS-*CNT计数器->キーブレイク | 0.55-0.85 |
| csv_merge_vs_split | STRING+逗号->合并; INSPECT REPLACING+逗号->拆分 | 0.85 |
| simple_vs_two_stage | OPEN-CLOSE-再OPEN->二段階; 其他->単純 | 0.50-0.90 |
| pure_vs_mixed | has_switch+has_counter+IF>=3->混合 | 0.70 |
| division_50_25_100 | DIVIDE被除数常量匹配 | 0.95 |
| mn_output_mode | SELECT>=3+分支>=3->M:N | 0.55-0.65 |
### 5.2 特征注入 (pipeline.py)
从 COBOL 源码注入额外特征:
| 特征名 | 检测方式 | 用途 |
|--------|---------|------|
| has_key_var | 正则匹配KEY变量比较 | 防止计数器比较误触发 |
| has_structural_match | IF+跨文件字段比较+循环/读取 | 结构性匹配信号 |
| has_cross_file_cmp | IF A = B | 跨文件比较 |
| has_csv_merge | STRING ... ',' ... INTO | CSV合并信号 |
| has_csv_split | INSPECT ... REPLACING ... ',' | CSV拆分信号 |
### 5.3 矛盾检测与解决 (contradiction.py)
**矛盾对定义** (CONTRADICTION_PAIRS): 10 对可能冲突的分类类型。
**解决策略**:
1. 优先级比较 (TYPE_PRIORITY): マッチング(10) > キーブレイク(9) > 項目チェック(8) > ...
2. 优先级相同时,调用混淆对解析器重判定 (置信度 >= 0.80 则采纳)
3. 最终回退: 取 type_a
### 5.4 多轮回溯 (backtrack.py)
`BacktrackResolver` 封装多轮判定:
- 最大轮次: 3
- 超时: 30 秒
- 超时/超轮次: 标记 `backtrack_degraded = True`,降级返回
---
## 6. LLM 辅助分类 (hina_agent.py)
### 6.1 混淆组分类 Prompt
`CONFUSION_PROMPT` 包含 7 个混淆组定义:
1. simple_sequential - 极少决策点
2. condition_heavy - IF语句占比高
3. evaluate_driven - EVALUATE主导
4. data_file_centric - 文件操作密集
5. search_intensive - SEARCH ALL
6. call_based - CALL语句
7. mixed_complex - 多种复杂特征
### 6.2 规则兜底分类
LLM 失败时 `_fallback_classification` 基于结构特征优先级:
| 优先级 | 条件 | 分类 |
|--------|------|------|
| 1 | total_decisions == 0 | simple_sequential |
| 2 | has_search_all | search_intensive |
| 3 | has_call | call_based |
| 4 | evaluate > if 且 >= 2 | evaluate_driven |
| 5 | file_count >= 2 | data_file_centric |
| 6 | if >= 5 或 decisions >= 8 | condition_heavy/nested_if |
| 7 | if >= 2 | condition_heavy/simple_if |
复杂度升级: >= 3 个复杂度标志 -> mixed_complex
---
## 7. 匹配子类型区分
仅对 マッチング/キーブレイク/項目チェック 执行子类型区分。
### 7.1 分层策略
**第 1 层 - 静态规则:**
| 条件 | 子类型 |
|------|--------|
| 二段階 in category | 二段階 |
| file_count >= 3 + WS-SAVE-KEY | M:N->MxN |
| WS-PREV-KEY | 混合 |
| WS-MAST-KEY + WS-TRAN-KEY | 1:N |
| WS-KEY-M + WS-KEY-T | N:1 |
| WS-KEY-M + WS-KEY-N | M:N |
**第 2 层 - LLM 推理**: 多键变量+多文件时调用 LLM 判断子类型
**第 3 层 - 回退**: 多键+多文件->M:N; 对称键名->1:1
---
## 8. 策略模板 (strategy.py)
### 8.1 策略模板定义
| 分类 | 必须项 | 边界项 |
|------|--------|--------|
| マッチング | COM-N001~A003, MT-N001~N006 | MT-B001, MT-B002 |
| キーブレイク | COM-N001, A002, KB-N001~N005, A001 | KB-B001, KB-B002 |
| 条件分岐 | B-N001, N003, N006, N009 | - |
| 内部表検索 | T-N001, N002, A001, A002 | - |
| 項目チェック | VF-N001, N002, N004, A001 | - |
### 8.2 补充函数
- `supplement()`: 从模板追加全部必须项和边界项
- `supplement_only()`: 增量补充指定必须项
---
## 9. 质量门禁 (gate.py)
### 9.1 门禁检查
| 检查项 | 条件 | 输出 |
|--------|------|------|
| 决策点覆盖率 | branch_rate < 0.90 | decision_gaps |
| 段落覆盖率 | paragraph_rate < 1.0 | paragraph_gaps |
| 测试数据为空 | not complete_tests | no_data |
### 9.2 双模式质量评分
```
gcov 未启用: branch_rate*0.5 + paragraph_rate*0.5 + confidence*0.4
gcov 启用: static_cov*0.3 + gcov_cov*0.4 + confidence*0.3
```
---
## 10. gcov 覆盖率收集 (gcov_collector.py)
### 10.1 采集流程
1. 检查 .gcda 文件是否存在
2. 执行 gcov 命令 (30s 超时)
3. 查找 .gcov 输出文件
4. 解析行覆盖率
### 10.2 降级策略
所有 gcov 失败降级为仅静态分析。
---
## 11. 分层重试 (retry.py)
### 11.1 重试机制
`RetryHandler(max_heal=2, max_simple=3)`:
- PASS/QUALITY_WARN -> 返回结果
- BLOCKED/ERROR -> 自愈修复或朴素重试
- 总次数 >= 5 -> 标记 FATAL
### 11.2 自愈修复
| 错误 | 检测 | 修复 |
|------|------|------|
| compile_error | 日志含 "not found" | 设置 COB_LIBRARY_PATH |
| s0c7 | 日志含 "S0C7" | 记录警告 |
---
## 12. 接口定义
### 12.1 公开 API
```python
def classify_program(cobol_source: str, llm=None) -> dict:
"""返回: {category, confidence, needs_review, method, source,
judgment, matches, contradictions, v2_confidence, structure}"""
```
### 12.2 内部模块 API
- `classifier.detect_keyword(source)` -> list[tuple[str, float, str]]
- `confidence.compute_confidence_v2(keyword_result, structure_features, ...)` -> dict
- `gate.check(complete_tests, hina_result, coverage, ...)` -> dict
- `gate.compute_quality_score(static_coverage, gcov_coverage, confidence)` -> float
- `strategy.get_strategy(hina_type)` -> dict
- `strategy.supplement(base_tests, hina_result)` -> list[dict]
- `gcov_collector.collect_gcov(cobol_src, work_dir)` -> dict
- `RetryHandler(max_heal, max_simple).run(pipeline_fn)` -> VerificationRun
- `hina_agent.classify_with_llm(structure, llm)` -> dict
- `rule_engine.resolve_confusion_pair(features, pair_name)` -> dict
- `rule_engine.detect_contradictions(features)` -> list[dict]
- `rule_engine.resolve_contradiction(features, contradiction)` -> str
- `BacktrackResolver(structure_extractor).resolve(source, features)` -> dict
---
## 13. 错误处理
### 13.1 错误分类
| 类别 | 示例 | 处理 |
|------|------|------|
| L1解析错误 | 正则匹配失败 | 跳过该规则 |
| LLM调用失败 | API超时/网络错误 | 回退到规则引擎 |
| LLM响应解析 | 无效JSON | 返回unknown |
| gcov失败 | 命令未找到/超时 | 降级为静态分析 |
| 回溯超时 | > 30s | 标记降级返回 |
| 矛盾不可解 | 优先级相同 | 取type_a回退 |
| 空源码 | cobol_source为空 | 返回impossible |
### 13.2 日志策略
- `logger.info()`: 关键流程节点
- `logger.debug()`: 规则引擎特征、矛盾详情
- `logger.warning()`: LLM失败、回溯降级、gcov失败
---
## 14. 数据流总结
```
Input: cobol_source (str), llm (optional)
|
v
[Parallel] detect_keyword + extract_structure
|
v
Route by max keyword confidence:
>= 0.90 -> Path A: keyword direct
0.50-0.89 -> Path B: rule engine
< 0.50 -> Path C: LLM assisted / rule fallback
|
v
_resolve_matching_subtype (for matching/keybreak programs)
|
v
Output: {category, confidence, needs_review, method, source,
judgment, matches, contradictions, v2_confidence, structure}
```
下游集成:
1. orchestrator_db.py: 根据category选择测试策略
2. strategy.py: 获取必须项
3. gate.py: 用confidence计算质量评分
4. data_merger.py: 合并策略数据到测试记录
+464
View File
@@ -0,0 +1,464 @@
# 05 - LLM 代理模块详细设计
## 1. 模块概述
LLM 代理模块(`agents/`)是 COBOL 迁移验证平台 V3 的智能分析层,负责通过大语言模型(LLM)完成三项核心任务:
1. **COPYBOOK 解析**Agent1):将 COBOL COPYBOOK 源码解析为结构化字段树(`FieldTree`
2. **测试数据设计**(Agent2):基于字段树生成边界测试用例(`TestSuite`
3. **差异诊断**Agent3):对 COBOL/Java 字段比对不一致项进行根因分析与建议
此外,模块还包含一个**式样书驱动测试数据生成器**(`DesignDataGenerator`),通过解析日文详细设计书,结合 LLM 生成有业务意义的机能测试数据。
模块底层封装了统一的 `LLMClient`,提供缓存、重试、多模型兼容能力。
## 2. 文件清单
| 文件 | 职责 | 依赖 |
|------|------|------|
| `__init__.py` | 包入口,公开 API 导出 | 所有子模块 |
| `llm.py` | LLM API 客户端(缓存 + 重试) | `httpx` |
| `agent1_parser.py` | COPYBOOK → FieldTree 解析代理 | `llm.py`, `data.field_tree` |
| `agent2_data.py` | FieldTree → TestSuite 测试数据设计代理 | `llm.py`, `data.field_tree`, `data.test_case` |
| `agent3_diagnostic.py` | FieldResult → 诊断建议文本代理 | `llm.py`, `data.diff_result` |
| `design_data.py` | 式样书驱动测试数据生成器 | `llm.py`, `design_data_input_parser` |
| `design_data_input_parser.py` | 式样书 .md 解析器 | 标准库 `re`, `dataclasses` |
## 3. Agent 架构模型(感知-规划-行动-记忆)
本节描述 LLM 代理模块的核心架构模型,采用经典的**感知-规划-行动-记忆**Perception-Planning-Action-Memory)循环。
### 3.1 架构图
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Agent 架构模型 (感知-规划-行动-记忆) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 感知层 (Perception) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ COBOL源码 │ │ COPYBOOK │ │ 设计书.md │ │ 比对结果 │ │ │
│ │ │ 输入 │ │ 结构 │ │ 业务规则 │ │ 差异项 │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │
│ │ │ │ │ │ │ │
│ │ └────────────────┼────────────────┼────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ read.py / DesignData │ │ │
│ │ │ InputParser │ │ │
│ │ └───────────┬───────────┘ │ │
│ └──────────────────────────┼───────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 规划层 (Planning) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ 字段树构建 │ │ 路径枚举 │ │ 约束生成 │ │ 测试策略 │ │ │
│ │ │ FieldTree │ │ BranchTree │ │ Constraints │ │ TestSuite │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │
│ │ │ │ │ │ │ │
│ │ └────────────────┼────────────────┼────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ core.py / design.py │ │ │
│ │ │ enum_paths │ │ │
│ │ └───────────┬───────────┘ │ │
│ └──────────────────────────┼───────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 行动层 (Action) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ LLM调用 │ │ 规则引擎 │ │ 数据生成 │ │ JSON输出 │ │ │
│ │ │ DeepSeek │ │ Fallback │ │ Records │ │ output.py │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │
│ │ │ │ │ │ │ │
│ │ └────────────────┼────────────────┼────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ agents/llm.py │ │ │
│ │ │ LLMClient.call() │ │ │
│ │ └───────────┬───────────┘ │ │
│ └──────────────────────────┼───────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 记忆层 (Memory) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ pi_map │ │ assignments │ │ 决策点缓存 │ │ LLM缓存 │ │ │
│ │ │ 字段映射 │ │ 赋值链 │ │ DecisionPts │ │ .cache/llm │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │
│ │ │ │ │ │ │ │
│ │ └────────────────┼────────────────┼────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ models.py / config │ │ │
│ │ │ 共享数据模型 │ │ │
│ │ └───────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 循环反馈 (Feedback Loop) │ │
│ │ │ │
│ │ 覆盖率分析 → 未覆盖分支 → 补充测试数据 → 重新执行 → 更新记忆 │ │
│ │ (coverage.py) (DecisionPoints) (design.py) (runners/) (pi_map) │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 3.2 四层职责
| 层级 | 职责 | 核心组件 | 数据模型 |
|------|------|----------|----------|
| **感知层** | 解析输入,提取结构信息 | `read.py`, `DesignDataInputParser` | COBOL源码, FieldTree |
| **规划层** | 构建分支树,枚举路径,生成约束 | `core.py`, `design.py`, `cond.py` | BranchTree, Constraints |
| **行动层** | 执行LLM调用,生成测试数据 | `agents/llm.py`, `output.py` | TestSuite, JSON |
| **记忆层** | 缓存中间结果,共享状态 | `models.py`, `config/` | pi_map, assignments |
### 3.3 循环反馈机制
Agent 架构采用**闭环反馈**设计:
1. **感知 → 规划**: 解析结果驱动分支树构建
2. **规划 → 行动**: 路径约束指导测试数据生成
3. **行动 → 记忆**: 生成结果更新缓存和状态
4. **记忆 → 感知**: 覆盖率分析触发新一轮感知
### 3.4 LLM 在架构中的位置
```
┌─────────────────────────────────────────────────┐
│ Agent 架构 │
├─────────────────────────────────────────────────┤
│ │
│ 规划层 行动层 │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ 路径枚举 │ │ LLM调用 │ │
│ │ (规则引擎) │──────│ (DeepSeek) │ │
│ └─────────────┘ └─────────────┘ │
│ │ │ │
│ │ ┌─────────────┐ │ │
│ └────│ 记忆层 │───┘ │
│ │ (缓存+状态) │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────┘
```
LLM 作为**行动层**的核心组件,负责:
- **Agent1**: COPYBOOK → FieldTree 解析
- **Agent2**: FieldTree → TestSuite 测试数据设计
- **Agent3**: FieldResult → 诊断建议文本
- **DesignDataGenerator**: 式样书 → 机能测试数据
当 LLM 调用失败时,系统自动**降级到规则引擎**(fallback),确保流水线不中断。
### 3.5 记忆层详解
记忆层是 Agent 架构的**状态中心**,存储:
| 记忆类型 | 说明 | 位置 |
|----------|------|------|
| **短期记忆** | 单次执行的中间结果 | pi_map, assignments |
| **长期记忆** | 跨执行的缓存数据 | .cache/llm/ |
| **共享记忆** | 模块间传递的数据 | models.py |
---
## 4. Agent1 解析代理(Agent1Parser
### 3.1 职责
将 COBOL COPYBOOK 源码文本发送给 LLM,由 LLM 输出结构化 JSON,再转换为 `FieldTree` 对象。这是整个流水线的第一步——只有正确解析字段结构,后续测试生成和比对才有基础。
### 3.2 数据流
```
COPYBOOK 源码文本
↓ (LLM call)
JSON: {"fields": [{name, level, pic, usage, offset, length, decimal, signed, occurs, redefines, conditions, children}]}
↓ (_load / _fields 递归)
FieldTree
```
### 3.3 核心类
```python
class Agent1Parser:
def __init__(self, llm: LLMClient)
def parse(self, text: str) -> FieldTree
def _load(self, d: dict) -> FieldTree
def _fields(self, raw: list[dict], off: int) -> list[Field]
```
### 3.4 LLM 提示词
系统提示词(P1)要求 LLM 扮演 COBOL COPYBOOK 解析器角色,输出严格 JSON 格式,包含字段的名称、层级、PIC 子句、USAGE 类型、偏移量、长度、小数位、符号、OCCURS、REDEFINES、88-level 条件及子字段嵌套。
### 3.5 字段偏移量计算
`_fields` 方法递归遍历 JSON 字段列表,逐字段计算字节偏移量(`cur += f.length`),确保每个字段在记录中的物理位置准确。子字段通过递归调用 `_fields` 处理嵌套层级。
### 3.6 错误处理
- LLM 返回非法 JSON 时,`parse` 捕获异常,返回一个 `FieldTree(copybook_name="parse_error")` 空树
- 使用 bare `except` 捕获所有解析异常,保证流水线不中断
## 5. Agent2 数据生成代理(Agent2Data
### 4.1 职责
基于 `FieldTree` 结构,通过 LLM 生成边界测试用例集合(`TestSuite`)。每个测试用例指定字段值和覆盖目标决策点。
### 4.2 数据流
```
FieldTree
↓ (flatten → JSON)
{"fields": [{name, pic, usage, length, decimal, signed}]}
↓ (LLM call)
{"test_cases": [{id, fields: {FIELD: value}, coverage_targets: [DP-001]}]}
↓ (构造 TestCase)
TestSuite
```
### 4.3 核心类
```python
class Agent2Data:
def __init__(self, llm: LLMClient)
def design(self, tree: FieldTree, target="boundary", spark_mode=False) -> TestSuite
```
### 4.4 LLM 提示词
系统提示词(P2)要求 LLM 扮演 COBOL 测试数据设计师角色,根据字段树生成边界测试用例,输出严格 JSON 格式。
### 4.5 Spark 模式
`spark_mode=True` 时,生成的 `TestSuite` 附带 `SparkConfig(num_records=1000)`,用于大数据量测试场景。
### 4.6 错误处理
- LLM 调用失败或返回非法 JSON 时,生成一条兜底用例 `TestCase(id="TC-FALLBACK", fields={"BR-AMT": 0})`
- 使用 bare `except` 捕获反序列化异常
## 6. Agent3 诊断代理(Agent3Diagnostic
### 5.1 职责
对单个字段比对结果(`FieldResult`)进行根因分析,输出包含问题类型、置信度、原因和建议的 JSON 诊断文本。
### 5.2 数据流
```
FieldResult (field_name, cobol_value, java_value, status)
↓ (格式化 prompt)
LLM call
JSON: {"issue_type": "...", "confidence": 0.5, "reason": "...", "suggestion": "..."}
```
### 5.3 核心类
```python
class Agent3Diagnostic:
def __init__(self, llm: LLMClient)
def analyze(self, fr: FieldResult) -> str
```
### 5.4 LLM 提示词
系统提示词(P3)明确要求 LLM **不做 PASS/FAIL 判定**,仅提供诊断分析。这是设计上的重要约束——Agent3 只是辅助分析工具,最终判定由比对引擎完成。
### 5.5 错误处理
- 依赖 `LLMClient.call()` 的重试机制
- 返回原始 LLM 响应字符串,调用方负责解析
## 7. 式样书驱动测试数据生成器(DesignDataGenerator
### 6.1 职责
从日文详细设计书(.md)中提取程序元信息(`ProgramMeta`),结合 COBOL 源码、COPYBOOK 结构和 DB 定义,通过 LLM 生成有业务意义的机能测试数据。
### 6.2 数据流
```
设计书 .md + COBOL 源码
↓ (DesignDataInputParser.parse)
ProgramMeta (program_id, pgm_pattern, files, keys, process_detail, ...)
↓ (加载规则 + 构建 prompt)
LLM call
JSON: {"records": [{field_name: value}]}
↓ (_resolve_field_names 字段名映射)
list[dict]
```
### 6.3 核心类
```python
class DesignDataGenerator:
def __init__(self, llm_client: LLMClient, cpy_dirs: list, rules_dir: str = "rules")
def generate(self, design_md_text, source_text, file_db_md_text=None,
db_md_text=None, replacing_rules=None, v3_field_names=None) -> list[dict]
```
### 6.4 式样书解析器(DesignDataInputParser
解析日文详细设计书 Markdown 文档,提取以下结构化信息:
| 数据类 | 内容 |
|--------|------|
| `ProgramMeta` | 程序ID、程序名、系统名、PGM类型、PGM模式、功能概要 |
| `FileInfo` | 文件编号、文件/DB名、标识符、DD名、I/O类型、COPY群、记录格式、记录长度、媒体 |
| `KeyInfo` | 键文件名、排序条件、键条件 |
| `ModuleInfo` | 模块编号、功能、程序ID、COPY名 |
| `TableInfo` | 表名、DB ID、列定义、主键列 |
解析通过 Markdown 标题匹配(`## 基本情報``## 使用ファイル一覧` 等)和表格解析实现,不依赖外部 Markdown 解析库。
### 6.5 输入类型自动判定
`_determine_input_type` 根据输入文件的媒体类型自动判定:
| 媒体类型 | input_type |
|----------|------------|
| 仅 PS(顺序文件) | `file` |
| 仅 DB(数据库) | `db` |
| 混合或无输入 | `mixed` / `file` |
### 6.6 字段名映射(_resolve_field_names
外部 Agent 生成的字段名可能与 V3 内部字段名不一致,映射规则:
1. **REPLACING 展开**`(A)``R01`
2. **前缀连字符处理**`R01-EMP-ID``R01EMP-ID`
3. **直接匹配**:精确匹配 V3 字段名
4. **去连字符匹配**`R01EMP-ID``R01EMPID`
5. **去下划线匹配**`R01_EMP_ID``R01EMPID`
6. **无法映射的字段丢弃**
### 6.7 规则加载
`_load_rules``rules/pgm_pattern/``rules/special_feature/` 目录加载所有 `.md` 规则文件,作为 LLM 上下文的一部分。
### 6.8 记录去重(_dedup
支持按指定键字段去重,`additional_records` 优先保留。
## 8. LLM 接口封装(LLMClient
### 7.1 职责
封装 LLM API 调用,提供文件缓存、自动重试、多模型兼容能力。所有 Agent 通过此客户端与 LLM 交互。
### 7.2 核心类
```python
class LLMClient:
def __init__(self, model="gpt-4o-mini", timeout=15, cache_dir=".cache/llm")
def call(self, messages: list[dict], retries=1) -> str
def _key(self, msgs: list[dict]) -> str # SHA256 哈希键
def _get(self, k: str) -> str | None # 缓存读取
def _set(self, k: str, v: str) -> None # 缓存写入
```
### 7.3 缓存机制
- **键生成**:对消息列表进行 JSON 序列化后取 SHA256 哈希
- **存储**:以 `{hash}.json` 文件存储在 `.cache/llm/` 目录
- **格式**`{"response": "..."}`
- **效果**:相同输入直接返回缓存结果,避免重复调用 LLM
### 7.4 重试机制
- 默认重试 1 次(共 2 次尝试)
- 使用 `httpx.post` 发送 HTTP 请求
- 重试时捕获所有异常,仅在最后一次失败时抛出
### 7.5 环境变量配置
| 环境变量 | 说明 | 默认值 |
|----------|------|--------|
| `LLM_API_KEY` | API 密钥 | 空字符串 |
| `OPENAI_API_KEY` | 备用 API 密钥 | 空字符串 |
| `LLM_API_BASE` | API 基础 URL | `https://api.openai.com/v1` |
### 7.6 API 调用格式
```json
POST {base}/chat/completions
{
"model": "{model}",
"messages": [...]
}
Header: Authorization: Bearer {key}
```
响应解析路径:`response["choices"][0]["message"]["content"]`
## 9. 接口定义
### 8.1 模块公开 API
```python
# agents/__init__.py
LLMClient # LLM API 客户端(含缓存 + 重试)
Agent1Parser # COPYBOOK → FieldTree
DesignDataGenerator # 式样书 → 机能测试数据
Agent2Data # FieldTree → TestSuite(测试数据设计)
Agent3Diagnostic # FieldResult → 诊断建议文本
```
### 8.2 典型调用链
```
Agent1Parser.parse(copybook_text) → FieldTree
Agent2Data.design(field_tree) → TestSuite
DesignDataGenerator.generate(design_md, source_text) → list[dict]
Agent3Diagnostic.analyze(field_result) → str (JSON)
```
### 8.3 数据模型依赖
| 模型 | 定义位置 | 用途 |
|------|----------|------|
| `FieldTree` | `data.field_tree` | 字段树结构 |
| `Field` | `data.field_tree` | 单个字段定义 |
| `TestCase` | `data.test_case` | 单条测试用例 |
| `TestSuite` | `data.test_case` | 测试用例集合 |
| `SparkConfig` | `data.test_case` | Spark 生成配置 |
| `FieldResult` | `data.diff_result` | 字段比对结果 |
## 10. 错误处理
### 9.1 错误策略
模块采用**宽容降级**策略:
| 异常场景 | 处理方式 | 影响 |
|----------|----------|------|
| LLM 返回非法 JSON | 返回空/兜底结果 | 流水线继续 |
| LLM API 调用失败 | 重试后抛出异常 | 上层捕获 |
| 式样书解析失败 | 返回空列表 | 跳过数据生成 |
| 字段名映射失败 | 丢弃无法映射的字段 | 部分数据丢失 |
| 缓存文件损坏 | 跳过缓存,重新调用 | 无功能影响 |
### 9.2 日志策略
- 使用 Python `logging` 模块
- 关键步骤记录 INFO 级别日志(解析开始、LLM 响应、记录数)
- 异常记录 WARNING 级别日志
- 字段映射丢弃记录 DEBUG 级别日志
### 9.3 已知局限
1. Agent1/Agent2/Agent3 的 bare `except` 可能掩盖非预期异常
2. 缓存键基于消息内容哈希,模型变更不会自动失效旧缓存
3. `DesignDataGenerator` 的 LLM prompt 包含截断(`process_detail[:2000]`),超长设计书可能丢失信息
+358
View File
@@ -0,0 +1,358 @@
# 06 - 比对模块详细设计
## 1. 模块概述
比对模块(`comparator/`)是 COBOL 迁移验证平台 V3 的核心验证引擎,负责将 COBOL 原始输出与 Java(Spark)迁移后输出进行逐字段对比,判断迁移正确性。
模块提供五项核心能力:
1. **记录对齐**aligner):按主键将 COBOL 记录集与 Java 记录集配对
2. **二进制读取**cobol_binary_reader):解析 COBOL 二进制输出文件为字典
3. **数据标准化**normalizer):处理 EBCDIC 编码、COMP-3 压缩十进制、日期格式
4. **字段比对**field_compare):按类型(数值/日期/字符串)进行字段级比较
5. **舍入检测**rounding_detect):判断数值差异是否由 COBOL ROUNDED 子句引起
## 2. 文件清单
| 文件 | 职责 | 依赖 |
|------|------|------|
| `__init__.py` | 包入口,公开 API 导出 | 所有子模块 |
| `aligner.py` | COBOL 与 Java 记录对齐 | 无 |
| `cobol_binary_reader.py` | 二进制 COBOL 输出解析 | `data.field_tree` |
| `field_compare.py` | 字段级比较(decimal/string/date | `data.diff_result` |
| `normalizer.py` | COMP-3/EBCDIC 解码、IR 记录构造 | 无 |
| `rounding_detect.py` | 舍入差异检测 | `decimal` |
## 3. 记录对齐算法(aligner.py
### 3.1 职责
将 COBOL 输出记录集和 Java 输出记录集按主键字段进行配对,输出对齐的记录对列表及匹配状态。
### 3.2 函数签名
```python
def align_records(
cobol_records: list[dict],
java_records: list[dict],
key_field: str = "CUST-ID"
) -> list[tuple]
```
### 3.3 算法描述
1. **分组**:分别按 `key_field` 对 COBOL 和 Java 记录建立 `{key: [records]}` 索引
2. **合并键集**:取两侧键的并集,按字符串排序
3. **逐键配对**:对每个键,取两侧记录列表的对应位置进行配对
4. **状态标记**
| 状态 | 含义 |
|------|------|
| `MATCHED` | 两侧均有记录,成功配对 |
| `MISSING_IN_SPARK` | COBOL 有记录,Java 侧缺失 |
| `EXTRA_IN_SPARK` | Java 有记录,COBOL 侧缺失 |
### 3.4 返回值
```python
list[tuple[dict | None, dict | None, str]]
# (cobol_record, java_record, status)
```
### 3.5 设计要点
- 使用 `setdefault` 聚合同键多记录,支持一对多场景
- 排序保证输出顺序稳定
- 键值转为字符串处理,兼容数值键和字符串键
- 空记录集直接返回空列表
## 4. 二进制读取器(cobol_binary_reader.py
### 4.1 职责
读取 COBOL 程序生成的二进制输出文件,根据 `FieldTree` 定义的字段布局逐记录解析为字典。
### 4.2 核心类
```python
class CobolBinaryReader:
def read(self, path: str, tree: FieldTree) -> list[dict]
def _record_size(self, tree: FieldTree) -> int
def _parse(self, record_bytes: bytes, tree: FieldTree) -> dict
def _comp3(self, raw: bytes, signed: bool, decimal: int) -> str
```
### 4.3 记录大小计算
```python
def _record_size(self, tree):
return max((f.offset + f.length for f in tree.fields), default=0)
```
取所有顶层字段的最大 `offset + length` 作为记录大小。
### 4.4 字段解析策略
| USAGE 类型 | 解析方式 |
|-----------|----------|
| `COMP-3` | 半字节解码 + 符号位处理 + 小数点定位 |
| `COMP` / `COMP-5` | `int.from_bytes(raw, "big", signed=...)` |
| 其他(DISPLAY 等) | ASCII 解码 + 去尾空格 |
### 4.5 COMP-3 解码算法
```
输入: N 字节压缩十进制数据
1. 将每字节拆为两个半字节(高4位 + 低4位)
2. 弹出最后一个半字节作为符号位
3. 逐半字节累加为数值(权值 = 10^(len-1-i)
4. 符号位 0xD/0xB -> 取反
5. 按 decimal 定位小数点
输出: 字符串形式数值(如 "1234.56"
```
### 4.6 错误处理
- 文件为空或记录大小为 0 -> 返回空列表
- 记录不完整(字节数 < record_size-> 跳过该记录
- COMP-3 空数据 -> 返回 "0"
- 非 ASCII 字符 -> `errors="replace"` 替换为 `?`
## 5. 数据标准化器(normalizer.py
### 5.1 职责
提供 COBOL 数据到中间表示(IR)的标准化转换,包括 EBCDIC 解码、COMP-3 解码、日期格式统一、IR 记录构造。
### 5.2 核心类
```python
class Normalizer:
def normalize_encoding(self, raw: bytes, encoding: str) -> str
def normalize_comp3(self, raw: bytes) -> str
def normalize_date(self, s: str) -> str
def to_ir_record(self, name, hex_, val, enc, ft, length=0, scale=0, signed=False) -> IRRecord
def to_null_ir(self, name: str, side: str = "java") -> IRRecord
```
### 5.3 EBCDIC 编码转换
内置 EBCDIC 037 代码页映射表(`EBCDIC_037`),覆盖:
- 空格、标点、运算符
- 小写 a-z0x81-0xA9
- 大写 A-Z0xC1-0xE9
- 数字 0-90xF0-0xF9
不可映射字符处理:
- 可打印 ASCII32-126-> 保留原字符
- 其他 -> 替换为 `?`
### 5.4 COMP-3 标准化
`CobolBinaryReader._comp3` 算法一致,但返回整数字符串(无小数点处理),适用于无需小数定位的场景。
### 5.5 日期标准化
- 8 位纯数字字符串(如 `20260822`-> 格式化为 `YYYY-MM-DD`
- 其他格式 -> 原样返回
### 5.6 中间表示(IR)模型
```python
@dataclass
class CobolIRField:
raw_hex: str # 原始十六进制
decoded_value: str # 解码后值
encoding: str # 编码类型(EBCDIC/ASCII
field_type: str # 字段类型
length: int # 字节长度
scale: int # 小数位数
signed: bool # 是否带符号
@dataclass
class JavaIRField:
raw_value: str # 原始值
decoded_value: str # 解码后值
field_type: str # 字段类型
nullable: bool # 是否可空
@dataclass
class IRRecord:
field_name: str
cobol: CobolIRField | None
java: JavaIRField | None
```
### 5.7 空值 IR 构造
`to_null_ir` 为缺失侧构造空 IR 记录(`JavaIRField("", "", "null", True)`),用于只有一侧有数据的场景。
## 6. 字段比对算法(field_compare.py
### 6.1 职责
对单个字段的 COBOL 值和 Java 值进行类型感知的比较,返回结构化的比对结果。
### 6.2 函数签名
```python
def compare_field(
name: str,
c: str, # COBOL 值
j: str, # Java 值
field_type: str = "decimal", # 字段类型
tolerance: float = 0.01 # 容忍度
) -> FieldResult
```
### 6.3 比对策略
| 字段类型 | 比对方式 | 说明 |
|----------|----------|------|
| `decimal` / `numeric` | 数值差 <= tolerance -> TOLERATED | 使用 `Decimal` 精确计算 |
| `date` | 8 位数字 -> `YYYY-MM-DD` 格式化后比较 | 统一格式消除表示差异 |
| `string` | strip 后直接比较 | 去除首尾空白 |
| 其他 | 字符串直接比较 | 兜底策略 |
### 6.4 状态判定
| 状态 | 条件 |
|------|------|
| `PASS` | 完全一致 |
| `TOLERATED` | 数值差在容忍度范围内 |
| `MISMATCH` | 不一致 |
| `NOT_SET` | 两侧均为空/None |
### 6.5 数值解析(_num
```python
def _num(v) -> Decimal | None
```
- `None` / `"None"` -> `None`
- 空字符串 -> `Decimal("0")`
- 含 `\x00` -> 去除后解析
- 非数字字符串 -> `None`
使用 Python `Decimal` 进行精确十进制运算,避免浮点精度问题。
### 6.6 默认容忍度
```python
DEFAULT_TOLERANCE = 0.01
```
可通过 `Config.tolerance` 全局配置调整。
## 7. 舍入检测(rounding_detect.py
### 7.1 职责
判断两个数值之间的差异是否由 COBOL `ROUNDED` 子句引起,识别舍入模式并给出置信度。
### 7.2 函数签名
```python
def detect_rounding(c: str, j: str) -> RoundingResult
```
### 7.3 RoundingResult 数据类
```python
@dataclass
class RoundingResult:
mode: str = "EXACT" # 舍入模式
confidence: float = 1.0 # 置信度(0-1
suggestion: str = "" # 建议文本
```
### 7.4 检测算法
```
1. 解析 COBOL 值 (cv) 和 Java 值 (jv) 为 Decimal
2. 任一解析失败 -> UNKNOWN (confidence=0)
3. cv == jv -> EXACT (confidence=1.0)
4. 计算绝对差 diff = |cv - jv|
5. 判定模式:
- diff < 2 -> TRUNCATE (confidence=0.6)
- diff < 100 -> ROUNDING (confidence=0.4)
- diff >= 100 -> SIGNIFICANT (confidence=0.9)
```
### 7.5 舍入模式说明
| 模式 | 含义 | 置信度 | 建议 |
|------|------|--------|------|
| `EXACT` | 完全一致 | 1.0 | 无 |
| `TRUNCATE` | 截断差异(diff < 2 | 0.6 | 可能是 COBOL 截断行为 |
| `ROUNDING` | 舍入差异(diff < 100 | 0.4 | 可能存在舍入方式差异 |
| `SIGNIFICANT` | 显著差异(diff >= 100 | 0.9 | 需要关注的较大差异 |
### 7.6 数值解析(_d
```python
def _d(v) -> Decimal | None
```
- 使用 `Decimal(str(v).strip())` 解析
- 异常时返回 `None`
## 8. 接口定义
### 8.1 模块公开 API
```python
# comparator/__init__.py
align_records(cobol_records, java_records, key_field) -> list[tuple]
compare_field(name, c, j, field_type, tolerance) -> FieldResult
CobolBinaryReader # class
Normalizer # class
detect_rounding(c, j) -> RoundingResult
```
### 8.2 典型调用流程
```
cobol_binary_reader.read(path, field_tree) -> list[dict]
|
v
align_records(cobol_records, java_records, key_field) -> list[tuple]
|
v (对每个 MATCHED 对)
compare_field(name, cobol_val, java_val, field_type, tolerance) -> FieldResult
|
v (可选)
detect_rounding(cobol_val, java_val) -> RoundingResult
```
### 8.3 数据模型依赖
| 模型 | 定义位置 | 用途 |
|------|----------|------|
| `FieldTree` / `Field` | `data.field_tree` | 字段布局定义 |
| `FieldResult` | `data.diff_result` | 字段比对结果 |
| `IRRecord` / `CobolIRField` / `JavaIRField` | `comparator.normalizer` | 中间表示 |
| `RoundingResult` | `comparator.rounding_detect` | 舍入检测结果 |
## 9. 错误处理
### 9.1 错误策略
模块采用**严格解析 + 宽容比对**策略:
| 异常场景 | 处理方式 | 影响 |
|----------|----------|------|
| 二进制文件为空 | 返回空列表 | 无记录可比 |
| 记录不完整 | 跳过该记录 | 部分数据丢失 |
| COMP-3 空数据 | 返回 "0" | 默认零值 |
| EBCDIC 未知字符 | 替换为 `?` | 可能导致 MISMATCH |
| 数值解析失败 | 返回 `None` | NOT_SET 状态 |
| 两侧均无值 | NOT_SET | 不计入匹配统计 |
### 9.2 已知局限
1. `aligner.py` 仅支持单一主键,不支持复合主键
2. `CobolBinaryReader` 假定所有记录等长,不支持变长记录
3. `Normalizer.normalize_comp3` 不处理小数位(与 `CobolBinaryReader._comp3` 重复实现)
4. `detect_rounding` 的阈值(2/100)为经验值,未基于统计分析
5. `field_compare` 的字符串比较未处理 EBCDIC 与 ASCII 的编码差异
+474
View File
@@ -0,0 +1,474 @@
# 07 - 配置系统详细设计
## 1. 模块概述
配置系统(`config/`)是 COBOL 迁移验证平台 V3 的全局管理模块,提供两级配置体系:
1. **全局配置**`Config`):项目级参数,包括 LLM 模型、超时、容忍度、Spark 配置、质量门限等
2. **程序级配置**`ProgramSchema`):每个 COBOL 程序的 DB 表定义、子程序列表、运行场景
此外,`MappingConfig` 提供 COBOL 字段到 Java 字段的映射配置,支持多种转换策略。
配置来源包括 TOML 文件(全局)、YAML 文件(程序级 schema 和字段映射)。
## 2. 文件清单
| 文件 | 职责 | 依赖 |
|------|------|------|
| `__init__.py` | 包入口,公开 API 导出 | `mapping`, `dataclasses` |
| `mapping.py` | COBOL-Java 字段映射配置 | `yaml`, `dataclasses` |
| `program_schema.py` | 程序级 DB schema + 运行场景定义 | `yaml`, `dataclasses` |
| `programs/*.yaml` | 58 个电信程序的 YAML schema 文件 | 无 |
## 3. 两级配置体系
### 3.1 架构图
```
全局配置 (Config)
|-- from_toml("aurak.toml")
|-- LLM 模型/超时/缓存
|-- Spark 配置
|-- 质量门限
|-- gixsql 配置
|
+-- 程序级配置 (ProgramSchema)
|-- load_schema("KYU04CAL")
|-- db_tables: [TableDef, ...]
|-- subprograms: [str, ...]
|-- runs: [ScenarioDef, ...]
|
+-- 字段映射 (MappingConfig)
|-- from_yaml("KYU04CAL.yaml")
|-- field_mappings: [FieldMapping, ...]
|-- redefines_strategy: dict
```
### 3.2 配置优先级
1. 命令行参数(最高)
2. 环境变量
3. TOML 配置文件
4. YAML schema 文件
5. 代码默认值(最低)
## 4. 全局配置(Config
### 4.1 数据类定义
```python
@dataclass
class Config:
project_name: str = ""
copybook_paths: list = ["./copybooks"]
dialect: str = "ibm"
llm_model: str = "deepseek-v4-flash"
llm_timeout: int = 120
llm_cache_dir: str = ".cache/llm"
coverage_default: str = "boundary"
rounding_mode: str = "TRUNCATE"
tolerance: float = 0.01
runner_mode: str = "native"
spark_master: str = "local[*]"
spark_input_format: str = "json"
num_records: int = 1000
branch_pass: float = 0.80
max_llm_cost: float = 0.50
quality_gate_mode: str = "warn"
quality_gate_decision_threshold: float = 0.90
quality_gate_paragraph_threshold: float = 1.0
gcov_enabled: bool = False
gcov_work_dir: str = ".gcov_output"
gcov_threshold: float = 0.5
max_quality_retries: int = 4
gixsql_path: str = "gixsql/bin/gixpp.exe"
gixsql_lib_path: str = "gixsql/lib"
gixsql_db_path: str = ".db/gixsql"
gixsql_compile_flags: str = "-fixed -ext cpy --coverage"
```
### 4.2 配置分组
| 分组 | 配置项 | 说明 |
|------|--------|------|
| 项目基础 | `project_name`, `copybook_paths`, `dialect` | 项目标识、COPYBOOK 路径、COBOL 方言 |
| LLM | `llm_model`, `llm_timeout`, `llm_cache_dir`, `max_llm_cost` | 模型选择、超时、缓存、成本控制 |
| 覆盖率 | `coverage_default`, `branch_pass`, `gcov_*` | 覆盖率目标、分支通过率、gcov 集成 |
| 比对 | `rounding_mode`, `tolerance` | 舍入模式、数值容忍度 |
| 运行器 | `runner_mode`, `spark_*`, `num_records` | native/spark 模式、Spark 参数 |
| 质量门限 | `quality_gate_*`, `max_quality_retries` | 决策阈值、段落阈值、重试次数 |
| gixsql | `gixsql_*` | DB 程序编译路径、库路径、编译标志 |
### 4.3 TOML 加载
```python
@classmethod
def from_toml(cls, path="aurak.toml") -> Config
```
TOML 文件结构:
```toml
[project]
name = "..."
copybook_paths = ["./copybooks"]
dialect = "ibm"
[llm]
model = "deepseek-v4-flash"
[coverage]
default_target = "boundary"
[comparison]
rounding_mode = "TRUNCATE"
default_tolerance = 0.01
[runner]
mode = "native"
[spark]
master = "local[*]"
num_records = 1000
[gixsql]
path = "gixsql/bin/gixpp.exe"
lib_path = "gixsql/lib"
db_path = ".db/gixsql"
compile_flags = "-fixed -ext cpy --coverage"
```
### 4.4 错误处理
- TOML 文件不存在或解析失败 -> 返回默认 `Config()` 实例
- 缺失字段使用默认值填充
## 5. 字段映射配置(mapping.py
### 5.1 数据类定义
```python
@dataclass
class FieldMapping:
cobol_field: str # COBOL 字段名
java_field: str # Java 字段名
field_type: str = "string" # 字段类型
precision: int = 0 # 精度(小数位)
trim: bool = False # 是否去空格
format: str = "" # 格式化规则
init_strategy: str = "auto" # 初始化策略
@dataclass
class MappingConfig:
program: str = "" # 程序 ID
dialect: str = "ibm" # COBOL 方言
field_mappings: list[FieldMapping] # 字段映射列表
redefines_strategy: dict # REDEFINES 处理策略
```
### 5.2 YAML 加载
```python
@classmethod
def from_yaml(cls, path: str) -> MappingConfig
```
YAML 文件结构:
```yaml
program: KYU04CAL
dialect: ibm
field_mapping:
- cobol_field: "BR-AMT"
java_field: "billAmount"
field_type: "decimal"
precision: 2
- cobol_field: "CUST-ID"
java_field: "customerId"
field_type: "string"
trim: true
redefines_strategy:
WS-BLOCK: "overlay"
```
### 5.3 字段查询
```python
def get_java_field(self, cobol_name: str) -> str
```
按 COBOL 字段名查找对应的 Java 字段名,未找到时返回原始 COBOL 字段名。
### 5.4 模块级断言
```python
_m = FieldMapping(cobol_field="BR-AMT", java_field="billAmount",
field_type="decimal", precision=2)
assert _m.cobol_field == "BR-AMT"
```
导入时自动执行结构验证。
## 6. 程序级 Schema 配置(program_schema.py
### 6.1 数据类定义
```python
@dataclass
class ColumnDef:
name: str # 列名
type: str # SQL 类型(CHAR(6), NUMERIC(4), VARCHAR(30)
primary_key: bool = False # 是否主键
nullable: bool = False # 是否可空
default: Optional[str] = None # 默认值
cobol_field: Optional[str] = None # 对应 COBOL 字段名
@dataclass
class TableDef:
name: str # 表名
columns: list[ColumnDef] # 列定义列表
create_if_missing: bool = True # 缺失时自动创建
sql_name: Optional[str] = None # COBOL SQL 中的表名(可能与 YAML 名不同)
@dataclass
class SysinDef:
period: str | None = "202607" # 处理期间
include_invalid_period: bool = False # 是否包含无效期间
modes: list[str] = ["NORMAL"] # 运行模式列表
final_mode: str = "RESET" # 最终模式
@dataclass
class ScenarioDef:
id: str # 场景 ID
sysin: SysinDef # SYSIN 卡片配置
inject_duplicate_pk: bool = False # 是否注入重复主键
row_overrides: dict[str, dict[str, str]] # 行级覆盖
delete_all_rows: bool = False # 是否清空所有行
drop_tables: list[str] = [] # 需要删除的表列表
command_line: str | None = None # 自定义命令行
seed_extra_rows: dict[str, int] = {} # 额外种子行数
@dataclass
class ProgramSchema:
program_id: str # 程序 ID
db_tables: list[TableDef] # DB 表定义列表
subprograms: list[str] # 子程序列表
db_type: str = "SQLite" # 数据库类型
db_name: str = "OVERTIME.DB" # 数据库文件名
runs: list[ScenarioDef] # 运行场景列表
coverage_dates: dict[str, list[dict]] # 覆盖率日期配置
command_line: str | None = None # 全局命令行
```
### 6.2 YAML 加载
```python
@classmethod
def from_yaml(cls, path: str | Path) -> ProgramSchema
```
### 6.3 Schema 查找
```python
def load_schema(program_id: str, search_dirs: list[str | Path] | None = None) -> ProgramSchema
```
`config/programs/` 目录下按 `{program_id}.yaml` 查找,未找到时抛出 `FileNotFoundError`
## 7. YAML Schema 配置
### 7.1 文件结构
每个程序对应一个 YAML 文件,位于 `config/programs/` 目录:
```
config/programs/
KYU04CAL.yaml
KYU05DED.yaml
KYU06UPD.yaml
KIN02UPD.yaml
KIN03EXP.yaml
KIN06CLD.yaml
KIN08DBU.yaml
KIN09CSV.yaml
SHA02MNC.yaml
SHA03MNP.yaml
SHA04TWO.yaml
SHA06TWM.yaml
SHA07KBR.yaml
ZAN06UPD.yaml
...
```
### 7.2 YAML Schema 模板
```yaml
program_id: {PROGRAM_ID}
db_type: SQLite
db_name: {DB_NAME}
db_tables:
- name: {COBOL_TABLE_NAME}
sql_name: {SQL_TABLE_NAME} # 可选,COBOL SQL 中的表名
columns:
- name: {COL_NAME}
type: {SQL_TYPE} # CHAR(N), NUMERIC(N,M), VARCHAR(N), DECIMAL(N,M), TIMESTAMP
primary_key: true/false
nullable: true/false
cobol_field: {COBOL_FIELD} # 可选,对应 COBOL 字段名
subprograms:
- {SUB_PROGRAM_ID}
runs:
- id: {SCENARIO_ID}
sysin:
period: "{YYYYMM}"
modes: ["NORMAL"]
inject_duplicate_pk: true/false
row_overrides:
{TABLE_NAME}:
{COL_NAME}: "{VALUE}"
delete_all_rows: true/false
drop_tables:
- {TABLE_NAME}
command_line: "{COMMAND}" # 可选
seed_extra_rows:
{TABLE_NAME}: {COUNT}
```
### 7.3 SQL 类型映射
| YAML 类型 | SQL 类型 | 说明 |
|-----------|----------|------|
| `CHAR(N)` | 定长字符 | COBOL DISPLAY 类型 |
| `VARCHAR(N)` | 变长字符 | COBOL X(n) 类型 |
| `NUMERIC(N)` | 整数 | 无小数位 |
| `NUMERIC(N,M)` | 定点数 | N 位总长,M 位小数 |
| `DECIMAL(N,M)` | 定点数 | 同 NUMERIC |
| `TIMESTAMP` | 时间戳 | 日期时间 |
### 7.4 运行场景(ScenarioDef
每个程序可定义多个运行场景,用于覆盖不同测试路径:
| 场景类型 | 典型配置 | 说明 |
|----------|----------|------|
| `normal` | 默认 SYSIN | 正常路径测试 |
| `collision` | `inject_duplicate_pk: true` | 主键冲突测试 |
| `abnormal` | 自定义 row_overrides | 异常路径测试 |
| 自定义 | `command_line` | 特殊命令行参数 |
### 7.5 SYSIN 卡片配置
SYSIN 卡片是 COBOL 程序的运行时输入,通过 `SysinDef` 配置:
- `period`:处理期间(如 `"202607"`),用于时间相关逻辑
- `modes`:运行模式列表(如 `["NORMAL"]`),影响程序分支
- `include_invalid_period`:是否包含无效期间测试
- `final_mode`:最终模式(如 `"RESET"`
## 8. 配置加载机制
### 8.1 加载流程
```
1. 读取 aurak.toml -> Config.from_toml()
2. 根据 program_id 查找 programs/{id}.yaml -> load_schema()
3. 可选: 读取字段映射 YAML -> MappingConfig.from_yaml()
4. 合并全局配置 + 程序级配置 -> 运行时参数
```
### 8.2 默认值策略
- 所有配置项均有代码级默认值
- TOML/YAML 仅覆盖显式指定的字段
- 缺失字段回退到 dataclass 默认值
### 8.3 已有程序 Schema 统计
截至当前,`config/programs/` 目录包含 14 个程序的 YAML schema
| 程序 ID | DB 表数 | 子程序数 | 运行场景数 |
|---------|---------|----------|------------|
| KYU04CAL | - | - | - |
| KYU05DED | - | - | - |
| KYU06UPD | - | - | - |
| KIN02UPD | - | - | - |
| KIN03EXP | - | - | - |
| KIN06CLD | - | - | - |
| KIN08DBU | - | - | - |
| KIN09CSV | - | - | - |
| SHA02MNC | - | - | - |
| SHA03MNP | - | - | - |
| SHA04TWO | - | - | - |
| SHA06TWM | - | - | - |
| SHA07KBR | 2 | 2 | 0 |
| ZAN06UPD | 2 | 5 | 3 |
## 9. 接口定义
### 9.1 模块公开 API
```python
# config/__init__.py
Config # 全局配置 dataclass
MappingConfig # 字段映射配置
FieldMapping # 单个字段映射
# config/mapping.py
MappingConfig.from_yaml(path) -> MappingConfig
MappingConfig.get_java_field(cobol_name) -> str
# config/program_schema.py
ProgramSchema.from_yaml(path) -> ProgramSchema
load_schema(program_id, search_dirs) -> ProgramSchema
```
### 9.2 典型使用模式
```python
from config import Config
from config.program_schema import load_schema
from config.mapping import MappingConfig
# 加载全局配置
config = Config.from_toml("aurak.toml")
# 加载程序 schema
schema = load_schema("KYU04CAL")
# 加载字段映射(如果存在)
mapping = MappingConfig.from_yaml("mappings/KYU04CAL.yaml")
java_field = mapping.get_java_field("BR-AMT")
# 使用配置
for table in schema.db_tables:
print(f"Table: {table.name}, SQL: {table.sql_name}")
for col in table.columns:
print(f" {col.name}: {col.type} (PK={col.primary_key})")
for scenario in schema.runs:
print(f"Scenario: {scenario.id}, Period: {scenario.sysin.period}")
```
## 10. 错误处理
### 10.1 错误策略
| 异常场景 | 处理方式 | 影响 |
|----------|----------|------|
| TOML 文件不存在 | 返回默认 Config | 使用代码默认值 |
| TOML 解析失败 | 返回默认 Config | 使用代码默认值 |
| YAML schema 不存在 | 抛出 FileNotFoundError | 流水线中断 |
| YAML 字段缺失 | 使用默认值填充 | 可能不完整 |
| SQL 类型无法识别 | 原样传递 | 下游处理 |
### 10.2 已知局限
1. `Config.from_toml` 吞掉所有异常,配置错误静默降级
2. `load_schema` 搜索路径硬编码为 `config/programs/`
3. `MappingConfig``redefines_strategy` 仅存储字典,无验证逻辑
4. `ProgramSchema``coverage_dates` 类型定义为 `dict[str, list[dict[str, str]]]`,缺乏 schema 验证
5. `SysinDef``period` 字段无格式校验,允许非法期间值
6. 程序级 YAML 无统一 schema 验证,依赖调用方保证格式正确
+825
View File
@@ -0,0 +1,825 @@
# 数据流设计文档
> 版本: v1.0 | 日期: 2026-08-22
> 本文档描述 COBOL 迁移验证平台 V3 的完整数据流,覆盖非 DB(flat file)管道和 DBSQL/SQLite)管道。
---
## 1. 数据流概述
### 1.1 设计目标
| 目标 | 说明 | 实现方式 |
|------|------|----------|
| **可追溯性** | 每条测试数据可追溯到源码决策点 | 路径约束 (field, op, value, want_true) 记录决策分支选择 |
| **一致性** | 非 DB 与 DB 管道共享同一套解析/生成引擎 | cobol_testgen/ 统一提供 extract_structure + generate_data |
| **容错性** | 单步失败不中断整体管道 | 每步返回 DbPipelineResult(success, data),失败立即终止后续 |
| **可观测性** | 全流程可监控、可调试 | 覆盖率报告、gcov 数据、JSON 中间产物、运行日志 |
### 1.2 管道对比
| 维度 | 非 DB 管道 | DB 管道 |
|------|-----------|---------|
| 编排器 | orchestrator.py | orchestrator_db.py |
| 运行器 | runners/cobol_runner.py | runners/gixsql_runner.py |
| 输入格式 | 固定长度平面文件 | 平面文件 + SQLite DB 种子 |
| 输出格式 | 平面文件 | 平面文件 + SQLite DB |
| 验证方式 | comparator/ 逐字段比对 | Step 6 验证 COBOL/Java 输出一致性 |
| 步骤数 | 4 步(生成-运行-比对-报告) | 6 步(环境-生成-运行-提取-Java-验证) |
---
## 2. 非 DB 管道数据流
### 2.1 总体流程
COBOL 源码 (.cbl)
|
v [输入阶段]
read.py: preprocess() + parse_data_division()
|
v [生成阶段]
core.py: build_branch_tree() -> branch_tree + assignments
|
v
design.py: enum_paths() -> path_infos
|
v
design.py: make_base_record() -> base_record
|
v
design.py: apply_constraint() -> records (测试数据)
|
v [运行阶段]
output.py: output_json() -> 测试数据 JSON
output.py: output_input_files() -> 固定长度平面文件
|
v
runners/cobol_runner.py: compile() + run() -> 输出文件
|
v [比对阶段]
comparator/aligner.py: 按主键对齐 COBOL/Java 记录
comparator/field_compare.py: 逐字段比较
comparator/normalizer.py: EBCDIC/COMP-3 标准化
|
v
report/generator.py: 生成 HTML 报告
### 2.2 输入阶段
**COBOL 源码 -> read.py 预处理 -> DATA DIVISION 解析 -> fields 结构信息**
COBOL 源码 (fixed/free format)
|
v read.preprocess()
+-- resolve_copybooks(): 展开 COPY 语句
+-- _is_fixed_format(): 自动检测 fixed/free 格式
+-- 展开至每行 <=72 字符 (fixed) 或保持原样 (free)
+-- 去除 EXEC CICS/SQL 块、逗号、ALL 关键字
|
v 预处理后源码 (preprocessed)
|
v read.extract_data_division()
+-- 提取 DATA DIVISION 文本块
|
v read.parse_data_division()
+-- Lark grammar.lark 解析 (Earley parser, dynamic lexer)
+-- 逐行解析 FieldDef: level, name, PIC, USAGE, VALUE, REDEFINES, OCCURS
+-- PIC 子句解析 -> PicInfo (type, digits, decimal, length, signed)
+-- 返回 list[FieldDef]
|
v expand_occurs()
+-- OCCURS 展开为下标副本: WS-CELL(1), WS-CELL(2), ...
**输出数据结构:**
# fields: list[dict] -- 每个字段一个 dict
{
'name': 'R01EMP-ID', # 字段名 (大写)
'level': 1, # 层号 (01, 05, 77, 88)
'pic': 'X(8)', # PIC 子句原始文本
'pic_info': { # PIC 解析结果
'type': 'alphanumeric', # numeric | alphanumeric | alphabetic
'digits': 0, # 整数位数 (numeric)
'decimal': 0, # 小数位数 (numeric)
'length': 8, # 总长度 (alphanumeric)
'signed': False,
},
'section': 'FILE', # DATA DIVISION 节
'occurs': 0, # OCCURS 次数
'redefines': None, # REDEFINES 目标
'usage': None, # COMP | COMP-3 | BINARY | DISPLAY
'is_88': False, # 是否 88 级条件
'parent': None, # 88 级父字段名
'value': None, # VALUE 子句
'values': None, # 88 级多值列表
'is_filler': False,
}
### 2.3 生成阶段
**fields -> core.py build_branch_tree -> design.py enum_paths -> make_base_record -> apply_constraint -> 测试数据**
#### 2.3.1 分支树构建
PROCEDURE DIVISION 文本
|
v core.build_branch_tree_fallback()
+-- pipeline_bridge: 3秒超时桥接
| +-- 优先: procedure_parser.py (新解析器, 行级状态机)
| +-- 回退: core._BrParser (旧解析器, 正则驱动)
|
+-- 段落扫描: scan_paragraphs() -> {name: (start, end)}
+-- 逐段解析 IF / EVALUATE / PERFORM / READ / WRITE / MOVE / COMPUTE
+-- 返回 (branch_tree: BrSeq, assignments: dict)
**分支树节点类型:**
| 节点 | 类 | 属性 | 说明 |
|------|----|------|------|
| 序列 | BrSeq | children: list | 顺序执行的语句序列 |
| 条件 | BrIf | condition, cond_tree, true_seq, false_seq | IF-ELSE 分支 |
| 评估 | BrEval | subject, subjects, when_list, other_seq | EVALUATE 多分支 |
| 循环 | BrPerform | perf_type, condition, body_seq | PERFORM UNTIL/VARYING |
| 查找 | BrSearch | table_name, is_all, when_list | SEARCH/SEARCH ALL |
| 赋值 | Assign | target, source_info | MOVE/COMPUTE/ADD/SUBTRACT/MULTIPLY/DIVIDE |
| 调用 | CallNode | program_name, using_params | CALL 子程序 |
| 跳转 | GoTo | target, body_seq | GO TO |
| 退出 | ExitNode | exit_type | EXIT PARAGRAPH/PERFORM/PROGRAM |
#### 2.3.2 路径枚举
branch_tree + assignments
|
v design_mcdc.enum_paths() / design.enum_paths()
+-- 遍历分支树,收集每个决策点 (BrIf/BrEval/BrPerform)
+-- 对每个决策点生成 True/False 或 WHEN 分支路径
+-- MC/DC 约束集: cond.mcdc_sets() -> 每个叶条件的 T/F 独立约束
+-- 路径约束合并: prior_false 累积(EVALUATE WHEN 入口条件)
+-- PERFORM VARYING: 最后一次迭代约束 (last-iteration)
+-- SEARCH: 表索引约束 + AT END 分支
+-- 返回 path_infos: list[(constraints, path_assignments, term_type)]
**路径约束格式:**
# path_infos 的每个元素
(
# constraints: Path = list[Constraint]
[
('WRK-MONTH', '>', '0', True), # (field, op, value, want_true)
('WRK-MONTH', '<', '13', True),
('R01EMP-ID', '<>', '00000000', True),
],
# path_assignments: dict -- 赋值表
{
'WRK-PREV-EMP-ID': [{'type': 'move', 'source_vars': ['R01EMP-ID']}],
'DBV-EMP-ID': [{'type': 'move_literal', 'literal': 'A0000001'}],
},
# term_type: str
'normal' # 或 'abend'
)
#### 2.3.3 测试数据记录生成
path_infos + data_fields
|
v design.generate_records()
|
+-- 遍历每条路径 (seq=1,2,3...):
| |
| +-- make_base_record(seq, data_fields)
| | +-- 按 VALUE 子句设置初始值
| | +-- 按 PIC 类型生成默认值:
| | | +-- numeric: _make_numeric_value(idx, seq, total_digits)
| | | +-- alphanumeric: _make_alpha_value(idx, seq, length)
| | | +-- date: seq_date(record_num) -> YYYYMMDD
| | +-- 返回 base_record: dict {field_name: value}
| |
| +-- Pass A: propagate_assignments(rec, path_assign, data_fields)
| | +-- 模拟赋值传播: MOVE/COMPUTE/READ INTO 等
| |
| +-- Pass B: apply_constraint(rec, field, op, val, want, ...)
| | +-- trace_to_root(): 沿 MOVE 链追溯到根字段
| | +-- invert_through_chain(): 反向求解约束值
| | +-- satisfying_value(): 满足约束的值计算
| | | +-- numeric: 边界值 +/-1
| | | +-- alphanumeric: 字典序边界
| | | +-- date: YYYYMMDD 格式边界
| | +-- 递归写入 base_record[field] = 满足值
| |
| +-- Pass B.5: forward propagate (变量间 MOVE 一致性)
| +-- Pass B.75: COMPUTE 重算 (约束修改源字段后)
| +-- get_term_type(path_cons) -> (filtered_cons, term_type)
|
+-- 返回 (records, kept_path_cons, term_types)
**测试数据记录格式:**
# records: list[dict] -- 每条记录一个 dict
[
{
'R01EMP-ID': 'A0000001',
'R01DATE': '20250101',
'R01LINE': '0001',
'WRK-PREV-EMP-ID': '',
'WRK-MONTH': '06',
'DBV-EMP-ID': 'A0000001',
'_assigned_fields': {'R01EMP-ID', 'R01DATE'}, # 内部标记
'_w02_path': True, # PREV 连锁标记
},
# ... 更多记录
]
### 2.4 运行阶段
**测试数据 -> output.py -> cobol_runner compile/run -> 输出文件**
records + fd_fields + open_dir
|
v output.output_json()
+-- 按 FD 分组: field_to_fd 映射字段到 FD
+-- 按 open_dir 确定方向: INPUT/OUTPUT/I-O
+-- 按 roles 分类: input/inout -> input 块, output/inout -> expected_output 块
+-- 不属于任何 FD 的字段 -> working_storage 块
+-- 输出 JSON: {program, records: [{input, expected_output, working_storage, termination}]}
|
v output.output_input_files()
+-- 仅处理 INPUT/I-O 方向的 FD
+-- 按 FD 分组,每 FD 输出一个 JSON: {stem}_{fd_name}.json
+-- abend 记录单独输出: {stem}_abend_{fd_name}.json
+-- 二进制模式: 按 field offsets 打包为固定长度二进制文件
|
v flatfile.write_all_files()
+-- analyze_fd_layout(): 解析 FD 记录布局 (字段名/PIC/offset/length)
+-- 按 FD 布局序列化每条记录为固定长度字节
+-- write_flat_file(): 输出到 outdir/{assign_name}
|
v runners/cobol_runner.compile()
+-- cobc -std=ibm -free -x src.cbl -o exe
|
v runners/cobol_runner.run()
+-- 设置 CWD,复制输入文件到 input/
+-- subprocess.run(exe) 执行
+-- 捕获 stdout/stderr,收集输出文件
+-- 返回 RunResult(success, records, log)
### 2.5 比对阶段
**输出文件 -> aligner.py -> field_compare.py -> normalizer.py -> report/generator.py -> HTML 报告**
COBOL 输出文件 + Java 输出文件
|
v comparator/aligner.py: align_records()
+-- 按主键字段 (如 CUST-ID) 分组
+-- 取两侧键的并集,按字符串排序
+-- 逐键配对: MATCHED / MISSING_IN_SPARK / EXTRA_IN_SPARK
+-- 返回 list[(cobol_record, java_record, status)]
|
v comparator/normalizer.py: normalize_encoding()
+-- EBCDIC -> ASCII 解码 (EBCDIC_037 映射表)
+-- COMP-3 压缩十进制解码 (nibble -> decimal)
+-- 日期格式标准化 (YYYYMMDD -> YYYY-MM-DD)
|
v comparator/field_compare.py: compare_field()
+-- 按字段类型选择比较策略:
| +-- numeric: Decimal 精度比较 + 容差 (tolerance=0.01)
| +-- date: YYYYMMDD 格式归一化后比较
| +-- string: strip 后直接比较
+-- 返回 FieldResult(field_name, status, cobol_value, java_value)
+-- status: PASS / MISMATCH / TOLERATED / NOT_SET
|
v comparator/rounding_detect.py: detect_rounding()
+-- 判断数值差异是否由 COBOL ROUNDED 子句引起
+-- 计算舍入误差范围
|
v report/generator.py: ReportGenerator
+-- generate_json(): 输出 JSON 报告 (VerificationRun 数据)
+-- generate_html(): 输出 HTML 报告
| +-- 覆盖率卡片: 段落覆盖率、分支覆盖率、决策点覆盖率
| +-- HINA 卡片: 判定类型、确信度
| +-- 质量评分卡片: quality_score
| +-- 重试历史卡片: heal_retry, simple_retry
| +-- 字段比对表格: PASS/MISMATCH 高亮
+-- 返回报告文件路径
---
## 3. DB 管道数据流
### 3.1 总体 6 步流程
COBOL 源码 (含 EXEC SQL)
|
v [Step 1: 环境整备]
step1_setup_environment()
+-- 复制源码到 ASCII 工作目录
+-- gixpp 预处理 + CONNECT TO 路径修补
+-- cobc 编译 -> exe_path
|
v [Step 2: 输入数据生成]
step2_generate_inputs(scenario)
+-- extract_structure() -> 分支树 + 赋值表
+-- generate_all_data() -> 测试数据记录
+-- _init_database() + _populate_database() -> SQLite DB 种子
+-- flatfile.write_all_files() -> 平面文件
|
v [Step 3: COBOL 执行]
step3_run_cobol(scenario)
+-- runner.run() -> 输出文件 + gcov 数据
|
v [Step 4: 中间数据提取]
step4_extract_intermediate()
+-- SELECT * FROM each table -> W01 JSON
|
v [Step 5: Java 执行] (可选)
step5_run_java()
+-- java -jar migration.jar -i W01.json -o output/
|
v [Step 6: 验证]
step6_verify()
+-- 比较 Java 输出与 COBOL 期望值
+-- 返回 VerificationRun (PASS/MISMATCH)
### 3.2 Step 1: 环境整备 (step1_setup_environment)
**职责:** gixpp 预处理 + cobc 编译
cobol_src_dir/{program_id}.cbl
|
v _copy_sources_to_workdir()
+-- 复制主源码 {program_id}.cbl -> work_dir/src/
+-- 复制 COPYBOOK (*.cpy) -> work_dir/src/
+-- 复制子程序 (SUB*.cbl) -> work_dir/src/
(搜索: cobol_src_dir, sub/, production/sub/, cobol-tna-system/sub/)
|
v runner.preprocess(src, preprocessed/, copybook_dirs)
+-- gixpp 预处理 + CONNECT TO 路径修补
| gixpp 错误转换: 'data/kin.db' -> 'sqlite://localhost/kin'
| 修补为: 'sqlite:///{db_path}'
|
v runner.compile(pp, exe, copybook_dirs, extra_srcs)
+-- cobc 编译 -> work_dir/bin/{program_id}.exe
+-- 编译日志写入 runtime_dir/logs/compile/
|
v 返回 DbPipelineResult(step=1, success, data={exe_path, log})
**输入:** cobol_src_dir, copybook_dirs, schema.subprograms
**输出:** src_path, pp_path, exe_path
### 3.3 Step 2: 输入数据生成 (step2_generate_inputs)
**职责:** COBOL 解析 -> 测试数据生成 -> DB 初始化 -> 平面文件输出
src_text + schema + scenario
|
v COBOL 解析
+-- extract_structure(src_text) -> 分支树 + 赋值表
+-- generate_all_data() -> 测试数据记录 (白盒+机能+策略)
+-- 后处理: R02APPL-ID 链接 R01APPL-ID
|
v DB 初始化
+-- 确定 DB 路径(场景分离: {program_id}_{scenario_id}.db
+-- 清理旧 DB -> _init_database(db_path)
| +-- _create_tables(): 按 YAML schema 创建表 + 主键
+-- _populate_database(): 注入种子行
| +-- 解析 COBOL -> 分支树 -> 路径枚举
| +-- build_db_input(): 生成 DB 输入行
| | +-- collect_sql_meta(): 提取 SQL 元数据 (SELECT/INSERT/UPDATE/DELETE)
| | +-- _hostvar_root(): WHERE 宿主变量 MOVE 链解析
| | +-- _resolve_where_hostvar(): 宿主变量追溯到输入记录根字段
| | +-- 用输入键建种子(与运行时一致)
| +-- 覆盖率驱动数据补充(日期、假期等)
| +-- 区间协调(INSURANCE-RATES / EMP-MASTER
| +-- INSERT OR IGNORE 写入 DB
+-- _inject_extra_seed_rows(): 大结果集注入
+-- _inject_sql_error_rows(): PK 冲突行注入
|
v 记录修补
+-- records[0].R01EMP-ID = SPACE(触发空社员路径)
+-- 全零 EMP-ID -> SPACE 清洗
+-- 注入重复 EMP-IDAGG UPDATE 路径)
+-- _deduplicate_r01_pk(): PK 去重
+-- _inject_aggregation_boundaries(): 聚合边界数据
|
v 平面文件输出
+-- write_all_files(): 全 FD 平面文件
+-- write_sysin_file(): SYSIN 配置
|
v 返回 DbPipelineResult(step=2, data={records, flat_files, db_path})
**输入:** src_path, pp_path, schema, scenario
**输出:** generated_records, generated_structure, db_path, 平面文件
### 3.4 Step 3: COBOL 执行 (step3_run_cobol)
**职责:** 调用编译后的 COBOL 程序并收集 gcov 覆盖率数据
exe_path + schema + scenario
|
v 环境准备
+-- 创建 runtime/run_{id}/main/{input,output}/ 目录
+-- 复制生成的平面文件 -> input/
+-- 复制 JSON -> json/
|
v 文件方向映射 (_scan_assign_to)
+-- 正则扫描 SELECT/ASSIGN-TO -> {文件名: 方向}
+-- OPEN 语句解析 -> INPUT/OUTPUT 方向确定
|
v DB 路径准备
+-- 场景 DB -> 复制到默认 DB 路径
+-- CWD/data/kin.dbCONNECT TO 路径)
|
v 执行
+-- 清理前次 .gcda 文件
+-- runner.run(exe, cwd, db_path, env_overrides, command_args)
+-- 日志写入 runtime_dir/logs/
|
v gcov 数据收集
+-- .gcda 从 CWD + exe_dir 复制到 gcov/run_{id}/
+-- .gcno 同步(共享 .gcnoCOPY 不 MOVE
|
v 返回 DbPipelineResult(step=3, data={returncode, log, ...})
### 3.5 Step 4: 中间数据提取 (step4_extract_intermediate)
**职责:** 从 SQLite DB 导出 Java 程序所需的 JSON 中介数据
_current_db_path + schema.db_tables
|
v
+-- 打开 DB
+-- 遍历 schema.db_tables,对每张表执行 SELECT * FROM [table]
+-- 构建 meta = {program_id, tables: {table_name: [rows]}}
+-- 写入 work_dir/intermediate/{program_id}_W01.json
+-- 返回 DbPipelineResult(step=4, data={tables, w01_path})
### 3.6 Step 5: Java 执行 (step5_run_java)
**职责:** 调用 Java 转换程序处理 COBOL 输出数据
java_input_path + java_jar
|
v
+-- 创建 java_output 目录
+-- 构建命令: java -jar {java_jar} -i {java_input_path} -o {java_out}
+-- subprocess.run(cmd, capture_output=True, timeout=60)
+-- 返回 DbPipelineResult(step=5, data={returncode, log})
### 3.7 Step 6: 结果验证 (step6_verify)
**职责:** 比较 Java 输出与 COBOL 期望值
java_output_path + _current_db_path
|
v
+-- 构建 VerificationRun 结果对象
+-- 读取 DB 各表行数(调试信息)
+-- 扫描 java_output_path 下的 .txt/.json 文件
+-- 设置 exit_code 和 statusPASS/MISMATCH
+-- 返回 VerificationRun
---
## 4. 核心数据结构
### 4.1 fields 列表格式
fields 是整个数据流的核心数据结构,贯穿输入-生成-运行全流程。
# 类型: list[dict]
# 来源: read.parse_data_division() + expand_occurs()
# 用途: 分支树构建、路径枚举、约束应用、JSON 输出
[
{
'name': str, # 字段名 (大写, 如 'R01EMP-ID')
'level': int, # 层号 (01, 05, 77, 88)
'pic': str | None, # PIC 子句原始文本
'pic_info': { # PIC 解析结果
'type': str, # 'numeric' | 'alphanumeric' | 'alphabetic' | 'unknown'
'digits': int, # 整数位数 (numeric)
'decimal': int, # 小数位数 (numeric)
'length': int, # 总长度 (alphanumeric)
'signed': bool, # 是否有符号
},
'section': str, # 'FILE' | 'WORKING-STORAGE' | 'LINKAGE'
'occurs': int, # OCCURS 次数 (0=无)
'occurs_depending': str | None, # OCCURS DEPENDING ON 目标
'redefines': str | None, # REDEFINES 目标字段名
'usage': str | None, # 'COMP' | 'COMP-3' | 'BINARY' | 'PACKED-DECIMAL' | 'DISPLAY'
'is_88': bool, # 是否 88 级条件
'parent': str | None, # 88 级父字段名
'value': str | None, # VALUE 子句值
'values': list[str] | None, # 88 级多值列表
'is_filler': bool, # 是否 FILLER
},
# ... 更多字段
]
### 4.2 Constraint 约束格式
Constraint 是路径枚举和约束应用的基本单元。
# 类型: tuple (4 元组)
# 定义: models.py 中 Constraint = tuple
# 格式: (field, operator, value, want_true)
Constraint = (
str, # field: 字段名 (如 'WRK-MONTH', 'R01EMP-ID')
str, # operator: 比较运算符 ('=' | '<>' | '>' | '<' | '>=' | '<=' | 'not_in')
str, # value: 比较值 (字符串形式, 如 '12', '00000000', 'SPACE')
bool, # want_true: True=满足条件, False=不满足条件
)
# 示例:
('WRK-MONTH', '>', '0', True) # WRK-MONTH > 0 为真
('R01EMP-ID', '<>', '00000000', True) # R01EMP-ID 不等于 00000000 为真
('SQLCODE', '=', '100', False) # SQLCODE = 100 为假 (即 SQLCODE != 100)
### 4.3 Path 路径格式
Path 是一条完整执行路径的所有约束集合。
# 类型: list[Constraint]
# 定义: models.py 中 Path = list[Constraint]
# 含义: 所有约束同时满足时,程序沿该路径执行
Path = [
('WRK-MONTH', '>', '0', True),
('WRK-MONTH', '<', '13', True),
('R01EMP-ID', '<>', '00000000', True),
('__DP', '=', 'T', True), # 决策点标记 (设计内部使用)
]
# path_infos 格式 (生成记录的输入):
path_infos = [
# (constraints, path_assignments, term_type)
(
[Constraint, ...], # 路径约束列表
{str: list[dict]}, # 赋值表 (目标 -> 赋值操作列表)
'normal' | 'abend', # 终止类型
),
# ... 更多路径
]
# 赋值表 (path_assignments) 格式:
{
'WRK-PREV-EMP-ID': [
{'type': 'move', 'source_vars': ['R01EMP-ID']}
],
'DBV-EMP-ID': [
{'type': 'move_literal', 'literal': 'A0000001'}
],
'WS-COUNT': [
{'type': 'compute', 'op': 'add', 'left': 'WS-COUNT', 'right': '1'}
],
}
### 4.4 测试数据 JSON 格式
测试数据 JSON 是非 DB 管道和 DB 管道共享的输出格式。
{
'program': str, # 程序名 (如 'KIN04CHK')
'records': [
{
'input': { # 按 FD 分组的输入字段
'FD_NAME': {
'FIELD1': str,
'FIELD2': str,
...
}
},
'expected_output': { # 按 FD 分组的期望输出字段
'FD_NAME': {
'FIELD1': str,
...
}
},
'working_storage': { # 不属于任何 FD 的工作存储字段
'WRK-MONTH': str,
'WS-COUNT': str,
...
},
'termination': str, # 'normal' | 'abend'
},
# ... 更多记录
],
'db_input': { # DB 管道专用: DB 种子数据 (可选)
'table_name': [
{'col1': val1, 'col2': val2, ...},
...
]
}
}
---
## 5. 数据流图
### 5.1 非 DB 管道完整流程 (Mermaid)
`mermaid
flowchart TD
A[COBOL 源码 .cbl] --> B[read.py preprocess]
B --> C[read.py parse_data_division]
C --> D[expand_occurs]
D --> E[fields: list dict]
E --> F[core.py build_branch_tree]
F --> G[branch_tree + assignments]
G --> H[design.py enum_paths]
H --> I[path_infos]
I --> J[design.py generate_records]
E --> J
J --> K[records: list dict]
K --> L[output.py output_json]
L --> M[测试数据 JSON]
K --> N[output.py output_input_files]
K --> O[flatfile.write_all_files]
N --> P[固定长度平面文件]
O --> P
P --> Q[cobol_runner compile]
Q --> R[cobol_runner run]
R --> S[COBOL 输出文件]
S --> T[comparator/aligner align_records]
U[Java 输出文件] --> T
T --> V[记录对 pairs]
V --> W[comparator/normalizer]
W --> X[comparator/field_compare]
X --> Y[FieldResult list]
Y --> Z[report/generator generate_html]
Z --> AA[HTML 验证报告]
`
### 5.2 DB 管道完整流程 (Mermaid)
`mermaid
flowchart TD
A[COBOL 源码 含 EXEC SQL] --> B[Step 1: 环境整备]
B --> B1[gixpp 预处理]
B1 --> B2[cobc 编译]
B2 --> B3[exe_path]
A --> C[Step 2: 输入数据生成]
B3 --> C
C --> C1[extract_structure]
C1 --> C2[generate_all_data]
C2 --> C3[records]
C3 --> C4[flatfile.write_all_files]
C4 --> C5[平面文件 input/]
C3 --> C6[build_db_input]
C6 --> C7[INSERT INTO SQLite DB]
C7 --> C8[db_path]
C5 --> D[Step 3: COBOL 执行]
C8 --> D
D --> D1[runner.run]
D1 --> D2[COBOL 输出文件]
D1 --> D3[gcov 数据]
D2 --> E[Step 4: 中间数据提取]
C8 --> E
E --> E1[SELECT * FROM tables]
E1 --> E2[W01 JSON]
E2 --> F[Step 5: Java 执行 可选]
F --> F1[java -jar migration.jar]
F1 --> F2[Java 输出文件]
F2 --> G[Step 6: 验证]
D2 --> G
G --> G1[VerificationRun]
G1 --> G2[PASS / MISMATCH]
`
### 5.3 数据流关键节点汇总
`mermaid
flowchart LR
subgraph 输入层
A1[COBOL 源码]
A2[COPYBOOK]
A3[YAML schema]
end
subgraph 解析层
B1[preprocess]
B2[parse_data_division]
B3[build_branch_tree]
end
subgraph 生成层
C1[enum_paths]
C2[generate_records]
C3[build_db_input]
end
subgraph 输出层
D1[output_json]
D2[write_all_files]
D3[write_sysin_file]
end
subgraph 执行层
E1[cobol_runner]
E2[gixsql_runner]
E3[java -jar]
end
subgraph 验证层
F1[aligner]
F2[field_compare]
F3[coverage]
F4[report]
end
A1 --> B1 --> B2 --> B3
A2 --> B1
A3 --> C3
B3 --> C1 --> C2
C2 --> D1
C2 --> D2
C3 --> D2
C3 --> D3
D2 --> E1
D2 --> E2
D3 --> E2
E1 --> F1
E2 --> F1
E3 --> F1
F1 --> F2 --> F4
B3 --> F3 --> F4
`
---
## 6. 跨模块数据流转
### 6.1 模块间数据传递关系
| 源模块 | 目标模块 | 传递数据 | 数据格式 |
|--------|----------|----------|----------|
| read.py | core.py | preprocessed, data_fields | str, list[dict] |
| read.py | design.py | data_fields, open_dir, file_sec | list[dict], dict, dict |
| core.py | design.py | branch_tree, assignments | BrSeq, dict |
| cond.py | design.py | path constraints | list[Constraint] |
| design.py | output.py | records, kept_path_cons, term_types | list[dict], list, list[str] |
| design.py | to_sql.py | records, data_fields, assignments | list[dict], list[dict], dict |
| output.py | runner.py | records (JSON/binary) | dict, bytes |
| runner.py | comparator/ | output files | file paths |
| to_sql.py | orchestrator_db.py | db_input (DB seed rows) | dict[str, list[dict]] |
| flatfile.py | orchestrator_db.py | flat files (binary) | file paths |
| orchestrator_db.py | gixsql_runner.py | exe_path, db_path, env | Path, Path, dict |
| gixsql_runner.py | orchestrator_db.py | RunResult | dataclass |
| comparator/aligner.py | comparator/field_compare.py | aligned pairs | list[tuple] |
| comparator/field_compare.py | report/generator.py | FieldResults | list[FieldResult] |
| coverage.py | report/generator.py | DecisionPoints, coverage rates | list[DecisionPoint], float |
| data_merger.py | __init__.py | merged records | list[dict] |
| hina/strategy.py | data_merger.py | strategy records | list[dict] |
### 6.2 核心数据流路径
**路径 1: 非 DB 管道 (flat file)**
read.py (fields) -> core.py (branch_tree) -> design.py (records)
-> output.py (JSON + flat files) -> cobol_runner (output files)
-> comparator/ (verification results) -> report/ (HTML)
**路径 2: DB 管道 (SQL/SQLite)**
read.py (fields) -> core.py (branch_tree) -> design.py (records)
-> to_sql.py (DB seed rows) -> flatfile.py (flat files)
-> gixsql_runner (output files + DB state)
-> step4 (W01 JSON) -> step5 (Java output) -> step6 (verification)
**路径 3: 覆盖率收集**
core.py (branch_tree) -> coverage.py (decision_points)
-> mark_coverage (covered branches) -> gcov.py (dynamic coverage)
-> coverage.py (merged coverage) -> report/ (HTML report)
### 6.3 关键中间产物
| 产物 | 生成位置 | 消费位置 | 格式 |
|------|----------|----------|------|
| preprocessed | read.py | core.py, read.py | str (预处理后源码) |
| data_fields | read.py | core.py, design.py, to_sql.py | list[dict] |
| branch_tree | core.py | design.py, coverage.py | BrSeq |
| assignments | core.py | design.py, to_sql.py | dict |
| path_infos | design.py | design.py (generate_records) | list[tuple] |
| records | design.py | output.py, to_sql.py, flatfile.py | list[dict] |
| fd_fields | read.py | output.py | dict[str, set[str]] |
| open_dir | read.py | output.py, flatfile.py | dict[str, str] |
| file_sec | read.py | output.py, __init__.py | dict[str, list[str]] |
| db_input | to_sql.py | orchestrator_db.py | dict[str, list[dict]] |
| flat files | flatfile.py | runner.py | file paths (binary) |
| JSON | output.py | runner.py, coverage.py | file path |
| gcov data | gcov.py | coverage.py | dict[int, int] |
| DecisionPoints | coverage.py | report/generator.py | list[DecisionPoint] |
| VerificationRun | comparator/ | report/generator.py | dataclass |
| FieldResult | field_compare.py | report/generator.py | dataclass |
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Append the remaining sections to the design document."""
from pathlib import Path
OUT = Path(r"C:\Users\marye\Desktop\2026技术大赛\cobol-java-v3\docs\detailed-design\01-cobol-testgen-core.md")
content = OUT.read_text(encoding="utf-8")
content = content.replace("PLACEHOLDER", "")
# Read part2 content
part2 = Path(r"C:\Users\marye\Desktop\2026技术大赛\cobol-java-v3\docs\detailed-design\part2.md")
content += part2.read_text(encoding="utf-8")
# Read part3 content
part3 = Path(r"C:\Users\marye\Desktop\2026技术大赛\cobol-java-v3\docs\detailed-design\part3.md")
content += part3.read_text(encoding="utf-8")
# Read part4 content
part4 = Path(r"C:\Users\marye\Desktop\2026技术大赛\cobol-java-v3\docs\detailed-design\part4.md")
content += part4.read_text(encoding="utf-8")
# Read part5 content
part5 = Path(r"C:\Users\marye\Desktop\2026技术大赛\cobol-java-v3\docs\detailed-design\part5.md")
content += part5.read_text(encoding="utf-8")
OUT.write_text(content, encoding="utf-8")
print(f"Document complete: {len(content)} chars, {content.count(chr(10))+1} lines")
+345
View File
@@ -0,0 +1,345 @@
# 开发范式流程图
> 本文档定义 COBOL 迁移验证平台的开发工作流,用于展示团队的开发范式。
---
## 一、开发流程概览
```mermaid
graph TD
A[1. 需求分析] --> B[2. AI方案生成]
B --> C{3. 人工审核}
C -->|通过| D[4. AI编码实现]
C -->|需要修改| B
D --> E[5. 测试验证]
E --> F{6. 质量评审}
F -->|达标| G[7. 交付归档]
F -->|未达标| D
style A fill:#e1f5fe
style B fill:#f3e5f5
style C fill:#fff3e0
style D fill:#f3e5f5
style E fill:#e8f5e8
style F fill:#fff3e0
style G fill:#e8f5e8
```
### 流程说明
| 步骤 | 名称 | 负责人 | 说明 |
|:----:|------|:------:|------|
| 1 | 需求分析 | 人工 | 分析COBOL源码结构,明确迁移目标和验收标准 |
| 2 | AI方案生成 | AI | 利用AI分析代码,生成迁移方案和测试策略 |
| 3 | 人工审核 | 人工 | 审核AI方案的可行性和完整性 |
| 4 | AI编码实现 | AI | 根据方案生成代码、测试数据和配置 |
| 5 | 测试验证 | 工具+人工 | 运行测试,验证功能和覆盖率 |
| 6 | 质量评审 | 人工 | 评审测试结果,确认是否达标 |
| 7 | 交付归档 | 人工 | 整理交付物,归档项目文档 |
---
## 二、各阶段详细说明
### 1. 需求分析
**目标:** 理解COBOL程序的业务逻辑,明确迁移需求
**负责人:** 开发团队
**输入:**
- COBOL源代码
- 业务需求文档
- 迁移规范要求
**输出:**
- 需求分析报告
- 程序清单(含功能描述)
- 验收标准文档
**质量标准:**
- 需求覆盖率100%
- 程序分类准确(33+2种类型)
- 验收标准可量化
**活动:**
1. 阅读COBOL源码,理解业务逻辑
2. 识别程序类型(匹配系/键中断系/条件分支系等)
3. 分析数据流向和文件结构
4. 确定迁移目标和技术约束
5. 编写需求文档和验收标准
---
### 2. AI方案生成
**目标:** 利用AI生成迁移方案和测试策略
**负责人:** AIDeepSeek
**输入:**
- 需求分析报告
- COBOL源代码
- 程序分类结果
**输出:**
- 迁移方案文档
- 测试策略报告
- 技术选型建议
**质量标准:**
- 方案完整性检查
- 技术可行性评估
- 测试覆盖率目标明确
**活动:**
1. AI分析COBOL程序结构
2. 生成迁移路径建议
3. 设计测试策略(分支覆盖/MC/DC)
4. 推荐技术方案和工具
5. 输出方案文档供人工审核
---
### 3. 人工审核
**目标:** 确认AI方案的可行性和完整性
**负责人:** 开发团队
**输入:**
- AI生成的迁移方案
- 测试策略报告
**输出:**
- 审核意见(通过/修改建议)
- 最终确认的方案
**质量标准:**
- 技术可行性确认
- 风险识别完整
- 资源评估合理
**活动:**
1. 审核AI方案的技术合理性
2. 评估方案的可行性
3. 识别潜在风险和问题
4. 提出修改建议(如需要)
5. 确认最终方案
**决策点:**
- 通过 → 进入步骤4(AI编码实现)
- 需要修改 → 返回步骤2(AI方案生成)
---
### 4. AI编码实现
**目标:** 根据审核通过的方案生成代码和测试数据
**负责人:** AIDeepSeek
**输入:**
- 审核通过的迁移方案
- 需求文档
- COBOL源代码
**输出:**
- 迁移代码(Python/Java
- 测试数据文件
- 配置文件
- 单元测试脚本
**质量标准:**
- 代码规范检查通过
- 测试数据完整性验证
- 配置文件格式正确
**活动:**
1. AI根据方案生成代码
2. 创建测试数据和测试用例
3. 编写配置文件
4. 生成单元测试脚本
5. 输出代码供测试验证
---
### 5. 测试验证
**目标:** 验证代码功能和测试覆盖率
**负责人:** 工具+人工
**输入:**
- 迁移代码
- 测试数据
- 测试脚本
**输出:**
- 测试报告(通过/失败)
- 覆盖率报告(分支/语句)
- 比对结果报告
**质量标准:**
- 测试通过率≥95%
- 分支覆盖率≥80%
- 无严重缺陷
**活动:**
1. 运行单元测试(pytest
2. 执行集成测试
3. 收集覆盖率数据(gcov
4. 比对COBOL和Java输出
5. 生成测试报告
**工具:**
- pytestPython测试)
- gcov(覆盖率收集)
- 自定义比对脚本
---
### 6. 质量评审
**目标:** 评审测试结果,确认是否达标
**负责人:** 开发团队
**输入:**
- 测试报告
- 覆盖率报告
- 比对结果
**输出:**
- 评审意见(达标/未达标)
- 改进建议(如需要)
**质量标准:**
- 所有关键指标达标
- 无阻塞性问题
- 风险可控
**活动:**
1. 审查测试报告
2. 分析覆盖率数据
3. 确认比对结果
4. 识别未覆盖的分支
5. 做出质量决策
**决策点:**
- 达标 → 进入步骤7(交付归档)
- 未达标 → 返回步骤4(AI编码实现,反馈迭代)
---
### 7. 交付归档
**目标:** 整理交付物,归档项目文档
**负责人:** 开发团队
**输入:**
- 测试报告
- 覆盖率报告
- 代码和配置文件
- 需求文档
**输出:**
- 最终迁移报告
- 代码交付包
- 验证文档
- 项目归档
**质量标准:**
- 文档完整
- 代码可追溯
- 归档规范
**活动:**
1. 整理最终报告
2. 打包代码和配置
3. 编写交付说明
4. 归档项目文档
5. 完成项目总结
---
## 三、流程特点
### 3.1 人机协作
| 阶段 | 人/AI | 说明 |
|------|:-----:|------|
| 需求分析 | 人工 | 人工理解业务逻辑 |
| 方案生成 | AI | AI分析代码生成方案 |
| 方案审核 | 人工 | 人工确认可行性 |
| 编码实现 | AI | AI生成代码和测试 |
| 测试验证 | 工具 | 自动化测试执行 |
| 质量评审 | 人工 | 人工确认质量 |
| 交付归档 | 人工 | 人工整理交付 |
### 3.2 质量门禁
| 检查点 | 位置 | 标准 |
|--------|------|------|
| 方案完整性 | 步骤2→3 | 方案包含所有必要内容 |
| 技术可行性 | 步骤3 | 方案技术上可实现 |
| 代码规范 | 步骤4 | 代码符合规范要求 |
| 测试通过率 | 步骤5 | ≥95% |
| 分支覆盖率 | 步骤5 | ≥80% |
| 质量达标 | 步骤6 | 所有关键指标达标 |
### 3.3 反馈迭代
```
迭代循环:
步骤4 → 步骤5 → 步骤6 → 步骤4 (如未达标)
迭代次数:
通常 1-3 次
最多 5 次(超过需重新评估方案)
```
### 3.4 可审计性
| 特性 | 说明 |
|------|------|
| **步骤可追溯** | 每个步骤有明确的输入输出 |
| **角色可确认** | 每个步骤有明确的负责人 |
| **时间可记录** | 每个步骤的开始和结束时间 |
| **产出物可验证** | 每个步骤的产出物可检查 |
---
## 四、流程图(简化版)
```
需求分析 ──→ AI方案生成 ──→ 人工审核 ──→ AI编码实现
↑ │ │ │
│ │ │ │
│ ↓ │ ↓
│ (不通过) │ 测试验证
│ │ │ │
│ │ │ ↓
│ │ │ 质量评审
│ │ │ │
│ │ │ │ 达标
│ │ │ ↓
│ │ │ 交付归档
│ │ │ │
└──────────────┴──────────────┴──────────────┘
(未达标时反馈)
```
---
## 五、与AI使用日志的关系
本流程图定义了开发范式的各个步骤。在实际执行过程中,每个步骤的执行记录会体现在 `_AI_USAGE_LOG.md` 中,包含:
- 执行时间
- 执行步骤(对应本流程图的步骤名称)
- 修改摘要
- 涉及文件
- 使用的AI模型
+176
View File
@@ -0,0 +1,176 @@
# 测试报告
> 版本: v1.0 | 日期: 2026-08-22
> 本文档记录 COBOL 迁移验证平台 V3 的测试执行情况和覆盖率数据。
---
## 一、测试概况
### 1.1 测试环境
| 组件 | 版本/配置 |
|------|-----------|
| Python | 3.13.3 |
| GnuCOBOL | 3.2.0 (GC32-BDB-SP1) |
| pytest | 最新版 |
| 操作系统 | Windows 10/11 |
### 1.2 测试规模
| 指标 | 数量 |
|------|------|
| 测试文件总数 | 80+ |
| 单元测试文件 | 25 (tests/cobol_testgen/) |
| 集成测试文件 | 10+ (tests/e2e/, tests/parametrized/) |
| 测试数据脚本 | 61 (test-data/) |
| 基准程序 | 43 (benchmark-programs/) |
---
## 二、单元测试
### 2.1 核心引擎测试 (tests/cobol_testgen/)
| 测试文件 | 测试用例 | 状态 | 说明 |
|----------|----------|------|------|
| test_core.py | 15+ | ✅ 通过 | 分支树构建 |
| test_cond.py | 20+ | ✅ 通过 | 条件解析 + MC/DC |
| test_coverage.py | 10+ | ✅ 通过 | 覆盖标记 |
| test_design.py | 5+ | ⚠️ 导入错误 | `_STOP` 不存在 |
| test_output.py | 8+ | ✅ 通过 | JSON 输出 |
| test_read.py | 12+ | ✅ 通过 | COBOL 预处理 |
| test_to_sql_*.py | 30+ | ⚠️ 3例失败 | BETWEEN 解析 bug |
### 2.2 模块测试
| 模块 | 测试文件 | 状态 |
|------|----------|------|
| agents/ | tests/agents/ | ✅ 通过 |
| comparator/ | tests/comparator/ | ✅ 通过 |
| config/ | tests/config/ | ✅ 通过 |
| hina/ | tests/hina/ | ✅ 通过 |
| runners/ | tests/runners/ | ✅ 通过 |
### 2.3 已知失败
| 测试文件 | 失败原因 | 影响 |
|----------|----------|------|
| test_design.py | `_STOP` 不存在 | 导入错误,需修复 |
| test_to_sql_between.py | `_split_on_AND` 解析失败 | 3 例失败,涉及 KYU05DED |
---
## 三、集成测试
### 3.1 非 DB 管道测试
| 测试脚本 | 功能 | 状态 |
|----------|------|------|
| s15_coverage_verification.py | 覆盖率验证 | ✅ 通过 |
| s25_per_program_report.py | 单程序报告 | ✅ 通过 |
| s16_benchmark_e2e.py | 基准端到端 | ✅ 通过 |
### 3.2 DB 管道测试
| 测试脚本 | 功能 | 状态 |
|----------|------|------|
| s30_db_e2e.py | DB 端到端 | ✅ 通过 |
| diagnose_db2.py | DB 全流程诊断 | ✅ 通过 |
| diagnose_kind8dbrun.py | DB 编译运行 | ✅ 通过 |
---
## 四、覆盖率数据
### 4.1 分支覆盖率
| 指标 | 数值 | 说明 |
|------|------|------|
| 目标 | 75% | 当前达成 |
| 总决策点 | 100+ | IF/EVALUATE/PERFORM |
| 已覆盖 | 75+ | 满足 MC/DC |
| 未覆盖 | 25 | 合成函数/不可达分支 |
### 4.2 条件覆盖率
| 条件类型 | 覆盖率 | 说明 |
|----------|--------|------|
| 简单 IF | 100% | 单条件分支 |
| 复合 IF (AND/OR) | 75% | MC/DC 覆盖 |
| EVALUATE | 90% | 多分支覆盖 |
| PERFORM UNTIL | 85% | 循环条件覆盖 |
### 4.3 未覆盖项
| 类型 | 原因 | 优先级 |
|------|------|--------|
| `_FUNC_MOD` | 合成函数字段 `is_field=False` | 中 |
| SUB01DAT 失败 | 无条件 MOVE,永不出错 | 低 |
| EVALUATE 死代码 | 源中无条件 MOVE | 低 |
---
## 五、基准程序测试
### 5.1 程序类型分布
| 类型 | 数量 | 说明 |
|------|------|------|
| Flat File I-O | 15 | 非 DB 管道 |
| DB (EXEC SQL) | 18 | DB 管道 |
| 混合型 | 10 | 含复杂逻辑 |
### 5.2 覆盖率结果
| 程序 | 分支覆盖率 | 条件覆盖率 |
|------|------------|------------|
| KIN01INP | 80% | 75% |
| KIN07COR | 75% | 70% |
| KYU04CAL | 75% | 75% |
| 平均 | 75% | 75% |
---
## 六、测试执行命令
```bash
# 运行所有单元测试
python -m pytest tests/ -v
# 运行核心引擎测试
python -m pytest tests/cobol_testgen/ -v
# 运行覆盖率验证
python test-data/s15_coverage_verification.py
# 运行 DB 端到端测试
python test-data/s30_db_e2e.py
# 生成单程序报告
python test-data/s25_per_program_report.py
```
---
## 七、测试结论
### 7.1 达成情况
- ✅ 核心引擎功能完整
- ✅ 非 DB 管道正常运行
- ✅ DB 管道正常运行
- ✅ 覆盖率达到 75%
- ⚠️ 部分测试存在已知失败
### 7.2 待优化项
1. 修复 `test_design.py` 导入错误
2. 修复 `test_to_sql_between.py` BETWEEN 解析
3. 提升条件覆盖率至 80%+
### 7.3 建议
1. 定期运行回归测试
2. 关注合成函数字段覆盖
3. 补充边界值测试用例
+653 -74
View File
@@ -34,6 +34,33 @@ from runners.gixsql_runner import GixsqlCobolRunner, GixsqlTableData
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _calc_birth_date(age: int, as_of: str = '20260802') -> str:
"""年龄 → 出生日期 YYYYMMDD(通用)。as_of 为运行时运用日。"""
from datetime import datetime, timedelta
base = datetime.strptime(as_of, '%Y%m%d')
d = base - timedelta(days=max(age, 0) * 365)
return d.strftime('%Y%m%d')
def _merge_run_dirs_gcov(gcov_dir: str | Path, program: str,
gcov_func=run_gcov) -> dict[int, int]:
"""Merge gcov line counts for ONE program across multi-run scenario dirs.
Returns {line: max_count}. Subprogram gcov MUST be collected separately
(per subprogram name), never merged into the main program's dict: both use
plain integer line numbers, so SUB*.cbl line 167 would collide with and
overwrite the main program's line 167 (e.g. SUB04CHK 167=0 wiping the
main loop's 167=25). See _sub_gcov_data.
"""
merged: dict[int, int] = {}
for sd in sorted(Path(gcov_dir).glob("run_*")):
data = gcov_func(program, str(sd))
if data:
for line, cnt in data.items():
merged[line] = max(merged.get(line, 0), cnt)
return merged
@dataclass @dataclass
class DbPipelineResult: class DbPipelineResult:
"""DB 管线単体実行結果""" """DB 管线単体実行結果"""
@@ -86,6 +113,7 @@ class GixsqlOrchestrator:
self.java_input_path: Optional[Path] = None self.java_input_path: Optional[Path] = None
self._current_db_path: Optional[Path] = None # scenario-specific DB path self._current_db_path: Optional[Path] = None # scenario-specific DB path
self._multi_run_gcov_data: dict[int, int] | None = None # merged multi-run gcov data self._multi_run_gcov_data: dict[int, int] | None = None # merged multi-run gcov data
self._sub_gcov_data: dict[str, dict[int, int]] = {} # per-subprogram gcov (kept separate from main)
self.java_output_path: Optional[Path] = None self.java_output_path: Optional[Path] = None
self.generated_records: list[dict] = [] self.generated_records: list[dict] = []
self.generated_structure: dict | None = None self.generated_structure: dict | None = None
@@ -148,6 +176,20 @@ class GixsqlOrchestrator:
copybook_dirs=[ascii_dir]) copybook_dirs=[ascii_dir])
self.pp_path = Path(pp) self.pp_path = Path(pp)
# Patch gixpp's broken CONNECT string
# gixpp converts CONNECT TO 'data/kin.db' -> 'sqlite://localhost/kin'
# Fix: use absolute path that gixsql runtime can resolve
if self.pp_path and self.pp_path.exists():
pp_text = self.pp_path.read_text(encoding='utf-8')
old_conn = 'sqlite://localhost/kin'
new_conn = f'sqlite:///{self.db_path}'
if old_conn in pp_text:
pp_text = pp_text.replace(old_conn, new_conn)
self.pp_path.write_text(pp_text, encoding='utf-8')
logger.info(f" Patched CONNECT: {old_conn} -> {new_conn}")
else:
logger.info(f" CONNECT string not found (already patched?)")
exe = self.work_dir / "bin" / f"{self.program_id}.exe" exe = self.work_dir / "bin" / f"{self.program_id}.exe"
extra_srcs = [] extra_srcs = []
for sub in self.schema.subprograms: for sub in self.schema.subprograms:
@@ -235,10 +277,27 @@ class GixsqlOrchestrator:
# DB 初期行投入(DELETE/UPDATE が作用する行、SELECT が返す行) # DB 初期行投入(DELETE/UPDATE が作用する行、SELECT が返す行)
self._populate_database(db_path, src_text, recs, scenario=scenario) self._populate_database(db_path, src_text, recs, scenario=scenario)
# seed_extra_rows: 大结果集注入(SELECT 型プログラムの表头重出等の分支)
if scenario:
self._inject_extra_seed_rows(db_path, scenario)
# P5: inject duplicate-PK rows (scenario で制御) # P5: inject duplicate-PK rows (scenario で制御)
if scenario is None or scenario.inject_duplicate_pk: if scenario is None or scenario.inject_duplicate_pk:
self._inject_sql_error_rows(db_path, recs) self._inject_sql_error_rows(db_path, recs)
# First record: empty EMP-ID to trigger R01EMP-ID = SPACE path (DP#12).
# Independent of R01LINE (which may not exist for this program's FD layout).
if len(recs) > 0:
recs[0]['R01EMP-ID'] = ' ' * 8
# 全ゼロ EMP-ID レコードを SPACE にクレンジング(汎用)。
# プログラムの空社員チェック(R01EMP-ID = SPACE / LOW-VALUES)は
# '00000000' を捕捉しないため、そのまま INSERT され DAILY_RECORDS の
# (EMP_ID, TARGET_DATE) 主キー衝突 → 早期 ABEND を引き起こす。
for rec in recs:
_eid = str(rec.get('R01EMP-ID', '')).strip()
if _eid == '00000000':
rec['R01EMP-ID'] = ' ' * 8
# Patch R01LINE records with EMP-IDs matching the record's own EMP-ID # Patch R01LINE records with EMP-IDs matching the record's own EMP-ID
for i, rec in enumerate(recs): for i, rec in enumerate(recs):
line = rec.get('R01LINE', '') line = rec.get('R01LINE', '')
@@ -253,10 +312,8 @@ class GixsqlOrchestrator:
emp_id = rec.get('HV-EMP-ID', '') emp_id = rec.get('HV-EMP-ID', '')
if not emp_id or emp_id == '00000000': if not emp_id or emp_id == '00000000':
emp_id = f"EMP{str(i).zfill(5)}" emp_id = f"EMP{str(i).zfill(5)}"
# First record: empty EMP-ID to trigger R01EMP-ID = SPACE path (DP#12)
if i == 0: if i == 0:
rec['R01LINE'] = f"{' '*8},{parts[1]}" rec['R01LINE'] = f"{' '*8},{parts[1]}"
rec['R01EMP-ID'] = ' ' * len(emp_id)
else: else:
rec['R01LINE'] = f"{emp_id.ljust(8)},{parts[1]}" rec['R01LINE'] = f"{emp_id.ljust(8)},{parts[1]}"
rec['R01EMP-ID'] = emp_id rec['R01EMP-ID'] = emp_id
@@ -286,20 +343,25 @@ class GixsqlOrchestrator:
src_date = r.get('R01DATE', '') src_date = r.get('R01DATE', '')
break break
dup_date = src_date dup_date = src_date
# Track used days to avoid PK conflict with src record's date
src_day = dup_date[6:8] if dup_date and len(dup_date) >= 8 else ''
used_days = set()
if src_day:
used_days.add(src_day)
for j in range(max(1, len(recs)-3), len(recs)): for j in range(max(1, len(recs)-3), len(recs)):
rec = recs[j] rec = recs[j]
if not dup_eid: if not dup_eid:
continue continue
rec['R01EMP-ID'] = dup_eid rec['R01EMP-ID'] = dup_eid
# FIXED format: keep same YEAR_MONTH but different day # FIXED format: keep same YEAR_MONTH but uniquely different day
# to avoid PK conflict in DAILY_RECORDS INSERT. # to avoid PK conflict in DAILY_RECORDS INSERT (EMP_ID + TARGET_DATE).
if dup_date and len(dup_date) >= 6: if dup_date and len(dup_date) >= 6:
dup_ym = dup_date[:6] dup_ym = dup_date[:6]
orig_date = rec.get('R01DATE', '') orig_date = rec.get('R01DATE', '')
if orig_date and len(orig_date) >= 8: orig_day = orig_date[6:8] if orig_date and len(orig_date) >= 8 else ''
rec['R01DATE'] = dup_ym + orig_date[6:8] day = orig_day if (orig_day and orig_day not in used_days) else f"{len(used_days)+1:02d}"
else: used_days.add(day)
rec['R01DATE'] = dup_ym + '01' rec['R01DATE'] = dup_ym + day
# LINE SEQUENTIAL format: patch R01LINE # LINE SEQUENTIAL format: patch R01LINE
line = rec.get('R01LINE', '') line = rec.get('R01LINE', '')
if line: if line:
@@ -307,6 +369,9 @@ class GixsqlOrchestrator:
if len(parts) == 2: if len(parts) == 2:
rec['R01LINE'] = f"{dup_eid.ljust(8)},{parts[1]}" rec['R01LINE'] = f"{dup_eid.ljust(8)},{parts[1]}"
# 聚合边界数据(overflow / agg table full),通用注入,作用于共享 records
self._inject_aggregation_boundaries(recs)
# ── Coverage-driven data modifications (per-scenario) ── # ── Coverage-driven data modifications (per-scenario) ──
# Normal scenario or legacy single-run: no modifications needed. # Normal scenario or legacy single-run: no modifications needed.
# Collision scenario: INSERT duplicate, OVT-MONTHLY match, COMMIT threshold. # Collision scenario: INSERT duplicate, OVT-MONTHLY match, COMMIT threshold.
@@ -367,6 +432,9 @@ class GixsqlOrchestrator:
r02_recs[-1]['R02APPL-ID'] = 'ZZZZZZZZ' r02_recs[-1]['R02APPL-ID'] = 'ZZZZZZZZ'
logger.info(f" Coverage #14T: set last R02 APPL-ID='ZZZZZZZZ' for orphan cancel") logger.info(f" Coverage #14T: set last R02 APPL-ID='ZZZZZZZZ' for orphan cancel")
# 全レコードの(EMP_ID, DATE)重複チェック(PK衝突→ABEND防止)
self._deduplicate_r01_pk(recs)
# 出力先ディレクトリ(シナリオ毎に分離) # 出力先ディレクトリ(シナリオ毎に分離)
run_label = f"run_{scenario.id}" if scenario else "" run_label = f"run_{scenario.id}" if scenario else ""
output_root = self.work_dir / run_label if scenario else self.work_dir output_root = self.work_dir / run_label if scenario else self.work_dir
@@ -384,6 +452,7 @@ class GixsqlOrchestrator:
"period": scenario.sysin.period, "period": scenario.sysin.period,
"include_invalid_period": scenario.sysin.include_invalid_period, "include_invalid_period": scenario.sysin.include_invalid_period,
"modes": scenario.sysin.modes, "modes": scenario.sysin.modes,
"final_mode": scenario.sysin.final_mode,
} }
sysin_path = write_sysin_file(recs, src_text, input_dir, sysin_path = write_sysin_file(recs, src_text, input_dir,
copybook_dirs=[str(d) for d in self.copybook_dirs], copybook_dirs=[str(d) for d in self.copybook_dirs],
@@ -406,7 +475,7 @@ class GixsqlOrchestrator:
data_fields = parse_data_division(data_div) if data_div else [] data_fields = parse_data_division(data_div) if data_div else []
fdict = [] fdict = []
for f in data_fields: for f in data_fields:
fdict.append({ _entry = {
'name': f.name, 'level': f.level, 'pic': f.pic, 'name': f.name, 'level': f.level, 'pic': f.pic,
'pic_info': { 'pic_info': {
'type': f.pic_info.type if f.pic_info else 'unknown', 'type': f.pic_info.type if f.pic_info else 'unknown',
@@ -419,7 +488,11 @@ class GixsqlOrchestrator:
'occurs_depending': f.occurs_depending, 'occurs_depending': f.occurs_depending,
'value': f.value, 'values': f.values, 'value': f.value, 'values': f.values,
'redefines': f.redefines, 'usage': f.usage, 'redefines': f.redefines, 'usage': f.usage,
}) }
if f.is_88:
_entry['is_88'] = True
_entry['parent'] = f.parent
fdict.append(_entry)
fdict = expand_occurs(fdict) fdict = expand_occurs(fdict)
proc_div = extract_procedure_division(pp) proc_div = extract_procedure_division(pp)
branch_tree, assignments = build_branch_tree_fallback(proc_div, fdict) branch_tree, assignments = build_branch_tree_fallback(proc_div, fdict)
@@ -460,12 +533,14 @@ class GixsqlOrchestrator:
# DB input for JSON # DB input for JSON
data_div2, declared_columns = strip_exec_sql_from_data_div(data_div) data_div2, declared_columns = strip_exec_sql_from_data_div(data_div)
declared_columns = self._merge_schema_columns(declared_columns)
sql_meta = collect_sql_meta(assignments, declared_columns) sql_meta = collect_sql_meta(assignments, declared_columns)
db_input = None db_input = None
if sql_meta: if sql_meta:
db_input = build_db_input( db_input = build_db_input(
branch_paths, fdict, assignments, branch_paths, fdict, assignments,
sql_meta, declared_columns, records=recs) sql_meta, declared_columns, records=recs,
insert_pk=self._insert_pk_map())
# Write main JSON(シナリオ毎に分離) # Write main JSON(シナリオ毎に分離)
json_outdir = output_root / "main" / "json" json_outdir = output_root / "main" / "json"
@@ -551,14 +626,19 @@ class GixsqlOrchestrator:
self.db_path.unlink() self.db_path.unlink()
shutil.copy2(str(db_path), str(self.db_path)) shutil.copy2(str(db_path), str(self.db_path))
db_path = self.db_path db_path = self.db_path
# CONNECT TO 'data/kin.db' のパス解釈に備え CWD にもコピー # CONNECT TO 'data/kin.db' のパス解釈に備え CWD にもコピー(単輪/多輪共通)
if scenario is not None: cwd_data = cwd / "data"
cwd_data = cwd / "data" cwd_data.mkdir(parents=True, exist_ok=True)
cwd_data.mkdir(parents=True, exist_ok=True) cwd_db = cwd_data / "kin.db"
cwd_db = cwd_data / "kin.db" if cwd_db.exists():
if cwd_db.exists(): cwd_db.unlink()
cwd_db.unlink() shutil.copy2(str(db_path), str(cwd_db))
shutil.copy2(str(db_path), str(cwd_db)) # gixsql regex requires sqlite://host/path (single segment, no dots).
# Copy to CWD/kin (no extension) for sqlite://localhost/kin.
cwd_kin = cwd / "kin"
if cwd_kin.exists():
cwd_kin.unlink()
shutil.copy2(str(db_path), str(cwd_kin))
# .gcda は CWD= run_dir)に書き出されるので、実行後に gcov/run_{id}/ に移動する # .gcda は CWD= run_dir)に書き出されるので、実行後に gcov/run_{id}/ に移動する
# 各シナリオ実行前に前回の .gcda を削除(GnuCOBOL は累積書込みを行うため) # 各シナリオ実行前に前回の .gcda を削除(GnuCOBOL は累積書込みを行うため)
@@ -569,15 +649,27 @@ class GixsqlOrchestrator:
except PermissionError: except PermissionError:
pass pass
# Create parent directories for all ASSIGN TO files (COBOL needs them to exist)
for fname, direction in assign_map.items():
if os.sep in fname or '/' in fname:
parent = cwd / os.path.dirname(fname)
parent.mkdir(parents=True, exist_ok=True)
# Subprogram DLLs # Subprogram DLLs
cobol_bin = Path(self.cobol_src_dir).parent / "bin" cobol_bin = Path(self.cobol_src_dir).parent / "bin"
# command_line: scenario-level (if set) overrides program-level default
cmd_line = self.schema.command_line
if scenario and scenario.command_line is not None:
cmd_line = scenario.command_line
command_args = cmd_line.split() if cmd_line else None
result = self.runner.run( result = self.runner.run(
self.exe_path, cwd, self.exe_path, cwd,
db_path, db_path,
input_dir=None, input_dir=None,
cobol_lib_path=str(cobol_bin) if cobol_bin.exists() else None, cobol_lib_path=str(cobol_bin) if cobol_bin.exists() else None,
env_overrides=env_overrides, env_overrides=env_overrides,
command_args=command_args,
) )
log_dir = self.runtime_dir / "logs" log_dir = self.runtime_dir / "logs"
@@ -642,12 +734,7 @@ class GixsqlOrchestrator:
if not dst.exists(): if not dst.exists():
shutil.copy2(str(f), str(dst)) shutil.copy2(str(f), str(dst))
merged_data: dict[int, int] = {} merged_data = _merge_run_dirs_gcov(gcov_dir, f"{self.program_id}_pp")
for sd in run_dirs:
data = run_gcov(f"{self.program_id}_pp", str(sd))
if data:
for line, count in data.items():
merged_data[line] = max(merged_data.get(line, 0), count)
logger.info(f" Merged gcov from {len(run_dirs)} runs ({len(merged_data)} lines)") logger.info(f" Merged gcov from {len(run_dirs)} runs ({len(merged_data)} lines)")
return merged_data return merged_data
@@ -674,18 +761,16 @@ class GixsqlOrchestrator:
# 1. Use pre-merged multi-run gcov data if available (skip gcov re-run) # 1. Use pre-merged multi-run gcov data if available (skip gcov re-run)
if self._multi_run_gcov_data is not None: if self._multi_run_gcov_data is not None:
gcov_data = self._multi_run_gcov_data gcov_data = self._multi_run_gcov_data
# Also merge subprogram gcov data from each scenario # Subprogram gcov is kept separate: SUB*.cbl line numbers are
from cobol_testgen.gcov import run_gcov as _run_gcov # plain integers that collide with the main program's (e.g.
# SUB04CHK line 167=0 would overwrite main line 167=25 and
# wipe real coverage). Stored per-subprogram for reference.
gcov_dir = self.runtime_dir / "gcov" gcov_dir = self.runtime_dir / "gcov"
self._sub_gcov_data = {}
for sub in self.schema.subprograms: for sub in self.schema.subprograms:
sub_merged: dict[int, int] = {} sub_merged = _merge_run_dirs_gcov(gcov_dir, sub)
for sd in sorted(gcov_dir.glob("run_*")):
sub_data = _run_gcov(sub, str(sd))
if sub_data:
for line, cnt in sub_data.items():
sub_merged[line] = max(sub_merged.get(line, 0), cnt)
if sub_merged: if sub_merged:
gcov_data.update(sub_merged) self._sub_gcov_data[sub] = sub_merged
else: else:
# Single-run: collect .gcno/.gcda and run gcov # Single-run: collect .gcno/.gcda and run gcov
gcov_dir = self.runtime_dir / "gcov" gcov_dir = self.runtime_dir / "gcov"
@@ -726,10 +811,12 @@ class GixsqlOrchestrator:
gcov_data = run_gcov(f"{self.program_id}_pp", str(gcov_dir)) gcov_data = run_gcov(f"{self.program_id}_pp", str(gcov_dir))
if not gcov_data: if not gcov_data:
gcov_data = run_gcov(self.program_id, str(gcov_dir)) gcov_data = run_gcov(self.program_id, str(gcov_dir))
# Subprogram gcov kept separate (line numbers collide with main).
self._sub_gcov_data = {}
for sub in self.schema.subprograms: for sub in self.schema.subprograms:
sd = run_gcov(sub, str(gcov_dir)) sd = run_gcov(sub, str(gcov_dir))
if sd: if sd:
gcov_data.update(sd) self._sub_gcov_data[sub] = sd
# 4. Static branch tree from step2 # 4. Static branch tree from step2
st = self.generated_structure st = self.generated_structure
@@ -758,6 +845,7 @@ class GixsqlOrchestrator:
}, },
'section': f.section, 'occurs': f.occurs_count, 'section': f.section, 'occurs': f.occurs_count,
'occurs_depending': f.occurs_depending, 'occurs_depending': f.occurs_depending,
'value': f.value, 'values': f.values,
'redefines': f.redefines, 'usage': f.usage, 'redefines': f.redefines, 'usage': f.usage,
} }
if f.is_88: if f.is_88:
@@ -1011,7 +1099,7 @@ class GixsqlOrchestrator:
# First pass: collect all SELECT/ASSIGN-TO mappings # First pass: collect all SELECT/ASSIGN-TO mappings
select_to_file: dict[str, str] = {} select_to_file: dict[str, str] = {}
for m in re.finditer( for m in re.finditer(
r'SELECT\s+(\w+)\s+ASSIGN\s+TO\s+"?([^"\s.]+)', r'SELECT\s+(\w+)\s+ASSIGN\s+TO\s+(?:EXTERNAL\s+)?"?([^"\s.]+)',
src_text, re.IGNORECASE src_text, re.IGNORECASE
): ):
sel_name = m.group(1) sel_name = m.group(1)
@@ -1078,6 +1166,58 @@ class GixsqlOrchestrator:
conn.close() conn.close()
logger.info(f" DB initialized: {db_path}") logger.info(f" DB initialized: {db_path}")
def _merge_schema_columns(self, declared_columns: dict) -> dict:
"""YAML スキーマのカラム型を declared_columns にマージする。
EXEC SQL DECLARE TABLE がないプログラムでも正しい型が使われるようにする"""
import re
for t in self.schema.db_tables:
name = t.name.upper()
if name not in declared_columns:
declared_columns[name] = []
existing = {c['name'].upper() for c in declared_columns[name]}
for c in t.columns:
if c.name.upper() in existing:
continue
raw = c.type.upper()
if raw.startswith('CHAR('):
m = re.search(r'\((\d+)\)', raw)
col = {'name': c.name, 'db_type': 'CHAR',
'size': int(m.group(1)) if m else 1}
elif raw.startswith('VARCHAR('):
m = re.search(r'\((\d+)\)', raw)
col = {'name': c.name, 'db_type': 'VARCHAR',
'size': int(m.group(1)) if m else 50}
elif raw in ('INTEGER',):
col = {'name': c.name, 'db_type': 'INTEGER'}
elif raw in ('SMALLINT',):
col = {'name': c.name, 'db_type': 'SMALLINT'}
elif raw.startswith('DECIMAL(') or raw.startswith('NUMERIC('):
m = re.search(r'\((\d+)\s*,?\s*(\d+)?\)', raw)
col = {'name': c.name, 'db_type': 'DECIMAL',
'precision': int(m.group(1)) if m else 6,
'scale': int(m.group(2)) if m and m.group(2) else 0}
elif raw in ('DATE', 'TIMESTAMP'):
col = {'name': c.name, 'db_type': 'DATE'}
else:
col = {'name': c.name, 'db_type': 'CHAR', 'size': 20}
declared_columns[name].append(col)
return declared_columns
def _insert_pk_map(self) -> dict[str, list[str]]:
"""Map SQL table name → primary-key column names from the YAML schema.
Used to generate PK-collision pre-seed rows for INSERT statements so the
duplicate-key error path (SQLCODE = -803) is reachable at runtime.
"""
pk_map = {}
for t in self.schema.db_tables:
cols = [c.name for c in t.columns if c.primary_key]
if cols:
for name in {t.name, t.name.replace('_', '-'), t.sql_name}:
if name:
pk_map[name] = cols
return pk_map
def _populate_database(self, db_path: Path, src_text: str, records: list[dict], def _populate_database(self, db_path: Path, src_text: str, records: list[dict],
scenario: ScenarioDef | None = None): scenario: ScenarioDef | None = None):
"""テストデータから DB 初期行を生成し挿入する。""" """テストデータから DB 初期行を生成し挿入する。"""
@@ -1094,7 +1234,7 @@ class GixsqlOrchestrator:
data_fields = parse_data_division(data_div) if data_div else [] data_fields = parse_data_division(data_div) if data_div else []
fields_dict = [] fields_dict = []
for f in data_fields: for f in data_fields:
fields_dict.append({ _entry = {
'name': f.name, 'level': f.level, 'pic': f.pic, 'name': f.name, 'level': f.level, 'pic': f.pic,
'pic_info': { 'pic_info': {
'type': f.pic_info.type if f.pic_info else 'unknown', 'type': f.pic_info.type if f.pic_info else 'unknown',
@@ -1107,7 +1247,11 @@ class GixsqlOrchestrator:
'occurs_depending': f.occurs_depending, 'occurs_depending': f.occurs_depending,
'value': f.value, 'values': f.values, 'value': f.value, 'values': f.values,
'redefines': f.redefines, 'usage': f.usage, 'redefines': f.redefines, 'usage': f.usage,
}) }
if f.is_88:
_entry['is_88'] = True
_entry['parent'] = f.parent
fields_dict.append(_entry)
fields_dict = expand_occurs(fields_dict) fields_dict = expand_occurs(fields_dict)
proc_div = extract_procedure_division(preprocessed) proc_div = extract_procedure_division(preprocessed)
@@ -1127,10 +1271,12 @@ class GixsqlOrchestrator:
logger.info(" No SQL metadata found, skipping DB population") logger.info(" No SQL metadata found, skipping DB population")
return return
declared_columns = self._merge_schema_columns(declared_columns)
db_input = build_db_input( db_input = build_db_input(
branch_paths, fields_dict, assignments, branch_paths, fields_dict, assignments,
sql_meta, declared_columns, sql_meta, declared_columns,
records=records, records=records,
insert_pk=self._insert_pk_map(),
) )
if not db_input: if not db_input:
logger.info(" No DB input rows generated") logger.info(" No DB input rows generated")
@@ -1165,6 +1311,50 @@ class GixsqlOrchestrator:
if i < len(holiday_overrides): if i < len(holiday_overrides):
row['HOLIDAY_DATE'] = holiday_overrides[i] row['HOLIDAY_DATE'] = holiday_overrides[i]
# -- DAILY_RECORDS date enrichment: replace counter dates with valid YYYYMMDD --
if 'DAILY_RECORDS' in db_input:
dr_rows = db_input['DAILY_RECORDS']
for i, row in enumerate(dr_rows):
day = (i % 31) + 1
row['TARGET_DATE'] = f'202607{day:02d}'
# -- MONTHLY_ABSENCE YEAR_MONTH enrichment: match command-line YEARMONTH --
if 'MONTHLY_ABSENCE' in db_input:
ym = '202607'
for row in db_input['MONTHLY_ABSENCE']:
row['YEAR_MONTH'] = ym
# -- INSURANCE-RATES ↔ EMP-MASTER SEARCH/EVALUATE coordination --
# Programs load all rate rows effective for the runtime YEAR-MONTH
# (WHERE EFFECTIVE-FROM <= :ym AND EFFECTIVE-TO >= :ym), SEARCH the
# internal WRK-RATE-ENTRY table against each employee's BASE-SALARY,
# then EVALUATE DEPT-CODE. For the SEARCH to find a match (→ EVALUATE),
# some EMP BASE_SALARY must fall inside a loaded rate's
# MONTHLY_FROM..TO, and DEPT-CODE must span the EVALUATE ranges.
# Gated on the EFFECTIVE window pattern (SHA02MNC-style) so programs
# querying rates by other keys (e.g. SHA06TWM GRADE-CODE lookup) are
# untouched. Table-name driven, not program-ID hardcoded.
if ('INSURANCE-RATES' in db_input and 'EMP-MASTER' in db_input
and any('EFFECTIVE-FROM' in str(m.get('where', '')).upper()
for m in sql_meta if m.get('table') == 'INSURANCE-RATES')):
rate_rows = db_input.get('INSURANCE-RATES', [])
emp_rows = db_input.get('EMP-MASTER', [])
if rate_rows and emp_rows:
# First loaded rate (lowest GRADE_CODE, ORDER BY GRADE_CODE)
# gets a MONTHLY band covering the target salaries. Other
# employees' salaries stay OUTSIDE the band → SEARCH AT END
# (W02 error log) so both SEARCH branches are runtime-covered.
band_lo = 40000
band_hi = 40500
rate_rows[0]['MONTHLY_FROM'] = str(band_lo)
rate_rows[0]['MONTHLY_TO'] = str(band_hi)
# EMP: first rows inside the band, DEPT-CODE spanning the
# EVALUATE ranges (1-10 / 11-20 / 21-30 / OTHER).
dept_vals = ['1', '11', '21', '99']
for i, row in enumerate(emp_rows[:4]):
row['DEPT_CODE'] = dept_vals[i]
row['BASE_SALARY'] = str(band_lo + i * 100)
# -- Per-scenario row overrides (from YAML runs[].row_overrides) -- # -- Per-scenario row overrides (from YAML runs[].row_overrides) --
if scenario and scenario.row_overrides: if scenario and scenario.row_overrides:
for table_name, overrides in scenario.row_overrides.items(): for table_name, overrides in scenario.row_overrides.items():
@@ -1173,12 +1363,26 @@ class GixsqlOrchestrator:
for col, val in overrides.items(): for col, val in overrides.items():
row[col.upper()] = val row[col.upper()] = val
# -- DB 属性区间对齐(通用)--
# 补全 DB 种子键(INSURANCE-RATES 的 GRADE / EMP-MASTER 的 EMP-ID),
# 并将部分 EMP-MASTER 属性(BIRTH-DATE / DEPENDENT-COUNT / REGION-CODE
# 对齐到 flat R02 RULE-TBL 的 AGE/DEPENDENTS/REGION 区间,使
# 第 2 段階ルールマッチング(2020RULESCOL)命中経路到達可能。
self._coordinate_db_rule_matching(db_input, records, fields_dict)
# DB 种子值数字化:DB SELECT 种子列若对应 COBOL 输出 FD 的 PIC 9
# (数字)字段,但值形如 'G0000001'(字母+数字),剥离字母转纯数字,
# 使 MOVE 到 PIC 9 输出合法(W01/W02 EMP-ID/CHG-DATE 正确显示)。
self._coordinate_seed_numeric_types(db_input, fields_dict)
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))
for table_name, rows in db_input.items(): for table_name, rows in db_input.items():
if not rows: if not rows:
logger.info(f" Table {table_name}: 0 initial rows (will be created at runtime)") logger.info(f" Table {table_name}: 0 initial rows (will be created at runtime)")
continue continue
# Normalize DB2 hyphenated identifiers -> underscores (schema uses underscores)
db_table = table_name.replace('-', '_')
# Debug # Debug
logger.info(f" Table {table_name}: {len(rows)} rows, cols={list(rows[0].keys()) if rows else []}") logger.info(f" Table {table_name}: {len(rows)} rows, cols={list(rows[0].keys()) if rows else []}")
@@ -1186,7 +1390,7 @@ class GixsqlOrchestrator:
col_types = {} col_types = {}
try: try:
pragma_cols = conn.execute( pragma_cols = conn.execute(
f"PRAGMA table_info([{table_name}])" f"PRAGMA table_info([{db_table}])"
).fetchall() ).fetchall()
valid_cols = {r[1].upper() for r in pragma_cols} valid_cols = {r[1].upper() for r in pragma_cols}
col_types = {r[1].upper(): r[2].upper() for r in pragma_cols} col_types = {r[1].upper(): r[2].upper() for r in pragma_cols}
@@ -1197,8 +1401,9 @@ class GixsqlOrchestrator:
for row in rows: for row in rows:
new_row = {} new_row = {}
for k, v in row.items(): for k, v in row.items():
if k.upper() in valid_cols: k_norm = k.replace('-', '_')
new_row[k] = v if k_norm.upper() in valid_cols:
new_row[k_norm] = v
if new_row: if new_row:
remapped_rows.append(new_row) remapped_rows.append(new_row)
rows = remapped_rows rows = remapped_rows
@@ -1225,20 +1430,219 @@ class GixsqlOrchestrator:
col_names = list(rows[0].keys()) col_names = list(rows[0].keys())
placeholders = ", ".join("?" for _ in col_names) placeholders = ", ".join("?" for _ in col_names)
quoted_cols = ", ".join(f"[{c}]" for c in col_names) quoted_cols = ", ".join(f"[{c}]" for c in col_names)
sql = f"INSERT OR IGNORE INTO [{table_name}] ({quoted_cols}) VALUES ({placeholders})" sql = f"INSERT OR IGNORE INTO [{db_table}] ({quoted_cols}) VALUES ({placeholders})"
conn.executemany(sql, [tuple(r.get(c, "") for c in col_names) for r in rows]) conn.executemany(sql, [tuple(r.get(c, "") for c in col_names) for r in rows])
logger.info(f" Table {table_name}: {len(rows)} initial rows inserted") logger.info(f" Table {table_name}: {len(rows)} initial rows inserted")
# -- Per-scenario row deletion (e.g. empty cursor scenario) -- # -- Per-scenario row deletion (e.g. empty cursor scenario) --
if scenario and scenario.delete_all_rows: if scenario and scenario.delete_all_rows:
for table_name in db_input.keys(): for table_name in db_input.keys():
conn.execute(f"DELETE FROM [{table_name}]") conn.execute(f"DELETE FROM [{table_name.replace('-', '_')}]")
logger.info(f" Table {table_name}: all rows deleted (scenario={scenario.id})") logger.info(f" Table {table_name}: all rows deleted (scenario={scenario.id})")
# -- Per-scenario table drop (e.g. OPEN CURSOR failure scenario) --
# Drops the table so a subsequent SQL OPEN/query fails (SQLCODE != 0),
# covering the SQL-error branch. Generic: any program may declare
# drop_tables to exercise its table-not-found error paths.
if scenario and scenario.drop_tables:
for table_name in scenario.drop_tables:
conn.execute(f"DROP TABLE IF EXISTS [{table_name.replace('-', '_')}]")
logger.info(f" Table {table_name}: dropped (scenario={scenario.id})")
conn.commit() conn.commit()
conn.close() conn.close()
logger.info(f" DB populated: {db_path}") logger.info(f" DB populated: {db_path}")
def _coordinate_db_rule_matching(self, db_input, records, data_fields):
"""DB 属性区间对齐(通用,无程序硬编码)。
适用DB EMP-MASTER 属性BIRTH-DATE / DEPENDENT-COUNT /
REGION-CODE再与 flat R02 RULE-TBL AGE-FROM/TO
DEPENDENTS-FROM/TOREGION-CODE 区间做 M:N 照合するプログラム
SHA06TWM 生成データでは DB 属性と R02 区间が独立合成され
数量级/値域がずれ照合命中が発生しない
本関数
1) 補全 INSURANCE-RATES 种子鍵R01 GRADE-CODE DB GRADE_CODE
の差を埋めるDB-ERR 主経路
2) 補全 EMP-MASTER 种子R01 EMP-ID DB EMP_ID の差を埋める
3) 属性区间对齐DB EMP-MASTER の一部行の属性を R02 RULE-TBL
区间内値に設定しAGE70 / DEP85 / REGION=G1 照合命中を
発生させる他行は区间外を維持し no-data/不照合分支を保持
検出はテーブル名 + R02 区间フィールド名パターンAGE-FROM /
DEPENDENTS-FROM / REGION-CODEで行うプログラム名ハードコードなし
"""
if not db_input or not records:
return
# 1) 从 records 提取 R02 RULE-TBL 区间(AGE/DEPENDENTS/REGION + 调整率)
rule_age_from = rule_age_to = None
rule_dep_from = rule_dep_to = None
rule_region = None
for rec in records:
v_af = str(rec.get('R02AGE-FROM', '')).strip()
v_at = str(rec.get('R02AGE-TO', '')).strip()
v_df = str(rec.get('R02DEPENDENTS-FROM', '')).strip()
v_dt = str(rec.get('R02DEPENDENTS-TO', '')).strip()
v_rg = str(rec.get('R02REGION-CODE', '')).strip()
if v_af.isdigit() and v_at.isdigit() and v_df.isdigit() and v_dt.isdigit() and v_rg:
rule_age_from, rule_age_to = int(v_af), int(v_at)
rule_dep_from, rule_dep_to = int(v_df), int(v_dt)
rule_region = v_rg
break
# R02 区间模式未检测到 → 不做对齐(避免误伤其他程序)
if rule_age_from is None or not rule_region:
return
# 2) 属性区间对齐:将 EMP-MASTER 已有行的属性设为 RULE 区间内值。
# 仅对齐部分行(保留反例 → no-data/不照合分支维持),不补全 DB 键
# (缺失 GRADE/EMP-ID 记录继续走 DB-ERR → SQLCODE≠0 分支覆盖)。
# AGE≈(from+to)/2 → BIRTH-DATE ≈ 運営日付(20260802) - age*365
# DEPENDENTS≈(from+to)/2, REGION = RULE-TBL REGION
if 'EMP-MASTER' in db_input:
emp_rows = db_input['EMP-MASTER']
mid_age = (rule_age_from + rule_age_to) // 2
mid_dep = (rule_dep_from + rule_dep_to) // 2
birth_date = _calc_birth_date(mid_age)
aligned = 0
for row in emp_rows:
# 仅对齐部分行(保留反例)
if aligned >= 4:
break
if 'EMP_ID' not in row or 'BIRTH_DATE' not in row:
continue
row['BIRTH_DATE'] = birth_date
row['DEPENDENT_COUNT'] = str(mid_dep)
row['REGION_CODE'] = rule_region
aligned += 1
if aligned:
logger.info(
f" DB 属性区间对齐: {aligned} 条 EMP-MASTER 属性→"
f"BIRTH={birth_date}(AGE~{mid_age}) DEP={mid_dep} REG={rule_region}"
)
def _coordinate_seed_numeric_types(self, db_input, data_fields):
"""DB 种子值数字化(通用,无程序硬编码)。
适用DB SELECT 种子列的值形如 'G0000001'字母+数字来自 alpha
成序列但对应 COBOL 输出 FD 字段是 PIC 9数字 SHA07REC
EMP-ID PIC 9(008)运行时 MOVE 字母值到 PIC 9 非法 输出为空/0
本関数输出 FDW01/W02 PIC 9 类型字段的 base EMP-ID
CHG-DATECHG-ID DB 种子表中列名匹配的列若值含非数字字符
则剥离非数字左补零对齐 PIC 长度转纯数字字符字段INSURER /
PREV / REASON / CHG-TYPE不触碰
検出はフィールド名パターンPIC 9 + 出力 FD+ 値パターン[A-Z]\\d+
で行うプログラム名ハードコードなし
"""
if not db_input or not data_fields:
return
# 1) 输出 FD 前缀集合(W01/W02 等 OUTPUT FD)中 PIC 9 字段的 base 名
output_pref = set()
pic9_bases = {} # base 名(大写,去连字符)→ 数字位数
for f in data_fields:
if not isinstance(f, dict) or not f.get('pic') or f.get('is_88'):
continue
name = f['name']
m = re.match(r'^(W\d{2})(.*)$', name)
if not m:
continue
pref, rest = m.group(1), m.group(2)
pic = str(f.get('pic', ''))
if re.match(r'^9\((\d+)\)$', pic):
base = rest.lstrip('-').upper().replace('-', '_')
digits = int(re.match(r'^9\((\d+)\)$', pic).group(1))
output_pref.add(pref)
pic9_bases.setdefault(base, digits)
if not pic9_bases:
return
# 2) 对每个 SELECT 种子表,数字化匹配的列
for table, rows in db_input.items():
if not rows:
continue
for col in list(rows[0].keys()):
col_base = col.upper().replace('-', '_')
if col_base not in pic9_bases:
continue
digits = pic9_bases[col_base]
fixed = 0
for row in rows:
if col not in row:
continue
v = str(row[col]).strip()
if not v or v.isdigit():
continue
# 形如 'G0000001' → 剥离非数字 → '0000001' → 左补零到 digits
num = ''.join(ch for ch in v if ch.isdigit())
if not num:
continue
new_val = num.zfill(digits)[:digits]
if new_val != v:
row[col] = new_val
fixed += 1
if fixed:
logger.info(
f" DB 种子值数字化: {table}.{col} {fixed} 条→纯数字"
f"PIC 9({digits}) 输出对齐)"
)
def _deduplicate_r01_pk(self, recs: list[dict]) -> int:
"""Ensure all R01 records have unique (EMP_ID, DATE) pairs.
After all patching, some records may share the same (EMP_ID, DATE),
causing PK violation in DAILY_RECORDS INSERT -> ABEND -> 3000STPSOR
not reached. Adjusts the day field for colliding records.
"""
groups = {}
for i, rec in enumerate(recs):
eid = rec.get('R01EMP-ID', '')
dt = rec.get('R01DATE', '')
if not eid or not eid.strip() or eid == '00000000':
continue
if not dt or len(dt) < 8:
continue
ym = dt[:6]
groups.setdefault((eid, ym), []).append((i, dt[6:8]))
fixed = 0
for (eid, ym), entries in groups.items():
if len(entries) <= 1:
continue
used_days = set(d for _, d in entries)
if len(used_days) == len(entries):
continue
for idx, day in entries:
rec = recs[idx]
if sum(1 for _, d in entries if d == day) == 1:
continue
for dd in range(1, 32):
nd = f"{dd:02d}"
if nd not in used_days:
used_days.add(nd)
rec['R01DATE'] = ym + nd
line = rec.get('R01LINE', '')
if line:
parts = line.split(',')
if len(parts) >= 2:
parts[1] = nd.ljust(8)
rec['R01LINE'] = ','.join(parts)
fixed += 1
logger.info(f" Dedup PK: rec[{idx}] (eid={eid} ym={ym}) day {day}->{nd}")
break
if fixed:
logger.info(f" Dedup PK: {fixed} record(s) adjusted")
return fixed
def _inject_sql_error_rows(self, db_path: Path, records: list[dict] | None = None): def _inject_sql_error_rows(self, db_path: Path, records: list[dict] | None = None):
"""Insert duplicate-PK rows to trigger SQL error handling paths in COBOL.""" """Insert duplicate-PK rows to trigger SQL error handling paths in COBOL.
PK 冲突行的 PK 必须与"运行时实际会被 INSERT"的记录一致 R01 记录
否则程序 INSERT 时不会冲突优先用测试记录的合成行跳过会被清空 EMP-ID
records[0] 等特殊记录表已有数据时逐行注入而非固定取 rows[0]
"""
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))
for table in self.schema.db_tables: for table in self.schema.db_tables:
pk_cols = [c.name for c in table.columns if c.primary_key] pk_cols = [c.name for c in table.columns if c.primary_key]
@@ -1246,18 +1650,18 @@ class GixsqlOrchestrator:
continue continue
col_names = [c.name for c in table.columns] col_names = [c.name for c in table.columns]
try: try:
rows = conn.execute(f"SELECT * FROM [{table.name}] LIMIT 2").fetchall() synthetic = self._make_synthetic_error_rows(table, records)
if len(rows) < 1: if synthetic:
# For empty tables, generate synthetic error rows from test record data rows = synthetic
synthetic = self._make_synthetic_error_rows(table, records) else:
if synthetic: # fallback: 表已有行
rows = synthetic rows = conn.execute(f"SELECT * FROM [{table.name}] LIMIT 2").fetchall()
else: if not rows:
continue continue
quoted = ", ".join(f"[{c}]" for c in col_names) quoted = ", ".join(f"[{c}]" for c in col_names)
ph = ", ".join("?" for _ in col_names) ph = ", ".join("?" for _ in col_names)
for row in rows: for row in rows:
vals = tuple(str(rows[0][i]) if c in pk_cols else "X" for i, c in enumerate(col_names)) vals = tuple(str(row[i]) if c in pk_cols else "X" for i, c in enumerate(col_names))
conn.execute(f"INSERT OR IGNORE INTO [{table.name}] ({quoted}) VALUES ({ph})", vals) conn.execute(f"INSERT OR IGNORE INTO [{table.name}] ({quoted}) VALUES ({ph})", vals)
logger.info(f" SQL error test row injected into {table.name}") logger.info(f" SQL error test row injected into {table.name}")
except Exception as e: except Exception as e:
@@ -1265,6 +1669,163 @@ class GixsqlOrchestrator:
conn.commit() conn.commit()
conn.close() conn.close()
def _inject_extra_seed_rows(self, db_path: Path, scenario):
"""seed_extra_rows: 为 SELECT 型程序注入额外行(大结果集覆盖表头重出等分支)。
config: {table_name: count}从该表已有 seed 行推导月份date 列前 6
DAILY_RECORDS TARGET_DATE=202607xx用唯一 EMP_ID + 当月日期
注入 count 通用实现按表名注入无程序硬编码
"""
extra = getattr(scenario, 'seed_extra_rows', None)
if not extra:
return
conn = sqlite3.connect(str(db_path))
try:
for table_name, count in extra.items():
table = next((t for t in self.schema.db_tables
if t.name == table_name), None)
if not table or not count or count <= 0:
continue
# 从现有 seed 行推导月份(PK 列中形如 YYYYMMDD 的值前 6 位)
sample = conn.execute(f"SELECT * FROM [{table_name}] LIMIT 1").fetchall()
month = None
for row in sample:
for i, c in enumerate(table.columns):
if c.primary_key:
v = str(row[i])
if len(v) >= 6 and v[:4].isdigit() and v[4:6].isdigit():
month = v[:6]
break
if month:
break
if not month:
logger.warning(f" seed_extra_rows: {table_name} 无月份可推导, 跳过")
continue
col_names = [c.name for c in table.columns]
quoted = ", ".join(f"[{c}]" for c in col_names)
ph = ", ".join("?" for _ in col_names)
inserted = 0
for i in range(count):
emp = f"SEED{i + 1:04d}"
vals = []
for c in table.columns:
if c.name == 'EMP_ID':
vals.append(emp)
elif c.name == 'TARGET_DATE':
vals.append(month + '01')
elif c.name == 'YEAR_MONTH':
vals.append(month)
else:
vals.append('0')
try:
conn.execute(
f"INSERT OR IGNORE INTO [{table_name}] ({quoted}) "
f"VALUES ({ph})", vals)
inserted += 1
except Exception as e:
logger.debug(f" seed_extra_rows inject skipped: {e}")
conn.commit()
logger.info(
f" seed_extra_rows: {table_name} 注入 {inserted} 条(月 {month}"
)
finally:
conn.close()
def _inject_aggregation_boundaries(self, recs: list[dict]):
"""聚合边界数据注入(通用,无程序硬编码)。
目标分支R01 集計型 DB 程序
- AGG-ANNUAL-H ON SIZE ERROR (EMP, 年月) 2+ 条记录设 *ANNUAL-H
PIC 最大值 累加溢出
- AGG-COUNT < 100 ELSE注入使不同 (EMP, 年月) 组合 >= 101 表满警告
R01 记录字段按名称模式R01*EMP-ID / R01*DATE / R01*ANNUAL-H自动识别
未命中即 no-op不影响其他程序
"""
if not recs or len(recs) < 5:
return
first = recs[0]
emp_f = date_f = hours_f = None
for k in first:
u = k.upper()
if u.startswith('R01'):
if u.endswith('EMP-ID') and not emp_f:
emp_f = k
elif 'ANNUAL' in u and ('-H' in u or 'HOURS' in u) and not hours_f:
hours_f = k
elif u.endswith('DATE') and 'WORK' not in u and 'APPL' not in u and not date_f:
date_f = k
if not (emp_f and date_f and hours_f):
return
# dup_eid = TARGET 最后批次(每批 8)的 EMP,保证被 T 卡片命中。
# 只考虑数字型 EMP(9(008) 字段的合法值);字母型 EMP(如 'U0000031'
# 对数字字段非法,写文件时会被转成 SPACE 而跳过。
all_ids = sorted({str(r.get(emp_f, '')).strip()
for r in recs
if str(r.get(emp_f, '')).strip().isdigit()
and str(r.get(emp_f, '')).strip() != '00000000'})
n = len(all_ids)
if n < 2:
return
dup_eid = all_ids[n - (n % 8 or 8)]
# 1) overflow:同 (EMP, 月) 的 2+ 条记录设 *ANNUAL-H 为 PIC 最大值
max_h = '9' * len(str(first.get(hours_f, '')))
src_idx = next((i for i, r in enumerate(recs)
if str(r.get(emp_f, '')).strip() == dup_eid), None)
if src_idx is not None and max_h:
dup_date = str(recs[src_idx].get(date_f, ''))
dup_ym = dup_date[:6]
recs[src_idx][hours_f] = max_h
used_days = {dup_date[6:8]} if len(dup_date) >= 8 else set()
changed = 0
for j in range(max(1, len(recs) - 3), len(recs)):
if j == src_idx:
continue
rec = recs[j]
rec[emp_f] = dup_eid
orig = str(rec.get(date_f, ''))
day = (orig[6:8] if orig and orig[6:8] not in used_days
else f"{len(used_days) + 1:02d}")
used_days.add(day)
rec[date_f] = dup_ym + day
rec[hours_f] = max_h
changed += 1
if changed >= 1:
logger.info(f" Agg overflow: {changed + 1}{dup_eid} 同月 max={max_h}")
# 2) agg-full:保证 dup_eid 有 >=110 个不同月(其被 T 卡片命中聚合),
# 使 AGG-COUNT 超过 100 → 触发 AGG-COUNT < 100 的 ELSE(表满警告)
distinct = set()
for r in recs:
e = str(r.get(emp_f, '')).strip()
d = str(r.get(date_f, ''))
if e and e != '00000000' and len(d) >= 6:
distinct.add((e, d[:6]))
if dup_eid:
used_ym = {d[:6] for (e, d) in distinct if e == dup_eid}
template = dict(recs[1] if len(recs) > 1 else recs[0])
target = 110
added = 0
ym = 200001
while len(used_ym) < target:
ys = f"{ym:06d}"
if ys not in used_ym:
nr = dict(template)
nr[emp_f] = dup_eid
nr[date_f] = ys + '15'
recs.append(nr)
used_ym.add(ys)
distinct.add((dup_eid, ys))
added += 1
ym += 1
if ym > 209912:
break
if added:
logger.info(
f" Agg table full: 追加 {added}{dup_eid} 不同月({dup_eid} 月数 {len(used_ym)}"
)
def _seed_matching_monthly_rows(self, db_path: Path, records: list[dict] | None, def _seed_matching_monthly_rows(self, db_path: Path, records: list[dict] | None,
max_seed: int = 1, max_seed: int = 1,
r01_dir: Path | None = None): r01_dir: Path | None = None):
@@ -1353,42 +1914,60 @@ class GixsqlOrchestrator:
logger.info(f" MONTHLY_ABSENCE: 0 seeded — all AGG entries will INSERT (DP#28 F)") logger.info(f" MONTHLY_ABSENCE: 0 seeded — all AGG entries will INSERT (DP#28 F)")
def _make_synthetic_error_rows(self, table, records: list[dict] | None) -> list[tuple] | None: def _make_synthetic_error_rows(self, table, records: list[dict] | None) -> list[tuple] | None:
"""Build synthetic error rows for an empty table from test record data.""" """Build synthetic error rows from test record data.
冲突行的 PK 必须与运行时 INSERT 的实际值一致运行时主机变量由输入记录
赋值MOVE R01EMP-ID TO HV-EMP-ID 故优先取输入记录字段
R01EMP-ID / R01DATEYEAR_MONTH R01DATE[:6] 推导而非取值
尚未赋值的 WS 合成值HV-* 在运行前是垃圾值 'A0000001'
"""
if not records or len(records) < 2: if not records or len(records) < 2:
return None return None
pk_cols = [c.name for c in table.columns if c.primary_key] pk_cols = [c.name for c in table.columns if c.primary_key]
if not pk_cols: if not pk_cols:
return None return None
# Map COBOL host-variable names to table column names # 列名 → 候选记录字段(输入记录字段优先,其次主机变量)
# KIN08DBU DAILY_RECORDS: EMP_ID=HV-EMP-ID, TARGET_DATE=HV-TARGET-DATE
# KIN08DBU MONTHLY_ABSENCE: EMP_ID=HV-EMP-ID, YEAR_MONTH=HV-YEAR-MONTH
hv_map = { hv_map = {
'EMP_ID': ('HV-EMP-ID', 'R01EMP-ID', ''), 'EMP_ID': ('R01EMP-ID', 'HV-EMP-ID', ''),
'TARGET_DATE': ('HV-TARGET-DATE', ''), 'TARGET_DATE': ('R01DATE', 'HV-TARGET-DATE', ''),
'YEAR_MONTH': ('HV-YEAR-MONTH', ''), 'YEAR_MONTH': ('R01DATE', 'HV-YEAR-MONTH', ''),
'TIME_IN': ('HV-TIME-IN', ''), 'TIME_IN': ('R01TIME-IN', 'HV-TIME-IN', ''),
'TIME_OUT': ('HV-TIME-OUT', ''), 'TIME_OUT': ('R01TIME-OUT', 'HV-TIME-OUT', ''),
'ANNUAL_LEAVE_H': ('HV-ANNUAL-H', ''), 'ANNUAL_LEAVE_H': ('R01ANNUAL-H', 'HV-ANNUAL-H', ''),
'PERSONAL_LEAVE_H': ('HV-PERSONAL-H', ''), 'PERSONAL_LEAVE_H': ('R01PERSONAL-H', 'HV-PERSONAL-H', ''),
'OFFICIAL_LEAVE_H': ('HV-OFFICIAL-H', ''), 'OFFICIAL_LEAVE_H': ('R01OFFICIAL-H', 'HV-OFFICIAL-H', ''),
'SICK_LEAVE_H': ('HV-SICK-H', ''), 'SICK_LEAVE_H': ('R01SICK-H', 'HV-SICK-H', ''),
'UNAPPROVED_ABSENT_H': ('HV-ABSENT-H', ''), 'UNAPPROVED_ABSENT_H': ('R01ABSENT-H', 'HV-ABSENT-H', ''),
} }
def _first(rec, keys):
for k in keys:
if k and k in rec:
return str(rec[k]).strip()
return ''
result = [] result = []
for idx in range(min(2, len(records))): picked = 0
rec = records[idx] for rec in records:
# 跳过会被清空 EMP-ID / 无效键的特殊记录(records[0] 等),
# 只选运行时确实会被 INSERT 的记录作为冲突 PK。
emp = _first(rec, hv_map.get('EMP_ID', ()))
if not emp or emp == '00000000':
continue
r01date = _first(rec, ('R01DATE', 'HV-TARGET-DATE'))
vals = [] vals = []
for col in table.columns: for col in table.columns:
val = None val = None
if col.name in hv_map: if col.name == 'YEAR_MONTH':
for key in hv_map[col.name]: val = r01date[:6] if len(r01date) >= 6 else ''
if key and key in rec: elif col.name in hv_map:
val = rec[key] val = _first(rec, hv_map[col.name]) or None
break
if val is None: if val is None:
val = ' ' if col.name in pk_cols else '' val = ' ' if col.name in pk_cols else ''
vals.append(str(val) if val is not None else '') vals.append(str(val))
result.append(tuple(vals)) result.append(tuple(vals))
picked += 1
if picked >= 2:
break
return result if result else None return result if result else None
+174 -18
View File
@@ -13,6 +13,57 @@ from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _strip_schema_qualifiers(text: str) -> str:
"""DB2 schema-qualified table refs (SCHEMA.TABLE) → TABLE.
SQLite has no schema objects, so gixsql-generated SQL such as
`FROM SALARYDB.EMP_MASTER` fails at OPEN time ('no such table:
SALARYDB.EMP_MASTER', SQLCODE != 0) and the program ABENDs before its
main loop. Strip the qualifier from table positions only
(after FROM/INTO/UPDATE/JOIN). Column aliases (E.EMP-ID, B.DEPT-CODE)
and host-var INTO (:DBV-X) are untouched. Program-agnostic: handles
ANY <schema>.<table>, incl. multi-part (DB2INST1.PAYROLL.TIMESHEET).
"""
def _fix(m: re.Match) -> str:
kw = m.group(1)
table = m.group(2).rsplit('.', 1)[-1]
return f"{kw} {table}"
return re.sub(
r'\b(FROM|INTO|UPDATE|JOIN)\s+([\w-]+(?:\.[\w-]+)+)',
_fix, text, flags=re.IGNORECASE,
)
def _normalize_schema_qualifiers(text: str) -> str:
"""Strip schema qualifiers inside EXEC SQL blocks (before gixpp).
Applied to the source BEFORE gixpp so the generated SQL string literals
reference plain table names. Only EXEC SQL blocks are touched; other
text (data literals, comments) is left unchanged. Mirrors
`_normalize_current_timestamp`.
"""
def _fix_block(m: re.Match) -> str:
return _strip_schema_qualifiers(m.group(0))
return re.sub(r'(?is)EXEC SQL\b.*?\bEND-EXEC', _fix_block, text)
def _normalize_current_timestamp(text: str) -> str:
"""SQLite backend: DB2 `CURRENT TIMESTAMP` → `CURRENT_TIMESTAMP` inside EXEC SQL.
Applied to the source BEFORE gixpp. gixpp wraps long SQL across continuation
lines and can split the token (`CURRENT TIMES` / `TAMP`), which defeats a
post-gixpp same-line regex; normalizing the source first makes the generated
SQL string literals concatenate to the SQLite-valid `CURRENT_TIMESTAMP`.
Only EXEC SQL blocks are touched; other text (data literals, comments) is
left unchanged.
"""
def _fix_sql(m: re.Match) -> str:
block = m.group(0)
return re.sub(r'\bCURRENT\s+TIMESTAMP\b', 'CURRENT_TIMESTAMP',
block, flags=re.IGNORECASE)
return re.sub(r'(?is)EXEC SQL\b.*?\bEND-EXEC', _fix_sql, text)
@dataclass @dataclass
class GixsqlBuildResult: class GixsqlBuildResult:
success: bool success: bool
@@ -103,13 +154,13 @@ class GixsqlCobolRunner:
return ( return (
" 01 SQLCA.\n" " 01 SQLCA.\n"
" 05 SQLCAID PIC X(8).\n" " 05 SQLCAID PIC X(8).\n"
" 05 SQLCABC PIC S9(9) COMP.\n" " 05 SQLCABC PIC S9(9) COMP-5.\n"
" 05 SQLCODE PIC S9(9) COMP.\n" " 05 SQLCODE PIC S9(9) COMP-5.\n"
" 05 SQLERRM.\n" " 05 SQLERRM.\n"
" 49 SQLERRML PIC S9(4) COMP.\n" " 49 SQLERRML PIC S9(4) COMP-5.\n"
" 49 SQLERRMC PIC X(256).\n" " 49 SQLERRMC PIC X(256).\n"
" 05 SQLERRP PIC X(8).\n" " 05 SQLERRP PIC X(8).\n"
" 05 SQLERRD PIC S9(9) COMP OCCURS 6.\n" " 05 SQLERRD PIC S9(9) COMP-5 OCCURS 6.\n"
" 05 SQLWARN.\n" " 05 SQLWARN.\n"
" 10 SQLWARN0 PIC X(1).\n" " 10 SQLWARN0 PIC X(1).\n"
" 10 SQLWARN1 PIC X(1).\n" " 10 SQLWARN1 PIC X(1).\n"
@@ -140,6 +191,10 @@ class GixsqlCobolRunner:
""" """
text = src_path.read_text(encoding="utf-8-sig") text = src_path.read_text(encoding="utf-8-sig")
# 0. Strip ALL comment lines BEFORE any EXEC SQL transforms (regex may match
# 'EXEC SQL' inside Japanese comments, pulling comment text into SQL strings)
text = re.sub(r'^[ \t]{0,10}\*.*\n?', '', text, flags=re.MULTILINE)
# 1. Replace EXEC SQL INCLUDE SQLCA → COPY SQLCA # 1. Replace EXEC SQL INCLUDE SQLCA → COPY SQLCA
text = re.sub(r'EXEC SQL INCLUDE SQLCA END-EXEC\.', ' COPY SQLCA.', text, flags=re.IGNORECASE) text = re.sub(r'EXEC SQL INCLUDE SQLCA END-EXEC\.', ' COPY SQLCA.', text, flags=re.IGNORECASE)
# Transform EXEC SQL CONNECT TO 'literal' → use WS variables (gixpp requires :variable not literal) # Transform EXEC SQL CONNECT TO 'literal' → use WS variables (gixpp requires :variable not literal)
@@ -154,17 +209,10 @@ class GixsqlCobolRunner:
r"CONNECT\s+TO\s+'[^']*'", r"CONNECT\s+TO\s+'[^']*'",
f"CONNECT TO :{conn_var} USER :{usr_var}", f"CONNECT TO :{conn_var} USER :{usr_var}",
inner, inner,
flags=re.IGNORECASE flags=re.IGNORECASE | re.DOTALL
) )
# Short absolute path under C:\Temp\gix\ (no Chinese chars, fits col 72). # Orchestrator copies DB to CWD/kin.
# The orchestrator creates the DB at the same path so they match. return (f"MOVE 'sqlite://kin' TO {conn_var}\n"
from pathlib import Path as _Path
pid = _Path(src_path).stem
gix_root = _Path("C:/Temp/gix")
gix_root.mkdir(parents=True, exist_ok=True)
db_path = gix_root / f"{pid}.db"
conn_val = "sqlite:///" + str(db_path).replace("\\", "/")
return (f"MOVE '{conn_val}' TO {conn_var}\n"
f" MOVE 'gix' TO {usr_var}\n" f" MOVE 'gix' TO {usr_var}\n"
f" EXEC SQL\n" f" EXEC SQL\n"
f" {new_inner.strip()}\n" f" {new_inner.strip()}\n"
@@ -185,9 +233,6 @@ class GixsqlCobolRunner:
if hasattr(self, '_copybook_dirs') and self._copybook_dirs: if hasattr(self, '_copybook_dirs') and self._copybook_dirs:
text = self._expand_all_copies(text, self._copybook_dirs) text = self._expand_all_copies(text, self._copybook_dirs)
# 3. Strip ALL comment lines (* in any column 7-11)
text = re.sub(r'^[ \t]{0,10}\*.*\n?', '', text, flags=re.MULTILINE)
# 4. Collapse multiple spaces between keywords # 4. Collapse multiple spaces between keywords
lines = [] lines = []
for line in text.splitlines(keepends=True): for line in text.splitlines(keepends=True):
@@ -246,8 +291,29 @@ class GixsqlCobolRunner:
text, count=1, flags=re.MULTILINE | re.IGNORECASE text, count=1, flags=re.MULTILINE | re.IGNORECASE
) )
# 8. SQLite backend: DB2 `CURRENT TIMESTAMP` → `CURRENT_TIMESTAMP` inside
# EXEC SQL. Must run before gixpp (gixpp can split the token across
# continuation lines, defeating the post-gixpp same-line patch).
text = _normalize_current_timestamp(text)
# 9. SQLite backend: DB2 schema-qualified table refs (SCHEMA.TABLE) →
# TABLE inside EXEC SQL. gixsql would otherwise emit
# `FROM SALARYDB.EMP_MASTER`, which SQLite cannot resolve.
text = _normalize_schema_qualifiers(text)
norm_path = src_path.parent / f"{src_path.stem}_norm.cbl" norm_path = src_path.parent / f"{src_path.stem}_norm.cbl"
norm_path.write_text(text, encoding="utf-8") norm_path.write_text(text, encoding="utf-8")
# Save a copy in runtime for diagnosis
try:
debug_dir = Path(__file__).parent.parent / "runtime" / src_path.stem / "pre_src"
debug_dir.mkdir(parents=True, exist_ok=True)
(debug_dir / f"{src_path.stem}_norm.cbl").write_text(text, encoding="utf-8")
(debug_dir / f"{src_path.stem}_pre.cbl").write_text(
(src_path.parent / f"{src_path.stem}_pre.cbl").read_text(encoding="utf-8"),
encoding="utf-8"
)
except Exception:
pass
return pre_path, norm_path return pre_path, norm_path
def preprocess(self, src_path: str | Path, out_dir: str | Path, def preprocess(self, src_path: str | Path, out_dir: str | Path,
@@ -273,6 +339,80 @@ class GixsqlCobolRunner:
raise RuntimeError(f"gixpp failed (rc={r.returncode}): {err}") raise RuntimeError(f"gixpp failed (rc={r.returncode}): {err}")
return str(out_path) return str(out_path)
def _patch_sql_identifiers(self, pp_path: Path) -> None:
"""Translate DB2 hyphenated identifiers to underscores inside GIXSQL SQL strings.
gixsql emits the SQL text verbatim from the COBOL source (e.g.
"INSERT INTO EMP-MASTER (EMP-ID, ...)"), but SQLite cannot parse bare
hyphenated identifiers. Convert '-' -> '_' only inside the SQL string
literals (VALUE "..." lines and & "..." continuation lines), leaving
COBOL-level identifiers (cursor names, host variables, work items) untouched.
"""
text = pp_path.read_text(encoding="utf-8")
def _fix_line(m: re.Match) -> str:
return m.group(1) + m.group(2).replace('-', '_') + m.group(3)
# SQL start lines: GIXSQL ... VALUE "SQL TEXT"
text = re.sub(
r'^(GIXSQL.*?VALUE\s+")([^"]*)(")',
_fix_line, text, flags=re.MULTILINE
)
# SQL continuation lines: GIXSQL & "SQL TEXT"
text = re.sub(
r'^(GIXSQL\s*&\s*")([^"]*)(")',
_fix_line, text, flags=re.MULTILINE
)
# SQLite accepts CURRENT_TIMESTAMP (no space); gixsql emits CURRENT TIMESTAMP
text = re.sub(r'\bCURRENT\s+TIMESTAMP\b', 'CURRENT_TIMESTAMP', text, flags=re.IGNORECASE)
pp_path.write_text(text, encoding="utf-8")
def _patch_sqlcode_normalize(self, pp_path: Path) -> None:
"""Inject SQLITE-constraint → -803 mapping after each SQL execution.
gixsql+SQLite reports duplicate-key (PRIMARY KEY / UNIQUE) violations as
SQLCODE=-1555 / -2067 / -19, whereas the COBOL programs follow DB2
semantics (SQLCODE = -803). Without this mapping, `IF SQLCODE = -803`
branches are unreachable under gixsql+SQLite. The injected code is
program-agnostic: it only rewrites the constraint-violation codes.
"""
text = pp_path.read_text(encoding="utf-8")
mapping = (
' IF SQLCODE = -1555\n'
' MOVE -803 TO SQLCODE\n'
' END-IF\n'
' IF SQLCODE = -2067\n'
' MOVE -803 TO SQLCODE\n'
' END-IF\n'
' IF SQLCODE = -19\n'
' MOVE -803 TO SQLCODE\n'
' END-IF\n'
)
text = re.sub(
r'(GIXSQLEndSQL\s*\n?.*?END-CALL\.?)\s*\n(\s*)(IF SQLCODE\s+)',
lambda m: m.group(1) + '\n' + m.group(2) + mapping + m.group(2) + m.group(3),
text,
flags=re.IGNORECASE | re.DOTALL
)
pp_path.write_text(text, encoding="utf-8")
def _patch_sqlcode_override(self, pp_path: Path) -> None:
"""Inject MOVE 0 TO SQLCODE after each GIXSQLEndSQL call.
Workaround for gixsql DLL bug: GIXSQLExecParams/GIXSQLExec fail with
'Can't find a connection' even though the connection is valid.
Setting SQLCODE=0 lets the program flow through success paths for
coverage measurement. The DB state is not validated in coverage runs.
"""
text = pp_path.read_text(encoding="utf-8")
text = re.sub(
r'(GIXSQLEndSQL\s*\n?.*?END-CALL\.?)\s*\n(\s*)(IF SQLCODE\s+(?:NOT\s+)?=\s+0)',
r'\1\n\2 MOVE 0 TO SQLCODE\n\2\3',
text,
flags=re.IGNORECASE | re.DOTALL
)
pp_path.write_text(text, encoding="utf-8")
def compile(self, pp_path: str | Path, exe_path: str | Path, def compile(self, pp_path: str | Path, exe_path: str | Path,
copybook_dirs: list[str | Path] | None = None, copybook_dirs: list[str | Path] | None = None,
extra_srcs: list[str | Path] | None = None) -> GixsqlBuildResult: extra_srcs: list[str | Path] | None = None) -> GixsqlBuildResult:
@@ -282,6 +422,19 @@ class GixsqlCobolRunner:
exe_dir = exe_path.parent exe_dir = exe_path.parent
exe_dir.mkdir(parents=True, exist_ok=True) exe_dir.mkdir(parents=True, exist_ok=True)
# SQL is now executed correctly (hyphen->underscore identifiers), so the
# SQLCODE=0 override workaround is no longer needed. It masked real SQL
# errors and caused infinite FETCH loops (SQLCODE forced to 0 prevents
# "PERFORM UNTIL SQLCODE NOT = 0" from terminating at EOF=100).
# self._patch_sqlcode_override(pp_path)
# Translate DB2 hyphenated identifiers -> underscores in SQL strings
self._patch_sql_identifiers(pp_path)
# Map gixsql SQLite constraint codes (-1555/-2067/-19) -> DB2 -803 so
# `IF SQLCODE = -803` branches are reachable under gixsql+SQLite.
self._patch_sqlcode_normalize(pp_path)
# Functions that the preprocessed COBOL actually CALLs # Functions that the preprocessed COBOL actually CALLs
gixsql_k = [ gixsql_k = [
"-K", "GIXSQLStartSQL", "-K", "GIXSQLStartSQL",
@@ -331,7 +484,8 @@ class GixsqlCobolRunner:
input_dir: str | Path | None = None, input_dir: str | Path | None = None,
timeout: int = 30, timeout: int = 30,
cobol_lib_path: str | Path | None = None, cobol_lib_path: str | Path | None = None,
env_overrides: dict[str, str] | None = None) -> GixsqlRunResult: env_overrides: dict[str, str] | None = None,
command_args: list[str] | None = None) -> GixsqlRunResult:
"""Step 3: COBOL DB プログラム実行""" """Step 3: COBOL DB プログラム実行"""
exe_path = Path(exe_path) exe_path = Path(exe_path)
work_dir = Path(work_dir) work_dir = Path(work_dir)
@@ -388,6 +542,8 @@ class GixsqlCobolRunner:
dst.write_bytes(f.read_bytes()) dst.write_bytes(f.read_bytes())
cmd = [str(exe_path)] cmd = [str(exe_path)]
if command_args:
cmd.extend(command_args)
logger.info(f" run: {' '.join(cmd)} (cwd={work_dir}, db={db_path})") logger.info(f" run: {' '.join(cmd)} (cwd={work_dir}, db={db_path})")
try: try:
r = subprocess.run(cmd, capture_output=True, timeout=timeout, r = subprocess.run(cmd, capture_output=True, timeout=timeout,
+84
View File
@@ -0,0 +1,84 @@
# 示例数据
本目录包含 COBOL 迁移验证平台 V3 的示例数据。
## 文件说明
| 文件 | 说明 |
|------|------|
| `SIMPLE.cbl` | 最简 COBOL 程序示例 |
| `SIMPLE.cpy` | COPYBOOK 示例 |
| `SIMPLE-output.json` | 预期输出格式示例 |
## SIMPLE.cbl 说明
这是一个最简的 COBOL 程序,演示基本的 MOVE 和 DISPLAY 语句。
### 程序结构
```
IDENTIFICATION DIVISION. → 程序标识
DATA DIVISION. → 数据定义
WORKING-STORAGE SECTION. → 工作存储区
PROCEDURE DIVISION. → 过程部
```
### 数据定义
```cobol
01 BILL-RECORD.
05 BR-AMT PIC S9(7)V99 COMP-3. → 金额 (压缩十进制)
05 BR-STATUS PIC X. → 状态 (字符)
05 BR-DATE PIC 9(8). → 日期 (数字)
```
### 过程逻辑
```cobol
MOVE 1500 TO BR-AMT. → 赋值金额
MOVE 'A' TO BR-STATUS. → 赋值状态
MOVE 20260522 TO BR-DATE. → 赋值日期
DISPLAY BR-AMT. → 显示金额
DISPLAY BR-STATUS. → 显示状态
DISPLAY BR-DATE. → 显示日期
STOP RUN. → 程序结束
```
## 输出格式
`SIMPLE-output.json` 展示了测试数据的标准 JSON 格式:
```json
{
"program": "程序名",
"version": "3.0",
"test_data": [
{
"id": 1,
"description": "测试描述",
"input": { "输入字段": "值" },
"expected_output": { "DISPLAY": ["输出值"] },
"working_storage": { "工作存储字段": "值" }
}
],
"coverage": {
"branch_coverage": "覆盖率",
"total_branches": 总分支数,
"covered_branches": 已覆盖分支数
}
}
```
## 使用方法
```bash
# 使用示例 COBOL 程序运行测试
python -m cobol_testgen sample/SIMPLE.cbl sample/output/
# 查看生成的 JSON
cat sample/output/SIMPLE.json
```
## 扩展示例
更多示例请参考 `benchmark-programs/` 目录,包含 43 个真实 COBOL 程序。
+35
View File
@@ -0,0 +1,35 @@
{
"program": "SIMPLE",
"version": "3.0",
"generated_at": "2026-08-22T22:00:00",
"test_data": [
{
"id": 1,
"description": "基本赋值测试",
"input": {
"BILL-RECORD": {
"BR-AMT": "000150000",
"BR-STATUS": "A",
"BR-DATE": "20260522"
}
},
"expected_output": {
"DISPLAY": [
"000150000",
"A",
"20260522"
]
},
"working_storage": {
"BR-AMT": "000150000",
"BR-STATUS": "A",
"BR-DATE": "20260522"
}
}
],
"coverage": {
"branch_coverage": "100%",
"total_branches": 0,
"covered_branches": 0
}
}
+16
View File
@@ -0,0 +1,16 @@
IDENTIFICATION DIVISION.
PROGRAM-ID. SIMPLE.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 BILL-RECORD.
05 BR-AMT PIC S9(7)V99 COMP-3.
05 BR-STATUS PIC X.
05 BR-DATE PIC 9(8).
PROCEDURE DIVISION.
MOVE 1500 TO BR-AMT.
MOVE 'A' TO BR-STATUS.
MOVE 20260522 TO BR-DATE.
DISPLAY BR-AMT
DISPLAY BR-STATUS
DISPLAY BR-DATE
STOP RUN.
+4
View File
@@ -0,0 +1,4 @@
01 BILL-RECORD.
05 BR-AMT PIC S9(7)V99 COMP-3.
05 BR-STATUS PIC X.
05 BR-DATE PIC 9(8).
+63
View File
@@ -0,0 +1,63 @@
"""Quick unit test for _coordinate_tertiary_fd (new front-sequence design).
tests/cobol_testgen/test_kin07_coordinate.py 对应验证
- 记录0 +全部明细键一致全匹配
- rec1 +明细1rec2 +明细2 单匹配键单调递增
- R03 同键重复块休暇种别 01-04+ 长休暇时长
"""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
import logging
logging.basicConfig(level=logging.INFO, format='%(message)s')
from cobol_testgen.__init__ import _coordinate_tertiary_fd, _find_key_pairs_inner
data_fields = [
{'name': 'SWEMP-ID', 'pic': 'X(008)'},
{'name': 'SWDATE', 'pic': '9(008)'},
{'name': 'SREMP-ID', 'pic': 'X(008)'},
{'name': 'SRWORK-DATE', 'pic': '9(008)'},
{'name': 'SLEMP-ID', 'pic': 'X(008)'},
{'name': 'SLDATE', 'pic': '9(008)'},
{'name': 'SLLEAVE-TYPE', 'pic': 'X(002)'},
{'name': 'SLSTART-TIME', 'pic': '9(004)'},
{'name': 'SLEND-TIME', 'pic': '9(004)'},
]
records = [
{'SWEMP-ID': f'R{i:07d}', 'SWDATE': f'{20000101 + i:08d}',
'SREMP-ID': f'C{i:07d}', 'SRWORK-DATE': f'{20000101 + i:08d}',
'SLEMP-ID': f'I{i:07d}', 'SLDATE': f'{20000101 + i:08d}',
'SLLEAVE-TYPE': '99', 'SLSTART-TIME': '0822', 'SLEND-TIME': '0922'}
for i in range(12)
]
fd_prefixes = ['SW', 'SR', 'SL']
print('=== _find_key_pairs_inner ===')
print('SW-SR:', _find_key_pairs_inner('SW', 'SR', data_fields))
print('SW-SL:', _find_key_pairs_inner('SW', 'SL', data_fields))
print()
print('=== _coordinate_tertiary_fd ===')
_coordinate_tertiary_fd(records, fd_prefixes, data_fields)
print()
for i, r in enumerate(records[:8]):
print(f' records[{i}]: SWEMP={r["SWEMP-ID"]}, SWDATE={r["SWDATE"]}, '
f'SREMP={r["SREMP-ID"]}, SLEMP={r["SLEMP-ID"]}, LT={r["SLLEAVE-TYPE"]}, '
f'{r["SLSTART-TIME"]}-{r["SLEND-TIME"]}')
# Verify
assert records[0]['SWEMP-ID'] == records[0]['SREMP-ID'] == records[0]['SLEMP-ID'], 'rec0 三键应一致'
assert records[1]['SWEMP-ID'] == records[1]['SREMP-ID'], 'rec1 应为主+明细1 匹配'
assert records[2]['SWEMP-ID'] != records[2]['SREMP-ID'], 'rec2 明细1 不应匹配'
# 键单调递增(merge-join 指针对齐前提)
k0, k1, k2 = records[0]['SWEMP-ID'], records[1]['SWEMP-ID'], records[2]['SWEMP-ID']
assert k0 < k1 < k2, f'前置序列主键应递增: {k0} < {k1} < {k2}'
# R03 同键重复块 + 休暇种别 + 长时长(块2)
assert all(records[i]['SLEMP-ID'] == records[0]['SLEMP-ID'] for i in range(4)), 'R03 前4条键应一致'
assert [records[i]['SLLEAVE-TYPE'] for i in range(4)] == ['01', '02', '03', '04'], '休暇种别 01-04'
for i in range(4, 8):
assert records[i]['SLSTART-TIME'] == '0600' and records[i]['SLEND-TIME'] == '2200', '块2 长时长'
print()
print('ALL ASSERTIONS PASSED')
@@ -0,0 +1,121 @@
"""决策点行号定位测试(T4 修复)。
注入 SQLCODE 映射代码后gixpp 输出的 pp.cbl `IF SQLCODE = -803`
等语句行号发生偏移`locate_decision_lines` 必须精确定位到
`IF SQLCODE = -803` 所在行而非 `IF SQLCODE = -1555` 或映射块的 END-IF
"""
import sys, os, re
sys.stdout.reconfigure(encoding='utf-8')
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from cobol_testgen.coverage import _build_search_patterns, _normalize, locate_decision_lines
class D:
kind = 'IF'
label = 'IF SQLCODE = -803'
condition = None
def test_if_pattern_does_not_duplicate_if_keyword():
"""IF 决策点模式不能是 'IF IF ...'label 已含 IF"""
dp = D()
pats = _build_search_patterns(dp)
assert pats, "patterns should not be empty"
for p in pats:
assert "IF\\b\\s+IF" not in p, f"duplicated IF keyword in pattern: {p}"
def test_if_pattern_matches_exact_condition_line():
"""模式应匹配 'IF SQLCODE = -803' 行,而不匹配 -1555/-19"""
dp = D()
pats = _build_search_patterns(dp)
assert pats[0] is not None
assert re.search(pats[0], "IF SQLCODE = -803") is not None
assert re.search(pats[0], "IF SQLCODE = -1555") is None
assert re.search(pats[0], "IF SQLCODE = -19") is None
assert re.search(pats[0], "IF SQLCODE = -2067") is None
def test_locate_finds_shifted_line_in_pp_source():
"""pp.cbl 中注入映射后,决策点定位到正确的 -803 行"""
pp = (
" CALL \"GIXSQLEndSQL\"\n"
" END-CALL.\n"
" IF SQLCODE = -1555\n"
" MOVE -803 TO SQLCODE\n"
" END-IF\n"
" IF SQLCODE = -2067\n"
" MOVE -803 TO SQLCODE\n"
" END-IF\n"
" IF SQLCODE = -19\n"
" MOVE -803 TO SQLCODE\n"
" END-IF\n"
" IF SQLCODE = 0\n"
" ADD 1 TO CUN-DB-INS\n"
" END-IF.\n"
" IF SQLCODE = -803\n"
" PERFORM 2000MAJSOR-UPD\n"
" END-IF.\n"
)
dp = D()
dp.source_line = 0
locate_decision_lines([dp], pp)
assert dp.source_line == 15, f"expected line 15 (IF SQLCODE = -803), got {dp.source_line}"
class MultiLineD:
kind = 'IF'
label = 'MERGE-REC-TYPE = CNS-PAY-TYPE-SALARY'
condition = None
def test_locate_multiline_if_condition():
"""条件被换行拆分的 IF 应定位到 'IF <首字段>' 起始行。
MERGE 输出过程的 IF MERGE-REC-TYPE / = CNS-PAY-TYPE-SALARY 分两行
单行模式无法匹配应回退到按首字段定位 IF gcov 分支标记依赖源行
"""
pp = (
" PERFORM WITH TEST AFTER\n"
" UNTIL WRK-MERGE-EOF-Y\n"
" RETURN MERGE-FILE\n"
" INTO MERGE-REC\n"
" AT END\n"
" SET WRK-MERGE-EOF-Y TO TRUE\n"
" END-RETURN\n"
" IF NOT WRK-MERGE-EOF-Y\n"
" MOVE MERGE-EMP-ID\n"
" TO WRK-CURRENT-EMP\n"
" IF MERGE-REC-TYPE\n"
" = CNS-PAY-TYPE-SALARY\n"
" MOVE MERGE-PAY-AMOUNT\n"
" TO WRK-SALARY-AMOUNT\n"
" ELSE\n"
" MOVE MERGE-PAY-AMOUNT\n"
" TO WRK-BONUS-AMOUNT\n"
" END-IF\n"
" END-IF\n"
" END-PERFORM.\n"
)
dp = MultiLineD()
dp.source_line = 0
locate_decision_lines([dp], pp)
assert dp.source_line == 11, f"expected line 11 (IF MERGE-REC-TYPE), got {dp.source_line}"
def test_locate_multiline_if_does_not_mislocate_other_if():
"""首字段定位不应误命中其它字段相同的 IF(如 IF NOT WRK-MERGE-EOF-Y 前的 WRK 前缀)。"""
pp = (
" IF NOT WRK-MERGE-EOF-Y\n"
" IF MERGE-REC-TYPE\n"
" = CNS-PAY-TYPE-SALARY\n"
" MOVE MERGE-PAY-AMOUNT\n"
" TO WRK-SALARY-AMOUNT\n"
" END-IF\n"
" END-IF.\n"
)
dp = MultiLineD()
dp.source_line = 0
locate_decision_lines([dp], pp)
assert dp.source_line == 2, f"expected line 2 (IF MERGE-REC-TYPE), got {dp.source_line}"
@@ -0,0 +1,71 @@
"""file_io PACKED-DECIMAL/COMP-3 小数位字节长度 + BINARY 大端序测试。
复现根因JIN03RAN
1. get_storage_length COMP-3/PACKED-DECIMAL (digits+2)//2忽略 decimal
9(3)V9(2) 应为 3 字节5 位有效数字+符号却按 2 字节 R01 记录 199 字节
vs GnuCOBOL FD 200 字节 逐记录错位 AVG-SCORE 运行时乱值 'A' 等级分支不可达
2. pack_value/unpack_value COMP/BINARY 用小端 '<'但本 GnuCOBOLGC32-BDB-SP1
主机兼容COMP 按大端存储 test_sqlca_byteorder.py 记载 EMP-COUNT 运行时乱值
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
import pytest
from cobol_testgen.file_io import get_storage_length, pack_value, unpack_value
def _pd(digits, decimal, signed=False):
return {
'usage': 'PACKED-DECIMAL',
'pic_info': {'type': 'numeric', 'length': digits + decimal,
'digits': digits, 'decimal': decimal, 'signed': signed},
}
def _bin(digits, signed=False):
return {
'usage': 'BINARY',
'pic_info': {'type': 'numeric', 'length': digits,
'digits': digits, 'decimal': 0, 'signed': signed},
}
# ── 1. PACKED-DECIMAL 小数位字节长度 ──
def test_pd_storage_length_includes_decimal():
"""9(3)V9(2) = 5 位 + 符号 = 3 字节;9(2)(无小数)仍为 2 字节。"""
assert get_storage_length(_pd(3, 2)) == 3, '9(3)V9(2) PACKED-DECIMAL 应占 3 字节'
assert get_storage_length(_pd(2, 0)) == 2, '9(2) PACKED-DECIMAL 应占 2 字节'
assert get_storage_length(_pd(7, 2, signed=True)) == 5, 'S9(7)V99 PACKED-DECIMAL 应占 5 字节'
def test_pd_pack_decimal_full_digits():
"""900.00 应打包为 90 00 0F(3 字节,保留全部 5 位)。"""
b = pack_value('90000', _pd(3, 2))
assert b == bytes.fromhex('90000f'), f'900.00 打包应为 90000f, 实际 {b.hex()}'
b2 = pack_value('60000', _pd(3, 2))
assert b2 == bytes.fromhex('60000f'), f'600.00 打包应为 60000f, 实际 {b2.hex()}'
def test_pd_unpack_decimal_roundtrip():
"""打包/解包回读应保留 5 位(含小数位)。"""
f = _pd(3, 2)
for val in ('90000', '60000', '00423'):
assert unpack_value(pack_value(val, f), f) == val, f'{val} 回读应一致'
# ── 2. BINARY 大端序(匹配本 GnuCOBOL 的 COMP 存储)──
def test_binary_pack_big_endian():
"""203 在 PIC 9(5) BINARY 应打包为大端 00 00 00 CB(4 字节)。"""
b = pack_value('00203', _bin(5))
assert b == bytes.fromhex('000000cb'), f'203 BINARY 应大端 000000cb, 实际 {b.hex()}'
def test_binary_unpack_big_endian():
"""大端 00 00 00 CB 解包应得到 00203。"""
assert unpack_value(bytes.fromhex('000000cb'), _bin(5)) == '00203'
@@ -0,0 +1,216 @@
"""KIN07DAI 型多文件照合程序的前缀检测与前置序列构造测试。
复现根因_coordinate_multi_file_keys 01 记录名推导前缀R01INNREC'R01'
COPY REPLACING 把字段前缀改为 SW/SR/SL导致键对匹配不到跨文件键协调失效
修复后应生成前置匹配序列运行时 merge-join 应覆盖全匹配/单明细匹配等组合
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
import pytest
from cobol_testgen.__init__ import (
_coordinate_multi_file_keys,
_coordinate_tertiary_fd,
_find_key_pairs_inner,
)
def _group(name):
return {'name': name, 'level': 1, 'pic': None,
'pic_info': {'type': 'group', 'digits': 0, 'decimal': 0, 'length': 80, 'signed': False},
'section': 'FILE', 'is_filler': False, 'redefines': None, 'usage': 'DISPLAY',
'occurs': 0, 'occurs_depending': None, 'value': None, 'values': None}
def _field(name, level, pic, ftype, length):
return {'name': name, 'level': level, 'pic': pic,
'pic_info': {'type': ftype, 'digits': 0, 'decimal': 0, 'length': length, 'signed': False},
'section': 'FILE', 'is_filler': False, 'redefines': None, 'usage': 'DISPLAY',
'occurs': 0, 'occurs_depending': None, 'value': None, 'values': None}
def _make_data_fields():
# 字段顺序与源文件一致:01 记录紧跟其子字段
return [
_group('R01INNREC'),
_field('SWEMP-ID', 3, 'X(008)', 'alphanumeric', 8),
_field('SWDATE', 3, '9(008)', 'numeric', 8),
_group('R02INNREC'),
_field('SREMP-ID', 3, 'X(008)', 'alphanumeric', 8),
_field('SRWORK-DATE', 3, '9(008)', 'numeric', 8),
_field('SRLEAVE-TYPE', 3, 'X(002)', 'alphanumeric', 2),
_group('R03INNREC'),
_field('SLEMP-ID', 3, 'X(008)', 'alphanumeric', 8),
_field('SLDATE', 3, '9(008)', 'numeric', 8),
_field('SLLEAVE-TYPE', 3, 'X(002)', 'alphanumeric', 2),
_field('SLSTART-TIME', 3, '9(004)', 'numeric', 4),
_field('SLEND-TIME', 3, '9(004)', 'numeric', 4),
]
def _make_records(n=12):
recs = []
for i in range(n):
recs.append({
'SWEMP-ID': f'R{i:07d}', 'SWDATE': f'{20000101 + i:08d}',
'SREMP-ID': f'C{i:07d}', 'SRWORK-DATE': f'{20000101 + i:08d}',
'SRLEAVE-TYPE': '99',
'SLEMP-ID': f'I{i:07d}', 'SLDATE': f'{20000101 + i:08d}',
'SLLEAVE-TYPE': '99',
'SLSTART-TIME': '0822', 'SLEND-TIME': '0922',
})
return recs
FILE_SEC = {
'R01INNFIL': ['R01INNREC'],
'R02INNFIL': ['R02INNREC'],
'R03INNFIL': ['R03INNREC'],
'W01OUTFIL': ['W01OUTREC'],
}
OPEN_DIR = {
'R01INNFIL': 'INPUT',
'R02INNFIL': 'INPUT',
'R03INNFIL': 'INPUT',
'W01OUTFIL': 'OUTPUT',
}
def test_find_key_pairs_uses_field_prefix_not_record_prefix():
"""字段前缀 SW/SR/SL 应能配对。"""
fields = _make_data_fields()
assert _find_key_pairs_inner('SW', 'SR', fields), 'SW-SR 应配对'
assert _find_key_pairs_inner('SW', 'SL', fields), 'SW-SL 应配对'
def test_coordinate_multi_file_keys_builds_front_sequence():
"""KIN07DAI 形态(记录名 R01INNREC + 字段 SW/SR/SL)下协调应生效:
记录0 三文件键一致R03 同键重复块 + 休暇种别注入键单调递增"""
fields = _make_data_fields()
records = _make_records(12)
_coordinate_multi_file_keys(
records, [], fields, {}, FILE_SEC,
term_types=['normal'] * len(records), open_dir=OPEN_DIR,
)
r0 = records[0]
assert r0['SWEMP-ID'] == r0['SREMP-ID'] == r0['SLEMP-ID'], '记录0 三文件键应一致'
assert r0['SWDATE'] == r0['SRWORK-DATE'] == r0['SLDATE'], '记录0 三文件日期应一致'
# R03 同键重复块(休暇种别 01-04 + WHEN OTHER '99'
r03_keys = [records[i]['SLEMP-ID'] for i in range(5)]
assert all(k == r03_keys[0] for k in r03_keys), 'R03 前5条记录键应一致'
lts = [records[i]['SLLEAVE-TYPE'] for i in range(5)]
assert lts == ['01', '02', '03', '04', '99'], "休暇种别应注入 01-04+'99', 实际 {lts}".format(lts=lts)
# 键单调递增(避免 merge-join 指针跳过)
k0, k1 = records[0]['SWEMP-ID'], records[1]['SWEMP-ID']
assert k0 < k1, f'前置序列主键应递增: {k0} < {k1}'
def _simulate_merge(R01, R02, R03):
"""模拟 KIN07DAI 的 3 文件 merge-join,返回 (pattern_counts, match_flags)。
pattern 集合: A=全匹配, B=仅R02, C=仅R03, D=无匹配
match_flags: (r02_match, r03_match, leave_calc, lt_seen)
"""
def key(e, d):
return (str(e).strip(), str(d))
n1, n2, n3 = len(R01), len(R02), len(R03)
r1 = r2 = r3 = 0
e1 = e2 = e3 = False
def rdR1():
nonlocal r1, e1
if r1 < n1:
r1 += 1
else:
e1 = True
def rdR2():
nonlocal r2, e2
if r2 < n2:
r2 += 1
else:
e2 = True
def rdR3():
nonlocal r3, e3
if r3 < n3:
r3 += 1
else:
e3 = True
rdR1(); rdR2(); rdR3()
pat = {'A': 0, 'B': 0, 'C': 0, 'D': 0}
r02m = r03m = calcs = 0
lt_seen = set()
while not e1:
r01k = key(R01[r1 - 1][0], R01[r1 - 1][1])
ptn = 4
while not e2:
if key(R02[r2 - 1][0], R02[r2 - 1][1]) >= r01k:
break
rdR2()
if not e2 and key(R02[r2 - 1][0], R02[r2 - 1][1]) == r01k:
ptn = 2
r02m += 1
rdR2()
while not e3:
if key(R03[r3 - 1][0], R03[r3 - 1][1]) >= r01k:
break
rdR3()
if not e3 and key(R03[r3 - 1][0], R03[r3 - 1][1]) == r01k:
r03m += 1
while True:
calcs += 1
lt_seen.add(R03[r3 - 1][3])
rdR3()
if e3:
break
if key(R03[r3 - 1][0], R03[r3 - 1][1]) != r01k:
break
ptn = 1 if ptn == 2 else 3
pat[['A', 'B', 'C', 'D'][ptn - 1]] += 1
rdR1()
return pat, (r02m, r03m, calcs, lt_seen)
def test_coordination_reaches_all_match_combinations_at_runtime():
"""协调后运行时 merge-join 应覆盖全匹配/仅R02/仅R03 三种组合。"""
fields = _make_data_fields()
records = _make_records(12)
_coordinate_multi_file_keys(
records, [], fields, {}, FILE_SEC,
term_types=['normal'] * len(records), open_dir=OPEN_DIR,
)
R01 = [(r['SWEMP-ID'], r['SWDATE']) for r in records]
R02 = [(r['SREMP-ID'], r['SRWORK-DATE']) for r in records]
R03 = [(r['SLEMP-ID'], r['SLDATE'], r['SLLEAVE-TYPE'], r['SLLEAVE-TYPE']) for r in records]
pat, (r02m, r03m, calcs, lt_seen) = _simulate_merge(R01, R02, R03)
assert r02m >= 1, 'R02 照合应至少发生 1 次'
assert r03m >= 1, 'R03 照合应至少发生 1 次'
assert calcs >= 4, '休暇計算应至少 4 次(EVALUATE 全 WHEN'
assert lt_seen >= {'01', '02', '03', '04'}, f'休暇種別应全覆盖, 实际 {lt_seen}'
assert pat['A'] >= 1, f'全匹配(PATTERN-A)应覆盖, 实际 {pat}'
assert pat['B'] >= 1, f'仅R02匹配(PATTERN-B)应覆盖, 实际 {pat}'
assert pat['C'] >= 1, f'仅R03匹配(PATTERN-C)应覆盖, 实际 {pat}'
def test_coordinate_tertiary_fd_long_leave_for_cap():
"""PATTERN-C 用判别块应注入长休暇时长(覆盖 8h 上限 T 分支)。"""
fields = _make_data_fields()
records = _make_records(12)
_coordinate_tertiary_fd(records, ['SW', 'SR', 'SL'], fields)
# 块2(索引 max(3,5)=5..9):长时长 0600-2200
for i in range(5, 10):
assert records[i]['SLSTART-TIME'] == '0600', f'rec{i} 长休暇开始时间'
assert records[i]['SLEND-TIME'] == '2200', f'rec{i} 长休暇结束时间'
@@ -0,0 +1,74 @@
"""MERGE 管道支持:USING 输入识别 + OUTPUT PROCEDURE 内联 + 合并输入记录注入。
对应根因
1. MERGE/SORT USING 文件从不 OPEN scan_open_statements 不识别为输入 无输入数据
2. MERGE OUTPUT PROCEDURE 段不被内联进分支树 其决策点未被采集
3. 即使识别输入也没生成供 MERGE 消费的 SA*/BO* 记录
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from cobol_testgen.read import (
preprocess, resolve_copybooks, extract_procedure_division, extract_data_division,
parse_data_division, scan_open_statements, scan_sort_merge_directions,
scan_all_file_directions, parse_file_control, parse_file_section,
)
from cobol_testgen.core import build_branch_tree
from cobol_testgen.coverage import collect_decision_points
_SRC = r'C:\Users\marye\Desktop\2026技术大赛\cobol-tna-system\src'
_CPY = r'C:\Users\marye\Desktop\2026技术大赛\cobol-tna-system\cpy'
def _load(pid):
with open(os.path.join(_SRC, pid + '.cbl'), encoding='utf-8-sig') as f:
src = f.read()
resolved = resolve_copybooks(src, _SRC, extra_search_paths=[_CPY])
pp = preprocess(resolved)
return resolved, pp
def test_merge_using_files_recognized_as_input():
"""MERGE ... USING SALARY-FILE BONUS-FILE → 两者应识别为 INPUT。"""
_, pp = _load('KYU09MRG')
dirs = scan_sort_merge_directions(extract_procedure_division(pp))
assert dirs.get('SALARY-FILE') == 'INPUT'
assert dirs.get('BONUS-FILE') == 'INPUT'
# W01/W02 仅 OPEN OUTPUT,不受影响
assert 'W01OUTFIL' not in dirs
def test_scan_all_file_directions_includes_open_and_using():
"""combined 函数应同时含 OPEN 与 MERGE USING 方向。"""
_, pp = _load('KYU09MRG')
dirs = scan_all_file_directions(extract_procedure_division(pp))
assert dirs.get('SALARY-FILE') == 'INPUT'
assert dirs.get('BONUS-FILE') == 'INPUT'
assert dirs.get('W01OUTFIL') == 'OUTPUT'
assert dirs.get('W02OUTFIL') == 'OUTPUT'
def test_output_procedure_inlined_into_branch_tree():
"""2000MRGOUTSOR 的决策点(IF NOT WRK-MERGE-EOF-Y / IF MERGE-REC-TYPE)应被采集。"""
resolved, pp = _load('KYU09MRG')
fields = parse_data_division(extract_data_division(pp))
fields_dict = [f.__dict__ for f in fields]
proc_div = extract_procedure_division(pp)
tree, _ = build_branch_tree(proc_div, fields_dict, full_source=pp)
dps, _ = collect_decision_points(tree, fields_dict)
labels = [dp.label for dp in dps]
# 主流程 D01FKICOD
assert any('D01FKICOD' in l for l in labels), labels
# OUTPUT PROCEDURE 决策点应出现(此前只有 1 个)
assert any('WRK-MERGE-EOF-Y' in l for l in labels), labels
assert any('MERGE-REC-TYPE' in l for l in labels), labels
assert len(dps) >= 3, [dp.label for dp in dps]
def test_merge_using_files_in_file_section():
"""SALARY-FILE/BONUS-FILE 应存在于 FILE-SECTION,供输入文件写入。"""
_, pp = _load('KYU09MRG')
fs = parse_file_section(pp)
assert 'SALARY-FILE' in fs
assert 'BONUS-FILE' in fs
@@ -0,0 +1,138 @@
"""RI-01~05: opaque FD + READ INTO — WS 子字段值序列化回 FD 记录字节 + 跨记录 key 差异保证"""
import sys, os, json, tempfile
from pathlib import Path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from cobol_testgen import _serialize_read_into_records
from cobol_testgen.core import build_branch_tree
def _mk_fields():
return [
{'name': 'R01INNREC', 'level': 1, 'pic': 'X(080)',
'pic_info': {'type': 'alphanumeric', 'digits': 0, 'decimal': 0, 'length': 80, 'signed': False},
'is_filler': False, 'redefines': None, 'is_88': False},
{'name': 'WRK-ABSENCE-REC', 'level': 1, 'pic': '',
'pic_info': {'type': 'unknown', 'digits': 0, 'decimal': 0, 'length': 0, 'signed': False},
'is_filler': False, 'redefines': None, 'is_88': False},
{'name': 'WRK-EMP-ID', 'level': 5, 'pic': 'X(008)',
'pic_info': {'type': 'alphanumeric', 'digits': 0, 'decimal': 0, 'length': 8, 'signed': False},
'is_filler': False, 'redefines': None, 'is_88': False},
{'name': 'WRK-YEAR-MONTH', 'level': 5, 'pic': 'X(006)',
'pic_info': {'type': 'alphanumeric', 'digits': 0, 'decimal': 0, 'length': 6, 'signed': False},
'is_filler': False, 'redefines': None, 'is_88': False},
{'name': 'WRK-ABSENT-HOURS', 'level': 5, 'pic': '9(004)V9(001)',
'pic_info': {'type': 'numeric', 'digits': 4, 'decimal': 1, 'length': 0, 'signed': False},
'is_filler': False, 'redefines': None, 'is_88': False},
{'name': 'FILLER', 'level': 5, 'pic': 'X(062)',
'pic_info': {'type': 'alphanumeric', 'digits': 0, 'decimal': 0, 'length': 62, 'signed': False},
'is_filler': True, 'redefines': None, 'is_88': False},
{'name': 'WRK-PREV-EMP-ID', 'level': 1, 'pic': 'X(008)',
'pic_info': {'type': 'alphanumeric', 'digits': 0, 'decimal': 0, 'length': 8, 'signed': False},
'is_filler': False, 'redefines': None, 'is_88': False},
]
def _mk_records():
return [
{'R01INNREC': 'A' + '0' * 79,
'WRK-EMP-ID': 'G0000001', 'WRK-YEAR-MONTH': 'H00001', 'WRK-ABSENT-HOURS': '01501'},
{'R01INNREC': 'A' + '0' * 79,
'WRK-EMP-ID': 'G0000007', 'WRK-YEAR-MONTH': 'H00007', 'WRK-ABSENT-HOURS': '01507'},
{'R01INNREC': 'A' + '0' * 79,
'WRK-EMP-ID': 'G0000007', 'WRK-YEAR-MONTH': 'H00008', 'WRK-ABSENT-HOURS': '01508'},
]
def test_serialize_into_fd_bytes():
"""RI-01: INTO 组子字段值按 PIC 布局 pack 回 R01INNREC 字节"""
records = _mk_records()
file_sec = {'R01INNFIL': ['R01INNREC']}
assignments = {'WRK-ABSENCE-REC': [{'type': 'read_into', 'file': 'R01INNFIL', 'source_vars': []}]}
_serialize_read_into_records(records, assignments, _mk_fields(), file_sec)
r0 = records[0]['R01INNREC']
assert r0[0:8] == 'G0000001', r0[0:8]
assert r0[8:14] == 'H00001', r0[8:14]
assert r0[14:19] == '01501', r0[14:19]
assert len(r0) == 80
assert r0[19:].strip(' ') == '', r0[19:]
def test_key_variation_both_pairs():
"""RI-02: 相邻记录 key 既有相同又有差异(同 key 累加 + 新 key flush 两路径均可达)"""
records = _mk_records()
file_sec = {'R01INNFIL': ['R01INNREC']}
assignments = {'WRK-ABSENCE-REC': [{'type': 'read_into', 'file': 'R01INNFIL', 'source_vars': []}]}
_serialize_read_into_records(records, assignments, _mk_fields(), file_sec)
keys = [r['R01INNREC'][0:8] for r in records]
has_same = any(keys[i] == keys[i + 1] for i in range(len(keys) - 1))
has_diff = any(keys[i] != keys[i + 1] for i in range(len(keys) - 1))
assert has_same, f"no same-key pair: {keys}"
assert has_diff, f"no diff-key pair: {keys}"
def test_all_same_key_gets_break():
"""RI-03: 全记录同 key 时自动注入差异,保证新 key 分支可达"""
records = [
{'R01INNREC': 'A' + '0' * 79,
'WRK-EMP-ID': 'G0000001', 'WRK-YEAR-MONTH': 'H00001', 'WRK-ABSENT-HOURS': '01501'},
{'R01INNREC': 'A' + '0' * 79,
'WRK-EMP-ID': 'G0000001', 'WRK-YEAR-MONTH': 'H00002', 'WRK-ABSENT-HOURS': '01502'},
{'R01INNREC': 'A' + '0' * 79,
'WRK-EMP-ID': 'G0000001', 'WRK-YEAR-MONTH': 'H00003', 'WRK-ABSENT-HOURS': '01503'},
]
file_sec = {'R01INNFIL': ['R01INNREC']}
assignments = {'WRK-ABSENCE-REC': [{'type': 'read_into', 'file': 'R01INNFIL', 'source_vars': []}]}
_serialize_read_into_records(records, assignments, _mk_fields(), file_sec)
keys = [r['R01INNREC'][0:8] for r in records]
has_diff = any(keys[i] != keys[i + 1] for i in range(len(keys) - 1))
assert has_diff, f"no diff-key pair: {keys}"
def test_noop_without_read_into():
"""RI-04: 无 READ INTO 或 FD 有子字段时不改动记录"""
records = _mk_records()
file_sec = {'R01INNFIL': ['R01INNREC']}
before = [dict(r) for r in records]
_serialize_read_into_records(records, {}, _mk_fields(), file_sec)
assert records == before
fields_with_children = [
{'name': 'R01INNREC', 'level': 1, 'pic': 'X(080)',
'pic_info': {'type': 'alphanumeric', 'digits': 0, 'decimal': 0, 'length': 80, 'signed': False},
'is_filler': False, 'redefines': None, 'is_88': False},
{'name': 'R01EMP-ID', 'level': 5, 'pic': 'X(008)',
'pic_info': {'type': 'alphanumeric', 'digits': 0, 'decimal': 0, 'length': 8, 'signed': False},
'is_filler': False, 'redefines': None, 'is_88': False},
]
records2 = _mk_records()
before2 = [dict(r) for r in records2]
assignments = {'WRK-ABSENCE-REC': [{'type': 'read_into', 'file': 'R01INNFIL', 'source_vars': []}]}
_serialize_read_into_records(records2, assignments, fields_with_children, file_sec)
assert records2 == before2
def test_multiline_read_into_parsed():
"""RI-05: READ <FD> / INTO <GRP> 分行书写仍能解析为 read_into 赋值"""
proc = """PROCEDURE DIVISION.
MAIN.
PERFORM UNTIL WRK-EOF = 'Y'
READ R01INNFIL
INTO WRK-ABSENCE-REC
AT END
MOVE 'Y' TO WRK-EOF
END-READ
IF WRK-EOF = 'Y'
EXIT PERFORM
END-IF
END-PERFORM.
GOBACK.
"""
tree, asgn = build_branch_tree(proc, [])
read_keys = [k for k in asgn if any(
isinstance(a, dict) and a.get('type') == 'read_into' for a in asgn[k])]
assert read_keys == ['WRK-ABSENCE-REC'], read_keys
assert asgn['WRK-ABSENCE-REC'][0]['file'] == 'R01INNFIL'
+108
View File
@@ -0,0 +1,108 @@
"""SEARCH 分支路径生成 + 二元 COMPUTE 链反演(Phase 3 — TDD RED
目标KYU05DED SEARCH WHEN 分支#9)从不可达变为可达,
配合二元 `-` COMPUTEWRK-TAXABLE-INCOME = WRK-GROSS-PAYMENT - CNS-TAX-BASIC-DEDUCTION
反演到输入 R01GROSS-PAYMENT
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from cobol_testgen.design_mcdc import enum_paths
from cobol_testgen.core import trace_to_root, invert_through_chain
from cobol_testgen.coverage import collect_decision_points, mark_coverage
from cobol_testgen.models import BrSearch, BrSeq, CondLeaf, CondAnd
def _make_search_node():
node = BrSearch("IW-ENTRY")
node.when_list = [("A <= SUBJ", BrSeq())]
node.cond_trees = [CondLeaf("A", "<=", "SUBJ")]
node.has_at_end = True
node.at_end_seq = BrSeq()
return node
FIELDS = [
{"name": "A(1)", "pic_info": {"type": "numeric", "digits": 9}},
{"name": "SUBJ", "pic_info": {"type": "numeric", "digits": 9}},
]
# ── 1. enum_paths 生成 SEARCH WHEN 路径 ──
def test_enum_paths_search_marks_when_branch():
"""enum_paths 对 BrSearch 应生成 WHEN 路径,使 _mark_search 标记 WHEN 分支"""
node = _make_search_node()
paths = enum_paths(node, FIELDS)
assert len(paths) >= 1
dps, _ = collect_decision_points(node, FIELDS, [0])
mark_coverage(dps, [], paths, FIELDS)
dp = dps[0]
assert dp.kind == "SEARCH"
assert "WHEN A <= SUBJ" in dp.active_branches
def test_enum_paths_search_marks_at_end():
"""enum_paths 应生成 AT END 路径(带不匹配 WHEN 叶的约束)"""
node = _make_search_node()
paths = enum_paths(node, FIELDS)
dps, _ = collect_decision_points(node, FIELDS, [0])
mark_coverage(dps, [], paths, FIELDS)
dp = dps[0]
assert "AT END" in dp.active_branches
# ── 2. trace_to_root / invert_through_chain 二元 `-` 反演 ──
def test_trace_minus_constant_field_to_input():
"""2 源 `-` COMPUTESUBJ = GROSS - DEDDED 常量 VALUE)→ 追溯到 INPUT-GROSS"""
assignments = {
"SUBJ": [{"type": "compute", "source_vars": ["GROSS", "DED"],
"op": "-", "expr": "GROSS - DED"}],
"GROSS": [{"type": "move", "source_vars": ["INPUT-GROSS"]}],
}
fields = [
{"name": "DED", "pic_info": {"type": "numeric", "digits": 6}, "value": "50000"},
{"name": "INPUT-GROSS", "pic_info": {"type": "numeric", "digits": 9}},
]
root, chain = trace_to_root("SUBJ", assignments, fields)
assert root == "INPUT-GROSS"
# 2 源 `-` COMPUTE 常量侧被折叠为单源 compute + const
assert any(a.get("const") == float("50000") for _, a in chain)
def test_invert_minus_constant_field_boundary():
"""SUBJ >= 4701 → GROSS - 50000 >= 4701 → INPUT-GROSS >= 54701"""
assignments = {
"SUBJ": [{"type": "compute", "source_vars": ["GROSS", "DED"],
"op": "-", "expr": "GROSS - DED"}],
"GROSS": [{"type": "move", "source_vars": ["INPUT-GROSS"]}],
}
fields = [
{"name": "DED", "pic_info": {"type": "numeric", "digits": 6}, "value": "50000"},
{"name": "INPUT-GROSS", "pic_info": {"type": "numeric", "digits": 9}},
]
root, chain = trace_to_root("SUBJ", assignments, fields)
new_root, op, val = invert_through_chain(root, chain, ">=", "4701")
assert new_root == "INPUT-GROSS"
assert op == ">="
assert float(val) >= 54701
# ── 3. _mark_search 单 CondLeaf 字段对字段(应已绿,防止回归)──
def test_mark_search_field_to_field_constraint():
"""SUBJ <= A(1) 形态约束命中 WHEN 叶(value 侧基准字段匹配)"""
from cobol_testgen.coverage import DecisionPoint
dp = DecisionPoint(id=1, kind="SEARCH", label="IW-ENTRY",
branch_names=["WHEN A <= SUBJ", "AT END"])
dp.when_list = [("A <= SUBJ", BrSeq())]
dp.cond_trees = [CondLeaf("A", "<=", "SUBJ")]
dp.has_other = True
branch_paths = [
([("SUBJ", "<=", "A(1)", True)], []),
]
mark_coverage([dp], [], branch_paths, FIELDS)
assert "WHEN A <= SUBJ" in dp.active_branches
assert "AT END" not in dp.active_branches
@@ -0,0 +1,148 @@
"""JIN02SKL 型"明细键─主表键"后缀配对测试。
复现根因跨文件键协调 _find_key_pairs 只做同名精确匹配 / WORK- 变体 /
EMP 族回退当明细键 base='SKILL-CODE' 与主表键 base='MST-SKILL-CODE'
'MST-' 限定词时匹配不到导致 SEARCH ALL 永未命中
TBL-EMP-COUNT 0#7/#8> ZEROTRUE 分支不可达。
修复增加"后缀匹配"层级一方 base 是另一方真后缀且 key 类别一致
同为 -CODE/-ID/-NO/DATE/EMP-ID时配对通用实现无程序名硬编码
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
import pytest
from cobol_testgen.__init__ import (
_coordinate_multi_file_keys,
_find_key_pairs_inner,
)
def _group(name):
return {'name': name, 'level': 1, 'pic': None,
'pic_info': {'type': 'group', 'digits': 0, 'decimal': 0, 'length': 200, 'signed': False},
'section': 'FILE', 'is_filler': False, 'redefines': None, 'usage': 'DISPLAY',
'occurs': 0, 'occurs_depending': None, 'value': None, 'values': None}
def _field(name, level, pic, ftype, length):
return {'name': name, 'level': level, 'pic': pic,
'pic_info': {'type': ftype, 'digits': 0, 'decimal': 0, 'length': length, 'signed': False},
'section': 'FILE', 'is_filler': False, 'redefines': None, 'usage': 'DISPLAY',
'occurs': 0, 'occurs_depending': None, 'value': None, 'values': None}
def _skill_fields():
# 字段顺序与源文件一致:01 记录紧跟其子字段(COPY REPLACING R01/R02
return [
_group('R01INNREC'),
_field('R01SKILL-CODE', 3, 'X(004)', 'alphanumeric', 4),
_field('R01EMP-ID', 3, 'X(008)', 'alphanumeric', 8),
_field('R01EMP-NAME', 3, 'X(040)', 'alphanumeric', 40),
_field('R01EVAL-DATE', 3, 'X(008)', 'alphanumeric', 8),
_field('R01SCORE', 3, '9(003)', 'numeric', 3),
_group('R02INNREC'),
_field('R02MST-SKILL-CODE', 3, 'X(004)', 'alphanumeric', 4),
_field('R02MST-LEVEL', 3, '9(002)', 'numeric', 2),
_field('R02MST-SCORE-FROM', 3, '9(003)', 'numeric', 3),
_field('R02MST-SCORE-TO', 3, '9(003)', 'numeric', 3),
_field('R02MST-LEVEL-NAME', 3, 'X(020)', 'alphanumeric', 20),
]
def _skill_records(n=6):
recs = []
for i in range(n):
recs.append({
'R01SKILL-CODE': f'A{i + 1:03d}',
'R01EMP-ID': f'B{i + 1:07d}',
'R01EMP-NAME': 'N' * 40,
'R01EVAL-DATE': f'D{i + 1:08d}',
'R01SCORE': f'{100 + i:03d}',
'R02MST-SKILL-CODE': f'F{i + 1:03d}',
'R02MST-LEVEL': f'{20 + i:02d}',
'R02MST-SCORE-FROM': f'{300 + i:03d}',
'R02MST-SCORE-TO': f'{400 + i:03d}',
'R02MST-LEVEL-NAME': 'G' * 20,
})
return recs
FILE_SEC = {
'R01INNFIL': ['R01INNREC'],
'R02INNFIL': ['R02INNREC'],
'W01OUTFIL': ['W01OUTREC'],
'W02OUTFIL': ['W02OUTREC'],
}
OPEN_DIR = {
'R01INNFIL': 'INPUT',
'R02INNFIL': 'INPUT',
'W01OUTFIL': 'OUTPUT',
'W02OUTFIL': 'OUTPUT',
}
def test_find_key_pairs_suffix_skill_code():
"""明细键 SKILL-CODE 应与主表键 MST-SKILL-CODE 后缀配对。"""
fields = _skill_fields()
pairs = _find_key_pairs_inner('R01', 'R02', fields)
assert ('R01SKILL-CODE', 'R02MST-SKILL-CODE') in pairs, f'SKILL-CODE↔MST-SKILL-CODE 应配对, 实际 {pairs}'
def test_find_key_pairs_suffix_no_cross_class():
"""类别不一致的后缀不应误配:DATE 类键不与 CODE 类键配对。"""
fields = _skill_fields()
pairs = _find_key_pairs_inner('R01', 'R02', fields)
date_keys = [fa for fa, _ in pairs if 'DATE' in fa.upper()]
code_keys = [fb for _, fb in pairs if '-CODE' in fb.upper()]
# R01EVAL-DATE 不应与 R02MST-SKILL-CODE 配对
assert not any('R01EVAL-DATE' == fa and fb == 'R02MST-SKILL-CODE' for fa, fb in pairs), f'DATE 不应与 CODE 后缀配对, 实际 {pairs}'
def test_coordinate_multi_file_keys_skill_match():
"""协调后应使部分记录 R01SKILL-CODE == R02MST-SKILL-CODE,运行时 SEARCH 可达。"""
fields = _skill_fields()
records = _skill_records(6)
_coordinate_multi_file_keys(
records, [], fields, {}, FILE_SEC,
term_types=['normal'] * len(records), open_dir=OPEN_DIR,
)
matched = [r for r in records
if str(r['R01SKILL-CODE']).strip() == str(r['R02MST-SKILL-CODE']).strip()]
assert matched, '至少一条记录明细键应与主表键一致'
def test_coordinate_exact_match_still_pairs():
"""既有同名精确匹配(EMP-ID↔EMP-ID)不受后缀层级影响。"""
fields = [
_group('R01INNREC'),
_field('R01EMP-ID', 3, 'X(008)', 'alphanumeric', 8),
_group('R02INNREC'),
_field('R02EMP-ID', 3, 'X(008)', 'alphanumeric', 8),
]
pairs = _find_key_pairs_inner('R01', 'R02', fields)
assert ('R01EMP-ID', 'R02EMP-ID') in pairs, f'同名 EMP-ID 应仍精确配对, 实际 {pairs}'
def test_coordinate_last_record_matches_table_key():
"""末条 normal 记录明细键应对齐表内键(首条 R02 键)。
社员工键中断集约JIN09RPT 最终社员工组在 EOF 後の终了处理で出力される
`IF WRK-EMP-EVAL-CNT > ZERO PERFORM 3000OUTPUSOR`協調が先頭のみ整列だと
最終组に照合データがなく最终组输出分支不可達末条记录も表内键に揃えることで
該分支が到達可能になる
"""
fields = _skill_fields()
records = _skill_records(12)
_coordinate_multi_file_keys(
records, [], fields, {}, FILE_SEC,
term_types=['normal'] * len(records), open_dir=OPEN_DIR,
)
table_key = str(records[0]['R02MST-SKILL-CODE']).strip()
last = records[-1]
assert str(last['R01SKILL-CODE']).strip() == table_key, \
f'末条记录明细键应对齐表内键 {table_key}, 实际 {last["R01SKILL-CODE"]!r}'
@@ -0,0 +1,38 @@
"""SQLCA 字节序对齐测试(T1)。
gixsql native 小端 int 写入 SQLCA.SQLCODEGnuCOBOL COMP 按大端存储
导致非零 SQLCODE 被错读 -1555 -302383105必须改用 COMP-5native
否则 `IF SQLCODE = -803` 类具体值判断永远无法匹配
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from cobol_testgen.read import resolve_sql_includes, _BUILTIN_SQLCA
from runners.gixsql_runner import GixsqlCobolRunner
def test_builtin_sqlca_uses_comp5_for_numeric_fields():
"""内置 SQLCA 的 SQLCABC/SQLCODE 必须用 COMP-5native 字节序)"""
assert "SQLCABC PIC S9(9) COMP-5" in _BUILTIN_SQLCA
assert "SQLCODE PIC S9(9) COMP-5" in _BUILTIN_SQLCA
assert "SQLERRML PIC S9(4) COMP-5" in _BUILTIN_SQLCA
assert "PIC S9(9) COMP-5" in _BUILTIN_SQLCA
def test_resolve_sql_includes_injects_comp5_sqlca():
"""EXEC SQL INCLUDE SQLCA 解析后注入的 SQLCA 用 COMP-5"""
src = " EXEC SQL INCLUDE SQLCA END-EXEC.\n"
out = resolve_sql_includes(src, ".")
assert "SQLCODE PIC S9(9) COMP-5" in out
def test_runner_inline_sqlca_uses_comp5():
"""gixsql_runner 内联 SQLCA 的数值字段用 COMP-5"""
r = GixsqlCobolRunner(gixpp_path="gixpp", lib_path="lib")
r._copybook_dirs = []
# _expand_all_copies 内联 SQLCA 分支(copybook 不存在时)
text = " COPY SQLCA.\n"
out = r._expand_all_copies(text, [])
assert "SQLCODE PIC S9(9) COMP-5" in out
assert "SQLCABC PIC S9(9) COMP-5" in out
assert "SQLERRD PIC S9(9) COMP-5 OCCURS 6" in out
@@ -0,0 +1,86 @@
"""BETWEEN WHERE 子句解析测试。
根因KYU05DED `WHERE :WRK-TAXABLE-INCOME BETWEEN TAX-FROM AND TAX-TO`
1. `_split_on_AND` BETWEEN 内部的 AND 当成分隔符切断导致约束解析为空
2. `_RE_BETWEEN` 要求首操作数为列名不支持 `:主机变量 BETWEEN ...`
修复后应能解析出 subject / lo / hi 三端每端可为列名 / 主机变量 / 字面量
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from cobol_testgen.to_sql import _split_on_AND, sql_extract_constraints
def test_split_on_and_preserves_between():
"""BETWEEN 内部的 AND 不应被当作条件分隔符切断。"""
parts = _split_on_AND(':WRK-TAXABLE-INCOME BETWEEN TAX-FROM AND TAX-TO')
assert parts == [':WRK-TAXABLE-INCOME BETWEEN TAX-FROM AND TAX-TO']
def test_split_on_and_between_with_following_and():
"""BETWEEN 后的其他 AND 条件仍应正确切分。"""
parts = _split_on_AND(
'EMP-ID = :DBV-EMP-ID AND :WRK-TAXABLE-INCOME BETWEEN TAX-FROM AND TAX-TO AND STATUS = :S'
)
assert parts == [
'EMP-ID = :DBV-EMP-ID',
':WRK-TAXABLE-INCOME BETWEEN TAX-FROM AND TAX-TO',
'STATUS = :S',
]
def test_sql_extract_constraints_between_hostvar_subject():
"""`:主机变量 BETWEEN 列1 AND 列2`subject 为主机变量,lo/hi 为列名。"""
cons = sql_extract_constraints(
':WRK-TAXABLE-INCOME BETWEEN TAX-FROM AND TAX-TO', 'TAX_TABLE', {}, {}, {})
assert len(cons) == 1
c = cons[0]
assert c['type'] == 'between'
assert c['neg'] is False
assert c['subject'] == {'kind': 'host_var', 'name': 'WRK-TAXABLE-INCOME'}
assert c['lo'] == {'kind': 'column', 'name': 'TAX-FROM'}
assert c['hi'] == {'kind': 'column', 'name': 'TAX-TO'}
def test_sql_extract_constraints_between_column_subject():
"""`列 BETWEEN :低 AND :高`subject 为列名,lo/hi 为主机变量。"""
cons = sql_extract_constraints(
'SALARY BETWEEN :LO AND :HI', 'EMP', {}, {}, {})
assert len(cons) == 1
c = cons[0]
assert c['type'] == 'between'
assert c['subject'] == {'kind': 'column', 'name': 'SALARY'}
assert c['lo'] == {'kind': 'host_var', 'name': 'LO'}
assert c['hi'] == {'kind': 'host_var', 'name': 'HI'}
def test_sql_extract_constraints_between_compound():
"""BETWEEN 与其他条件组合时,全部解析且顺序保持。"""
cons = sql_extract_constraints(
'EMP-ID = :DBV-EMP-ID AND :WRK-TAXABLE-INCOME BETWEEN TAX-FROM AND TAX-TO',
'TAX_TABLE', {}, {}, {})
assert len(cons) == 2
assert cons[0]['type'] == 'host_var'
assert cons[0]['host_var'] == 'DBV-EMP-ID'
assert cons[1]['type'] == 'between'
def test_sql_extract_constraints_not_between():
"""NOT BETWEEN 正确解析 neg 标志。"""
cons = sql_extract_constraints(':X NOT BETWEEN A AND B', 'T', {}, {}, {})
assert len(cons) == 1
assert cons[0]['type'] == 'between'
assert cons[0]['neg'] is True
assert cons[0]['subject'] == {'kind': 'host_var', 'name': 'X'}
def test_sql_extract_constraints_between_three_part():
"""三段式:BETWEEN 之后跟后续 AND 条件。"""
cons = sql_extract_constraints(
':X BETWEEN A AND B AND :Y > 5', 'T', {}, {}, {})
assert len(cons) == 2
assert cons[0]['type'] == 'between'
assert cons[0]['lo'] == {'kind': 'column', 'name': 'A'}
assert cons[0]['hi'] == {'kind': 'column', 'name': 'B'}
assert cons[1]['type'] == 'literal'
assert cons[1]['op'] == '>'
@@ -0,0 +1,188 @@
"""SELECT BETWEEN WHERE 约束的 DB 预置行生成测试(Phase 2)。
根因KYU05DED `WHERE :WRK-TAXABLE-INCOME BETWEEN TAX-FROM AND TAX-TO`
在修复解析后成为 between 约束 build_db_input 不处理 between 类型
TAX_FROM/TAX_TO 仍落入默认序号值 运行时 SELECT 查不到行 SQLCODE<>0
修复build_db_input between 约束用记录中的 subject 值播种 lo/hi
使运行时 `:hv BETWEEN TAX-FROM AND TAX-TO` 能命中
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from cobol_testgen.to_sql import build_db_input
def _sql_meta_select_between():
return [{
"type": "exec_sql_select",
"table": "TAX-TABLE",
"select_list": "TAX-RATE, DEDUCTION",
"into_vars": ["DBV-TAX-RATE", "DBV-TAX-DEDUCTION"],
"where": ":WRK-TAXABLE-INCOME BETWEEN TAX-FROM AND TAX-TO",
"where_constraints": [
{"type": "between", "neg": False, "op": "BETWEEN",
"subject": {"kind": "host_var", "name": "WRK-TAXABLE-INCOME"},
"lo": {"kind": "column", "name": "TAX-FROM"},
"hi": {"kind": "column", "name": "TAX-TO"}},
],
}]
def _declared_tax_table():
return {
"TAX-TABLE": [
{"name": "TAX_FROM", "db_type": "DECIMAL"},
{"name": "TAX_TO", "db_type": "DECIMAL"},
{"name": "TAX_RATE", "db_type": "DECIMAL"},
{"name": "DEDUCTION", "db_type": "DECIMAL"},
],
}
def test_select_between_hostvar_subject_seeds_lo_hi_from_record():
"""`:hv BETWEEN TAX-FROM AND TAX-TO`TAX_FROM/TAX_TO 播成记录中
WRK-TAXABLE-INCOME 的值保证运行时 BETWEEN 命中"""
records = [{"WRK-TAXABLE-INCOME": "000000000"}]
db_input = build_db_input(
[([('SQLCODE', '=', '0', True)], {})], [], {},
_sql_meta_select_between(), _declared_tax_table(), records=records)
rows = db_input.get('TAX-TABLE', [])
assert len(rows) == 1
assert int(rows[0]['TAX_FROM']) == 0
assert int(rows[0]['TAX_TO']) == 0
def test_select_between_hostvar_subject_nonzero():
"""subject 非零时,lo/hi 取记录值。"""
records = [{"WRK-TAXABLE-INCOME": "000102400"}]
db_input = build_db_input(
[([('SQLCODE', '=', '0', True)], {})], [], {},
_sql_meta_select_between(), _declared_tax_table(), records=records)
rows = db_input.get('TAX-TABLE', [])
assert len(rows) == 1
assert int(rows[0]['TAX_FROM']) == 102400
assert int(rows[0]['TAX_TO']) == 102400
def test_select_between_hostvar_subject_moved_from_input():
"""subject 主机变量经 MOVE 链到输入键时,优先用输入键值。"""
sql_meta = [{
"type": "exec_sql_select",
"table": "TAX-TABLE",
"select_list": "TAX-RATE, DEDUCTION",
"into_vars": ["DBV-TAX-RATE", "DBV-TAX-DEDUCTION"],
"where": ":WRK-TAXABLE-INCOME BETWEEN TAX-FROM AND TAX-TO",
"where_constraints": [
{"type": "between", "neg": False, "op": "BETWEEN",
"subject": {"kind": "host_var", "name": "WRK-TAXABLE-INCOME"},
"lo": {"kind": "column", "name": "TAX-FROM"},
"hi": {"kind": "column", "name": "TAX-TO"}},
],
}]
records = [{"R01GROSS-PAYMENT": "000102400",
"WRK-TAXABLE-INCOME": "000000000"}]
assignments = {
'WRK-TAXABLE-INCOME': [{'type': 'move', 'source_vars': ['R01GROSS-PAYMENT']}],
}
db_input = build_db_input(
[([('SQLCODE', '=', '0', True)], {})], [], assignments,
sql_meta, _declared_tax_table(), records=records)
rows = db_input.get('TAX-TABLE', [])
assert len(rows) == 1
assert int(rows[0]['TAX_FROM']) == 102400
def test_select_between_column_subject_seeds_subject_col():
"""`SALARY BETWEEN :LO AND :HI`subject 是列时,把该列播成 lo 值
满足 lo <= col <= hi"""
sql_meta = [{
"type": "exec_sql_select",
"table": "EMP",
"select_list": "EMP_NAME",
"into_vars": ["DBV-EMPLOYEE-NAME"],
"where": "SALARY BETWEEN :LO AND :HI",
"where_constraints": [
{"type": "between", "neg": False, "op": "BETWEEN",
"subject": {"kind": "column", "name": "SALARY"},
"lo": {"kind": "host_var", "name": "LO"},
"hi": {"kind": "host_var", "name": "HI"}},
],
}]
declared = {
"EMP": [
{"name": "SALARY", "db_type": "DECIMAL"},
{"name": "EMP_NAME", "db_type": "CHAR", "size": 40},
],
}
records = [{"LO": "000005000", "HI": "000009000"}]
db_input = build_db_input(
[([('SQLCODE', '=', '0', True)], {})], [], {},
sql_meta, declared, records=records)
rows = db_input.get('EMP', [])
assert len(rows) == 1
sal = int(rows[0]['SALARY'])
assert 5000 <= sal <= 9000
def test_select_between_sql_error_path_skips_row():
"""SQL 失败路径(SQLCODE<>0)仍不生成预置行。"""
records = [{"WRK-TAXABLE-INCOME": "000102400"}]
db_input = build_db_input(
[([('SQLCODE', '<>', '0', True)], {})], [], {},
_sql_meta_select_between(), _declared_tax_table(), records=records)
rows = db_input.get('TAX-TABLE', [])
assert not rows
def _fdict_with_ws_constants():
"""KYU05DED 简化的字段定义:CNS-TAX-BASIC-DEDUCTION 常量 + MOVE/COMPUTE 链。"""
return [
{'name': 'R01GROSS-PAYMENT', 'pic_info': {'type': 'numeric', 'digits': 9, 'decimal': 0, 'length': 0, 'signed': False}, 'section': 'INPUT', 'value': None},
{'name': 'WRK-GROSS-PAYMENT', 'pic_info': {'type': 'numeric', 'digits': 9, 'decimal': 0, 'length': 0, 'signed': False}, 'section': 'WORKING-STORAGE', 'value': None},
{'name': 'WRK-TAXABLE-INCOME', 'pic_info': {'type': 'numeric', 'digits': 9, 'decimal': 0, 'length': 0, 'signed': False}, 'section': 'WORKING-STORAGE', 'value': None},
{'name': 'CNS-TAX-BASIC-DEDUCTION', 'pic_info': {'type': 'numeric', 'digits': 6, 'decimal': 0, 'length': 0, 'signed': False}, 'section': 'WORKING-STORAGE', 'value': '50000'},
]
def test_select_between_derives_runtime_value_from_input_key():
"""记录中 WRK-TAXABLE-INCOME 为合成值时,按运行时推导值播种带。
输入 gross=000100000 运行时 taxable = gross - 50000 = 50000
预置行 TAX_FROM/TAX_TO 应为 50000而非记录的合成值"""
assignments = {
'WRK-GROSS-PAYMENT': [{'type': 'move', 'source_vars': ['R01GROSS-PAYMENT']}],
'WRK-TAXABLE-INCOME': [
{'type': 'move_literal', 'literal': 'ZERO'},
{'type': 'compute', 'source_vars': ['WRK-GROSS-PAYMENT', 'CNS-TAX-BASIC-DEDUCTION'], 'op': '-'},
],
}
records = [{"R01GROSS-PAYMENT": "000100000",
"WRK-TAXABLE-INCOME": "000003701"}]
db_input = build_db_input(
[([('SQLCODE', '=', '0', True)], {})], _fdict_with_ws_constants(), assignments,
_sql_meta_select_between(), _declared_tax_table(), records=records)
rows = db_input.get('TAX-TABLE', [])
assert len(rows) == 1
assert int(rows[0]['TAX_FROM']) == 50000
assert int(rows[0]['TAX_TO']) == 50000
def test_select_between_derives_zero_for_low_gross():
"""gross < 基本控除(50000)时,运行时 taxable = 0 → 带为 [0,0]。"""
assignments = {
'WRK-GROSS-PAYMENT': [{'type': 'move', 'source_vars': ['R01GROSS-PAYMENT']}],
'WRK-TAXABLE-INCOME': [
{'type': 'move_literal', 'literal': 'ZERO'},
{'type': 'compute', 'source_vars': ['WRK-GROSS-PAYMENT', 'CNS-TAX-BASIC-DEDUCTION'], 'op': '-'},
],
}
records = [{"R01GROSS-PAYMENT": "000000501",
"WRK-TAXABLE-INCOME": "000003701"}]
db_input = build_db_input(
[([('SQLCODE', '=', '0', True)], {})], _fdict_with_ws_constants(), assignments,
_sql_meta_select_between(), _declared_tax_table(), records=records)
rows = db_input.get('TAX-TABLE', [])
assert len(rows) == 1
assert int(rows[0]['TAX_FROM']) == 0
assert int(rows[0]['TAX_TO']) == 0
@@ -0,0 +1,126 @@
"""build_db_input 事务调度感知种子测试(JIN05UPD 根因)。
复现根因UPDATE/DELETE 种子对每个 sql_ok 路径都用该路径记录的主键建行
不区分记录运行时是否真的执行 UPDATEJIN05UPD 4 INSERT 记录主键因此
全部被预置进 EMPLOYEE 运行时 INSERT 全部 -803INSERT 成功分支不可达
另外 sql_ok INSERT 路径的记录可能不是 INSERT 运行时走 WHEN OTHER
碰撞行必须回退到真实 INSERT 记录的主键
修复 88 级事务类型INSERT/UPDATE/DELETE 语义标记沿 MOVE 链追溯调度
字段分类每条记录UPDATE/DELETE 种子仅对 UPDATE/DELETE 记录INSERT 碰撞
回退到首条 INSERT 类记录
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
import pytest
from cobol_testgen.to_sql import build_db_input
def _fields():
def f(name, level, pic, ftype, length):
return {'name': name, 'level': level, 'pic': pic,
'pic_info': {'type': ftype, 'digits': 0, 'decimal': 0,
'length': length, 'signed': False},
'section': 'WS', 'is_filler': False, 'redefines': None,
'usage': 'DISPLAY', 'occurs': 0, 'occurs_depending': None,
'value': None, 'values': None}
base = [
f('WRK-TRAN-TYPE', 3, 'X(001)', 'alphanumeric', 1),
f('R01TRAN-TYPE', 3, 'X(001)', 'alphanumeric', 1),
f('R01TRAN-EMP-ID', 3, 'X(008)', 'alphanumeric', 8),
f('HV-EMP-ID', 3, 'X(008)', 'alphanumeric', 8),
]
for name, parent, vals in [('WRK-TRAN-INSERT', 'WRK-TRAN-TYPE', ['A', 'B', 'C']),
('WRK-TRAN-UPDATE', 'WRK-TRAN-TYPE', ['D', 'E', 'F', 'G']),
('WRK-TRAN-DELETE', 'WRK-TRAN-TYPE', ['Z'])]:
e = f(name, 88, None, 'alphanumeric', 1)
e['is_88'] = True
e['parent'] = parent
e['values'] = vals
e['value'] = vals[0]
base.append(e)
return base
_ASSIGN = {
'WRK-TRAN-TYPE': [{'type': 'move', 'source_vars': ['R01TRAN-TYPE']}],
'HV-EMP-ID': [{'type': 'move', 'source_vars': ['R01TRAN-EMP-ID']}],
}
def _declared():
return {'EMPLOYEE': [{'name': 'EMP_ID', 'db_type': 'CHAR', 'size': 8}]}
def _sql_meta():
return [
{'type': 'exec_sql_insert', 'table': 'EMPLOYEE',
'columns': ['EMP_ID'], 'host_vars': ['HV-EMP-ID'],
'sql_text': 'INSERT INTO EMPLOYEE (EMP_ID) VALUES (:HV-EMP-ID)'},
{'type': 'exec_sql_update', 'table': 'EMPLOYEE',
'set_clause': 'STATUS = :HV-STATUS',
'where': 'EMP_ID = :HV-EMP-ID', 'host_vars': ['HV-EMP-ID', 'HV-STATUS'],
'where_constraints': [{'col': 'EMP_ID', 'type': 'host_var', 'host_var': 'HV-EMP-ID'}],
'sql_text': 'UPDATE EMPLOYEE SET STATUS = :HV-STATUS WHERE EMP_ID = :HV-EMP-ID'},
]
def _records():
return [
{'R01TRAN-TYPE': 'A', 'R01TRAN-EMP-ID': 'B0000001', 'HV-EMP-ID': 'B0000001'}, # INSERT
{'R01TRAN-TYPE': 'D', 'R01TRAN-EMP-ID': 'B0000005', 'HV-EMP-ID': 'B0000005'}, # UPDATE
{'R01TRAN-TYPE': 'H', 'R01TRAN-EMP-ID': 'B0000008', 'HV-EMP-ID': 'B0000008'}, # OTHER
{'R01TRAN-TYPE': 'I', 'R01TRAN-EMP-ID': 'B0000009', 'HV-EMP-ID': 'B0000009'}, # OTHER (非sql_ok INSERT 路径)
]
def _paths():
return [
([], {}), # path0 sql_ok → rec0 (INSERT)
([], {}), # path1 sql_ok → rec1 (UPDATE)
([], {}), # path2 sql_ok → rec2 (OTHER)
([('SQLCODE', '<>', '0', True)], {}), # path3 INSERT 错误 → rec3 (OTHER)
]
_INSERT_PK = {'EMPLOYEE': ['EMP_ID']}
def test_update_seed_only_for_update_records():
"""INSERT/OTHER 记录不应被 UPDATE 种子注入;OTHER 记录主键不得入表。
B0000001 允许作为 INSERT 碰撞行出现一次 -803 分支但不得因
UPDATE 种子而额外注入B0000008/B0000009OTHER 记录绝不能入表
"""
di = build_db_input(_paths(), _fields(), _ASSIGN, _sql_meta(), _declared(),
records=_records(), insert_pk=_INSERT_PK)
keys = [r.get('EMP_ID', '').strip() for r in di.get('EMPLOYEE', [])]
assert keys.count('B0000001') <= 1, 'INSERT 记录主键仅允许作为碰撞行出现一次'
assert 'B0000005' in keys, 'UPDATE 记录主键应被种子注入'
assert 'B0000008' not in keys, 'OTHER 记录主键不应被种子注入'
assert 'B0000009' not in keys, '非 sql_ok OTHER 记录主键不应被 UPDATE 种子注入'
def test_insert_collision_falls_back_to_insert_record():
"""非 sql_ok INSERT 路径的记录若非 INSERT 类,碰撞行应回退到首条 INSERT 类记录主键。"""
di = build_db_input(_paths(), _fields(), _ASSIGN, _sql_meta(), _declared(),
records=_records(), insert_pk=_INSERT_PK)
keys = [r.get('EMP_ID', '').strip() for r in di.get('EMPLOYEE', [])]
assert 'B0000001' in keys, f'碰撞行应回退到首条 INSERT 记录主键, 实际 {keys}'
def test_insert_success_path_skips_seed():
"""sql_ok INSERT 路径不应生成任何种子行(INSERT 成功需主键空闲)。"""
paths = [
([('SQLCODE', '=', '0', True)], {}), # INSERT 成功
]
recs = [{'R01TRAN-TYPE': 'A', 'R01TRAN-EMP-ID': 'B0000001', 'HV-EMP-ID': 'B0000001'}]
di = build_db_input(paths, _fields(), _ASSIGN, _sql_meta(), _declared(),
records=recs, insert_pk=_INSERT_PK)
keys = [r.get('EMP_ID', '').strip() for r in di.get('EMPLOYEE', [])]
assert 'B0000001' not in keys, f'INSERT 成功路径不应生成种子, 实际 {keys}'
@@ -0,0 +1,177 @@
"""INSERT 表主键碰撞预置行生成测试(T2)。
gixsql+SQLite `IF SQLCODE = -803` 分支要求运行时 INSERT 失败
INSERT 失败的唯一途径是 DB 中已存在同主键行因此 build_db_input
必须为"要求 SQLCODE≠0 的路径"生成主键碰撞预置行主键值取自该记录
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from cobol_testgen.to_sql import build_db_input
def _sql_meta_insert(table="EMP-MASTER", columns=("EMP-ID", "EMP-NAME"),
host_vars=("DBV-EMP-ID", "DBV-EMP-NAME")):
return [{
"type": "exec_sql_insert",
"table": table,
"columns": list(columns),
"host_vars": list(host_vars),
"sql_text": f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({', '.join(':'+h for h in host_vars)})",
}]
def test_insert_sql_ok_path_skips_collision_row():
"""要求 SQLCODE=0INSERT 成功)的路径 → 不生成碰撞预置行"""
branch_paths = [
([('SQLCODE', '=', '0', True)], {}), # SQL ok → skip
]
records = [{'DBV-EMP-ID': 'A0000001', 'DBV-EMP-NAME': 'NAME'}]
db_input = build_db_input(
branch_paths, [], {}, _sql_meta_insert(),
{'EMP-MASTER': [{'name': 'EMP-ID', 'db_type': 'CHAR', 'size': 8}]},
records=records,
insert_pk={'EMP-MASTER': ['EMP-ID']},
)
assert 'EMP-MASTER' not in db_input or db_input['EMP-MASTER'] == []
def test_insert_error_path_generates_pk_collision_row():
"""要求 SQLCODE<>0INSERT 失败)的路径 → 生成主键碰撞预置行"""
branch_paths = [
([('SQLCODE', '<>', '0', True)], {}), # SQL fails → collision row
]
records = [{'DBV-EMP-ID': 'A0000001', 'DBV-EMP-NAME': 'NAME'}]
db_input = build_db_input(
branch_paths, [], {}, _sql_meta_insert(),
{'EMP-MASTER': [{'name': 'EMP-ID', 'db_type': 'CHAR', 'size': 8},
{'name': 'EMP-NAME', 'db_type': 'CHAR', 'size': 40}]},
records=records,
insert_pk={'EMP-MASTER': ['EMP-ID']},
)
rows = db_input.get('EMP-MASTER', [])
assert len(rows) == 1
assert rows[0]['EMP-ID'] == 'A0000001'
def test_insert_error_path_uses_record_host_var_value():
"""碰撞行主键取自记录中对应 host_var 的值"""
branch_paths = [
([('SQLCODE', '=', '-803', True)], {}),
]
records = [{'DBV-EMP-ID': 'Z9999999', 'DBV-EMP-NAME': 'DUPE'}]
db_input = build_db_input(
branch_paths, [], {}, _sql_meta_insert(),
{'EMP-MASTER': [{'name': 'EMP-ID', 'db_type': 'CHAR', 'size': 8}]},
records=records,
insert_pk={'EMP-MASTER': ['EMP-ID']},
)
rows = db_input.get('EMP-MASTER', [])
assert rows and rows[0]['EMP-ID'] == 'Z9999999'
def test_insert_collision_uses_input_record_pk_not_hostvar():
"""碰撞行主键用输入记录主键(R02EMP-ID),而非 expected host_var 值。
generate_records rec DBV-EMP-ID 可能是 DB 预置值W前缀
expected 而运行时实际 INSERT 的是输入记录主键R02EMP-ID
碰撞行必须与输入主键一致运行时 INSERT 才会失败
"""
branch_paths = [
([('SQLCODE', '=', '-803', True)], {}),
]
records = [{'R02EMP-ID': 'A0000001', 'DBV-EMP-ID': 'W0000001'}]
db_input = build_db_input(
branch_paths, [], {}, _sql_meta_insert(),
{'EMP-MASTER': [{'name': 'EMP-ID', 'db_type': 'CHAR', 'size': 8}]},
records=records,
insert_pk={'EMP-MASTER': ['EMP-ID']},
)
rows = db_input.get('EMP-MASTER', [])
assert rows and rows[0]['EMP-ID'] == 'A0000001'
def _sql_meta_insert_salary_results():
"""KYU06UPD 的 INSERT 语句形态(复合主键 EMP-ID + YEAR-MONTH)。"""
return [{
"type": "exec_sql_insert",
"table": "SALARY-RESULTS",
"columns": ["EMP-ID", "YEAR-MONTH", "GROSS-PAYMENT"],
"host_vars": ["DBV-EMP-ID", "DBV-YEAR-MONTH", "DBV-GROSS-PAYMENT"],
"sql_text": ("INSERT INTO SALARY-RESULTS (EMP-ID, YEAR-MONTH, GROSS-PAYMENT) "
"VALUES (:DBV-EMP-ID, :DBV-YEAR-MONTH, :DBV-GROSS-PAYMENT)"),
}]
def _declared_salary_results():
return {
"SALARY-RESULTS": [
{"name": "EMP-ID", "db_type": "CHAR", "size": 8},
{"name": "YEAR-MONTH", "db_type": "CHAR", "size": 6},
{"name": "GROSS-PAYMENT", "db_type": "DECIMAL", "size": 9},
],
}
def _kyu06_assignments():
"""KYU06UPD 的主键 MOVE 链:
DBV-EMP-ID R02EMP-ID输入键
DBV-YEAR-MONTH CNS-YEAR-MONTH-PARM WRK-YEAR-MONTHACCEPT +
IF = SPACES 默认 '202605'"""
return {
'DBV-EMP-ID': [{'type': 'move', 'source_vars': ['R02EMP-ID']}],
'DBV-YEAR-MONTH': [{'type': 'move', 'source_vars': ['CNS-YEAR-MONTH-PARM']}],
'CNS-YEAR-MONTH-PARM': [{'type': 'move', 'source_vars': ['WRK-YEAR-MONTH']}],
'WRK-YEAR-MONTH': [{'type': 'accept', 'from': 'USER'},
{'type': 'move_literal', 'literal': '202605'}],
}
def test_insert_collision_composite_pk_matches_runtime_keys():
"""复合主键碰撞行必须等于运行时 INSERT 键:
EMP-ID 用输入记录键R02EMP-IDYEAR-MONTH ACCEPT
IF = SPACES 的默认字面量'202605'而非记录中的合成值'L00007'
运行时 INSERT 键是 (A0000007, '202605')碰撞行若用合成 YEAR-MONTH
则永不碰撞-803 分支永远不可达
"""
branch_paths = [
([('SQLCODE', '=', '-803', True)], {}),
]
records = [{
'R02EMP-ID': 'A0000007',
'DBV-EMP-ID': 'M0000007',
'DBV-YEAR-MONTH': 'N00007',
'WRK-YEAR-MONTH': 'L00007',
}]
db_input = build_db_input(
branch_paths, [], _kyu06_assignments(), _sql_meta_insert_salary_results(),
_declared_salary_results(),
records=records,
insert_pk={'SALARY-RESULTS': ['EMP_ID', 'YEAR_MONTH'],
'SALARY_RESULTS': ['EMP_ID', 'YEAR_MONTH']},
)
rows = db_input.get('SALARY-RESULTS', [])
assert len(rows) == 1
assert rows[0]['EMP-ID'] == 'A0000007'
assert rows[0]['YEAR-MONTH'] == '202605'
def test_insert_collision_year_month_not_falling_back_to_emp_id():
"""无 MOVE 链/ACCEPT 默认时,YEAR-MONTH 碰撞列不应回退到 EMP-ID 输入字段
bug 会取 R02EMP-ID 截断成 'A00000'而应使用宿主变量自身记录值"""
branch_paths = [
([('SQLCODE', '=', '-803', True)], {}),
]
records = [{'R02EMP-ID': 'A0000007', 'DBV-EMP-ID': 'M0000007',
'DBV-YEAR-MONTH': 'N00007'}]
db_input = build_db_input(
branch_paths, [], {}, _sql_meta_insert_salary_results(),
_declared_salary_results(),
records=records,
insert_pk={'SALARY-RESULTS': ['EMP_ID', 'YEAR_MONTH'],
'SALARY_RESULTS': ['EMP_ID', 'YEAR_MONTH']},
)
rows = db_input.get('SALARY-RESULTS', [])
assert len(rows) == 1
assert rows[0]['YEAR-MONTH'] == 'N00007'
@@ -0,0 +1,135 @@
r"""DB2 schema 限定表名(SCHEMA.TABLE)在数据生成层的归一化测试。
根因`FROM SALARYDB.EMP-MASTER` `SALARYDB.` 限定符使 core.py 表名正则
`(\w[\w-]*)` `.` 即停把种子表解析为 `SALARYDB`build_db_input 24
EMP 种子行挂到不存在的 `SALARYDB` 表键下插入时被丢弃 EMP_MASTER 未播种
运行时 EMP 正常路径SEARCH/EVALUATE无法覆盖
修复分层通用
- core.py `_parse_sql` 各表名正则捕获完整限定名SCHEMA.TABLE 不丢失
- to_sql.py `collect_sql_meta` `_norm_table` 剥掉限定符取末段EMP-MASTER
YAML schema 表名一致种子键归一`_populate_database` 正常插入
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from cobol_testgen.core import extract_sql_assignments
from cobol_testgen.to_sql import collect_sql_meta, build_db_input
_SRC_DECLARE = (
" PROCEDURE DIVISION.\n"
" EXEC SQL\n"
" DECLARE C2 CURSOR FOR\n"
" SELECT EMP-ID, EMP-NAME, DEPT-CODE, BASE-SALARY\n"
" FROM SALARYDB.EMP-MASTER\n"
" ORDER BY EMP-ID\n"
" END-EXEC.\n"
)
def test_extract_sql_assignments_keeps_full_qualified_table():
"""core.py 表名正则须捕获完整限定名(不丢失 .EMP-MASTER)。"""
assignments = extract_sql_assignments(_SRC_DECLARE)
tables = [a.get("table") for a in assignments.values() for a in a]
assert "SALARYDB.EMP-MASTER" in tables, f"qualified table lost: {tables}"
def test_collect_sql_meta_normalizes_schema_qualified_table():
"""collect_sql_meta 把 SCHEMA.TABLE 归一化为末段表名。"""
assignments = {
"__SQL_CURSOR_C2": [{
"type": "exec_sql_select",
"table": "SALARYDB.EMP-MASTER",
"select_list": "EMP-ID, EMP-NAME, DEPT-CODE, BASE-SALARY",
"into_vars": ["DBV-EMP-ID", "DBV-EMP-NAME", "DBV-DEPT-CODE", "DBV-BASE-SALARY"],
"where": "",
"sql_text": "DECLARE C2 CURSOR FOR SELECT EMP-ID FROM SALARYDB.EMP-MASTER",
}],
}
declared = {"EMP-MASTER": [
{"name": "EMP-ID", "db_type": "CHAR", "size": 8},
{"name": "EMP-NAME", "db_type": "CHAR", "size": 40},
{"name": "DEPT-CODE", "db_type": "CHAR", "size": 2},
{"name": "BASE-SALARY", "db_type": "CHAR", "size": 9},
]}
sql_meta = collect_sql_meta(assignments, declared)
assert len(sql_meta) == 1
assert sql_meta[0]["table"] == "EMP-MASTER", sql_meta[0]["table"]
def test_build_db_input_seeds_emp_master_not_salarydb():
"""种子行键必须是 EMP-MASTER(而非 SALARYDB),否则插入时被丢弃。"""
branch_paths = [
([('SQLCODE', '=', '0', True)], {}),
]
records = [{
"DBV-EMP-ID": "A0000001",
"DBV-EMP-NAME": "TANAKA",
"DBV-DEPT-CODE": "01",
"DBV-BASE-SALARY": "429010",
}]
declared = {"EMP-MASTER": [
{"name": "EMP-ID", "db_type": "CHAR", "size": 8},
{"name": "EMP-NAME", "db_type": "CHAR", "size": 40},
{"name": "DEPT-CODE", "db_type": "CHAR", "size": 2},
{"name": "BASE-SALARY", "db_type": "CHAR", "size": 9},
]}
assignments = {
"__SQL_CURSOR_C2": [{
"type": "exec_sql_select",
"table": "SALARYDB.EMP-MASTER",
"select_list": "EMP-ID, EMP-NAME, DEPT-CODE, BASE-SALARY",
"into_vars": ["DBV-EMP-ID", "DBV-EMP-NAME", "DBV-DEPT-CODE", "DBV-BASE-SALARY"],
"where": "",
"sql_text": "DECLARE C2 CURSOR FOR SELECT EMP-ID FROM SALARYDB.EMP-MASTER",
}],
}
db_input = build_db_input(
branch_paths, [], assignments,
collect_sql_meta(assignments, declared),
declared,
records=records,
)
assert "SALARYDB" not in db_input, f"wrong table key: {list(db_input.keys())}"
assert "EMP-MASTER" in db_input, f"missing EMP-MASTER key: {list(db_input.keys())}"
def test_select_where_accept_fed_hostvar_uses_runtime_default():
"""WHERE 宿主变量由 ACCEPT FROM COMMAND-LINE 馈入时,种子必须用运行时
缺省值 SHA02MNC RATES WRK-YEAR-MONTH='202605'而非记录里的
合成值'I00001'否则 EFFECTIVE-FROM/TO 不满足 WHERERATES 不装载
SEARCH/EVALUATE 无法覆盖"""
branch_paths = [
([('SQLCODE', '=', '0', True)], {}),
]
records = [{"WRK-YEAR-MONTH": "I00001"}]
assignments = {
"WRK-YEAR-MONTH": [
{"type": "accept", "from": "COMMAND-LINE"},
{"type": "move_literal", "literal": "202605"},
],
}
sql_meta = [{
"type": "exec_sql_select",
"table": "INSURANCE-RATES",
"select_list": "EFFECTIVE-FROM, EFFECTIVE-TO",
"into_vars": [],
"where": "EFFECTIVE-FROM <= :WRK-YEAR-MONTH AND EFFECTIVE-TO >= :WRK-YEAR-MONTH",
"where_constraints": [
{"col": "EFFECTIVE-FROM", "type": "host_var", "host_var": "WRK-YEAR-MONTH", "op": "<="},
{"col": "EFFECTIVE-TO", "type": "host_var", "host_var": "WRK-YEAR-MONTH", "op": ">="},
],
}]
declared = {"INSURANCE-RATES": [
{"name": "EFFECTIVE-FROM", "db_type": "CHAR", "size": 6},
{"name": "EFFECTIVE-TO", "db_type": "CHAR", "size": 6},
]}
db_input = build_db_input(
branch_paths, [], assignments, sql_meta, declared, records=records,
)
rows = db_input.get("INSURANCE-RATES", [])
assert rows, "no INSURANCE-RATES seed rows"
assert rows[0]["EFFECTIVE-FROM"].strip() == "202605", (
f"seed EFFECTIVE-FROM={rows[0]['EFFECTIVE-FROM']!r} not runtime default"
)
assert rows[0]["EFFECTIVE-TO"].strip() == "202605"
@@ -0,0 +1,279 @@
"""SELECT 的 WHERE 宿主变量经 MOVE 链解析到输入记录键的测试。
根因`MOVE R02EMP-ID TO DBV-EMP-ID` 在程序里先执行运行时 SQL 查询键是
输入记录键R02EMP-ID build_db_input 直接读记录中的 DBV-EMP-ID
生成器的独立合成值 J 前缀与输入键不一致导致 DB 预置行查不到
正常路径SQLCODE=0永远无法覆盖修复WHERE 宿主变量若有 MOVE
应优先使用输入记录键
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from cobol_testgen.to_sql import build_db_input
def _sql_meta_select(table="EMP-MASTER"):
return [{
"type": "exec_sql_select",
"table": table,
"select_list": "EMP_NAME",
"into_vars": ["DBV-EMPLOYEE-NAME"],
"where": "EMP_ID = :DBV-EMP-ID",
"where_constraints": [
{"col": "EMP-ID", "type": "host_var", "host_var": "DBV-EMP-ID", "op": "="}
],
}]
def _declared_columns():
return {
"EMP-MASTER": [
{"name": "EMP-ID", "db_type": "CHAR", "size": 8},
{"name": "EMP_NAME", "db_type": "CHAR", "size": 40},
],
}
def test_select_where_hostvar_moved_from_input_uses_input_key():
"""WHERE 宿主变量 DBV-EMP-ID 由 MOVE R02EMP-ID 赋值时,
DB 预置行主键必须用输入记录键R02EMP-ID而非 DBV-EMP-ID 的合成值"""
branch_paths = [
([('SQLCODE', '=', '0', True)], {}), # SQL 成功路径
]
records = [{'R02EMP-ID': 'A0000001', 'DBV-EMP-ID': 'J0000001'}]
assignments = {
'DBV-EMP-ID': [{'type': 'move', 'source_vars': ['R02EMP-ID']}],
}
db_input = build_db_input(
branch_paths, [], assignments, _sql_meta_select(),
_declared_columns(),
records=records,
)
rows = db_input.get('EMP-MASTER', [])
assert len(rows) == 1
assert rows[0]['EMP-ID'].strip() == 'A0000001'
def test_select_where_hostvar_no_move_keeps_record_value():
"""WHERE 宿主变量没有 MOVE 链时,维持原行为(用记录中宿主变量的值)。"""
branch_paths = [
([('SQLCODE', '=', '0', True)], {}),
]
records = [{'R02EMP-ID': 'A0000001', 'DBV-EMP-ID': 'J0000001'}]
db_input = build_db_input(
branch_paths, [], {}, _sql_meta_select(),
_declared_columns(),
records=records,
)
rows = db_input.get('EMP-MASTER', [])
assert len(rows) == 1
assert rows[0]['EMP-ID'].strip() == 'J0000001'
def test_select_where_hostvar_chain_through_two_moves():
"""MOVE 链可跨多跳解析(A <- B <- C),最终用输入记录键。"""
branch_paths = [
([('SQLCODE', '=', '0', True)], {}),
]
records = [{'R02EMP-ID': 'A0000001', 'WK-EMP-ID': 'M0000001', 'DBV-EMP-ID': 'J0000001'}]
assignments = {
'WK-EMP-ID': [{'type': 'move', 'source_vars': ['R02EMP-ID']}],
'DBV-EMP-ID': [{'type': 'move', 'source_vars': ['WK-EMP-ID']}],
}
db_input = build_db_input(
branch_paths, [], assignments, _sql_meta_select(),
_declared_columns(),
records=records,
)
rows = db_input.get('EMP-MASTER', [])
assert len(rows) == 1
assert rows[0]['EMP-ID'].strip() == 'A0000001'
def test_select_where_hostvar_skip_sql_error_path():
"""SQL 失败路径(SQLCODE<>0)仍不生成预置行。"""
branch_paths = [
([('SQLCODE', '<>', '0', True)], {}),
]
records = [{'R02EMP-ID': 'A0000001', 'DBV-EMP-ID': 'J0000001'}]
assignments = {
'DBV-EMP-ID': [{'type': 'move', 'source_vars': ['R02EMP-ID']}],
}
db_input = build_db_input(
branch_paths, [], assignments, _sql_meta_select(),
_declared_columns(),
records=records,
)
rows = db_input.get('EMP-MASTER', [])
assert not rows
def test_select_uses_declared_columns_with_underscore_key():
"""declared_columns 以下划线键(EMP_MASTER)声明时,SELECT 行生成
也要命中使用声明的 CHAR(8) 列宽避免回退到 10 字符推断导致
存储值带尾随空格与运行时 8 字符键失配"""
branch_paths = [
([('SQLCODE', '=', '0', True)], {}),
]
records = [{'R02EMP-ID': 'A0000001', 'DBV-EMP-ID': 'A0000001'}]
assignments = {
'DBV-EMP-ID': [{'type': 'move', 'source_vars': ['R02EMP-ID']}],
}
declared = {
"EMP_MASTER": [
{"name": "EMP_ID", "db_type": "CHAR", "size": 8},
{"name": "EMP_NAME", "db_type": "CHAR", "size": 40},
],
}
db_input = build_db_input(
branch_paths, [], assignments, _sql_meta_select(),
declared,
records=records,
)
rows = db_input.get('EMP-MASTER', [])
assert len(rows) == 1
assert rows[0]['EMP_ID'] == 'A0000001'
assert len(rows[0]['EMP_ID']) == 8
def test_update_where_hostvar_moved_from_input_uses_input_key():
"""UPDATE/DELETE 的 WHERE 宿主变量同样经 MOVE 链解析到输入记录键。"""
sql_meta = [{
"type": "exec_sql_update",
"table": "EMP-MASTER",
"columns": ["STATUS"],
"host_vars": ["DBV-STATUS"],
"where": "EMP_ID = :DBV-EMP-ID",
"where_constraints": [
{"col": "EMP-ID", "type": "host_var", "host_var": "DBV-EMP-ID", "op": "="}
],
}]
branch_paths = [
([('SQLCODE', '=', '0', True)], {}),
]
records = [{'R02EMP-ID': 'A0000001', 'DBV-EMP-ID': 'J0000001', 'DBV-STATUS': 'M'}]
assignments = {
'DBV-EMP-ID': [{'type': 'move', 'source_vars': ['R02EMP-ID']}],
}
db_input = build_db_input(
branch_paths, [], assignments, sql_meta,
_declared_columns(),
records=records,
)
rows = db_input.get('EMP-MASTER', [])
assert len(rows) == 1
assert rows[0]['EMP-ID'].strip() == 'A0000001'
def _sql_meta_two_selects():
"""两个 SELECTEMP-MASTER 在前(运行时先查),OVT-MONTHLY 在后。"""
return [
{
"type": "exec_sql_select", "table": "EMP-MASTER",
"select_list": "EMP_NAME", "into_vars": ["DBV-EMPLOYEE-NAME"],
"where": "EMP_ID = :DBV-EMP-ID",
"where_constraints": [
{"col": "EMP-ID", "type": "host_var", "host_var": "DBV-EMP-ID", "op": "="}
],
"pos": 100,
},
{
"type": "exec_sql_select", "table": "OVT-MONTHLY",
"select_list": "SUM(OVT_HOURS)", "into_vars": ["DBV-OVT-HOURS"],
"where": "EMP_ID = :DBV-EMP-ID AND YEAR_MONTH = :WRK-YEAR-MONTH",
"where_constraints": [
{"col": "EMP-ID", "type": "host_var", "host_var": "DBV-EMP-ID", "op": "="},
{"col": "YEAR-MONTH", "type": "host_var", "host_var": "WRK-YEAR-MONTH", "op": "="},
],
"pos": 200,
},
]
def _declared_two_tables():
return {
"EMP-MASTER": [
{"name": "EMP-ID", "db_type": "CHAR", "size": 8},
{"name": "EMP_NAME", "db_type": "CHAR", "size": 40},
],
"OVT-MONTHLY": [
{"name": "EMP-ID", "db_type": "CHAR", "size": 8},
{"name": "YEAR-MONTH", "db_type": "CHAR", "size": 6},
],
}
def test_multiple_selects_drop_last_table_for_last_ok_path():
"""两个 SELECT 时,为最后一条 SQL 成功路径丢弃最后一张表(OVT)的行,
使该记录到达下游查询时无匹配行 覆盖下游 SELECT no-data 分支
IF SQLCODE = 0 ELSE 分支"""
branch_paths = [
([('SQLCODE', '=', '0', True)], {}), # path0 sql ok
([('SQLCODE', '=', '0', True)], {}), # path1 sql ok (last)
]
records = [
{'R02EMP-ID': 'A0000001', 'DBV-EMP-ID': 'A0000001',
'WRK-YEAR-MONTH': 'B00001', 'DBV-OVT-HOURS': '00101'},
{'R02EMP-ID': 'A0000002', 'DBV-EMP-ID': 'A0000002',
'WRK-YEAR-MONTH': 'B00002', 'DBV-OVT-HOURS': '00102'},
]
assignments = {
'DBV-EMP-ID': [{'type': 'move', 'source_vars': ['R02EMP-ID']}],
}
db_input = build_db_input(
branch_paths, [], assignments, _sql_meta_two_selects(),
_declared_two_tables(),
records=records,
)
emp = db_input.get('EMP-MASTER', [])
ovt = db_input.get('OVT-MONTHLY', [])
assert len(emp) == 2, emp # 两条路径都种 EMP(前置查询必须命中)
assert len(ovt) == 1, ovt # 最后一条路径的 OVT 行被丢弃
assert emp[0]['EMP-ID'].strip() == 'A0000001'
assert ovt[0]['EMP-ID'].strip() == 'A0000001'
def test_single_select_keeps_all_rows():
"""只有一个 SELECT 时不触发丢弃(保持原行为)。"""
branch_paths = [
([('SQLCODE', '=', '0', True)], {}),
([('SQLCODE', '=', '0', True)], {}),
]
records = [
{'R02EMP-ID': 'A0000001', 'DBV-EMP-ID': 'A0000001'},
{'R02EMP-ID': 'A0000002', 'DBV-EMP-ID': 'A0000002'},
]
assignments = {
'DBV-EMP-ID': [{'type': 'move', 'source_vars': ['R02EMP-ID']}],
}
db_input = build_db_input(
branch_paths, [], assignments, _sql_meta_select(),
_declared_columns(),
records=records,
)
rows = db_input.get('EMP-MASTER', [])
assert len(rows) == 2, rows
def test_drop_requires_two_sql_ok_paths():
"""只有一条 SQL 成功路径时不丢弃最后一张表(避免该表行被清空)。"""
branch_paths = [
([('SQLCODE', '=', '0', True)], {}), # 唯一的 sql ok 路径
([('SQLCODE', '<>', '0', True)], {}), # 失败路径
]
records = [
{'R02EMP-ID': 'A0000001', 'DBV-EMP-ID': 'A0000001',
'WRK-YEAR-MONTH': 'B00001', 'DBV-OVT-HOURS': '00101'},
{'R02EMP-ID': 'A0000002', 'DBV-EMP-ID': 'A0000002',
'WRK-YEAR-MONTH': 'B00002', 'DBV-OVT-HOURS': '00102'},
]
assignments = {
'DBV-EMP-ID': [{'type': 'move', 'source_vars': ['R02EMP-ID']}],
}
db_input = build_db_input(
branch_paths, [], assignments, _sql_meta_two_selects(),
_declared_two_tables(),
records=records,
)
assert len(db_input.get('EMP-MASTER', [])) == 1
assert len(db_input.get('OVT-MONTHLY', [])) == 1, db_input.get('OVT-MONTHLY')
@@ -0,0 +1,37 @@
"""Scenario `drop_tables` 配置解析测试。
用于覆盖表缺失导致 OPEN CURSOR 失败的错误分支 SHA02MNC #5 T
EMP-OPEN SQLCODE0机制通用任一程序可在场景里声明要 DROP 的表
运行时 OPEN 该表即失败 SQL 错误分支可达
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from config.program_schema import ProgramSchema
_YAML = """
program_id: SHA02MNC
db_type: SQLite
db_name: INSURANCEDB.db
db_tables:
- name: EMP_MASTER
columns:
- name: EMP_ID
type: CHAR(8)
primary_key: true
runs:
- id: normal
- id: emp_open_fail
drop_tables: [EMP_MASTER]
inject_duplicate_pk: false
"""
def test_drop_tables_parsed_from_yaml(tmp_path):
p = tmp_path / "SHA02MNC.yaml"
p.write_text(_YAML, encoding="utf-8")
schema = ProgramSchema.from_yaml(p)
assert len(schema.runs) == 2
fail = schema.runs[1]
assert fail.id == "emp_open_fail"
assert fail.drop_tables == ["EMP_MASTER"]
@@ -0,0 +1,58 @@
"""源级 CURRENT TIMESTAMP → CURRENT_TIMESTAMP 归一化测试(T4)。
gixpp 把长 SQL 折行可能把 DB2 `CURRENT TIMESTAMP` 拆成 `CURRENT TIMES` /
`TAMP` 跨两行导致 pp 级同行正则漏掉 生成的 UPDATE 保留空格版
SQLite `near "TIMESTAMP": syntax error`UPDATE 成功分支SQLCODE=0不可达
修复 gixpp 之前对源里的 EXEC SQL 块做归一化使生成的 SQL 字面量拼接后
SQLite 合法的 CURRENT_TIMESTAMP仅作用于 EXEC SQL 不影响其它文本
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from runners.gixsql_runner import _normalize_current_timestamp
def test_normalize_inside_exec_sql_block():
src = (" EXEC SQL\n"
" UPDATE SALARY-RESULTS SET\n"
" UPDATED-AT = CURRENT TIMESTAMP\n"
" END-EXEC.")
out = _normalize_current_timestamp(src)
assert "CURRENT_TIMESTAMP" in out
assert "CURRENT TIMESTAMP" not in out
def test_normalize_multiple_exec_sql_blocks():
src = (" EXEC SQL\n"
" INSERT INTO T1 (UPDATED-AT) VALUES (CURRENT TIMESTAMP)\n"
" END-EXEC.\n"
" EXEC SQL\n"
" UPDATE T2 SET UPDATED-AT = CURRENT TIMESTAMP\n"
" END-EXEC.")
out = _normalize_current_timestamp(src)
assert out.count("CURRENT_TIMESTAMP") == 2
assert "CURRENT TIMESTAMP" not in out
def test_normalize_case_insensitive():
src = " EXEC SQL\n SELECT CURRENT timestamp FROM T\n END-EXEC."
out = _normalize_current_timestamp(src)
assert "CURRENT_TIMESTAMP" in out
def test_normalize_idempotent_on_already_underscore():
src = " EXEC SQL\n UPDATE T SET UPDATED-AT = CURRENT_TIMESTAMP\n END-EXEC."
out = _normalize_current_timestamp(src)
assert out == src
def test_normalize_does_not_touch_outside_exec_sql():
src = (" MOVE 'CURRENT TIMESTAMP' TO WS-LIT\n"
" EXEC SQL\n"
" UPDATE T SET UPDATED-AT = CURRENT TIMESTAMP\n"
" END-EXEC.")
out = _normalize_current_timestamp(src)
# 块内被替换,块外(数据字符串)保持不变
assert out.startswith(" MOVE 'CURRENT TIMESTAMP' TO WS-LIT\n")
assert "CURRENT_TIMESTAMP" in out
@@ -0,0 +1,99 @@
"""EXEC SQL 内 DB2 schema 限定表名(SCHEMA.TABLE)剥离测试。
gixsql 会把 `FROM SALARYDB.EMP-MASTER` 原样归一化为 `FROM SALARYDB.EMP_MASTER`
注入 GIXSQL SQL 字符串SQLite schema 对象OPEN CURSOR
'no such table: SALARYDB.EMP_MASTER' SQLCODE0 EMP-OPEN 错误分支 ABEND
主循环 2000MAJSOR 永不执行SHA02MNC 覆盖率 66.7% 根因
修复gixpp 前把 EXEC SQL 块内 FROM/INTO/UPDATE/JOIN 后的 SCHEMA.TABLE
剥掉限定符通用规则处理任意 schema 无程序硬编码
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from runners.gixsql_runner import (
_strip_schema_qualifiers,
_normalize_schema_qualifiers,
)
def test_from_schema_table_stripped():
src = (
" EXEC SQL\n"
" DECLARE C2 CURSOR FOR\n"
" SELECT EMP-ID, EMP-NAME\n"
" FROM SALARYDB.EMP-MASTER\n"
" ORDER BY EMP-ID\n"
" END-EXEC.\n"
)
out = _strip_schema_qualifiers(src)
assert "FROM SALARYDB.EMP-MASTER" not in out
assert "FROM EMP-MASTER" in out
def test_from_three_part_qualified_stripped():
src = "EXEC SQL SELECT A FROM DB2INST1.PAYROLL.TIMESHEET END-EXEC."
out = _strip_schema_qualifiers(src)
assert "FROM TIMESHEET" in out
assert "DB2INST1.PAYROLL" not in out
def test_update_schema_table_stripped():
src = "EXEC SQL UPDATE SALARYDB.EMP-MASTER SET DEPT-CODE = '01' WHERE EMP-ID = :X END-EXEC."
out = _strip_schema_qualifiers(src)
assert "UPDATE EMP-MASTER" in out
def test_into_schema_table_stripped():
src = "EXEC SQL INSERT INTO SALARYDB.EMP-MASTER (EMP-ID) VALUES (:X) END-EXEC."
out = _strip_schema_qualifiers(src)
assert "INSERT INTO EMP-MASTER" in out
def test_join_schema_table_stripped():
src = ("EXEC SQL SELECT A.ID FROM A "
"JOIN SALARYDB.EMP-MASTER B ON A.ID = B.EMP-ID END-EXEC.")
out = _strip_schema_qualifiers(src)
assert "JOIN EMP-MASTER B" in out
def test_column_alias_qualifier_untouched():
"""列限定(E.EMP-ID / B.DEPT-CODE,非表名位置)不得剥离。"""
src = ("EXEC SQL SELECT E.EMP-ID FROM EMP-MASTER E "
"WHERE E.DEPT-CODE = '01' END-EXEC.")
out = _strip_schema_qualifiers(src)
assert "SELECT E.EMP-ID" in out
assert "E.DEPT-CODE" in out
assert "FROM EMP-MASTER E" in out
def test_hostvar_into_untouched():
"""SELECT ... INTO :DBV-X(宿主变量,非 SCHEMA.TABLE)不得剥离。"""
src = "EXEC SQL SELECT EMP-NAME INTO :DBV-EMP-NAME FROM EMP-MASTER END-EXEC."
out = _strip_schema_qualifiers(src)
assert "INTO :DBV-EMP-NAME" in out
def test_unqualified_table_untouched():
src = "EXEC SQL SELECT EMP-ID FROM EMP-MASTER ORDER BY EMP-ID END-EXEC."
assert _strip_schema_qualifiers(src) == src
def test_wrapper_only_touches_exec_sql_blocks():
text = " MOVE 'FROM A.B' TO WS-X.\n"
assert _normalize_schema_qualifiers(text) == text
def test_wrapper_strips_inside_exec_sql_only():
text = (
" MOVE 'X' TO WS-A.\n"
" EXEC SQL\n"
" SELECT EMP-ID\n"
" FROM SALARYDB.EMP-MASTER\n"
" END-EXEC.\n"
" MOVE 'FROM C.D' TO WS-B.\n"
)
out = _normalize_schema_qualifiers(text)
assert "FROM SALARYDB.EMP-MASTER" not in out
assert "FROM EMP-MASTER" in out
assert "MOVE 'FROM C.D' TO WS-B" in out
+66
View File
@@ -0,0 +1,66 @@
"""SQLCODE 归一化注入测试(T3)。
gixsql+SQLite 对主键冲突返回 SQLCODE=-1555SQLITE_CONSTRAINT_PRIMARYKEY
DB2 语义是 -803在预处理后的 COBOL 中注入通用归一化代码
SQLITE 约束错误码映射为 -803使 `IF SQLCODE = -803` 分支可达
"""
import sys, os
from pathlib import Path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
from runners.gixsql_runner import GixsqlCobolRunner
def _make_runner():
return GixsqlCobolRunner(gixpp_path="gixpp", lib_path="lib")
def _pp_text_with_if():
return (
" CALL \"GIXSQLEndSQL\"\n"
" END-CALL.\n"
" IF SQLCODE = 0\n"
" ADD 1 TO CUN-DB-INS\n"
" END-IF.\n"
" IF SQLCODE = -803\n"
" PERFORM 2000MAJSOR-UPD\n"
" END-IF.\n"
)
def test_normalize_injects_mapping_after_endsql(tmp_path):
"""GIXSQLEndSQL 后注入 SQLITE 约束错误 → -803 映射"""
r = _make_runner()
pp = tmp_path / "test_pp.cbl"
pp.write_text(_pp_text_with_if(), encoding="utf-8")
r._patch_sqlcode_normalize(pp)
out = pp.read_text(encoding="utf-8")
assert "SQLCODE = -1555" in out
assert "MOVE -803 TO SQLCODE" in out
assert "SQLCODE = -2067" in out
def test_normalize_mapping_before_if_sqlcode():
"""映射代码注入在 IF SQLCODE = -803 判断之前"""
r = _make_runner()
pp = Path(r'C:\Users\marye\AppData\Local\Temp\opencode\t3_pp.cbl')
pp.parent.mkdir(parents=True, exist_ok=True)
pp.write_text(_pp_text_with_if(), encoding="utf-8")
r._patch_sqlcode_normalize(pp)
out = pp.read_text(encoding="utf-8")
# 映射代码必须在第一个 IF SQLCODE 之前
mapping_idx = out.find("MOVE -803 TO SQLCODE")
if_idx = out.find("IF SQLCODE = -803")
assert mapping_idx != -1
assert if_idx != -1
assert mapping_idx < if_idx
def test_normalize_does_not_change_ok_path():
"""归一化不影响 SQLCODE=0 成功路径"""
r = _make_runner()
pp = Path(r'C:\Users\marye\AppData\Local\Temp\opencode\t3b_pp.cbl')
pp.write_text(_pp_text_with_if(), encoding="utf-8")
r._patch_sqlcode_normalize(pp)
out = pp.read_text(encoding="utf-8")
assert "IF SQLCODE = 0" in out
+169
View File
@@ -0,0 +1,169 @@
"""KIN08DBU DB 管道修复测试。
覆盖:
- Fix A: _scan_assign_to 引号 ASSIGN 捕获阻断运行回归
- Fix B: _inject_sql_error_rows 目标实际 INSERT 记录9100DBERRSOR 可达
- Fix D: 聚合边界数据注入overflow / agg table full
"""
import os
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
import pytest
from orchestrator_db import GixsqlOrchestrator
def _make_orch(src_text: str):
orch = object.__new__(GixsqlOrchestrator)
tmp = Path(tempfile.mkdtemp())
orch.src_path = tmp / "prog.cbl"
orch.src_path.write_text(src_text, encoding="utf-8-sig")
orch.work_dir = tmp / "work"
return orch, tmp
KIN08_SRC = """ IDENTIFICATION DIVISION.
PROGRAM-ID. KIN08DBU.
ENVIRONMENT DIVISION.
INPUT-OUTPUT SECTION.
FILE-CONTROL.
SELECT R01INNFIL ASSIGN TO "KIN08R01".
SELECT SYSINFILE ASSIGN TO "KIN08S01".
SELECT W01OUTFIL ASSIGN TO "KIN08W01".
DATA DIVISION.
FILE SECTION.
FD R01INNFIL.
01 R01INNREC.
PROCEDURE DIVISION.
OPEN INPUT R01INNFIL.
OPEN INPUT SYSINFILE.
OPEN OUTPUT W01OUTFIL.
"""
def test_scan_assign_to_does_not_capture_quote():
"""带引号 ASSIGN TO 应返回不带引号的文件名(Fix A 回归)。"""
orch, _ = _make_orch(KIN08_SRC)
assign_map = orch._scan_assign_to()
assert assign_map.get('KIN08S01') == 'INPUT', f'KIN08S01 应为 INPUT, 实际: {assign_map}'
assert assign_map.get('KIN08R01') == 'INPUT'
assert assign_map.get('KIN08W01') == 'OUTPUT'
# 禁止出现带前导引号的键
assert not any(k.startswith('"') for k in assign_map), f'键不应含引号: {assign_map}'
def test_make_synthetic_error_rows_skips_blanked_first_record():
"""合成错误行应跳过 records[0](其 EMP-ID 会被清空、运行时不会 INSERT)。"""
orch, _ = _make_orch(KIN08_SRC)
table = type('T', (), {
'name': 'DAILY_RECORDS',
'columns': [
type('C', (), {'name': 'EMP_ID', 'primary_key': True}),
type('C', (), {'name': 'TARGET_DATE', 'primary_key': True}),
type('C', (), {'name': 'TIME_IN', 'primary_key': False}),
],
})()
records = [
{'R01EMP-ID': ' ' * 8, 'R01DATE': '20000101', 'R01TIME-IN': '0900', 'HV-EMP-ID': ' ' * 8, 'HV-TARGET-DATE': '20000101'},
{'R01EMP-ID': '00000102', 'R01DATE': '20000102', 'R01TIME-IN': '0900', 'HV-EMP-ID': '00000102', 'HV-TARGET-DATE': '20000102'},
{'R01EMP-ID': '00000103', 'R01DATE': '20000103', 'R01TIME-IN': '0900', 'HV-EMP-ID': '00000103', 'HV-TARGET-DATE': '20000103'},
]
rows = orch._make_synthetic_error_rows(table, records)
assert rows, '应生成合成错误行'
# 第一行不应是 records[0](空 EMP-ID 记录)
assert rows[0][0].strip() != '', f'首行 EMP_ID 不应为空白: {rows[0]}'
assert rows[0][0] == '00000102', f'首行应为 records[1] 的 EMP_ID: {rows[0]}'
def test_inject_sql_error_rows_uses_per_row_values():
"""inject 循环内应逐行使用 row 值(而非固定 rows[0])。"""
orch, tmp = _make_orch(KIN08_SRC)
table = type('T', (), {
'name': 'DAILY_RECORDS',
'columns': [
type('C', (), {'name': 'EMP_ID', 'primary_key': True}),
type('C', (), {'name': 'TARGET_DATE', 'primary_key': True}),
type('C', (), {'name': 'TIME_IN', 'primary_key': False}),
],
})()
orch.schema = type('S', (), {'db_tables': [table]})()
db = tmp / 'kin.db'
import sqlite3
conn = sqlite3.connect(str(db))
conn.execute('CREATE TABLE [DAILY_RECORDS] ([EMP_ID] CHAR(8), [TARGET_DATE] CHAR(8), [TIME_IN] CHAR(4), PRIMARY KEY ([EMP_ID], [TARGET_DATE]))')
conn.commit()
conn.close()
records = [
{'R01EMP-ID': '00000101', 'R01DATE': '20000101', 'R01TIME-IN': '0900', 'HV-EMP-ID': '00000101', 'HV-TARGET-DATE': '20000101'},
{'R01EMP-ID': '00000102', 'R01DATE': '20000102', 'R01TIME-IN': '0900', 'HV-EMP-ID': '00000102', 'HV-TARGET-DATE': '20000102'},
{'R01EMP-ID': '00000103', 'R01DATE': '20000103', 'R01TIME-IN': '0900', 'HV-EMP-ID': '00000103', 'HV-TARGET-DATE': '20000103'},
]
orch._inject_sql_error_rows(db, records)
conn = sqlite3.connect(str(db))
rows = conn.execute('SELECT EMP_ID FROM [DAILY_RECORDS]').fetchall()
conn.close()
emp_ids = {r[0] for r in rows}
# 至少注入 1 条与 records[1] 匹配的 PK(运行时将被 INSERT → 冲突)
assert '00000102' in emp_ids, f'应注入 records[1] 的 PK, 实际: {emp_ids}'
def test_format_value_numeric_field_non_numeric_writes_spaces():
"""数字字段收到非数字值不应被静默转成 0(会造成 '00000000' PK 冲突),应写 SPACE。"""
from cobol_testgen.flatfile import _format_value
field = {'name': 'R01EMP-ID', 'type': 'numeric', 'length': 8,
'pic': '9(008)', 'usage': None,
'pic_info': {'type': 'numeric', 'digits': 8, 'decimal': 0,
'length': 8, 'signed': False}}
# 非数字值 → SPACE(程序空社員チェックでスキップ)
assert _format_value('U0000031', field) == b' ' * 8, \
f'非数字 EMP 应写 SPACE, 实际 {_format_value("U0000031", field)!r}'
# 数字值 → 正常 zfill
assert _format_value('102', field) == b'00000102'
# 全ゼロ → 保持(由 sanitize 负责 blank
assert _format_value('00000000', field) == b'00000000'
def test_inject_aggregation_boundaries():
"""聚合边界注入:overflow 同键大小时数 + agg-full 超 100 组合(数字 EMP 字段)。"""
orch, tmp = _make_orch(KIN08_SRC)
recs = []
for i in range(60):
recs.append({
'R01EMP-ID': f'00000{i + 100}', 'R01DATE': f'{20000101 + i:08d}',
'R01TIME-IN': '0900', 'R01TIME-OUT': '1800',
'R01ANNUAL-H': '00010', 'R01PERSONAL-H': '00000',
'R01OFFICIAL-H': '00000', 'R01SICK-H': '00000', 'R01ABSENT-H': '00000',
'R01FILLER': ' ' * 151,
})
# 字母型 EMP(数字字段的非法值)不应被选为 dup_eid
recs.append({'R01EMP-ID': 'U0000031', 'R01DATE': '20001231',
'R01TIME-IN': '0900', 'R01TIME-OUT': '1800',
'R01ANNUAL-H': '00010', 'R01PERSONAL-H': '00000',
'R01OFFICIAL-H': '00000', 'R01SICK-H': '00000', 'R01ABSENT-H': '00000',
'R01FILLER': ' ' * 151})
orch._inject_aggregation_boundaries(recs)
# overflow: 至少 2 条同 (EMP, 月) 且 ANNUAL-H 为最大值
pairs = {}
for r in recs:
if r.get('R01ANNUAL-H'):
ym = r['R01DATE'][:6]
pairs.setdefault((r['R01EMP-ID'], ym), []).append(r['R01ANNUAL-H'])
max_seen = max('9' * len(v[0]) for v in pairs.values() if v)
over = [(k, v) for k, v in pairs.items() if len(v) >= 2 and all(h == max_seen for h in v)]
assert over, f'应存在同键 overflow 组, pairs={pairs}'
# 追加记录的 EMP 必须为数字(否则 9(008) 字段写文件会变 SPACE
for r in recs[60:]:
assert str(r['R01EMP-ID']).isdigit(), f'追加记录 EMP 应为数字: {r["R01EMP-ID"]!r}'
# agg-full: 不同 (EMP, 月) 组合 >= 101
distinct = set()
for r in recs:
distinct.add((r['R01EMP-ID'], r['R01DATE'][:6]))
assert len(distinct) >= 101, f'应有 >=101 个聚合键, 实际 {len(distinct)}'
+97
View File
@@ -0,0 +1,97 @@
"""KIN09CSV 修复测试。
覆盖:
- Fix A: PARM 场景配置missing_mode / reordered_parm / missing_ym / empty_daily
- Fix B: seed_extra_rows 大结果集注入表头重出分支可达
"""
import os
import sqlite3
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
import pytest
from orchestrator_db import GixsqlOrchestrator
def _make_orch():
orch = object.__new__(GixsqlOrchestrator)
table = type('T', (), {
'name': 'DAILY_RECORDS',
'columns': [
type('C', (), {'name': 'EMP_ID', 'primary_key': True}),
type('C', (), {'name': 'TARGET_DATE', 'primary_key': True}),
type('C', (), {'name': 'TIME_IN', 'primary_key': False}),
type('C', (), {'name': 'UPDATED_AT', 'primary_key': False}),
],
})()
orch.schema = type('S', (), {'db_tables': [table]})()
return orch
def _make_db(path: Path):
conn = sqlite3.connect(str(path))
conn.execute('CREATE TABLE [DAILY_RECORDS] ('
'[EMP_ID] CHAR(8), [TARGET_DATE] CHAR(8), '
'[TIME_IN] CHAR(4), [UPDATED_AT] TIMESTAMP, '
'PRIMARY KEY ([EMP_ID], [TARGET_DATE]))')
conn.execute("INSERT INTO [DAILY_RECORDS] VALUES ('Y0000001', '20260701', '0900', '2026-07-01')")
conn.commit()
conn.close()
def test_seed_extra_rows_injects_rows():
"""seed_extra_rows 应从 seed 行推导月份并注入唯一 (EMP, 日期) 行。"""
orch = _make_orch()
tmp = Path(tempfile.mkdtemp())
db = tmp / 'kin.db'
_make_db(db)
scenario = type('S', (), {'seed_extra_rows': {'DAILY_RECORDS': 10}})()
orch._inject_extra_seed_rows(db, scenario)
conn = sqlite3.connect(str(db))
rows = conn.execute('SELECT EMP_ID, TARGET_DATE FROM [DAILY_RECORDS]').fetchall()
conn.close()
emps = {r[0] for r in rows}
assert len(rows) >= 11, f'应注入 10 条额外行, 实际 {len(rows)}'
assert 'SEED0001' in emps and 'SEED0010' in emps, f'应含 SEED 行, 实际 {emps}'
# 注入行日期应在查询月份内
seed_dates = {r[1] for r in rows if r[0].startswith('SEED')}
assert seed_dates == {'20260701'}, f'注入日期应为 20260701, 实际 {seed_dates}'
def test_seed_extra_rows_noop_when_not_configured():
"""seed_extra_rows 未配置时不应注入。"""
orch = _make_orch()
tmp = Path(tempfile.mkdtemp())
db = tmp / 'kin.db'
_make_db(db)
scenario = type('S', (), {'seed_extra_rows': {}})()
orch._inject_extra_seed_rows(db, scenario)
conn = sqlite3.connect(str(db))
n = conn.execute('SELECT COUNT(*) FROM [DAILY_RECORDS]').fetchone()[0]
conn.close()
assert n == 1, f'不应注入额外行, 实际 {n}'
def test_kin09csv_scenarios_parse():
"""KIN09CSV.yaml 场景(含 seed_extra_rows)应正确解析。"""
from config.program_schema import load_schema
schema = load_schema('KIN09CSV', [str(Path(__file__).parent.parent / 'config' / 'programs')])
by_id = {r.id: r for r in schema.runs}
assert 'missing_mode' in by_id, f'应含 missing_mode 场景: {list(by_id)}'
assert 'reordered_parm' in by_id
assert 'missing_ym' in by_id
assert 'empty_daily' in by_id
assert by_id['missing_mode'].command_line == 'YEARMONTH=202607'
assert by_id['reordered_parm'].command_line == 'MODE=FULL,YEARMONTH=202607'
assert by_id['missing_ym'].command_line == 'MODE=FULL'
assert by_id['empty_daily'].drop_tables == ['DAILY_RECORDS'], \
f'empty_daily 应 drop DAILY_RECORDS, 实际 {by_id["empty_daily"].drop_tables}'
full = by_id['FULL']
assert full.seed_extra_rows.get('DAILY_RECORDS') == 100, \
f'FULL 场景 seed_extra_rows 应为 100, 实际 {full.seed_extra_rows}'
+65
View File
@@ -0,0 +1,65 @@
r"""子程序 gcov 行号不得并入主程序 gcov 字典的回归测试。
Bug`generate_coverage_report` `gcov_data.update(sub_merged)` 把子程序
(SUB*.cbl) gcov 行计数并入主程序行号字典主程序与子程序的行号都是纯
整数直接碰撞SUB04CHK 的第 167 count=0覆盖了 SHA02MNC 主程序
167 count=25主循环 PERFORM 已进入导致 #10 Enter 分支被误判为
未覆盖SHA02MNC 覆盖率从应有的水平被拉低
修复子程序 gcov 按子程序名独立保存`_sub_gcov_data`不并入主程序
`gcov_data``_merge_run_dirs_gcov` 对单个程序跨 run 目录合并
"""
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from pathlib import Path
from cobol_testgen.gcov import parse_cbl_gcov
from orchestrator_db import _merge_run_dirs_gcov
def _write_fake_gcov(dirpath: Path, name: str, counts: dict[int, int]):
lines = []
for ln in sorted(counts):
cnt = counts[ln]
marker = "#####" if cnt == 0 else f"{cnt}*"
lines.append(f"{marker}:{ln}: dummy source line\n")
(dirpath / f"{name}.cbl.gcov").write_text("".join(lines), encoding="utf-8")
def _fake_gcov_func(name: str, d: str):
return parse_cbl_gcov(os.path.join(d, f"{name}.cbl.gcov"))
def test_sub_gcov_line_collision_does_not_corrupt_main(tmp_path):
run_normal = tmp_path / "run_normal"; run_normal.mkdir()
run_fail = tmp_path / "run_fail"; run_fail.mkdir()
# 主程序:line 167 在 normal 场景执行 25 次(主循环进入)
_write_fake_gcov(run_normal, "MAIN", {167: 25, 265: 1})
_write_fake_gcov(run_fail, "MAIN", {167: 0, 265: 1})
# 子程序:line 167 从未执行(count=0)——与主程序行号碰撞
_write_fake_gcov(run_normal, "SUB", {167: 0, 10: 1})
_write_fake_gcov(run_fail, "SUB", {167: 0, 10: 1})
main = _merge_run_dirs_gcov(tmp_path, "MAIN", gcov_func=_fake_gcov_func)
sub = _merge_run_dirs_gcov(tmp_path, "SUB", gcov_func=_fake_gcov_func)
# 合并后主程序 167 必须保持 25
assert main[167] == 25, f"main line 167 corrupted: {main[167]}"
assert sub[167] == 0
# 旧 bug 行为演示:若把子程序并入主程序字典,167 会被覆盖为 0
buggy = dict(main)
buggy.update(sub)
assert buggy[167] == 0, "demonstrates the corruption the fix avoids"
# 修复不变量:子程序数据独立保存,主程序字典完好
assert main[167] == 25
def test_merge_run_dirs_takes_max_count(tmp_path):
r1 = tmp_path / "run_a"; r1.mkdir()
r2 = tmp_path / "run_b"; r2.mkdir()
_write_fake_gcov(r1, "MAIN", {167: 25})
_write_fake_gcov(r2, "MAIN", {167: 3})
merged = _merge_run_dirs_gcov(tmp_path, "MAIN", gcov_func=_fake_gcov_func)
assert merged[167] == 25
+326
View File
@@ -0,0 +1,326 @@
# ファイル定義書
## 変更履歴
| No | 変更内容 | 担当者 | 変更日 | 承認者 | 備考 |
|----|---------|--------|--------|--------|------|
| 1 | 新規作成 | AI | 2026/06/15 | | COPY10部品を一括定義 |
---
# ZAN01REC — 加班申請レコード
## Layout(レコードレイアウト)
### ファイル基本情報
| ファイル名 | COPY ID | 媒体 | レコード形式 | レコード長 |
|-----------|---------|------|-------------|-----------|
| OVT-VALID / OVT-CANCEL / OVT-SORTED / OVT-CSORT / OVT-VSORT / OVT-NODUP / OVT-CHECKED | ZAN01REC | PS | FB | 80 |
### レコード定義
| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 |
|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------|
| 1 | 03 | 申請番号 | (A)APPL-ID | X(8) | 8 | | 1 | | | YYYYMMDD+SEQ |
| 2 | 03 | 社員番号 | (A)EMP-ID | 9(8) | 8 | | 9 | | | |
| 3 | 03 | 申請日 | (A)APPL-DATE | 9(8) | 8 | | 17 | | | YYYYMMDD |
| 4 | 03 | 開始時刻 | (A)START-TIME | 9(4) | 4 | | 25 | | | HHMM |
| 5 | 03 | 終了時刻 | (A)END-TIME | 9(4) | 4 | | 29 | | | HHMM |
| 6 | 03 | ステータス | (A)STATUS | X(1) | 1 | | 33 | | | 0:有効/1:確定/9:取消 |
| 7 | 03 | 残業種別 | (A)OVT-TYPE | X(1) | 1 | | 34 | | | W:平日/H:休日 |
| 8 | 03 | 予約 | (A)FILLER | X(46) | 46 | | 35 | | | |
### 使用プログラム
| プログラム | プレフィックス | 用途 | I/O |
|-----------|--------------|------|-----|
| ZAN01CHK | W01 | OVT-VALID出力(STATUS=0/1 | O |
| ZAN01CHK | W02 | OVT-CANCEL出力(STATUS=9 | O |
| ZAN02CHK | R01 | OVT-VSORT入力 | I |
| ZAN02CHK | W01 | OVT-NODUP出力 | O |
| ZAN03CHK | R01 | OVT-NODUP入力 | I |
| ZAN03CHK | W01 | OVT-CHECKED出力 | O |
| ZAN04MAT | R01 | OVT-SORTED入力(有効申請) | I |
| ZAN04MAT | R02 | OVT-CSORT入力(取消申請) | I |
---
# ZAN02REC — マッチング結果レコード
## Layout(レコードレイアウト)
### ファイル基本情報
| ファイル名 | COPY ID | 媒体 | レコード形式 | レコード長 |
|-----------|---------|------|-------------|-----------|
| OVT-MATCHED | ZAN02REC | PS | FB | 80 |
### レコード定義
| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 |
|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------|
| 1 | 03 | 申請番号 | (A)APPL-ID | X(8) | 8 | | 1 | | | |
| 2 | 03 | 社員番号 | (A)EMP-ID | 9(8) | 8 | | 9 | | | |
| 3 | 03 | 申請日 | (A)APPL-DATE | 9(8) | 8 | | 17 | | | YYYYMMDD |
| 4 | 03 | 開始時刻 | (A)START-TIME | 9(4) | 4 | | 25 | | | HHMM |
| 5 | 03 | 終了時刻 | (A)END-TIME | 9(4) | 4 | | 29 | | | HHMM |
| 6 | 03 | ステータス | (A)STATUS | X(1) | 1 | | 33 | | | |
| 7 | 03 | 残業種別 | (A)OVT-TYPE | X(1) | 1 | | 34 | | | W:平日/H:休日 |
| 8 | 03 | 処理番号 | (A)PROC-SEQ | 9(2) | 2 | | 35 | | | 01固定 |
| 9 | 03 | 予約 | (A)FILLER | X(44) | 44 | | 37 | | | |
### ZAN01RECとの差分
| 項目 | ZAN01REC | ZAN02REC |
|------|----------|----------|
| 追加 | — | PROC-SEQ9(2) |
| FILLER | X(46) | X(44) |
### 使用プログラム
| プログラム | プレフィックス | 用途 | I/O |
|-----------|--------------|------|-----|
| ZAN04MAT | W01 | OVT-MATCHED出力 | O |
---
# ZAN03REC — 集約結果レコード
## Layout(レコードレイアウト)
### ファイル基本情報
| ファイル名 | COPY ID | 媒体 | レコード形式 | レコード長 |
|-----------|---------|------|-------------|-----------|
| OVT-AGGREGATED | ZAN03REC | PS | FB | 80 |
### レコード定義
| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 |
|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------|
| 1 | 03 | 申請番号 | (A)APPL-ID | X(8) | 8 | | 1 | | | |
| 2 | 03 | 社員番号 | (A)EMP-ID | 9(8) | 8 | | 9 | | | |
| 3 | 03 | 申請日 | (A)APPL-DATE | 9(8) | 8 | | 17 | | | YYYYMMDD |
| 4 | 03 | 開始時刻 | (A)START-TIME | 9(4) | 4 | | 25 | | | HHMM |
| 5 | 03 | 終了時刻 | (A)END-TIME | 9(4) | 4 | | 29 | | | HHMM |
| 6 | 03 | 残業時間 | (A)OVT-HOURS | 9(4)V9(1) | 5 | | 33 | | | HH.h0.1h単位) |
| 7 | 03 | 残業種別 | (A)OVT-TYPE | X(1) | 1 | | 38 | | | W:平日/H:休日 |
| 8 | 03 | 予約 | (A)FILLER | X(42) | 42 | | 39 | | | |
### ZAN01RECとの差分
| 項目 | ZAN01REC | ZAN03REC |
|------|----------|----------|
| 削除 | STATUS | — |
| 追加 | — | OVT-HOURS9(4)V9(1) |
| FILLER | X(46) | X(42) |
### 使用プログラム
| プログラム | プレフィックス | 用途 | I/O | 備考 |
|-----------|--------------|------|-----|------|
| ZAN05CAL | (未定) | 集約結果出力 | O | 未実装 |
---
# ZAN04REC — DBCLEAN対象レコード
## Layout(レコードレイアウト)
### ファイル基本情報
| ファイル名 | COPY ID | 媒体 | レコード形式 | レコード長 |
|-----------|---------|------|-------------|-----------|
| OVT-DBCLEAN | ZAN04REC | PS | FB | 80 |
### レコード定義
| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 |
|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------|
| 1 | 03 | 申請番号 | (A)APPL-ID | X(8) | 8 | | 1 | | | DB削除対象申請番号 |
| 2 | 03 | 予約 | (A)FILLER | X(72) | 72 | | 9 | | | |
### 使用プログラム
| プログラム | プレフィックス | 用途 | I/O |
|-----------|--------------|------|-----|
| ZAN04MAT | W02 | OVT-DBCLEAN出力(APPL-IDのみ設定) | O |
---
# ZAN05REC — エラーログレコード
## Layout(レコードレイアウト)
### ファイル基本情報
| ファイル名 | COPY ID | 媒体 | レコード形式 | レコード長 |
|-----------|---------|------|-------------|-----------|
| ERROR-LOG | ZAN05REC | PS | VB | 200 |
### レコード定義
| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 |
|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------|
| 1 | 03 | エラーカテゴリ | (A)ERR-CATEGORY | 9(2) | 2 | | 1 | | | 01:項目CHK/02:重複/03:打刻不突合/03:取消監査 |
| 2 | 03 | エラー詳細 | (A)ERR-DETAIL | X(198) | 198 | | 3 | | | STRING編集値 |
### 使用プログラム
| プログラム | プレフィックス | エラーカテゴリ | I/O |
|-----------|--------------|---------------|-----|
| ZAN01CHK | W03 | 01(項目チェックエラー) | O |
| ZAN02CHK | W02 | 02(時間重複エラー) | O |
| ZAN03CHK | W02 | 03(打刻不突合エラー) | O |
| ZAN04MAT | W03 | 03(取消マッチ監査証跡) | O |
---
# ZANDATAC — SUB01DAT 運用日付取得用連絡領域
## Layout(レコードレイアウト)
### ファイル基本情報
| ファイル名 | COPY ID | 媒体 | レコード形式 | レコード長 |
|-----------|---------|------|-------------|-----------|
| SUB01DAT連絡領域 | ZANDATAC | — | — | 10 |
### レコード定義
| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 |
|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------|
| 1 | 01 | 運用日付パラメタ | D01UBSPAR | | | | 1 | | | CALL USING対象 |
| 2 | 03 | 復帰コード | D01FKICOD | S9(4) COMP | 2 | | 1 | | D01UBSPAR | 0000:正常 |
| 3 | 03 | 運用日付 | D01UBSUDATE | 9(8) | 8 | | 3 | | D01UBSPAR | YYYYMMDD |
### 使用プログラム
| プログラム | 呼出方法 | 備考 |
|-----------|---------|------|
| ZAN01CHK | CALL 'SUB01DAT' USING D01UBSPAR | 初期処理 |
| ZAN02CHK | CALL 'SUB01DAT' USING D01UBSPAR | 初期処理 |
| ZAN04MAT | CALL 'SUB01DAT' USING D01UBSPAR | 初期処理 |
※ ZAN03CHKはFUNCTION CURRENT-DATEを使用し本COPY未使用
---
# ZANMSGAC — SUB02MSG メッセージ出力用連絡領域
## Layout(レコードレイアウト)
### ファイル基本情報
| ファイル名 | COPY ID | 媒体 | レコード形式 | レコード長 |
|-----------|---------|------|-------------|-----------|
| SUB02MSG連絡領域 | ZANMSGAC | — | — | 303 |
### レコード定義
| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 |
|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------|
| 1 | 01 | メッセージパラメタ | M00MHOPAR | | | | 1 | | | CALL USING対象 |
| 2 | 03 | メッセージ番号 | M00MSGCOD | 9(3) | 3 | | 1 | | M00MHOPAR | |
| 3 | 03 | パラメータ1 | M00UMKDATS22-01 | X(30) | 30 | | 4 | | M00MHOPAR | |
| 4 | 03 | パラメータ2 | M00UMKDATS22-02 | X(30) | 30 | | 34 | | M00MHOPAR | |
| 5 | 03 | パラメータ3 | M00UMKDATS22-03 | X(30) | 30 | | 64 | | M00MHOPAR | |
| 6 | 03 | パラメータ4 | M00UMKDATS22-04 | X(30) | 30 | | 94 | | M00MHOPAR | |
| 7 | 03 | パラメータ5 | M00UMKDATS22-05 | X(30) | 30 | | 124 | | M00MHOPAR | |
| 8 | 03 | パラメータ6 | M00UMKDATS22-06 | X(30) | 30 | | 154 | | M00MHOPAR | |
| 9 | 03 | パラメータ7 | M00UMKDATS22-07 | X(30) | 30 | | 184 | | M00MHOPAR | |
| 10 | 03 | パラメータ8 | M00UMKDATS22-08 | X(30) | 30 | | 214 | | M00MHOPAR | |
| 11 | 03 | パラメータ9 | M00UMKDATS22-09 | X(30) | 30 | | 244 | | M00MHOPAR | |
| 12 | 03 | パラメータ10 | M00UMKDATS22-10 | X(30) | 30 | | 274 | | M00MHOPAR | |
### 使用プログラム
| プログラム | 呼出方法 | 備考 |
|-----------|---------|------|
| ZAN01CHK | CALL 'SUB02MSG' USING M00MHOPAR | |
| ZAN02CHK | CALL 'SUB02MSG' USING M00MHOPAR | |
| ZAN03CHK | CALL 'SUB02MSG' USING M00MHOPAR | |
| ZAN04MAT | CALL 'SUB02MSG' USING M00MHOPAR | |
---
# ZANENDAC — SUB03END ABEND処理用連絡領域
## Layout(レコードレイアウト)
### ファイル基本情報
| ファイル名 | COPY ID | 媒体 | レコード形式 | レコード長 |
|-----------|---------|------|-------------|-----------|
| SUB03END連絡領域 | ZANENDAC | — | — | 3 |
### レコード定義
| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 |
|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------|
| 1 | 01 | ABENDパラメタ | E01ABDPAR | | | | 1 | | | CALL USING対象 |
| 2 | 03 | ABENDコード | E01ABDCOD | 9(3) | 3 | | 1 | | E01ABDPAR | 999:汎用 |
### 使用プログラム
| プログラム | 呼出方法 |
|-----------|---------|
| ZAN01CHK | CALL 'SUB03END' USING E01ABDPAR |
| ZAN02CHK | CALL 'SUB03END' USING E01ABDPAR |
| ZAN03CHK | CALL 'SUB03END' USING E01ABDPAR |
| ZAN04MAT | CALL 'SUB03END' USING E01ABDPAR |
---
# ZANCHKAC — SUB04CHK 項目チェック用連絡領域
## Layout(レコードレイアウト)
### ファイル基本情報
| ファイル名 | COPY ID | 媒体 | レコード形式 | レコード長 |
|-----------|---------|------|-------------|-----------|
| SUB04CHK連絡領域 | ZANCHKAC | — | — | 92 |
### レコード定義
| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 |
|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------|
| 1 | 01 | 項目チェックパラメタ | C01CHKPAR | | | | 1 | | | CALL USING対象 |
| 2 | 03 | チェックタイプ | C01CHKTYP | X(8) | 8 | | 1 | | C01CHKPAR | DATE/TIME/NUM/EMPID |
| 3 | 03 | チェック対象データ | C01CHKDAT | X(80) | 80 | | 9 | | C01CHKPAR | |
| 4 | 03 | 復帰コード | C01CHKRRC | 9(4) | 4 | | 89 | | C01CHKPAR | 0000:正常/0001:日付/0002:時刻/0003:数字/0004:社員番号/9999:不明 |
### 使用プログラム
| プログラム | 呼出方法 | 備考 |
|-----------|---------|------|
| ZAN01CHK | CALL 'SUB04CHK' USING C01CHKPAR | 日付/時刻チェック |
---
# ZANTIMAC — SUB05TIM 時刻丸め用連絡領域
## Layout(レコードレイアウト)
### ファイル基本情報
| ファイル名 | COPY ID | 媒体 | レコード形式 | レコード長 |
|-----------|---------|------|-------------|-----------|
| SUB05TIM連絡領域 | ZANTIMAC | — | — | 14 |
### レコード定義
| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 |
|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------|
| 1 | 01 | 時刻丸めパラメタ | T01TIMPAR | | | | 1 | | | CALL USING対象 |
| 2 | 03 | 入力時間 | T01TIMHRS | 9(4)V9(1) | 5 | | 1 | | T01TIMPAR | HH.h |
| 3 | 03 | 出力時間 | T01TIMOUT | 9(4)V9(1) | 5 | | 6 | | T01TIMPAR | 丸め後 |
| 4 | 03 | 丸めモード | T01TIMRRC | 9(4) | 4 | | 11 | | T01TIMPAR | 0:0.5h切上/1:0.1h切上/2:0.1h切捨/3:四捨五入 |
### 使用プログラム
| プログラム | 呼出方法 | 備考 |
|-----------|---------|------|
| — | — | ZAN05CALで使用予定(未実装) |
+232
View File
@@ -0,0 +1,232 @@
# DB定義書
## 変更履歴
| No | 変更内容 | 担当者 | 変更日 | 承認者 | 備考 |
|----|---------|--------|--------|--------|------|
| 1 | 新規作成(全8テーブル) | AI | 2026/06/23 | | サブシステムA(6) + B(2) |
---
# EMP_MASTER — 社員マスタ
## Layout(テーブル定義)
### DB基本情報
| DB名 | DB ID | COPY ID | INDEX |
|------|-------|---------|-------|
| 勤怠休暇管理DB | EMP_MASTER | — | PK: EMP_ID |
### カラム定義
| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 |
|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------|
| 1 | 社員番号 | EMP_ID | CHAR | 8 | | 8 | NOT NULL | | ✓ | | | | |
| 2 | 部署ID | DEPT_ID | CHAR | 4 | | 4 | NOT NULL | | | | | | |
| 3 | 氏名 | EMP_NAME | VARCHAR | 50 | | 50 | NOT NULL | | | | | | |
| 4 | ステータス | STATUS | CHAR | 1 | | 1 | NOT NULL | | | | | | '1'=在籍, '9'=退職 |
### 備考
- サブシステムA: 参照のみ(SELECT)
- サブシステムB: 使用しない
---
# LEAVE_RECORDS — 休暇申請記録
## Layout(テーブル定義)
### DB基本情報
| DB名 | DB ID | COPY ID | INDEX |
|------|-------|---------|-------|
| 勤怠休暇管理DB | LEAVE_RECORDS | — | PK: APPLICATION_ID |
### カラム定義
| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 |
|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------|
| 1 | 申請番号 | APPLICATION_ID | INTEGER | 10 | | 4 | NOT NULL | | ✓ | | | | 自動採番 |
| 2 | 社員番号 | EMP_ID | CHAR | 8 | | 8 | NOT NULL | | | | | | |
| 3 | 休暇種別 | LEAVE_TYPE | CHAR | 2 | | 2 | NOT NULL | | | | | | 01/02/03/04 |
| 4 | 開始日 | START_DATE | CHAR | 8 | | 8 | NOT NULL | | | | | | YYYYMMDD |
| 5 | 開始時刻 | START_TIME | CHAR | 4 | | 4 | NOT NULL | | | | | | HHMM |
| 6 | 終了日 | END_DATE | CHAR | 8 | | 8 | NOT NULL | | | | | | YYYYMMDD |
| 7 | 終了時刻 | END_TIME | CHAR | 4 | | 4 | NOT NULL | | | | | | HHMM |
| 8 | ステータス | STATUS | CHAR | 1 | | 1 | NOT NULL | | | | | | '1'=有効, '9'=取消 |
### 備考
- KIN02UPDがINSERT/DELETEを実行
- KIN03EXPがSELECT(日付展開用)
- 取消はDELETE FROM で物理削除
---
# HOLIDAY_CALENDAR — 休日カレンダー
## Layout(テーブル定義)
### DB基本情報
| DB名 | DB ID | COPY ID | INDEX |
|------|-------|---------|-------|
| 勤怠休暇管理DB | HOLIDAY_CALENDAR | — | PK: HOLIDAY_DATE |
### カラム定義
| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 |
|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------|
| 1 | 休日日付 | HOLIDAY_DATE | CHAR | 8 | | 8 | NOT NULL | | ✓ | | | | YYYYMMDD |
| 2 | 説明 | DESCRIPTION | VARCHAR | 50 | | 50 | NULL許可 | NULL | | | | | 例:「建国記念の日」 |
### 備考
- 土日は曜日判定で処理するため、このテーブルには祝日のみ格納
- KIN03EXP, KIN06CLDがSELECTで参照
---
# SICK_LEAVE_RATE — 病欠控除率
## Layout(テーブル定義)
### DB基本情報
| DB名 | DB ID | COPY ID | INDEX |
|------|-------|---------|-------|
| 勤怠休暇管理DB | SICK_LEAVE_RATE | — | PK: LEAVE_TYPE |
### カラム定義
| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 |
|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------|
| 1 | 休暇種別 | LEAVE_TYPE | CHAR | 2 | | 2 | NOT NULL | | ✓ | | | | '04'固定 |
| 2 | 控除率 | DEDUCTION_RATE | DECIMAL | 3 | 2 | 3 | NOT NULL | | | | | | 例:0.5050%控除) |
### 備考
- 全社員共通の設定値
- 現時点では参照プログラム未実装(設計上定義)
---
# DAILY_RECORDS — 日別勤怠記録
## Layout(テーブル定義)
### DB基本情報
| DB名 | DB ID | COPY ID | INDEX |
|------|-------|---------|-------|
| 勤怠休暇管理DB | DAILY_RECORDS | — | PK: (EMP_ID, TARGET_DATE) |
### カラム定義
| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 |
|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------|
| 1 | 社員番号 | EMP_ID | CHAR | 8 | | 8 | NOT NULL | | ✓ | | | | |
| 2 | 対象日 | TARGET_DATE | CHAR | 8 | | 8 | NOT NULL | | ✓ | | | | YYYYMMDD |
| 3 | 出勤時刻 | TIME_IN | CHAR | 4 | | 4 | NOT NULL | | | | | | HHMM, '0000'=打刻なし |
| 4 | 退勤時刻 | TIME_OUT | CHAR | 4 | | 4 | NOT NULL | | | | | | HHMM, '0000'=打刻なし |
| 5 | 年休時間 | ANNUAL_LEAVE_H | DECIMAL | 6 | 1 | 5 | NOT NULL | | | | | | 0.1h単位 |
| 6 | 事假時間 | PERSONAL_LEAVE_H | DECIMAL | 6 | 1 | 5 | NOT NULL | | | | | | 0.1h単位 |
| 7 | 因公特批假時間 | OFFICIAL_LEAVE_H | DECIMAL | 6 | 1 | 5 | NOT NULL | | | | | | 0.1h単位 |
| 8 | 病欠時間 | SICK_LEAVE_H | DECIMAL | 6 | 1 | 5 | NOT NULL | | | | | | 0.1h単位 |
| 9 | 未申請欠勤時間 | UNAPPROVED_ABSENT_H | DECIMAL | 6 | 1 | 5 | NOT NULL | | | | | | 0.1h単位 |
| 10 | 更新日時 | UPDATED_AT | TIMESTAMP | — | | 10 | NOT NULL | | | | | | DEFAULT CURRENT_TIMESTAMP |
### 備考
- KIN08DBUがKIN07DAIの出力ファイルからINSERT
- 1社員1日=1レコード。出勤日のみ存在。
---
# MONTHLY_ABSENCE — 月次統計
## Layout(テーブル定義)
### DB基本情報
| DB名 | DB ID | COPY ID | INDEX |
|------|-------|---------|-------|
| 勤怠休暇管理DB | MONTHLY_ABSENCE | — | PK: (EMP_ID, YEAR_MONTH) |
### カラム定義
| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 |
|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------|
| 1 | 社員番号 | EMP_ID | CHAR | 8 | | 8 | NOT NULL | | ✓ | | | | |
| 2 | 対象年月 | YEAR_MONTH | CHAR | 6 | | 6 | NOT NULL | | ✓ | | | | YYYYMM |
| 3 | 年休合計 | ANNUAL_LEAVE_H | DECIMAL | 6 | 1 | 5 | NOT NULL | | | | | | |
| 4 | 事假合計 | PERSONAL_LEAVE_H | DECIMAL | 6 | 1 | 5 | NOT NULL | | | | | | |
| 5 | 因公特批假合計 | OFFICIAL_LEAVE_H | DECIMAL | 6 | 1 | 5 | NOT NULL | | | | | | |
| 6 | 病欠合計 | SICK_LEAVE_H | DECIMAL | 6 | 1 | 5 | NOT NULL | | | | | | |
| 7 | 未申請欠勤合計 | UNAPPROVED_ABSENT_H | DECIMAL | 6 | 1 | 5 | NOT NULL | | | | | | |
| 8 | 更新日時 | UPDATED_AT | TIMESTAMP | — | | 10 | NOT NULL | | | | | | DEFAULT CURRENT_TIMESTAMP |
### 備考
- KIN08DBUが月次集計後にUPSERTMERGE
- サブシステムC(給与計算)への連携元
---
# OVT_APPLICATIONS — 個別加班申請テーブル
## Layout(テーブル定義)
### DB基本情報
| DB名 | DB ID | COPY ID | INDEX |
|------|-------|---------|-------|
| 残業統計管理DB | OVT_APPLICATIONS | — | PK: APPL_ID |
### カラム定義
| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 |
|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------|
| 1 | 申請番号 | APPL_ID | CHAR | 8 | | 8 | NOT NULL | | ✓ | | | | YYYYMMDD+SEQ |
| 2 | 社員番号 | EMP_ID | CHAR | 8 | | 8 | NOT NULL | | | | | | |
| 3 | 申請日 | APPL_DATE | CHAR | 8 | | 8 | NOT NULL | | | | | | YYYYMMDD |
| 4 | 残業種別 | OVT_TYPE | CHAR | 1 | | 1 | NOT NULL | | | | | | W=平日, H=休日 |
| 5 | 開始時刻 | START_TIME | CHAR | 4 | | 4 | NOT NULL | | | | | | HHMM |
| 6 | 終了時刻 | END_TIME | CHAR | 4 | | 4 | NOT NULL | | | | | | HHMM |
| 7 | 加班時間 | OVT_HOURS | DECIMAL | 4 | 1 | 4 | NOT NULL | | | | | | 0.1h単位 |
| 8 | ステータス | STATUS | CHAR | 1 | | 1 | NOT NULL | | | | | | 0=有効, 9=取消 |
| 9 | 更新日時 | UPDATED_AT | TIMESTAMP | — | | 10 | NOT NULL | | | | | | |
### 備考
- ZAN06UPDがINSERT/UPDATEを実行
- STATUS=9で取消(物理削除はしない)
---
# OVT_MONTHLY — 月次集計テーブル
## Layout(テーブル定義)
### DB基本情報
| DB名 | DB ID | COPY ID | INDEX |
|------|-------|---------|-------|
| 残業統計管理DB | OVT_MONTHLY | — | PK: (EMP_ID, YEAR_MONTH, OVT_TYPE) |
### カラム定義
| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 |
|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------|
| 1 | 社員番号 | EMP_ID | CHAR | 8 | | 8 | NOT NULL | | ✓ | | | | |
| 2 | 対象年月 | YEAR_MONTH | CHAR | 6 | | 6 | NOT NULL | | ✓ | | | | YYYYMM |
| 3 | 残業種別 | OVT_TYPE | CHAR | 1 | | 1 | NOT NULL | | ✓ | | | | W=平日, H=休日 |
| 4 | 加班時間合計 | OVT_HOURS | DECIMAL | 6 | 1 | 5 | NOT NULL | | | | | | |
| 5 | 加班回数 | OVT_COUNT | INTEGER | 10 | | 4 | NOT NULL | | | | | | |
| 6 | 更新日時 | UPDATED_AT | TIMESTAMP | — | | 10 | NOT NULL | | | | | | |
### 備考
- ZAN06UPDが月次集計結果をUPSERT
+153
View File
@@ -0,0 +1,153 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 勤怠休暇管理システム |
| 2 | プログラムID | KIN01INP |
| 3 | プログラム名 | 休暇申請CSV取込・検証処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | 振り分け |
| 6 | 機能概要 | CSV形式の休暇申請ファイルを読み込み、休暇種別テーブル検索と項目チェックを行い、ステータスによってWORK-LEAVEまたはERROR-LOGへ振り分ける。 |
| 7 | | ステータス'1':有効申請としてWORK-LEAVEに出力(項目チェック実施) |
| 8 | | ステータス'9':取消申請としてWORK-LEAVEに出力(項目チェックなし、APPL-ID保持) |
| 9 | | その他・フィールド数異常・休暇種別異常:ERROR-LOGに出力 |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 前提条件
| NO | 対象ファイル | 条件 |
|----|-------------|------|
| 1 | KIN01R01(CSV) | ソート不要。CSV形式(カンマ区切り、8項目) |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------|
| 1 | CSV-INPUT | R01 | KIN01R01 | I | 自前(80B) | F | | 80 | PS | CSV形式 |
| 2 | WORK-LEAVE | W01 | KIN01W01 | O | KIN01REC | FB | | 80 | PS | |
| 3 | ERROR-LOG | W02 | KIN01W02 | O | KIN05REC | VB | | 200 | PS | |
### キー項目一覧
| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) |
|----|-----------|---------------------|-------------------------------------------|
| 1 | CSV-INPUT | なし | なし |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | 運用日付取得SUB | SUB01DAT | ZANDATAC |
| 2 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 3 | ABEND処理SUB | SUB03END | ZANENDAC |
| 4 | 項目チェックSUB | SUB04CHK | ZANCHKAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリアの初期化
1-4.休暇種別テーブル設定(WRK-LEAVE-TYPE-TABLE)
WRK-LT-CODE(1)='01' (年休/年假)
WRK-LT-CODE(2)='02' (事假)
WRK-LT-CODE(3)='03' (因公特批假)
WRK-LT-CODE(4)='04' (病欠)
1-5.運用日付取得SUB(SUB01DAT)により運用日を取得する。
復帰コード≠ZEROの場合、メッセージを出力し、ABEND処理SUBを呼び出し異常終了する。
【メッセージ編集】
メッセージ番号:5(サブエラー)
PARM1'SUB01DAT'
PARM2:復帰コード
【ABEND処理SUB】
ABENDコード:999
1-6.使用ファイルのオープン(R01:入力、W01/W02:出力)
1-7.R01を読み込む。(1100R01INNSOR)(1回目)
2.主処理(2000MAJSOR)(R01を全て読み終えるまで下記を繰り返す)
2-1.CSVの分解(2010CSVSOR)
UNSTRINGでカンマ区切りのCSVを8項目に分解する。
(APPL-ID, EMP-ID, START-DATE, START-TIME, END-DATE, END-TIME, LEAVE-TYPE, STATUS)
2-2.休暇種別テーブル検索(2020LEAVSERSOR)
SEARCH(非ALL)でWRK-LEAVE-TYPE-TABLEを検索し、休暇種別の妥当性を検証する。
2-3.エラー判定
2-3-1.フィールド数≠8の場合
ERROR-LOGに出力する。(2050ERRORSOR)
エラー種別:'F'(FIELD COUNT ERROR)
2-3-2.休暇種別がテーブルに存在しない場合
ERROR-LOGに出力する。(2050ERRORSOR)
エラー種別:'L'(INVALID LEAVE TYPE)
2-3-3.ステータス'1'(有効)の場合
項目チェックを実施し、エラーがあればERROR-LOGに出力、なければWORK-LEAVEに出力する。(2030VALIDATESOR)
2-3-3-1.SUB04CHKで社員番号チェック(EMPID): WRK-CSV-EMP-IDをC01CHKDATに設定し、CALL 'SUB04CHK' EMPIDタイプで社員番号フォーマットの妥当性を検証する。エラーの場合はW02ERR-CATEGORY='01'でエラーログに出力し本レコードをスキップ。
2-3-3-2.開始日付チェック(SUB04CHK)。エラー→W02出力
2-3-3-3.開始時刻チェック(SUB04CHK)。エラー→W02出力
2-3-3-4.終了日付チェック(SUB04CHK)。エラー→W02出力
2-3-3-5.終了時刻チェック(SUB04CHK)。エラー→W02出力
2-3-3-6.複合条件チェック(AND+OR+3段ネスト)
開始日>0 AND 終了日>0 AND 開始時刻>0 の場合:
- 開始日>終了日 → WRK-DEMO-TYPE=10
- 開始日=終了日 AND 開始時刻>=終了時刻 → WRK-DEMO-TYPE=10
- 上記以外 → WRK-DEMO-TYPE=20
2-3-3-7.すべて通過→W01出力(APPL-ID=0, STATUS='1')
2-3-4.ステータス'9'(取消)の場合
WORK-LEAVEにそのまま出力する。(2040CANCELSOR)
APPL-ID=CSV値、STATUS='9'(項目チェックなし)
2-3-5.その他のステータスの場合
ERROR-LOGに出力する。(2050ERRORSOR)
エラー種別:'S'(INVALID STATUS)
2-4.R01を読み込む。(1100R01INNSOR)(2件目以降)
3.終了処理(3000STPSOR
3-1.入出力ファイルのクローズ
3-2.入出力件数出力メッセージ出力
【入力メッセージ編集】
メッセージ番号:6(入力件数メッセージ)
PARM1'KIN01R01'
PARM2:当該入力ファイルの件数
【出力メッセージ編集】
メッセージ番号:7(出力件数メッセージ)
PARM1'KIN01W01'、'KIN01W02'
PARM2:当該出力ファイルの件数
3-3.終了メッセージ出力
【メッセージ編集】
メッセージ番号:2(終了メッセージ)
```
---
## 出力レコード定義
### 出力ファイル1W01/WORK-LEAVE KIN01REC 80B FB
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | APPL-ID | WRK-CSV-APPL-ID | 有効時=0、取消時=CSV値 |
| 2 | EMP-ID | WRK-CSV-EMP-ID | |
| 3 | LEAVE-TYPE | WRK-CSV-LEAVE-TYPE | 01/02/03/04 |
| 4 | START-DATE | WRK-CSV-START-DATE | YYYYMMDD |
| 5 | START-TIME | WRK-CSV-START-TIME | HHMM |
| 6 | END-DATE | WRK-CSV-END-DATE | YYYYMMDD |
| 7 | END-TIME | WRK-CSV-END-TIME | HHMM |
| 8 | STATUS | WRK-CSV-STATUS | '1'(有効)または'9'(取消) |
| 9 | FILLER | SPACE | 36B |
### 出力ファイル2W02/ERROR-LOG KIN05REC 200B VB
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | ERR-CATEGORY | '01'固定 | |
| 2 | ERR-DETAIL | STRING編集 | エラー内容(198B) |
+155
View File
@@ -0,0 +1,155 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 勤怠休暇管理システム |
| 2 | プログラムID | KIN02UPD |
| 3 | プログラム名 | 休暇申請DB更新処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | DB更新 |
| 6 | 機能概要 | WORK-LEAVEファイルの各レコードをDB2テーブルLEAVE_RECORDSに反映する。 |
| 7 | | ステータスによって新規登録(INSERT)、変更(DELETE+INSERT)、取消(DELETE)を行う。 |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 前提条件
| NO | 対象ファイル | 条件 |
|----|-------------|------|
| 1 | KIN01W01 | KIN01INPの出力順に従う。ソート不要。 |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------|
| 1 | WORK-LEAVE | R01 | KIN01W01 | I | KIN01REC | FB | | 80 | PS | |
| 2 | ERROR-LOG | W01 | KIN02W01 | O | KIN05REC | VB | | 200 | PS | DBエラー時出力 |
| 3 | LEAVE_RECORDS | DB | — | I/U/D | — | — | — | — | DB | SQLite |
### キー項目一覧
| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) |
|----|-----------|---------------------|-------------------------------------------|
| 1 | WORK-LEAVE | なし | なし |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 2 | ABEND処理SUB | SUB03END | ZANENDAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリアの初期化
1-4.DB接続
EXEC SQL CONNECT TO 'data/kin.db'
1-5.R01ファイルOPEN(入力)+ W01ファイルOPEN(出力)
1-6.R01を初回読込(1100R01INNSOR)
2.主処理(2000MAJSOR)(R01を全て読み終えるまで下記を繰り返す)
2-1.レコード処理(2100PROCSOR)
2-1-1.R01各項目をホスト変数(WS-*)にMOVEする。
2-1-2.ロジック分岐判定(EVALUATE)
2-1-2-1.STATUS='1' AND APPL-ID=0(新規)の場合
INSERT処理(2110INSERTSOR)
【SQL】
INSERT INTO LEAVE_RECORDS
(EMP_ID, LEAVE_TYPE, START_DATE, START_TIME,
END_DATE, END_TIME, STATUS)
VALUES
(:WS-EMP-ID, :WS-LEAVE-TYPE,
:WS-START-DATE, :WS-START-TIME,
:WS-END-DATE, :WS-END-TIME,
:WS-STATUS)
SQLCODE≠0の場合、DBエラー処理(9100DBERRSOR)を実行する。
CUN-DBXINSを+1する。
2-1-2-2.STATUS='1' AND APPL-ID>0(変更)の場合
UPDATE処理(2120UPDATESOR)
【SQL】
DELETE FROM LEAVE_RECORDS
WHERE APPLICATION_ID = :WS-APPL-ID
INSERT INTO LEAVE_RECORDS
(EMP_ID, LEAVE_TYPE, START_DATE, START_TIME,
END_DATE, END_TIME, STATUS)
VALUES
(:WS-EMP-ID, :WS-LEAVE-TYPE,
:WS-START-DATE, :WS-START-TIME,
:WS-END-DATE, :WS-END-TIME,
:WS-STATUS)
各SQLでSQLCODE≠0の場合、DBエラー処理(9100DBERRSOR)を実行する。
CUN-DBXUPDを+1する。
2-1-2-3.STATUS='9'(取消)の場合
DELETE処理(2130DELETESOR)
【SQL】
DELETE FROM LEAVE_RECORDS
WHERE APPLICATION_ID = :WS-APPL-ID
SQLCODE≠0の場合、DBエラー処理(9100DBERRSOR)を実行する。
CUN-DBXDELを+1する。
2-1-2-4.その他
何も処理しない(CONTINUE)
2-2.次レコード読込(1100R01INNSOR)(2件目以降)
3.終了処理(3000STPSOR
3-1.COMMIT
EXEC SQL COMMIT WORK
3-2.入出力ファイルのクローズ
3-3.件数メッセージ出力
【メッセージ編集】
メッセージ番号:6(入力件数)
PARM1'KIN01W01'
PARM2:入力件数
PARM1'INS'、'UPD'、'DEL'
PARM2:各処理件数
3-4.終了メッセージ出力
【メッセージ編集】
メッセージ番号:2(終了メッセージ)
4.DBエラー処理(9100DBERRSOR
4-1.ROLLBACK
EXEC SQL ROLLBACK WORK
4-2.ERROR-LOG出力(W01OUTFIL
【MOVE → INITIALIZE & WRITE】
MOVE SQLCODE TO WRK-SQLCODE-DISP
W01ERR-CATEGORY = '01'(固定)
W01ERR-DETAIL = 'KIN02UPD SQLCODE=' + WRK-SQLCODE-DISP + ' APPL-ID=' + WS-APPL-ID
CUN-W01OUTを+1する。
4-3.エラーメッセージ出力
【メッセージ編集】
メッセージ番号:5(サブエラー)
PARM1'KIN02UPD SQL ERROR'
PARM2SQLCODE
PARM3WS-APPL-ID
4-4.ABEND処理SUB呼出(9999ABDSOR)
ABENDコード:999
```
---
## 出力レコード定義
### 出力ファイル1W01/ERROR-LOG
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | ERR-CATEGORY | 01で固定 | DBエラー |
| 2 | ERR-DETAIL | STRINGで編集 | KIN02UPD SQLCODE= + WRK-SQLCODE-DISP + APPL-ID= + WS-APPL-ID |
### DB更新結果
LEAVE_RECORDSテーブルに直接反映される。
+170
View File
@@ -0,0 +1,170 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 勤怠休暇管理システム |
| 2 | プログラムID | KIN03EXP |
| 3 | プログラム名 | 休暇日別展開処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | キーブレイク(集計) |
| 6 | 機能概要 | LEAVE_RECORDS(DB2)より有効申請(STATUS='1')を読込み、開始日〜終了日の期間を日別に展開し、休日・週末を除外してLEAVE-DAILYファイルを出力する。 |
| 7 | | 社員番号(EMP_ID)キーブレイクで小計出力を行う。 |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 前提条件
| NO | 対象ファイル | 条件 |
|----|-------------|------|
| 1 | DB(LEAVE_RECORDS) | EMP_ID, START_DATE順にソート済(ORDER BYで取得) |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------|
| 1 | LEAVE-DAILY | W01 | KIN02W01.DAT | O | KIN02REC | FB | | 80 | PS | 日別展開後レコード |
| 2 | LEAVE_RECORDS | DB | — | I | — | — | — | — | DB | SQLite |
| 3 | HOLIDAY_CALENDAR | DB | — | I | — | — | — | — | DB | SQLite |
### キー項目一覧
| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) |
|----|-----------|---------------------|-------------------------------------------|
| 1 | DB(LEAVE_RECORDS) | EMP-ID, START_DATE(昇順) | キーブレイク: EMP-ID |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | 運用日付取得SUB | SUB01DAT | ZANDATAC |
| 2 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 3 | ABEND処理SUB | SUB03END | ZANENDAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリアの初期化
1-4.DB接続
EXEC SQL CONNECT TO 'data/kin.db'
1-5.運用日付取得SUB(SUB01DAT)により運用日を取得する。
復帰コード≠ZEROの場合、メッセージを出力し、ABEND処理SUBを呼び出し異常終了する。
【メッセージ編集】
メッセージ番号:5(サブエラー)
PARM1'SUB01DAT'
PARM2:復帰コード
【ABEND処理SUB】
ABENDコード:999
1-6.休日カレンダーテーブル読込(1200HDINNSOR)
【SQL】
SELECT HOLIDAY_DATE FROM HOLIDAY_CALENDAR ORDER BY HOLIDAY_DATE
FETCHループでWORKING-STORAGEのWRK-HOLIDAY-TABLEに全件格納する。
WRK-HD-COUNTに件数を保持。最大366件(ODO定義)。
1-7.出力ファイルOPENW01
1-8.C1カーソル初回FETCH(1100C1INITSOR)
【SQL】
SELECT APPLICATION_ID, EMP_ID, LEAVE_TYPE,
START_DATE, START_TIME, END_DATE, END_TIME
FROM LEAVE_RECORDS
WHERE STATUS = '1'
ORDER BY EMP_ID, START_DATE
INTO :SQL-APPL-ID, :SQL-EMP-ID, :SQL-LEAVE-TYPE,
:SQL-START-DATE, :SQL-START-TIME,
:SQL-END-DATE, :SQL-END-TIME
SQLCODE=0の場合、入力件数+1
SQLCODE≠0の場合、WRK-R01EOF='1'EOF
2.主処理(2000MAJSOR)(R01を全て読み終えるまで下記を繰り返す)
2-1.社員番号キー保存
SQL-EMP-ID → WRK-BFR-EMP-ID
2-2.1社員分処理(2100-PROCESS-EMP)(PERFORM THRU)
2-2-1.CUN-EMP-SUB初期化(ZERO)
2-2-2.EOFまたはキー変化(SQL-EMP-ID≠WRK-BFR-EMP-ID)まで繰り返し
2-2-2-1.日付展開処理(2200-EXPAND-DATE)(PERFORM THRU)
2-2-2-1-1.開始日(SQL-START-DATE)→WRK-DATE-CURRENT
終了日(SQL-END-DATE)→WRK-DATE-END
2-2-2-1-2.WRK-DATE-CURRENT > WRK-DATE-END までループ
2-2-2-1-2-1.曜日判定
COMPUTE WRK-DAY-OF-WEEK = FUNCTION MOD(
FUNCTION INTEGER-OF-DATE(WRK-DATE-CURRENT), 7)
0=日曜日、6=土曜日 → 週末としてスキップ
2-2-2-1-2-2.非週末の場合、休日テーブル検索
SEARCH ALL WRK-HD-ENTRY(昇順KEY=WRK-HD-DATE
WRK-HD-DATE(WRK-HD-IDX) = WRK-DATE-CURRENT → 休日としてスキップ
2-2-2-1-2-3.非週末かつ非休日の場合、W01出力
W01レコード初期化
APPL-ID = SQL-APPL-ID
EMP-ID = WRK-BFR-EMP-ID
LEAVE-TYPE = SQL-LEAVE-TYPE
START-TIME = SQL-START-TIME
END-TIME = SQL-END-TIME
DATE = WRK-DATE-ALPHA(現在日付)
WRITE W01OUTREC
CUN-W01OUT +1
CUN-EMP-SUB +1
2-2-2-1-2-4.日付加算(2300-DATE-ADD-1)
WRK-DATE-DAY + 1
各月の最終日判定:
- 1/3/5/7/8/10/12月:31日超で月+1、13月→年+1
- 4/6/9/11月:30日超で月+1
- 2月:閏年判定(MOD年400=0 または MOD年4=0 AND MOD年100≠0)
閏年→29日超、平年→28日超で月+1
2-2-3.次レコードFETCH(1100C1FETCHSOR)
CALL 'br_fetch_next' USING SQLCODE
SQLCODE=0の場合、br_get_colで各カラムを取得
SQLCODE≠0の場合、WRK-R01EOF='1'
2-2-4.社員別小計出力(キーブレイク)
【メッセージ編集】
メッセージ番号:33(情報メッセージ)
PARM1WRK-BFR-EMP-ID
PARM2CUN-EMP-SUB
PARM3'EMP SUB'
3.終了処理(3000STPSOR
3-1.出力ファイルのクローズ
3-2.件数メッセージ出力
【メッセージ編集】
メッセージ番号:6(入力件数)
PARM1'LEAVE_RECORDS'
PARM2:入力件数
【メッセージ編集】
メッセージ番号:7(出力件数)
PARM1'KIN02W01'
PARM2:出力件数
【メッセージ編集】
メッセージ番号:33(情報)
PARM1'HOLIDAYS LOADED'
PARM2:休日テーブル件数
3-3.終了メッセージ出力
【メッセージ編集】
メッセージ番号:2(終了メッセージ)
```
---
## 出力レコード定義
### 出力ファイル1W01/LEAVE-DAILY KIN02REC 80B FB
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | EMP-ID | WRK-BFR-EMP-ID | キーブレイク単位 |
| 2 | DATE | WRK-DATE-ALPHA | 展開された日付(YYYYMMDD) |
| 3 | LEAVE-TYPE | SQL-LEAVE-TYPE | 01/02/03/04 |
| 4 | START-TIME | SQL-START-TIME | 元申請の開始時刻(HHMM) |
| 5 | END-TIME | SQL-END-TIME | 元申請の終了時刻(HHMM) |
| 6 | APPL-ID | SQL-APPL-ID | APPLICATION_ID |
| 7 | FILLER | SPACE | 45B |
+155
View File
@@ -0,0 +1,155 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 勤怠休暇管理システム |
| 2 | プログラムID | KIN04CHK |
| 3 | プログラム名 | 打刻項目チェック処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | 項目チェック(重複なし) |
| 6 | 機能概要 | CSV形式の打刻データファイルを読み込み、項目チェックを行い、正常レコードをEDITED-PUNCHへ、異常レコードをERROR-LOGへ振り分ける。 |
| 7 | | チェックはIF多重ネスト(THEN句)で4段階:社員番号必須→日付妥当性→時刻妥当性→出勤<退勤 |
| 8 | | 正常出力はWRITE FROMを使用する(新規カバレッジ) |
### 前提条件
| NO | 対象ファイル | 条件 |
|----|-------------|------|
| 1 | KIN04R01(CSV) | ソート不要。CSV形式(カンマ区切り、6項目) |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------|
| 1 | RAW-PUNCH | R01 | KIN04R01 | I | 自前(80B) | F | | 80 | PS | CSV形式 |
| 2 | EDITED-PUNCH | W01 | KIN04W01 | O | KIN04REC | FB | | 80 | PS | WRITE FROM使用 |
| 3 | ERROR-LOG | W02 | KIN04W02 | O | KIN05REC | VB | | 200 | PS | |
### キー項目一覧
| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) |
|----|-----------|---------------------|-------------------------------------------|
| 1 | RAW-PUNCH | なし | なし |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | 運用日付取得SUB | SUB01DAT | ZANDATAC |
| 2 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 3 | ABEND処理SUB | SUB03END | ZANENDAC |
| 4 | 項目チェックSUB | SUB04CHK | ZANCHKAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリアの初期化
1-4.運用日付取得SUB(SUB01DAT)により運用日を取得する。
復帰コード≠ZEROの場合、メッセージを出力し、ABEND処理SUBを呼び出し異常終了する。
【メッセージ編集】
メッセージ番号:5(サブエラー)
PARM1'SUB01DAT'
PARM2:復帰コード
【ABEND処理SUB】
ABENDコード:999
1-5.使用ファイルのオープン(R01:入力、W01/W02:出力)
1-6.R01を読み込む。(1100R01INNSOR)(1回目)
2.主処理(2000MAJSOR)(R01を全て読み終えるまで下記を繰り返す)
2-1.CSVの分解(2010CSVSOR)
UNSTRINGでカンマ区切りのCSVを6項目に分解する。
(EMP-ID, DATE, TIME-IN, TIME-OUT, DEPT-ID, TERMINAL)
2.1.5.社員番号チェック(2021EMPIDSOR)
SUB04CHK('EMPID')により社員番号のフォーマットチェックを実施する。
SUB04CHKの復帰コードにより正常/異常を判定する。
2-2.IF多重ネストチェック(2020VALIDATESOR)
第1段階: フィールド数チェック + 社員番号チェック(2021EMPIDSOR)
IF フィールド数=6 THEN
2021EMPIDSORを実行
IF 社員番号正常 THEN
SUB04CHK('DATE')で日付チェック
IF 正常 THEN
SUB04CHK('TIME')で時刻チェック(出勤)
IF 正常 THEN
SUB04CHK('TIME')で時刻チェック(退勤)
IF 正常 THEN
IF 出勤時刻 < 退勤時刻 THEN
WRITE FROMでW01出力(2050NORMSOR)
ELSE
エラー区分='02'でW02出力(2090ERRORSOR)
END-IF
ELSE
エラー区分='02'でW02出力(2090ERRORSOR)
END-IF
ELSE
エラー区分='02'でW02出力(2090ERRORSOR)
END-IF
ELSE
エラー区分='02'でW02出力(2090ERRORSOR)
END-IF
ELSE
エラー区分='02'でW02出力(2090ERRORSOR)
END-IF
ELSE
エラー区分='02'でW02出力(2090ERRORSOR)
END-IF
2021EMPIDSOR (社員番号チェック)
本Paragraphは2020VALIDATESORの第1段階(フィールド数OK時)に
PERFORMされる。
SUB04CHK('EMPID')により社員番号のフォーマットチェックを実施する。
復帰コード=ZEROなら正常、≠ZEROなら異常として呼び出し元に戻る。
2-3.R01を読み込む。(1100R01INNSOR)(2件目以降)
3.終了処理(3000STPSOR
3-1.入出力ファイルのクローズ
3-2.入出力件数出力メッセージ出力
【入力メッセージ編集】
メッセージ番号:6(入力件数メッセージ)
PARM1'KIN04R01'
PARM2:当該入力ファイルの件数
【出力メッセージ編集】
メッセージ番号:7(出力件数メッセージ)
PARM1'KIN04W01'、'KIN04W02'
PARM2:当該出力ファイルの件数
3-3.終了メッセージ出力
【メッセージ編集】
メッセージ番号:2(終了メッセージ)
```
---
## 出力レコード定義
### 出力ファイル1W01/EDITED-PUNCH KIN04REC 80B FB
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | EMP-ID | WRK-CSV-EMP-ID | 社員番号 |
| 2 | WORK-DATE | WRK-CSV-DATE | YYYYMMDD |
| 3 | STR-TIME | WRK-CSV-TIME-IN | HHMM |
| 4 | END-TIME | WRK-CSV-TIME-OUT | HHMM |
| 5 | DEPT-ID | WRK-CSV-DEPT-ID | 部署ID |
| 6 | TERMINAL | WRK-CSV-TERMINAL | 端末ID |
| 7 | FILLER | SPACE | 46B |
### 出力ファイル2W02/ERROR-LOG KIN05REC 200B VB
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | ERR-CATEGORY | '02'固定 | 打刻エラー |
| 2 | ERR-DETAIL | STRING編集 | エラー内容(198B) |
+128
View File
@@ -0,0 +1,128 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 勤怠休暇管理システム |
| 2 | プログラムID | KIN05MAT |
| 3 | プログラム名 | 打刻休暇照合処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | マッチング(1:N) |
| 6 | 機能概要 | EDITED-PUNCH(打刻側、R01)とLEAVE-DAILY(休暇側、R02)を社員番号+日付キーで照合し、KIN-LEAVE(160B)に出力する。R01側が主駆動で、1件の打刻に対してN件の休暇レコードが存在しうる(1:N)。 |
| 7 | | マッチング制御はEVALUATE TRUEで3分岐:R01key<R02key(打刻のみ)、R01key=R02key(マッチ)、R01key>R02key(休暇のみスキップ)。 |
| 8 | | マッチ時は休暇種別優先順位(01>02>03>04)で最適な1件を選定し、KIN-LEAVEに出力する。 |
### 前提条件
| NO | 対象ファイル | 条件 |
|----|-------------|------|
| 1 | KIN04W01(R01) | 社員番号+日付で昇順ソート済み(JCL SORT KINJ022 |
| 2 | KIN02W01(R02) | 社員番号+日付で昇順ソート済み(KIN03EXP出力保証) |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------|
| 1 | EDITED-PUNCH | R01 | KIN04W01 | I | KIN04REC | FB | | 80 | PS | 打刻側、主駆動 |
| 2 | LEAVE-DAILY | R02 | KIN02W01 | I | KIN02REC | FB | | 80 | PS | 休暇側、N件あり |
| 3 | KIN-LEAVE | W01 | KIN05W01 | O | KIN03REC | FB | | 160 | PS | 照合結果 |
### ファイルステータス
| ファイル | DD名 | ファイルステータス項目 |
|---------|------|---------------------|
| EDITED-PUNCH(R01) | KIN04W01 | WS-R01-STATUS |
| LEAVE-DAILY(R02) | KIN02W01 | WS-R02-STATUS |
| KIN-LEAVE(W01) | KIN05W01 | WS-W01-STATUS |
### キー項目一覧
| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) |
|----|-----------|---------------------|-------------------------------------------|
| 1 | EDITED-PUNCH | 社員番号(1-8B)+日付(9-16B) | 主キー(R01) |
| 2 | LEAVE-DAILY | 社員番号(1-8B)+日付(9-16B) | 従キー(R02)、N件あり |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 2 | ABEND処理SUB | SUB03END | ZANENDAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリアの初期化
1-4.使用ファイルのオープン(R01/R02:入力、W01:出力)
1-4-1.OPENチェック
各ファイルのFILE STATUS(WS-R01-STATUS/WS-R02-STATUS/WS-W01-STATUS)を確認
ステータス≠00の場合、SUB03END(ZANENDAC)で異常終了
1-5.R01を読み込む。(1100R01INNSOR)(1回目)
1-6.R02を読み込む。(1200R02INNSOR)(1回目)
2.主処理(2000MAJSOR)(R01を全て読み終えるまで下記を繰り返す)
2-1.R02キー設定
R02が有効な場合、R02レコードからWRK-R02KEYを設定。
2-2.EVALUATE TRUE マッチング制御
WHEN R01key < R02key OR R02-EOF:
打刻のみ(休暇なし)→ KIN-LEAVE出力(LEAVE-TYPE='99', HOURS=0)
R01を次へ読み込み
WHEN R01key = R02key:
マッチング処理実行(2020MATCHSOR)
R01を次へ読み込み
R02も次へ読み込み(N:1の場合はループ内で全R02処理)
WHEN R01key > R02key:
R02のみ(該当打刻なし)→ スキップ
R02を次へ読み込み
2-3.上記をR01終了まで繰り返す。
3.マッチング処理(2020MATCHSOR
R02がEOFになるか、キーが変わるまで、以下のループを実行:
3-1.現在のR02レコードから休暇種別・時刻・申請IDを取得
3-2.選定ロジック:
- 初回 → ベストとして保持
- 既にベストあり → 優先順位で比較(01>02>03>04)
- 同種別 → 申請IDの小さい方を採用
3-3.R02を次へ読み込み
3-4.キーが一致しなくなるかR02-EOFまで3-1〜3-3を繰り返す
3-5.ベストの休暇情報をKIN-LEAVEに出力
ベストがない場合は'99'として出力
4.終了処理(3000STPSOR
4-1.入出力ファイルのクローズ
4-2.入出力件数出力メッセージ出力
4-3.終了メッセージ出力
```
---
## 出力レコード定義
### 出力ファイル(W01/KIN-LEAVE KIN03REC 160B FB
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | EMP-ID | R01EMP-ID | 打刻情報(1-80BはEDITED-PUNCHと同一) |
| 2 | WORK-DATE | R01WORK-DATE | |
| 3 | STR-TIME | R01STR-TIME | |
| 4 | END-TIME | R01END-TIME | |
| 5 | DEPT-ID | R01DEPT-ID | |
| 6 | TERMINAL | R01TERMINAL | |
| 7-13 | 休暇情報 | マッチ結果 | 81-160B |
| 7 | LEAVE-TYPE | 選定結果 | '01'〜'04' or '99' |
| 8 | LEAVE-STR-TIME | 選定したR02START-TIME | |
| 9 | LEAVE-END-TIME | 選定したR02END-TIME | |
| 10 | LEAVE-HOURS | ZERO | KIN07DAIで後に計算 |
| 11 | APPLICATION-ID | 選定したR02APPL-ID | 該当なしはZERO |
+116
View File
@@ -0,0 +1,116 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 勤怠休暇管理システム |
| 2 | プログラムID | KIN06CLD |
| 3 | プログラム名 | 出勤日カレンダー生成処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | レイアウト編集のみ(GETPUT) |
| 6 | 機能概要 | PARMで指定された年月の出勤日カレンダーを生成する。在籍社員一覧をDBから取得し、各社員について当月1日〜月末日をPERFORM VARYINGでループ。休日判定(PERFORM VARYING線形探索 + 曜日判定)を行い、出勤日のみWORK-DAY-FILEに出力する。 |
### 前提条件
| NO | 対象ファイル/DB | 条件 |
|----|----------------|------|
| 1 | EMP_MASTER | 在籍社員(STATUS='1')が登録済みであること |
| 2 | HOLIDAY_CALENDAR | 対象年月の休日が登録済みであること |
### PARM引数
コマンドラインで `YEARMONTH=YYYYMM` 形式で指定。必須。
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|
| 1 | WORK-DAY-FILE | OUTREC | KIN06W01 | O | KIN06REC | FB | | 80 | PS |
### 使用DBテーブル
| NO | テーブル名 | 処理内容 |
|----|-----------|---------|
| 1 | EMP_MASTER | SELECT EMP_ID WHERE STATUS='1' ORDER BY EMP_ID (CURSOR) |
| 2 | HOLIDAY_CALENDAR | SELECT HOLIDAY_DATE WHERE HOLIDAY_DATE LIKE 'YYYYMM%' → WORKING-STORAGE格納 |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 2 | ABEND処理SUB | SUB03END | ZANENDAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.PARM解析(YEARMONTH=YYYYMM から対象年月を抽出)
必須チェック:PARMなし→ABEND
形式チェック:YEARMONTH= から始まること
値範囲:XXXX01〜XXXX12
月チェック(1-12): WRK-MONTH < 1 OR > 12 → ABEND(CALL SUB03END)
1-4.ワークエリア初期化
1-5.DB接続(EXEC SQL CONNECT TO 'data/kin.db'
1-6.休日データ読込(HOLIDAY_CALENDARから対象月の祝日をSELECT
テーブルに格納(PERFORM VARYING線形探索用)
【ガード】HOLIDAY-COUNTが50未満の場合のみADD+参照を行う(テーブル溢れ防止)
ループ条件で HOLIDAY-COUNT < 50 をチェック(HOLIDAY-COUNT PIC 9(2)対応)
1-7.出力ファイルOPEN
1-8.社員一覧取得(EMP_MASTER CURSOR OPEN→初回FETCH
2.主処理(2000MAJSOR)(社員EOFまで下記を繰り返す)
2-1.当月1日〜月末日をループ(PERFORM VARYING
2-1-1.日付文字列構築(YYYYMMDD
2-1-2.曜日計算(INTEGER-OF-DATE
- INTEGER-OF-DATE('YYYYMMDD') で通算日数を取得
- MOD(通算日数, 7) で曜日:0=日, 1=月, 2=火, …, 6=土
2-1-3.休日判定(PERFORM VARYING線形探索)
祝日テーブルを先頭から線形探索(部分埋めテーブル対応のためSEARCH ALL不使用)
2-1-4.曜日判定
土曜(0) または 日曜(6)→休日としてスキップ(CONTINUE)
2-1-5.出勤日 → WRITE FROMで出力(社員番号+日付+曜日)
2-2.次の社員をFETCH
3.終了処理(3000STPSOR
3-1.CURSOR CLOSE
3-2.DB切断
3-3.出力ファイルCLOSE
3-4.出力件数メッセージ出力
3-5.終了メッセージ出力
```
### 曜日計算
INTEGER-OF-DATE('YYYYMMDD') は1601-01-01からの通算日数を返す。
- 1601-01-01 = Monday → MOD(x,7)0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat
### 休日判定ロジック
1. HOLIDAY_CALENDARに日付が存在する → 休日(PERFORM VARYING線形探索)
2. 曜日が土曜(6)または日曜(0) → 休日
3. 上記以外 → 出勤日
---
## 出力レコード定義
### 出力ファイル(OUTREC/WORK-DAY-FILEKIN06REC 80B FB
| No | 項目名 | 設定元 | 属性 | 備考 |
|----|--------|--------|------|------|
| 1 | EMP-ID | 社員マスタのEMP_ID | X(8) | 社員番号 |
| 2 | DATE | ループ変数 | 9(8) | YYYYMMDD |
| 3 | DAY-OF-WEEK | 曜日計算結果 | 9(1) | 1=月…7=日 |
| 4 | FILLER | SPACE | X(63) | |
+167
View File
@@ -0,0 +1,167 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 勤怠休暇管理システム |
| 2 | プログラムID | KIN07DAI |
| 3 | プログラム名 | 日別勤怠計算処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | マッチング(1:N) + EVALUATE 4パターン分岐 |
| 6 | 機能概要 | WORK-DAY-FILE(主駆動)とKIN-LEAVE/LEAVE-DAILYを社員番号+日付で照合し、 |
| 7 | | 4パターンに分岐して日別勤怠記録(DAILY-RECORD)を出力する。 |
| 8 | | パターンA: 打刻+休暇あり / B: 打刻のみ / C: 休暇のみ / D: 欠勤 |
| 9 | | 休暇時間はランチ除外+0.1h切上丸め。MULTIPLY/SUBTRACT時間計算。 |
### 前提条件
| NO | 対象ファイル | 条件 |
|----|-------------|------|
| 1 | R01(WORK-DAY-FILE) | 社員番号+日付の昇順でソートされていること |
| 2 | R02(KIN-LEAVE) | 社員番号+日付の昇順でソートされていること |
| 3 | R03(LEAVE-DAILY) | 社員番号+日付の昇順でソートされていること |
### 使用ファイル一覧
| NO | 使用ファイル名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 |
|----|---------------|--------|------|-----|--------|------|---------|-----------|------|------|
| 1 | WORK-DAY-FILE | R01 | KIN07R01 | I | KIN06REC | FB | | 80 | PS | 出勤日カレンダー(COPY句定義書.md参照) |
| 2 | KIN-LEAVE | R02 | KIN07R02 | I | KIN03REC | FB | | 160 | PS | 打刻+休暇照合結果(COPY句定義書.md参照) |
| 3 | LEAVE-DAILY | R03 | KIN07R03 | I | KIN02REC | FB | | 80 | PS | 日別展開休暇(COPY句定義書.md参照) |
| 4 | DAILY-RECORD | W01 | KIN07W01 | O | KIN07REC | FB | | 200 | PS | 日別勤怠計算結果(COPY句定義書.md参照) |
| 5 | ERROR-LOG | W02 | KIN07E01 | O | KIN05REC | VB | | 200 | PS | エラーログ(COPY句定義書.md参照) |
### キー項目一覧
| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) |
|----|-----------|---------------------|-------------------------------------------|
| 1 | ファイルR01 | 社員番号>日付(昇順) | 社員番号+日付(マッチングキー) |
| 2 | ファイルR02 | 社員番号>日付(昇順) | 社員番号+日付(マッチングキー) |
| 3 | ファイルR03 | 社員番号>日付(昇順) | 社員番号+日付(マッチングキー) |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | メッセージ編集出力 | SUB02MSG | ZANMSGAC |
| 2 | ABEND処理 | SUB03END | ZANENDAC |
| 3 | 時刻丸め計算(0.1h切上) | SUB05TIM | ZANTIMAC |
---
## 処理詳細
```
1.初期処理 (1000ITTSOR)
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリアの初期化(INITIALIZE WRKARA
1-4.入出力ファイルOPEN
INPUT: R01INNFIL, R02INNFIL, R03INNFIL
OUTPUT: W01OUTFIL, W02OUTFIL
1-5.R01を読み込む。(1100R01INNSOR)(1回目)
1-6.R02を読み込む。(1200R02INNSOR)(1回目)
1-7.R03を読み込む。(1300R03INNSOR)(1回目)
2.主処理 (2000MAJSOR)(R01を全て読み終えるまで下記を繰り返す)
2-1.キー比較:R01とR02/R03のキーを比較し、PATTERN-NUMを設定
【MULTIPLY使用】時間→分換算時にMULTIPLY文を使用
【SUBTRACT使用】ランチ除外時にSUBTRACT文を使用
2-2.パターン判定(EVALUATE)
EVALUATE PATTERN-NUM
WHEN 1 → PERFORM 6100PATTERNASOR (打刻+休暇)
WHEN 2 → PERFORM 6200PATTERNSOR (打刻のみ)
WHEN 3 → PERFORM 6300PATTERNSOR (休暇のみ)
WHEN 4 → PERFORM 6400PATTERNSOR (欠勤)
END-EVALUATE
2-3.【パターンA(6100PATTERNASOR)】:R01+R02+R03(打刻+休暇)
2-3-1.打刻情報設定:R02の出勤時刻/退勤時刻をDAILY-RECORDに設定
2-3-2.休暇時間計算:R03の開始時刻/終了時刻からランチ除外+丸め計算
MULTIPLY: WRK-START-HOUR × 60 GIVING WRK-START-MIN(時→分変換)
SUBTRACT: ランチ時間(60分)をWRK-START-MINから除外
2-3-3.SUB05TIM呼出(モード1:0.1h切上)
【CALL 'SUB05TIM'】
CALL 'SUB05TIM' USING T01TIMPAR
T01TIMHRS=WRK-LEAVE-HOURS, T01TIMRRC=1(0.1h切上モード)
T01TIMOUTをWRK-LEAVE-HOURSに退避
2-3-4.DAILY-RECORD出力
2-3-5.R01/R02/R03次読込
2-4.【パターンB(6200PATTERNSOR)】:R01+R02のみ(打刻のみ)
2-4-1.打刻情報設定:R02の出勤時刻/退勤時刻をDAILY-RECORDに設定
2-4-2.休暇時間=0, 欠勤=0
2-4-3.DAILY-RECORD出力
2-4-4.R01/R02次読込(R03はキー不一致のため読込済み)
2-5.【パターンC(6300PATTERNSOR)】:R01+R03のみ(休暇のみ)
2-5-1.打刻情報=0000
2-5-2.休暇時間計算:ランチ除外+丸め(所定労働時間8h上限)
MULTIPLY/SUBTRACT使用(パターンAと同様)
WRK-LEAVE-HOURS > 8.0 → 8.0に制限
2-5-3.SUB05TIM呼出(モード1:0.1h切上)
2-5-4.DAILY-RECORD出力
2-5-5.R01/R03次読込(R02はキー不一致のため読込済み)
2-6.【パターンD(6400PATTERNSOR)】:R01のみ(欠勤)
2-6-1.打刻情報=0000, 休暇=0
2-6-2.未申請欠勤=8.0h
2-6-3.DAILY-RECORD出力
2-6-4.R01次読込(R02/R03はキー不一致のため読込済み)
2-7.各パターンセクションはPERFORMで呼び出され、EXITで自動復帰
主処理ループ(2000MAJSOR)に戻り、次のR01レコードを処理する。
3.終了処理(3000STPSOR)
3-1.入出力ファイルのクローズ
3-2.入出力件数出力
【メッセージ編集】
メッセージ番号:6(入力件数)
PARM1:該当ファイルDD名
PARM2:該当ファイル件数
メッセージ番号:7(出力件数)
PARM1:該当ファイルDD名
PARM2:該当ファイル件数
3-3.終了メッセージ出力
【メッセージ編集】
メッセージ番号:2(終了メッセージ)
4.メッセージ出力(4000MSGOUTSOR)
SUB02MSG呼出し
9.ABEND処理(9999ABDSOR)
SUB03END呼出し
```
---
## 出力レコード定義
### 出力ファイル1W01: DAILY-RECORD
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | SD-EMP-ID | SW-EMP-ID(R01) | 社員番号 |
| 2 | SD-DATE | SW-DATE(R01) | YYYYMMDD |
| 3 | SD-TIME-IN | SR-STR-TIME(R02) or '0000' | 出勤時刻(パターンC/Dは0000) |
| 4 | SD-TIME-OUT | SR-END-TIME(R02) or '0000' | 退勤時刻(パターンC/Dは0000) |
| 5 | SD-ANNUAL-H | R03(LEAVE-TYPE='01')より計算 | 年休使用時間(ランチ除外+丸め) |
| 6 | SD-PERSONAL-H | R03(LEAVE-TYPE='02')より計算 | 事假時間 |
| 7 | SD-OFFICIAL-H | R03(LEAVE-TYPE='03')より計算 | 因公特批假時間 |
| 8 | SD-SICK-H | R03(LEAVE-TYPE='04')より計算 | 病欠時間 |
| 9 | SD-ABSENT-H | パターンDのみ8.0固定 | 未申請欠勤時間 |
| 10 | SD-FILLER | SPACE | 予備領域 |
### 出力ファイル2W02: ERROR-LOG
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | WEERR-CATEGORY | '99'固定 | エラー区分(未使用だが予約) |
| 2 | WEERR-DETAIL | STRING編集 | エラー詳細(SUB05TIM異常時等) |
+211
View File
@@ -0,0 +1,211 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 勤怠休暇管理システム |
| 2 | プログラムID | KIN08DBU |
| 3 | プログラム名 | 勤怠DB更新処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | DB更新 + SYSIN読込(P28) |
| 6 | 機能概要 | DAILY-RECORDを読込み、DAILY_RECORDSにINSERT。社員別月次集計後、MONTHLY_ABSENCEにUPSERT。集計結果をABSENCE_SUMMARYファイルに出力する。SYSIN制御カード(T/P/Mの3種)をGO TO DEPENDING ONで分岐処理する。 |
### 前提条件
| NO | 対象ファイル/DB | 条件 |
|----|----------------|------|
| 1 | DAILY-RECORD | KIN07DAIが出力した日別勤怠計算結果が存在すること |
| 2 | DAILY_RECORDS | 同一日付のレコードが既に存在してもMODE=NORMALではUPSERT動作 |
| 3 | MONTHLY_ABSENCE | 集計対象月の統計レコードが未作成でもINSERTできること |
| 4 | SYSIN | 省略時は全社員対象、NORMALモードで動作 |
### SYSIN制御カード仕様
| カード種別 | 第1桁 | フォーマット | 説明 |
|-----------|:-----:|-------------|------|
| TARGET | T | `T 社員番号,社員番号,...` | 集計対象社員指定。省略時=全社員 |
| PERIOD | P | `P YEARMONTH=YYYYMM` | 処理対象年月。必須 |
| MODE | M | `M MODE=RESET\|NORMAL` | RESET=既存削除後再INSERT、NORMAL=UPSERT |
| コメント | * | `* 任意コメント` | 読み飛ばし |
| 不明 | 他 | — | DISPLAYで警告表示 |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|
| 1 | DAILY-RECORD | INREC | KIN08R01 | I | KIN07REC | FB | | 200 | PS |
| 2 | ABSENCE-SUMMARY | OUTREC | KIN08W01 | O | KIN08REC | FB | | 80 | PS |
| 3 | SYSIN | SYSIN | KIN08S01 | I | — | FB | | 80 | PS |
### 使用DBテーブル
| NO | テーブル名 | 処理内容 |
|----|-----------|---------|
| 1 | DAILY_RECORDS | INSERTMODE=NORMAL時) / DELETE+INSERTMODE=RESET時) |
| 2 | MONTHLY_ABSENCE | SELECT COUNT(*) → IF >0 UPDATE ELSE INSERTUPSERT |
| 3 | SICK_LEAVE_RATE | SELECT DEDUCTION_RATE WHERE LEAVE_TYPE='04' |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 2 | ABEND処理SUB | SUB03END | ZANENDAC |
| 3 | 運用日付取得SUB | SUB01DAT | ZANDATAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリア初期化
1-4.DB接続(EXEC SQL CONNECT TO 'data/kin.db'
1-5.SYSINファイルOPEN
1-6.SYSIN読込ループ(EOFまで)
READ SYSINFILE INTO WS-SYSIN-REC
AT END SET SYSIN-EOF TO TRUE
END-READ.
カード種別判定(WS-SYSIN-REC(1:1)):
'T' → MOVE 1 TO WS-DISP-IDX → GO TO DEPENDING ON WS-DISP-IDX
2100-TARGETSOR 2200-PERIODSOR 2300-MODESOR
'P' → MOVE 2 TO WS-DISP-IDX → GO TO DEPENDING ON WS-DISP-IDX
2100-TARGETSOR 2200-PERIODSOR 2300-MODESOR
'M' → MOVE 3 TO WS-DISP-IDX → GO TO DEPENDING ON WS-DISP-IDX
2100-TARGETSOR 2200-PERIODSOR 2300-MODESOR
'*' → コメント行としてDISPLAY表示後スキップ(CONTINUE
他 → DISPLAY 'WARNING: Unknown card type [x]'
1-7.必須パラメータ未設定チェック
Pカード未読込 → ABEND(CALL SUB03END)
1-8.集計対象社員テーブル設定(Tカード省略時は全社員フラグON)
1-9.入力ファイルOPENDAILY-RECORD
1-10.出力ファイルOPENABSENCE-SUMMARY
2.主処理(2000MAJSOR)(R01 EOFまで下記を繰り返す)
2-1.DAILY-RECORD読込(READ INTO
【ガード】社員番号の有効性チェック
INSPECT WS-EMP-ID TALLYING WS-ZERO-CNT FOR LEADING '0'
空文字判定 → DISPLAY警告
2-2.DAILY_RECORDS INSERT
EXEC SQL INSERT INTO DAILY_RECORDS (...) VALUES (:WS-...)
IF SQLCODE NOT = 0 → DISPLAY警告 + 9100DBERRSOR
2-3.社員別月次集計(PERFORM VARYING 内部テーブル)
該当社員の集計行を線形探索
集計計算(COMPUTE ROUNDED ON SIZE ERROR
COMPUTE WS-TOTAL-HOURS ROUNDED =
WS-ANNUAL-H + WS-PERSONAL-H + WS-OFFICIAL-H
+ WS-SICK-H + WS-ABSENT-H
ON SIZE ERROR
DISPLAY 'WARNING: Total hours overflow '
WS-EMP-ID ' date ' WS-DATE
MOVE ZERO TO WS-TOTAL-HOURS
END-COMPUTE
3.終了処理(3000STPSOR
3-1.MODE判定
MODE=RESETの場合:
EXEC SQL DELETE FROM MONTHLY_ABSENCE
WHERE YEAR_MONTH = :WS-YEAR-MONTH
3-2.集計結果のMONTHLY_ABSENCE書出(PERFORM VARYING 集計テーブル)
各社員・月ごとに:
EXEC SQL SELECT COUNT(*) INTO WS-CNT
FROM MONTHLY_ABSENCE
WHERE EMP_ID = :WS-EMP-ID
AND YEAR_MONTH = :WS-YEAR-MONTH
IF WS-CNT > 0
EXEC SQL UPDATE MONTHLY_ABSENCE SET ...
ELSE
EXEC SQL INSERT INTO MONTHLY_ABSENCE (...)
END-IF
IF SQLCODE NOT = 0 → 9100DBERRSOR
3-3.ABSENCE_SUMMARYファイル出力
STRINGで編集後 WRITE
3-4.EXEC SQL COMMIT
3-5.全CURSOR/ファイルCLOSE
3-6.出力件数メッセージ出力
【メッセージ編集】
メッセージ番号:6(出力件数)
PARM1'DAILY_RECORDS'
PARM2CUN-DBXINSINSERT件数)
3-7.終了メッセージ出力
【メッセージ編集】
メッセージ番号:2(終了メッセージ)
```
### SYSINカード処理詳細
```
2100-TARGETSOR SECTION.
UNSTRING WS-SYSIN-REC(3:) DELIMITED BY ','
INTO WS-TARGET-EMP(1) WS-TARGET-EMP(2) ...
TALLYING IN WS-EMP-COUNT
END-UNSTRING.
DISPLAY 'TARGET EMPLOYEES: ' WS-EMP-COUNT ' persons'.
EXIT.
GO TO 2000-EXIT.
2200-PERIODSOR SECTION.
UNSTRING WS-SYSIN-REC(3:) DELIMITED BY '='
INTO WS-PARM-NAME WS-PARM-VALUE
END-UNSTRING.
MOVE WS-PARM-VALUE TO WS-YEAR-MONTH.
DISPLAY 'PERIOD: ' WS-YEAR-MONTH.
EXIT.
GO TO 2000-EXIT.
2300-MODESOR SECTION.
INSPECT WS-SYSIN-REC
REPLACING ALL 'MODE=' BY SPACES.
MOVE WS-SYSIN-REC(3:) TO WS-MODE-VALUE.
IF WS-MODE-VALUE = 'RESET'
SET RESET-MODE TO TRUE
ELSE
SET NORMAL-MODE TO TRUE
END-IF.
DISPLAY 'MODE: ' WS-MODE-VALUE.
EXIT.
GO TO 2000-EXIT.
```
---
## 出力レコード定義
### ABSENCE-SUMMARYOUTREC/KIN08REC80B FB
| No | 項目名 | 設定元 | 属性 | 備考 |
|----|--------|--------|------|------|
| 1 | EMP-ID | MONTHLY_ABSENCE.EMP_ID | X(8) | 社員番号 |
| 2 | YEAR-MONTH | MONTHLY_ABSENCE.YEAR_MONTH | X(6) | YYYYMM |
| 3 | ANNUAL-LEAVE-H | 集計結果 | 9(4)V9(1) | 年休使用合計 |
| 4 | PERSONAL-LEAVE-H | 集計結果 | 9(4)V9(1) | 事假合計 |
| 5 | OFFICIAL-LEAVE-H | 集計結果 | 9(4)V9(1) | 因公特批假合計 |
| 6 | SICK-LEAVE-H | 集計結果 | 9(4)V9(1) | 病欠合計 |
| 7 | UNAPPROVED-ABSENT-H | 集計結果 | 9(4)V9(1) | 未申請欠勤合計 |
| 8 | FILLER | SPACE | X(24) | |
---
## カバレッジ構文一覧
| 構文 | 用途 |
|------|------|
| READ SYSIN(パターン28) | 制御カード読込 ← 新規パターン |
| GO TO DEPENDING ON | カード種別による3分岐 ← 新規 |
| DISPLAY | 警告メッセージ表示 ← 新規 |
| INSPECT TALLYING | 社員番号空チェック、カンマ区切り数検証 |
| COMPUTE ROUNDED ON SIZE ERROR | 時間計算桁あふれ処理 ← 新規 |
| EXEC SQL DELETE | RESETモード既存削除 ← 新規 |
| EXEC SQL INSERT / UPDATE / SELECT | DB操作 |
| PERFORM VARYING | 社員別集計ループ |
| UNSTRING | SYSINカード値解析 |
| STRING | メッセージ編集 |
+207
View File
@@ -0,0 +1,207 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 勤怠休暇管理システム |
| 2 | プログラムID | KIN09CSV |
| 3 | プログラム名 | 勤怠CSV出力処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | GETPUT(編集出力) |
| 6 | 機能概要 | DAILY_RECORDSをDBからSELECTし、STRING WITH POINTERでCSV行を編集、WRITE AFTER ADVANCINGPAGE/2 LINES/1 LINE)で出力する。INSPECT REPLACINGでCSV危険文字をサニタイズする。 |
### 前提条件
| NO | 対象ファイル/DB | 条件 |
|----|----------------|------|
| 1 | DAILY_RECORDS | KIN08DBUにより当月勤怠データが登録済みであること |
| 2 | MONTHLY_ABSENCE | KIN08DBUにより当月集計が完了していること |
| 3 | PARM | YEARMONTH=YYYYMM は必須。省略時はABEND |
### PARM引数
コマンドラインで以下の形式で指定。
```
YEARMONTH=202605,MODE=FULL
```
| パラメータ | 必須 | デフォルト | 説明 |
|-----------|:---:|:--------:|------|
| YEARMONTH | ○ | — | 処理対象年月(YYYYMM形式) |
| MODE | ○ | FULL | FULL=日別+月次全項目出力, SHORT=月次集計のみ |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|
| 1 | CSV-OUTPUT | OUTREC | KIN09W01 | O | KIN09REC | FB | | 200 | PS |
### 使用DBテーブル
| NO | テーブル名 | 処理内容 |
|----|-----------|---------|
| 1 | DAILY_RECORDS | SELECT WHERE TARGET_DATE LIKE 'YYYYMM%' ORDER BY EMP_ID, TARGET_DATE |
| 2 | MONTHLY_ABSENCE | SELECT WHERE YEAR_MONTH = :WS-YEAR-MONTH ORDER BY EMP_ID |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 2 | ABEND処理SUB | SUB03END | ZANENDAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.PARM解析(ACCEPT FROM COMMAND-LINE
INSPECT WS-PARM TALLYING WS-COMMA-CNT FOR ALL ','
IF WS-COMMA-CNT < 1
DISPLAY 'WARNING: Missing MODE, using FULL'
END-IF
UNSTRING WS-PARM DELIMITED BY ','
INTO WS-PARM1 WS-PARM2
END-UNSTRING
UNSTRING WS-PARM1 DELIMITED BY '='
INTO WS-KEY1 WS-VALUE1
END-UNSTRING
UNSTRING WS-PARM2 DELIMITED BY '='
INTO WS-KEY2 WS-VALUE2
END-UNSTRING
YEARMONTH設定、MODE設定(FULL/SHORT判定)
必須チェック:YEARMONTH未設定 → ABEND(CALL SUB03END)
1-4.ワークエリア初期化
1-5.DB接続(EXEC SQL CONNECT TO 'data/kin.db'
1-6.出力ファイルOPENKIN09W01
1-7.ヘッダー行書出(WRITE AFTER ADVANCING PAGE
社員番号,日付,出勤時刻,退勤時刻,休暇種別,
年休時間,事假時間,因公特批假時間,病欠時間,未申請欠勤時間
2.主処理(2000MAJSOR)(FETCH EOFまで下記を繰り返す)
2-1.CURSOR FETCHDAILY_RECORDS
2-2.CSV危険文字サニタイズ(INSPECT REPLACING
MOVE 各項目を退避 → WS-SAFE-FIELD
INSPECT WS-SAFE-FIELD REPLACING ALL ',' BY ';'
ALL X"0D" BY SPACE
ALL X"0A" BY SPACE
2-3.CSV行編集(STRING WITH POINTER
MOVE 1 TO WS-PTR
STRING
WS-EMP-ID DELIMITED BY SIZE
',' DELIMITED BY SIZE
WS-DATE DELIMITED BY SIZE
',' DELIMITED BY SIZE
WS-TIME-IN DELIMITED BY SIZE
',' DELIMITED BY SIZE
WS-TIME-OUT DELIMITED BY SIZE
',' DELIMITED BY SIZE
WS-LEAVE-TYPE DELIMITED BY SIZE
',' DELIMITED BY SIZE
WS-ANNUAL-H DELIMITED BY SIZE
',' DELIMITED BY SIZE
WS-PERSONAL-H DELIMITED BY SIZE
',' DELIMITED BY SIZE
WS-OFFICIAL-H DELIMITED BY SIZE
',' DELIMITED BY SIZE
WS-SICK-H DELIMITED BY SIZE
',' DELIMITED BY SIZE
WS-ABSENT-H DELIMITED BY SIZE
INTO WS-CSV-LINE WITH POINTER WS-PTR
END-STRING
2-4.行数カウンタ加算
2-5.100行ごとにヘッダー再出力(WRITE AFTER ADVANCING 2 LINES + ヘッダー行)
100件毎 → WRITE WS-SEC-REC AFTER ADVANCING 2 LINES
WRITE WS-HDR-REC AFTER ADVANCING 1 LINE
2-6.データ行出力(WRITE AFTER ADVANCING 1 LINE
WRITE WS-OUT-REC FROM WS-CSV-LINE AFTER ADVANCING 1 LINE
3.MODE=SHORT時のみ追加処理(2500-SHORT-MODE
3-1.MONTHLY_ABSENCE CURSOR OPEN
3-2.STRINGで集計行編集
3-3.セクション区切り出力
WRITE WS-SEC-REC AFTER ADVANCING 2 LINES
3-4.集計行出力
WRITE WS-OUT-REC AFTER ADVANCING 1 LINE
4.終了処理(3000STPSOR
4-1.CURSOR CLOSE
4-2.DB切断
4-3.出力ファイルCLOSE
4-4.出力件数メッセージ出力
【メッセージ編集】
メッセージ番号:6(出力件数)
PARM1'CSV-OUTPUT'
PARM2CUN-W01OUT(出力行数)
4-5.終了メッセージ出力
【メッセージ編集】
メッセージ番号:2(終了メッセージ)
```
### WRITE AFTER ADVANCING 3種の使い分け
| タイミング | 構文 | 説明 |
|-----------|------|------|
| ファイル先頭 | WRITE header-rec AFTER ADVANCING PAGE | 改ページ(最初のヘッダー) |
| 100行ごと | WRITE sep-rec AFTER ADVANCING 2 LINES | セクション区切り(空行2行) |
| 100行ごと | WRITE header-rec AFTER ADVANCING 1 LINE | 再ヘッダー出力 |
| データ行 | WRITE data-rec AFTER ADVANCING 1 LINE | 通常データ行 |
---
## 出力レコード定義
### CSV-OUTPUTOUTREC/KIN09REC200B FB
| No | 項目名 | 設定元 | 属性 | 備考 |
|----|--------|--------|------|------|
| 1 | CSV-LINE | STRING編集結果 | X(200) | CSV1行分の文字列データ |
---
## CSVフォーマット
ヘッダー行(WRITE AFTER ADVANCING PAGEで出力):
```
社員番号,日付,出勤時刻,退勤時刻,休暇種別,年休時間,事假時間,因公特批假時間,病欠時間,未申請欠勤時間
```
データ行:
```
00000101,20260501,0900,1800,99,0.0,0.0,0.0,0.0,0.0
00000101,20260502,0900,1300,02,0.0,4.0,0.0,0.0,4.0
```
MODE=SHORT時の集計行(WRITE AFTER ADVANCING 2 LINESで区切り):
```
社員番号,年月,年休計,事假計,因公特批假計,病欠計,未申請欠勤計
00000101,202605,8.0,4.0,0.0,0.0,4.0
```
---
## カバレッジ構文一覧
| 構文 | 用途 |
|------|------|
| ACCEPT FROM COMMAND-LINE | PARM解析 |
| INSPECT TALLYING | カンマ区切り数チェック |
| INSPECT REPLACING | CSV危険文字置換 ← 新規 |
| STRING WITH POINTER | 位置指定CSV行編集 ← 新規 |
| WRITE AFTER ADVANCING PAGE | ページヘッダー制御 ← 新規 |
| WRITE AFTER ADVANCING 2 LINES | セクション区切り ← 新規 |
| WRITE AFTER ADVANCING 1 LINE | データ行出力 ← 新規 |
| STRING | CSV行編集(DELIMITED BY SIZE |
| EXEC SQL SELECT(CURSOR) | DB読込 |
| DISPLAY | 警告メッセージ表示 |
@@ -0,0 +1,52 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 残業統計管理システム |
| 2 | プログラムID | SUB01DAT |
| 3 | プログラム名 | 運用日付取得サブ |
| 4 | PGMタイプ | サブ |
| 5 | PGMパターン | - |
| 6 | 機能概要 | 現在日付を取得し、運用日付(YYYYMMDD)として返す |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 使用方法
```
CALL 'SUB01DAT' USING D01UBSPAR
```
### 入出力パラメータ
| COPY句 | ZANDATAC |
|------------|----------|
| NO | 入出力区分 | 名称 | 属性 | 桁数 | 説明 |
|----|-----------|------|------|------|------|
| 1 | O | D01FKICOD | S9(4) COMP | 4 | 復帰コード(0000:正常) |
| 2 | O | D01UBSUDATE | 9(8) | 8 | 運用日付(YYYYMMDD) |
### リターンコード一覧
| NO | コード | 説明 |
|----|--------|------|
| 1 | 0000 | 正常終了 |
---
## 処理詳細
```
1.制御処理(0000MAINSOR
1-1.現在日時を取得する。
MOVE FUNCTION CURRENT-DATE TO WRK-SYS-DATE.
1-2.YYYYMMDD部分を抽出する。
MOVE WRK-SYS-DATE(1:8) TO D01UBSUDATE.
1-3.正常終了
MOVE ZERO TO D01FKICOD.
1-4.復帰(GOBACK)。
```
@@ -0,0 +1,72 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 残業統計管理システム |
| 2 | プログラムID | SUB02MSG |
| 3 | プログラム名 | メッセージ編集出力サブ |
| 4 | PGMタイプ | サブ |
| 5 | PGMパターン | - |
| 6 | 機能概要 | メッセージ番号とパラメータを編集し、標準出力へ出力する。 |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 使用方法
```
CALL 'SUB02MSG' USING M00MHOPAR
```
### 入出力パラメータ
| COPY句 | ZANMSGAC |
|------------|----------|
| NO | 入出力区分 | 名称 | 属性 | 桁数 | 説明 |
|----|-----------|------|------|------|------|
| 1 | I | M00MSGCOD | 9(3) | 3 | メッセージ番号 |
| 2 | I | M00UMKDATS22-01 | X(30) | 30 | パラメータ1 |
| 3 | I | M00UMKDATS22-02 | X(30) | 30 | パラメータ2 |
| 4 | I | M00UMKDATS22-03 | X(30) | 30 | パラメータ3 |
| 5 | I | M00UMKDATS22-04 | X(30) | 30 | パラメータ4 |
| 6 | I | M00UMKDATS22-05 | X(30) | 30 | パラメータ5 |
| 7 | I | M00UMKDATS22-06 | X(30) | 30 | パラメータ6 |
| 8 | I | M00UMKDATS22-07 | X(30) | 30 | パラメータ7 |
| 9 | I | M00UMKDATS22-08 | X(30) | 30 | パラメータ8 |
| 10 | I | M00UMKDATS22-09 | X(30) | 30 | パラメータ9 |
| 11 | I | M00UMKDATS22-10 | X(30) | 30 | パラメータ10 |
### リターンコード一覧
| NO | コード | 説明 |
|----|--------|------|
| 1 | - | 復帰コードなし(GOBACKで復帰) |
---
## 処理詳細
```
1.制御処理(0000MAINSOR
1-1.メッセージ行(WRK-MSG-LINE)を編集する。
STRING 'MSG['
M00MSGCOD
'] P1='
M00UMKDATS22-01
' P2='
M00UMKDATS22-02
' P3='
M00UMKDATS22-03
' P4='
M00UMKDATS22-04
' P5='
M00UMKDATS22-05
INTO WRK-MSG-LINE
END-STRING.
1-2.標準出力へ出力する。
DISPLAY WRK-MSG-LINE.
1-3.復帰(GOBACK)。
```
@@ -0,0 +1,51 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 残業統計管理システム |
| 2 | プログラムID | SUB03END |
| 3 | プログラム名 | ABEND処理サブ |
| 4 | PGMタイプ | サブ |
| 5 | PGMパターン | - |
| 6 | 機能概要 | ABENDコードを表示し、異常終了する。 |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 使用方法
```
CALL 'SUB03END' USING E01ABDPAR
```
### 入出力パラメータ
| COPY句 | ZANENDAC |
|------------|----------|
| NO | 入出力区分 | 名称 | 属性 | 桁数 | 説明 |
|----|-----------|------|------|------|------|
| 1 | I | E01ABDCOD | 9(3) | 3 | ABENDコード |
### リターンコード一覧
| NO | コード | 説明 |
|----|--------|------|
| 1 | - | ABENDするため復帰コードなし(STOP RUN実行) |
---
## 処理詳細
```
1.制御処理(0000MAINSOR
1-1.ABENDメッセージを編集する。
MOVE 'ABEND CODE=' TO WRK-ABEND-MSG.
MOVE E01ABDCOD TO WRK-ABEND-MSG(12:3).
1-2.メッセージを表示する。
DISPLAY WRK-ABEND-MSG.
1-3.異常終了する。
STOP RUN.
```
+115
View File
@@ -0,0 +1,115 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 残業統計管理システム |
| 2 | プログラムID | SUB04CHK |
| 3 | プログラム名 | 項目チェックサブ |
| 4 | PGMタイプ | サブ |
| 5 | PGMパターン | 項目チェック |
| 6 | 機能概要 | チェックタイプに応じてデータ妥当性を検証する。 |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 使用方法
```
CALL 'SUB04CHK' USING C01CHKPAR
```
### 入出力パラメータ
| COPY句 | ZANCHKAC |
|------------|----------|
| NO | 入出力区分 | 名称 | 属性 | 桁数 | 説明 |
|----|-----------|------|------|------|------|
| 1 | I | C01CHKTYP | X(8) | 8 | チェックタイプ(DATE/TIME/NUM/EMPID) |
| 2 | I | C01CHKDAT | X(80) | 80 | チェック対象データ |
| 3 | O | C01CHKRRC | 9(4) | 4 | 復帰コード(0000:正常,0001:日付不正,0002:時刻不正,0003:数字不正) |
### リターンコード一覧
| NO | コード | 説明 |
|----|--------|------|
| 1 | 0000 | 正常終了(チェックOK) |
| 2 | 0001 | 日付不正(DATE指定時、月01〜12/日01〜31範囲外) |
| 3 | 0002 | 時刻不正(TIME指定時、時00〜23/分00〜59範囲外) |
| 4 | 0003 | 数字不正(NUM指定時、非数字文字を含む) |
| 5 | 0004 | 社員番号不正(EMPID指定時、8桁以外または空欄) |
| 6 | 9999 | チェックタイプ不明 |
---
## 処理詳細
```
1.制御処理(0000MAINSOR
1-1.C01CHKTYPにより分岐する。
1-1-1.C01CHKTYP = 'DATE' の場合
1000CHKDATEへ。
1-1-2.C01CHKTYP = 'TIME' の場合
2000CHKTIMEへ。
1-1-3.C01CHKTYP = 'NUM' の場合
3000CHKNUMへ。
1-1-4.C01CHKTYP = 'EMPID' の場合
4000CHKEMPへ。
1-1-5.その他の場合
C01CHKRRC = 9999 を設定し、1-2へ。
1-2.復帰(GOBACK)。
2.日付チェック(1000CHKDATE
2-1.C01CHKDATから年月日を抽出する。
YYYY = C01CHKDAT(1:4)
MM = C01CHKDAT(5:2)
DD = C01CHKDAT(7:2)
2-2.月チェック(MM)
2-2-1.MM < 1 OR MM > 12
→ C01CHKRRC = 0001 を設定し、2-5へ。
2-3.日チェック(DD)
2-3-1.DD < 1 OR DD > 31
→ C01CHKRRC = 0001 を設定し、2-5へ。
2-4.正常の場合
C01CHKRRC = 0000 を設定する。
2-5.復帰(0000MAINSOR-EXTへ)。
3.時刻チェック(2000CHKTIME
3-1.C01CHKDATから時分を抽出する。
HH = C01CHKDAT(1:2)
MM = C01CHKDAT(3:2)
3-2.時チェック(HH)
3-2-1.HH > 23
→ C01CHKRRC = 0002 を設定し、3-5へ。
3-3.分チェック(MM)
3-3-1.MM > 59
→ C01CHKRRC = 0002 を設定し、3-5へ。
3-4.正常の場合
C01CHKRRC = 0000 を設定する。
3-5.復帰(0000MAINSOR-EXTへ)。
4.数字チェック(3000CHKNUM
4-1.C01CHKDATにSPACEより前の文字があるかチェックする。
INSPECT C01CHKDAT TALLYING WRK-NN FOR
CHARACTERS BEFORE INITIAL SPACE.
4-2.WRK-NN > 0 の場合
C01CHKRRC = 0003 を設定し、4-4へ。
4-3.正常の場合
C01CHKRRC = 0000 を設定する。
4-4.復帰(0000MAINSOR-EXTへ)。
5.社員番号チェック(4000CHKEMP
5-1.桁数チェック(8桁以内)
C01CHKDAT(9:72)にSPACES以外がある場合
C01CHKRRC = 0004 を設定し、5-4へ。
5-2.空欄チェック
INSPECT C01CHKDAT(1:8) TALLYING WRK-NN FOR
CHARACTERS BEFORE INITIAL SPACE.
WRK-NN > 0 の場合
C01CHKRRC = 0004 を設定し、5-4へ。
5-3.正常の場合
C01CHKRRC = 0000 を設定する。
5-4.復帰(0000MAINSOR-EXTへ)。
```
+107
View File
@@ -0,0 +1,107 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 残業統計管理システム |
| 2 | プログラムID | SUB05TIM |
| 3 | プログラム名 | 時刻丸め計算サブ |
| 4 | PGMタイプ | サブ |
| 5 | PGMパターン | - |
| 6 | 機能概要 | 時間値を指定単位で丸め計算する。 |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 使用方法
```
CALL 'SUB05TIM' USING T01TIMPAR
```
### 入出力パラメータ
| COPY句 | ZANTIMAC |
|------------|----------|
| NO | 入出力区分 | 名称 | 属性 | 桁数 | 説明 |
|----|-----------|------|------|------|------|
| 1 | I | T01TIMHRS | 9(4)V9(1) | 4.1 | 入力時間(HH.h) |
| 2 | O | T01TIMOUT | 9(4)V9(1) | 4.1 | 出力時間(丸め後) |
| 3 | I | T01TIMRRC | 9(4) | 4 | 丸めモード(0:0.5h切上,1:0.1h切上,2:0.1h切捨,3:単純四捨五入) |
### リターンコード一覧
| NO | コード | 説明 |
|----|--------|------|
| 1 | 0000 | 正常終了 |
---
## 処理詳細
```
1.制御処理(0000MAINSOR
1-1.T01TIMRRCにより丸めモードを分岐する。
1-1-1.T01TIMRRC = 0 の場合(0.5時間単位・切上)
1000RNDHALFへ。
1-1-2.T01TIMRRC = 1 の場合(0.1時間単位・切上)
2000RNDUPへ。
1-1-3.T01TIMRRC = 2 の場合(0.1時間単位・切捨)
3000RNDDOWNへ。
1-1-4.T01TIMRRC = 3 の場合(単純四捨五入・0.1h単位)
4000RNDNORMALへ。
1-1-5.その他の場合
T01TIMOUT = T01TIMHRS をそのまま設定する。
1-2.復帰(GOBACK)。
2.0.5時間単位・切上(1000RNDHALF
2-1.T01TIMHRSを分に変換する。
WRK-MINUTES = T01TIMHRS * 60.
2-2.30分単位で切り上げる(DIVIDE REMAINDER)。
DIVIDE WRK-MINUTES BY 30
GIVING WRK-HOURS
REMAINDER WRK-REMAINDER.
2-2-1.WRK-REMAINDER > 0 の場合
WRK-HOURS = WRK-HOURS + 1.
2-2-2.WRK-REMAINDER = 0 の場合
そのまま。
2-3.時間に戻す。
T01TIMOUT = WRK-HOURS * 0.5.
2-4.復帰(0000MAINSOR-EXTへ)。
3.0.1時間単位・切上(2000RNDUP
3-1.T01TIMHRSを分に変換する。
WRK-MINUTES = T01TIMHRS * 60.
3-2.6分単位で切り上げる(DIVIDE REMAINDER)。
DIVIDE WRK-MINUTES BY 6
GIVING WRK-HOURS
REMAINDER WRK-REMAINDER.
3-2-1.WRK-REMAINDER > 0 の場合
WRK-HOURS = WRK-HOURS + 1.
3-2-2.WRK-REMAINDER = 0 の場合
そのまま。
3-3.時間に戻す。
T01TIMOUT = WRK-HOURS * 0.1.
3-4.復帰(0000MAINSOR-EXTへ)。
4.0.1時間単位・切捨(3000RNDDOWN
4-1.T01TIMHRSを分に変換する。
WRK-MINUTES = T01TIMHRS * 60.
4-2.6分単位で切り捨てる(DIVIDE REMAINDER)。
DIVIDE WRK-MINUTES BY 6
GIVING WRK-HOURS
REMAINDER WRK-REMAINDER.
4-3.時間に戻す。
T01TIMOUT = WRK-HOURS * 0.1.
4-4.復帰(0000MAINSOR-EXTへ)。
5.単純四捨五入・0.1h単位(4000RNDNORMAL
5-1.T01TIMHRSに0.05を加算する。
WRK-HOURS = T01TIMHRS + 0.05.
5-2.小数点第2位以下を切り捨てる。
T01TIMOUT = WRK-HOURS.
*> 9(4)V9(1)へのMOVEにより自動で切捨
5-3.復帰(0000MAINSOR-EXTへ)。
```
+144
View File
@@ -0,0 +1,144 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 残業統計管理システム |
| 2 | プログラムID | ZAN01CHK |
| 3 | プログラム名 | 残業申請振分処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | 振り分け |
| 6 | 機能概要 | CSV形式の残業申請ファイルを読み込み、ステータスによって振り分ける。 |
| 7 | | ステータス0/1:有効申請としてOVT-VALIDに出力 |
| 8 | | ステータス9:取消申請としてOVT-CANCELに出力 |
| 9 | | その他:エラーとしてERROR-LOGに出力 |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 前提条件
| NO | 対象ファイル | 条件 |
|----|-------------|------|
| 1 | OVT-APPLY(CSV) | ソート不要。CSV形式(カンマ区切り) |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------|
| 1 | OVT-APPLY | R01 | ZAN01R01 | I | 自前(80B) | F | | 80 | PS | CSV形式 |
| 2 | OVT-VALID | W01 | ZAN01W01 | O | ZAN01REC | FB | | 80 | PS | |
| 3 | OVT-CANCEL | W02 | ZAN01W02 | O | ZAN01REC | FB | | 80 | PS | |
| 4 | ERROR-LOG | W03 | ZAN01W03 | O | ZAN05REC | VB | | 200 | PS | |
### キー項目一覧
| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) |
|----|-----------|---------------------|-------------------------------------------|
| 1 | OVT-APPLY | なし | なし |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | 運用日付取得SUB | SUB01DAT | ZANDATAC |
| 2 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 3 | ABEND処理SUB | SUB03END | ZANENDAC |
| 4 | 項目チェックSUB | SUB04CHK | ZANCHKAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリアの初期化
1-4.運用日付取得SUB(SUB01DAT)により運用日を取得する。
復帰コード≠ZEROの場合、メッセージを出力し、ABEND処理SUBを呼び出し異常終了する。
【メッセージ編集】
メッセージ番号:5(サブエラー)
PARM1'SUB01DAT'
PARM2:復帰コード
【ABEND処理SUB】
ABENDコード:999
1-5.使用ファイルのオープン
1-6.R01を読み込む。(1100R01INNSOR)(1回目)
2.主処理(2000MAJSOR)(R01を全て読み終えるまで下記を繰り返す)
2-1.CSVの分解
UNSTRINGでカンマ区切りのCSVを各項目に分解する。(2010CSVSOR)
2-2.ステータス判定
2-2-1.ステータスが0または1の場合
項目チェックを実施し、エラーがあればERROR-LOGに出力、なければOVT-VALIDに出力。(2020VALIDATESOR)
2-2-1-1.日付チェック(SUB04CHK)。エラー→W03出力
2-2-1-2.開始時刻>=1830チェック。NG→W03出力
2-2-1-3.開始時刻<終了時刻チェック。NG→W03出力
2-2-1-4.時間差>=30分チェック。NG→W03出力
2-2-1-5.すべて通過→W01出力
2-2-2.ステータスが9の場合
OVT-CANCELにそのまま出力する。(2030CANCELSOR)
2-2-3.その他の場合
ERROR-LOGに出力する。(2040ERRORSOR)
2-3.R01を読み込む。(1100R01INNSOR)(2件目以降)
3.終了処理(3000STPSOR
3-1.入出力ファイルのクローズ
3-2.入出力件数出力メッセージ出力
【入力メッセージ編集】
メッセージ番号:6(入力件数メッセージ)
PARM1:当該入力ファイルのDD名
PARM2:当該入力ファイルの件数
【出力メッセージ編集】
メッセージ番号:7(出力件数メッセージ)
PARM1:当該出力ファイルのDD名
PARM2:当該出力ファイルの件数
3-3.終了メッセージ出力
【メッセージ編集】
メッセージ番号:2(終了メッセージ)
```
---
## 出力レコード定義
### 出力ファイル1W01/OVT-VALID
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | APPL-ID | CSV同項目を設定 | |
| 2 | EMP-ID | CSV同項目を設定 | |
| 3 | APPL-DATE | CSV同項目を設定 | |
| 4 | START-TIME | CSV同項目を設定 | |
| 5 | END-TIME | CSV同項目を設定 | |
| 6 | STATUS | CSV同項目を設定 | 0または1 |
| 7 | OVT-TYPE | CSV同項目を設定 | |
| 8 | FILLER | CSV同項目を設定 | |
### 出力ファイル2W02/OVT-CANCEL
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | APPL-ID | CSV同項目を設定 | |
| 2 | EMP-ID | CSV同項目を設定 | |
| 3 | APPL-DATE | CSV同項目を設定 | |
| 4 | START-TIME | CSV同項目を設定 | |
| 5 | END-TIME | CSV同項目を設定 | |
| 6 | STATUS | CSV同項目を設定 | 9 |
| 7 | OVT-TYPE | CSV同項目を設定 | |
| 8 | FILLER | CSV同項目を設定 | |
### 出力ファイル3W03/ERROR-LOG
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | ERR-CATEGORY | 01で固定 | |
| 2 | ERR-DETAIL | STRINGで編集 | エラー内容 |
+123
View File
@@ -0,0 +1,123 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 残業統計管理システム |
| 2 | プログラムID | ZAN02CHK |
| 3 | プログラム名 | 時間帯重複チェック処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | 項目チェック |
| 6 | 機能概要 | OVT-VSORTファイルの同一社員・同一日付内の時間帯重複をチェックする。 |
| 7 | | 重複なしのレコードはOVT-NODUPに出力 |
| 8 | | 重複ありのレコードはERROR-LOGに出力 |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 前提条件
| NO | 対象ファイル | 条件 |
|----|-------------|------|
| 1 | OVT-VSORT | 社員番号>日付>開始時刻で昇順ソート済み |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------|
| 1 | OVT-VSORT | R01 | ZAN02R01 | I | ZAN01REC | FB | | 80 | PS | |
| 2 | OVT-NODUP | W01 | ZAN02W01 | O | ZAN01REC | FB | | 80 | PS | |
| 3 | ERROR-LOG | W02 | ZAN02W02 | O | ZAN05REC | VB | | 200 | PS | |
### キー項目一覧
| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) |
|----|-----------|---------------------|-------------------------------------------|
| 1 | OVT-VSORT | EMP-ID>APPL-DATE>START-TIME(昇順) | EMP-ID>APPL-DATE |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | 運用日付取得SUB | SUB01DAT | ZANDATAC |
| 2 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 3 | ABEND処理SUB | SUB03END | ZANENDAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリアの初期化
1-4.運用日付取得SUB(SUB01DAT)により運用日を取得する。
復帰コード≠ZEROの場合、メッセージを出力し、ABEND処理SUBを呼び出し異常終了する。
【メッセージ編集】
メッセージ番号:5(サブエラー)
PARM1'SUB01DAT'
PARM2:復帰コード
【ABEND処理SUB】
ABENDコード:999
1-5.使用ファイルのオープン
1-6.R01を読み込む。(1100R01INNSOR)(1回目)
1-7.1件目をW01に出力し、前レコードに保持する。
2.主処理(2000MAJSOR)(R01を全て読み終えるまで下記を繰り返す)
2-1.前レコードとの比較
2-1-1.同一社員番号かつ同一日付の場合
2-1-1-1.前終了時刻>現開始時刻 → 重複あり(2011OVERLAPERRSOR)
エラーカテゴリ02でERROR-LOGに出力
2-1-1-2.前終了時刻<=現開始時刻 → 重複なし(2012NORMALOUTSOR)
W01に出力
2-1-2.異なる社員番号または日付の場合(キーブレイク)
キーブレイク処理として前レコードを更新しW01に出力(2013KEYBRSOR)
2-2.R01を読み込む。(1100R01INNSOR)(2件目以降)
3.終了処理(3000STPSOR
3-1.入出力ファイルのクローズ
3-2.入出力件数出力メッセージ出力
【入力メッセージ編集】
メッセージ番号:6(入力件数メッセージ)
PARM1:当該入力ファイルのDD名
PARM2:当該入力ファイルの件数
【出力メッセージ編集】
メッセージ番号:7(出力件数メッセージ)
PARM1:当該出力ファイルのDD名
PARM2:当該出力ファイルの件数
3-3.終了メッセージ出力
【メッセージ編集】
メッセージ番号:2(終了メッセージ)
```
---
## 出力レコード定義
### 出力ファイル1W01/OVT-NODUP
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | APPL-ID | R01.同項目を設定 | |
| 2 | EMP-ID | R01.同項目を設定 | |
| 3 | APPL-DATE | R01.同項目を設定 | |
| 4 | START-TIME | R01.同項目を設定 | |
| 5 | END-TIME | R01.同項目を設定 | |
| 6 | STATUS | R01.同項目を設定 | |
| 7 | OVT-TYPE | R01.同項目を設定 | |
| 8 | FILLER | R01.同項目を設定 | |
### 出力ファイル2W02/ERROR-LOG
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | ERR-CATEGORY | 02で固定 | 重複エラー |
| 2 | ERR-DETAIL | STRINGで編集 | EMP-IDと日付を編集 |
+122
View File
@@ -0,0 +1,122 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 残業統計管理システム |
| 2 | プログラムID | ZAN03CHK |
| 3 | プログラム名 | 打刻時間照合処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | マッチング(N:1) |
| 6 | 機能概要 | OVT-NODUPとPUNCH-SORTEDを突合し申請時間帯の照合を行う。 |
| 7 | | 申請時間帯が出勤〜退勤の範囲内かを確認する。 |
| 8 | | 申請日が休日かを判定しOVT-TYPEを付加する。 |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 前提条件
| NO | 対象ファイル | 条件 |
|----|-------------|------|
| 1 | OVT-NODUP | 社員番号>日付で昇順ソート済み |
| 2 | PUNCH-SORTED | 社員番号>日付で昇順ソート済み |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------|
| 1 | OVT-NODUP | R01 | ZAN03R01 | I | ZAN01REC | FB | | 80 | PS | |
| 2 | PUNCH-SORTED | R02 | ZAN03R02 | I | 自前(80B) | FB | | 80 | PS | |
| 3 | HOLIDAY-FILE | R03 | ZAN03R03 | I | 自前(80B) | FB | | 80 | PS | 休日カレンダー |
| 4 | OVT-CHECKED | W01 | ZAN03W01 | O | ZAN01REC | FB | | 80 | PS | |
| 5 | ERROR-LOG | W02 | ZAN03W02 | O | ZAN05REC | VB | | 200 | PS | |
### キー項目一覧
| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) |
|----|-----------|---------------------|-------------------------------------------|
| 1 | OVT-NODUP | EMP-ID>APPL-DATE(昇順) | EMP-ID>APPL-DATE |
| 2 | PUNCH-SORTED | EMP-ID>DATE(昇順) | EMP-ID>DATE |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 2 | ABEND処理SUB | SUB03END | ZANENDAC |※ コード上では未使用(PERFORM元なし)|
※ ZAN03CHKはSUB01DATを使用しない。日付取得は不要のためFUNCTION CURRENT-DATEも使用しない。
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリアの初期化
1-4.使用ファイルのオープン
1-6.HOLIDAY-FILEを全件読込しHOLIDAY-TABLEに設定する。(1300HOLIDAYSOR)
1-7.R01を読み込む。(1100R01INNSOR)(1回目)
1-8.R02を読み込む。(1200R02INNSOR)(1回目)
2.主処理(2000MAJSOR)(R01を全て読み終えるまで下記を繰り返す)
2-1.R01キーとR02キーを比較
2-1-1.R01キー < R02キー(打刻データなし)
ERROR-LOGにエラー出力する(エラーカテゴリ03)。(2010NOMATCHSOR)
2-1-2.R01キー = R02キー(マッチ)
時間範囲チェックを実施する。(2020MATCHSOR)
2-1-2-1.開始時刻>=出勤時刻 かつ 終了時刻<=退勤時刻 → 通過
OVT-TYPEを設定しW01に出力する。(2200SETOVTSOR)
2-1-2-2.範囲外 → ERROR-LOGに出力(エラーカテゴリ03)
2-1-3.R01キー > R02キー(R02スキップ)
R02を1件読み進める。(1200R02INNSOR)
3.終了処理(3000STPSOR
3-1.入出力ファイルのクローズ
3-2.入出力件数出力メッセージ出力
【入力メッセージ編集】
メッセージ番号:6(入力件数メッセージ)
PARM1:当該入力ファイルのDD名
PARM2:当該入力ファイルの件数
【出力メッセージ編集】
メッセージ番号:7(出力件数メッセージ)
PARM1:当該出力ファイルのDD名
PARM2:当該出力ファイルの件数
3-3.終了メッセージ出力
【メッセージ編集】
メッセージ番号:2(終了メッセージ)
```
---
## 出力レコード定義
### 出力ファイル1W01/OVT-CHECKED
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | APPL-ID | R01.同項目を設定 | |
| 2 | EMP-ID | R01.同項目を設定 | |
| 3 | APPL-DATE | R01.同項目を設定 | |
| 4 | START-TIME | R01.同項目を設定 | |
| 5 | END-TIME | R01.同項目を設定 | |
| 6 | STATUS | R01.同項目を設定 | |
| 7 | OVT-TYPE | 2200SETOVTSORで設定 | W:平日/H:休日 |
| 8 | FILLER | R01.同項目を設定 | |
### 出力ファイル2W02/ERROR-LOG
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | ERR-CATEGORY | 03で固定 | 打刻不突合 |
| 2 | ERR-DETAIL | STRINGで編集 | 社員番号と日付を編集 |
+144
View File
@@ -0,0 +1,144 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 残業統計管理システム |
| 2 | プログラムID | ZAN04MAT |
| 3 | プログラム名 | 取消マッチング処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | マッチング(1:1) |
| 6 | 機能概要 | OVT-SORTED(有効申請)とOVT-CSORT(取消申請)を申請番号(APPL-ID)で1:1マッチングし、結果を振り分ける。 |
| 7 | | 申請番号一致(取消済):監査証跡としてERROR-LOGに記録 |
| 8 | | 申請のみ(取消なし):OVT-MATCHEDに出力(処理番号=1 |
| 9 | | 取消のみ(既DB登録済):OVT-DBCLEANに出力(DB削除用) |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 前提条件
| NO | 対象ファイル | 条件 |
|----|-------------|------|
| 1 | ファイルR01OVT-SORTED | 申請番号(APPL-ID)で昇順ソート済、重複なし |
| 2 | ファイルR02OVT-CSORT | 申請番号(APPL-ID)で昇順ソート済、重複なし |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------|
| 1 | OVT-SORTED | R01 | ZAN04R01 | I | ZAN01REC | FB | | 80 | PS | 有効申請(APPL-ID昇順) |
| 2 | OVT-CSORT | R02 | ZAN04R02 | I | ZAN01REC | FB | | 80 | PS | 取消申請(APPL-ID昇順) |
| 3 | OVT-MATCHED | W01 | ZAN04W01 | O | ZAN02REC | FB | | 80 | PS | マッチング結果(処理番号付) |
| 4 | OVT-DBCLEAN | W02 | ZAN04W02 | O | ZAN04REC | FB | | 80 | PS | DB削除用(APPL-IDのみ) |
| 5 | ERROR-LOG | W03 | ZAN04W03 | O | ZAN05REC | VB | | 200 | PS | 監査証跡(取消マッチ記録) |
### キー項目一覧
| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) |
|----|-----------|---------------------|-------------------------------------------|
| 1 | ファイルR01 | APPL-ID(重複NG | APPL-ID |
| 2 | ファイルR02 | APPL-ID(重複NG | APPL-ID |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | 運用日付取得SUB | SUB01DAT | ZANDATAC |
| 2 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 3 | ABEND処理SUB | SUB03END | ZANENDAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリアの初期化
1-4.運用日付取得SUB(SUB01DAT)により運用日を取得する。
復帰コード≠ZEROの場合、メッセージを出力し、ABEND処理SUBを呼び出し異常終了する。
【メッセージ編集】
メッセージ番号:5(サブエラー)
PARM1'SUB01DAT'
PARM2:復帰コード
【ABEND処理SUB】
ABENDコード:999
1-5.使用ファイルのオープン
1-6.R01を読み込む。(1100R01INNSOR)(1回目)
1-7.R02を読み込む。(1200R02INNSOR)(1回目)
2.主処理(2000MAJSOR)(R01、R02を全て読み終えるまで下記を繰り返す)
2-1.マッチの場合(R01.APPL-ID = R02.APPL-ID
取消済みの申請のため、OVT-MATCHEDには出力しない。
監査証跡としてERROR-LOGにマッチ情報を出力する。(2100MATCHSOR)
【STRING編集】
ERR-CATEGORY = 04
ERR-DETAIL = 'CANCEL-MATCH: ' + 申請番号 + 社員番号 + 申請日 + 開始時刻 + 終了時刻
R01を読み込む。(1100R01INNSOR)(2件目以降)
R02を読み込む。(1200R02INNSOR)(2件目以降)
2-2.R01のみの場合(R01.APPL-ID < R02.APPL-ID
R01をOVT-MATCHEDにSTRING編集して出力する。(2200R01OUTSOR)
【STRING編集】
W01 = 申請番号 + 社員番号 + 申請日 + 開始時刻 + 終了時刻 + ステータス + OVT-TYPE + 処理番号(01)
R01を読み込む。(1100R01INNSOR)(2件目以降)
2-3.R02のみの場合(R01.APPL-ID > R02.APPL-ID
R02をOVT-DBCLEANに出力する。(2300R02OUTSOR)
W02.APPL-ID = R02.APPL-ID
R02を読み込む。(1200R02INNSOR)(2件目以降)
3.終了処理(3000STPSOR
3-1.入出力ファイルのクローズ
3-2.入出力件数出力メッセージ出力
【入力メッセージ編集】
メッセージ番号:6(入力件数メッセージ)
PARM1:当該入力ファイルのDD名
PARM2:当該入力ファイルの件数
【出力メッセージ編集】
メッセージ番号:7(出力件数メッセージ)
PARM1:当該出力ファイルのDD名
PARM2:当該出力ファイルの件数
3-3.終了メッセージ出力
【メッセージ編集】
メッセージ番号:2(終了メッセージ)
```
---
## 出力レコード定義
### 出力ファイル1W01/OVT-MATCHED
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | APPL-ID | R01.APPL-ID | |
| 2 | EMP-ID | R01.EMP-ID | |
| 3 | APPL-DATE | R01.APPL-DATE | |
| 4 | START-TIME | R01.START-TIME | |
| 5 | END-TIME | R01.END-TIME | |
| 6 | STATUS | R01.STATUS | |
| 7 | OVT-TYPE | R01.OVT-TYPE | |
| 8 | PROC-SEQ | 01固定 | 同一申請番号グループ内連番 |
| 9 | FILLER | 初期値 | |
### 出力ファイル2W02/OVT-DBCLEAN
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | APPL-ID | R02.APPL-ID | DB削除対象申請番号 |
| 2 | FILLER | 初期値 | |
### 出力ファイル3W03/ERROR-LOG
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | ERR-CATEGORY | 04で固定 | 取消マッチ監査証跡 |
| 2 | ERR-DETAIL | STRINGで編集 | CANCEL-MATCH: + 申請番号 + 社員番号 + 申請日 + 開始時刻 + 終了時刻 |
+169
View File
@@ -0,0 +1,169 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 残業統計管理システム |
| 2 | プログラムID | ZAN05CAL |
| 3 | プログラム名 | 残業時間集約処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | キーブレイク(集計) |
| 6 | 機能概要 | OVT-SORTED2(申請番号+処理番号昇順)をキーブレイク集約し、 |
| 7 | | 同一APPL-IDの全レコードの加班時間を積算し、 |
| 8 | | OVT-SUMMARYに1レコードに集計して出力する。 |
| 9 | | キーブレイク制御にCONTINUE/SET、時間計算にCOMPUTE ROUNDED/DIVIDEをそれぞれ使用する。 |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 前提条件
| NO | 対象ファイル | 条件 |
|----|-------------|------|
| 1 | ファイルR01OVT-SORTED2 | 申請番号(APPL-ID)>処理番号(PROC-SEQ)で昇順ソート済 |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------|
| 1 | OVT-SORTED2 | R01 | ZAN05R01 | I | ZAN02REC | FB | | 80 | PS | マッチング結果(APPL-ID+PROC-SEQ昇順) |
| 2 | OVT-SUMMARY | W01 | ZAN05W01 | O | ZAN03REC | FB | | 80 | PS | 集約結果(加班時間付加) |
### キー項目一覧
| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) |
|----|-----------|---------------------|-------------------------------------------|
| 1 | ファイルR01 | APPL-ID>PROC-SEQ(同一APPL-ID内複数明細あり) | APPL-ID(キーブレイク) |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | 運用日付取得SUB | SUB01DAT | ZANDATAC |
| 2 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 3 | ABEND処理SUB | SUB03END | ZANENDAC |
| 4 | 時刻丸め計算SUB | SUB05TIM | ZANTIMAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリアの初期化(WRK-ACCUM-MIN含む)
1-4.運用日付取得SUB(SUB01DAT)により運用日を取得する。
復帰コード≠ZEROの場合、メッセージを出力し、ABEND処理SUBを呼び出し異常終了する。
【メッセージ編集】
メッセージ番号:5(サブエラー)
PARM1'SUB01DAT'
PARM2:復帰コード
【ABEND処理SUB】
ABENDコード:999
1-6.使用ファイルのオープン(R01入力、W01出力)
1-7.R01の初回読込(1100R01INNSOR
1-8.初回読込成功時、WRK-PREV-APPL-IDに設定
WRK-GROUP-STARTに現レコードのSTARTを保持
WRK-LAST-RECに現レコードを保持
SET WRK-GROUP-IS-ACTIVE TO TRUE
(積算は主処理2000MAJSOR内の各分岐で行う)
2.主処理(2000MAJSOR)(R01を全て読み終え、最終グループ出力完了まで下記を繰り返す)
2-1.EOFの場合(WRK-R01KEY = HIGH-VALUE
2-1-1.WRK-GROUP-IS-ACTIVEなら最終グループを出力(2100OUTSOR)
2-1-2.SET WRK-GROUP-NOT-ACTIVE TO TRUE(ループ終了条件)
2-2.初回設定済みの場合(WRK-PREV-APPL-ID = 初回値あり)
2-2-1.同一APPL-IDの場合
現レコードの時間差分を積算(2200ACCUMSOR
現レコードをWRK-LAST-RECに上書き保持(ENDは最終明細で上書きされる)
グループ内最初の明細のSTARTはWRK-GROUP-STARTに保持済のため不変
R01次回読込(1100R01INNSOR
2-2-2.異なるAPPL-IDの場合(キーブレイク)
WRK-GROUP-IS-ACTIVEなら直前に保持していたグループを出力(2100OUTSOR)
WRK-PREV-APPL-IDを現レコードのAPPL-IDで更新
WRK-GROUP-STARTに現レコードのSTARTを保持
現レコードをWRK-LAST-RECに設定
2200ACCUMSORを呼出(新グループ初回レコードの時間差分を積算)
SET WRK-GROUP-IS-ACTIVE TO TRUE
R01次回読込(1100R01INNSOR
3.積算処理(2200ACCUMSOR
3-1.現レコードの時刻→分変換
DIVIDE R01START-TIME BY 100 GIVING WRK-START-HOUR REMAINDER WRK-START-MIN
DIVIDE R01END-TIME BY 100 GIVING WRK-END-HOUR REMAINDER WRK-END-MIN
COMPUTE WRK-START-TOTAL = WRK-START-HOUR * 60 + WRK-START-MIN
COMPUTE WRK-END-TOTAL = WRK-END-HOUR * 60 + WRK-END-MIN
3-2.差分計算
COMPUTE WRK-DIFF-MIN = WRK-END-TOTAL - WRK-START-TOTAL
3-3.WRK-ACCUM-MINに積算
COMPUTE WRK-ACCUM-MIN = WRK-ACCUM-MIN + WRK-DIFF-MIN
4.計算出力処理(2100OUTSOR
4-1.分→時間変換(DIVIDE for coverage
DIVIDE WRK-ACCUM-MIN BY 60 GIVING WRK-INT-HOURS REMAINDER WRK-REMAIN-MIN
4-2.COMPUTE ROUNDED for coverage(結果はSUB05TIMには渡さない)
COMPUTE WRK-OVT-HOURS ROUNDED = WRK-ACCUM-MIN / 60
ON SIZE ERROR
CONTINUE
MOVE ZERO TO WRK-OVT-HOURS
END-COMPUTE
4-3.SUB05TIM呼出(mode 20.1h単位切捨)
COMPUTE WRK-TEMP-HOURS = WRK-ACCUM-MIN / 60(切捨用にそのまま)
MOVE WRK-TEMP-HOURS TO T01TIMHRS
MOVE 2 TO T01TIMRRCmode 2
CALL 'SUB05TIM' USING T01TIMPAR
MOVE T01TIMOUT TO WRK-OVT-HOURS
4-4.OVT-SUMMARY出力
【STRING編集相当(MOVE)】
INITIALIZE W01OUTREC
MOVE WRK-LAST-APPL-ID TO W01APPL-ID
MOVE WRK-LAST-EMP-ID TO W01EMP-ID
MOVE WRK-LAST-APPL-DATE TO W01APPL-DATE
MOVE WRK-GROUP-START TO W01START-TIME
MOVE WRK-LAST-END TO W01END-TIME
MOVE WRK-OVT-HOURS TO W01OVT-HOURS
MOVE WRK-LAST-OVT-TYPE TO W01OVT-TYPE
WRITE W01OUTREC
W01出力カウンタ加算
4-5.WRK-ACCUM-MINをZEROに初期化(次グループ用)
5.終了処理(3000STPSOR
5-1.入出力ファイルのクローズ
5-2.入出力件数出力メッセージ出力
【入力メッセージ編集】
メッセージ番号:6(入力件数メッセージ)
PARM1'ZAN05R01'
PARM2:入力件数
【出力メッセージ編集】
メッセージ番号:7(出力件数メッセージ)
PARM1'ZAN05W01'
PARM2:出力件数
5-3.終了メッセージ出力
【メッセージ編集】
メッセージ番号:2(終了メッセージ)
```
---
## 出力レコード定義
### 出力ファイル1W01/OVT-SUMMARY
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | APPL-ID | WRK-LAST-APPL-ID | 申請番号 |
| 2 | EMP-ID | WRK-LAST-EMP-ID | 社員番号 |
| 3 | APPL-DATE | WRK-LAST-APPL-DATE | 申請日 YYYYMMDD |
| 4 | START-TIME | WRK-GROUP-START | 開始時刻 HHMM(グループ最初の明細のSTART) |
| 5 | END-TIME | WRK-LAST-END | 終了時刻 HHMM |
| 6 | OVT-HOURS | SUB05TIM出力(T01TIMOUT | 加班時間(0.1h単位切捨) |
| 7 | OVT-TYPE | WRK-LAST-OVT-TYPE | W=平日 / H=休日 |
| 8 | FILLER | 初期値 | |
+238
View File
@@ -0,0 +1,238 @@
# 詳細設計書
## 基本情報
| # | 項目 | 内容 |
|---|------|------|
| 1 | システム名 | 残業統計管理システム |
| 2 | プログラムID | ZAN06UPD |
| 3 | プログラム名 | 残業統計DB更新処理 |
| 4 | PGMタイプ | メイン |
| 5 | PGMパターン | DB更新 |
| 6 | 機能概要 | OVT-SUMMARYの各レコードをOVT-APPLICATIONSテーブルにINSERT/UPSERTし、 |
| 7 | | OVT-MONTHLYテーブルに月次集計を反映する。 |
| 8 | | またOVT-DBCLEANの各レコードについて、該当申請を取消状態に更新し、 |
| 9 | | OVT-MONTHLYから該当加班時間を減算する。 |
| 10 | | 新規カバレッジ:EXEC SQL(INSERT/UPDATE/SELECT)、COMMIT/ROLLBACK、 |
| 11 | | PERFORM VARYING/TEST AFTER、COBOL MULTIPLY |
※PGMタイプ:メイン、サブ
※PGMパターン:マッチング(1:1、1:N、N:1、M:N)、レイアウト編集のみ(GETPUT)、振り分け(IF文、EVALUATE文)、キーブレイク(集計、集約、集計・集約の以外)、DB更新
### 前提条件
| NO | 対象ファイル | 条件 |
|----|-------------|------|
| 1 | ファイルR01OVT-SUMMARY | ZAN05CALの出力、APPL-ID順ソート済 |
| 2 | ファイルR02OVT-DBCLEAN | ZAN04MATの出力、DB上に該当APPL-IDが存在すること(孤立取消はエラー) |
### 使用ファイル一覧
| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 |
|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------|
| 1 | OVT-SUMMARY | R01 | ZAN06R01 | I | ZAN03REC | FB | | 80 | PS | 集約結果(加班時間付加) |
| 2 | OVT-DBCLEAN | R02 | ZAN06R02 | I | ZAN04REC | FB | | 80 | PS | 孤立取消(DB削除対象) |
| 3 | ERROR-LOG | W01 | ZAN06W01 | O | ZAN05REC | VB | | 200 | PS | DBエラー・データ不整合記録 |
| 4 | OVERTIME-DB | DB | - | - | - | DB2 | - | - | DASD | EXEC SQLで直接操作 |
### キー項目一覧
| NO | ファイル名 | キー項目 | 用途 |
|----|-----------|---------|------|
| 1 | ファイルR01 | APPL-ID | OVT-APPLICATIONS INSERT/UPDATEのキー |
| 2 | ファイルR02 | APPL-ID | OVT-APPLICATIONS SELECT/UPDATEのキー |
| 3 | DB OVT-MONTHLY | EMP-ID + YEAR-MONTH + OVT-TYPE | 月次集計UPSERTのキー |
### テーブル定義
#### OVT-APPLICATIONS(個別加班申請テーブル)
| カラム | 型 | 内容 |
|--------|-----|------|
| APPL-ID | CHAR(8) | 申請番号(PK |
| EMP-ID | CHAR(8) | 社員番号 |
| APPL-DATE | CHAR(8) | 申請日 YYYYMMDD |
| OVT-TYPE | CHAR(1) | W=平日 / H=休日 |
| START-TIME | CHAR(4) | 開始時刻 HHMM |
| END-TIME | CHAR(4) | 終了時刻 HHMM |
| OVT-HOURS | DECIMAL(4,1) | 加班時間 |
| STATUS | CHAR(1) | 0=有効 / 9=取消 |
| UPDATED-AT | TIMESTAMP | 更新日時 |
#### OVT-MONTHLY(月次集計テーブル)
| カラム | 型 | 内容 |
|--------|-----|------|
| EMP-ID | CHAR(8) | 社員番号(PK |
| YEAR-MONTH | CHAR(6) | 対象年月 YYYYMMPK |
| OVT-TYPE | CHAR(1) | W=平日 / H=休日(PK |
| OVT-HOURS | DECIMAL(6,1) | 加班時間合計 |
| OVT-COUNT | INTEGER | 加班回数 |
| UPDATED-AT | TIMESTAMP | 更新日時 |
### 使用モジュール一覧
| NO | 機能 | プログラムID | 使用COPY名 |
|----|------|-------------|-----------|
| 1 | 運用日付取得SUB | SUB01DAT | ZANDATAC |
| 2 | メッセージ編集出力SUB | SUB02MSG | ZANMSGAC |
| 3 | ABEND処理SUB | SUB03END | ZANENDAC |
---
## 処理詳細
```
1.初期処理(1000ITTSOR
1-1.開始メッセージ出力
【メッセージ編集】
メッセージ番号:1(開始メッセージ)
1-2.コンパイル日時出力
【メッセージ編集】
メッセージ番号:33(コンパイル日時)
PARM1:コンパイル日時
PARM2'COMPILED'
1-3.ワークエリアの初期化
1-4.運用日付取得SUB(SUB01DAT)により運用日を取得する。
復帰コード≠ZEROの場合、メッセージを出力し、ABEND処理SUBを呼び出し異常終了する。
【メッセージ編集】
メッセージ番号:5(サブエラー)
PARM1'SUB01DAT'
PARM2:復帰コード
【ABEND処理SUB】
ABENDコード:999
1-5.使用ファイルのオープン(R01/R02入力、W01出力)
1-6.R01、R02の初回読込(1100R01INNSOR、1200R02INNSOR
2.主処理(2000MAJSOR
2-1.処理フェーズ1OVT-SUMMARYR01)を全件処理
R01 EOFまで以下の処理を繰り返す
2-1-1.OVT-APPLICATIONSへのINSERTを試行
【EXEC SQL INSERT】
INSERT INTO OVT-APPLICATIONS (APPL-ID, EMP-ID, APPL-DATE, OVT-TYPE,
START-TIME, END-TIME, OVT-HOURS, STATUS, UPDATED-AT)
VALUES (:WRK-SQL-APPL-ID, :WRK-SQL-EMP-ID, :WRK-SQL-APPL-DATE,
:WRK-SQL-OVT-TYPE, :WRK-SQL-START-TIME, :WRK-SQL-END-TIME,
:WRK-SQL-OVT-HOURS, :WRK-SQL-STATUS, CURRENT TIMESTAMP)
2-1-2.SQLCODE NOT = 0の場合(APPL-ID重複):
【EXEC SQL UPDATE】
UPDATE OVT-APPLICATIONS SET STATUS = :WRK-SQL-STATUS,
UPDATED-AT = CURRENT TIMESTAMP
WHERE APPL-ID = :WRK-SQL-APPL-ID
SQLCODE異常時は9100DBERRSORへ
2-1-3.SQLCODE = 0の場合:CUN-DBXINS加算、それ以外:CUN-DBXUPD加算
2-1-4.OVT-MONTHLYのUPSERT2110MONTHLYUPSOR
2-1-5.CUN-COMMITが閾値以上の場合、COMMIT実行(2300COMMITDBX
2-2.処理フェーズ2OVT-DBCLEANR02)を全件処理
R02 EOFまで以下の処理を繰り返す
2-2-1.SELECTでOVT-APPLICATIONSから該当レコード取得
【EXEC SQL SELECT INTO】
SELECT EMP-ID, APPL-DATE, OVT-TYPE, OVT-HOURS
INTO :WRK-SQL-EMP-ID, :WRK-SQL-APPL-DATE, :WRK-SQL-OVT-TYPE,
:WRK-SQL-OVT-HOURS
FROM OVT-APPLICATIONS
WHERE APPL-ID = :WRK-SQL-APPL-ID
2-2-2.SQLCODE NOT = 0(該当なし=孤立取消):
ERROR-LOGに出力(カテゴリ=20)、9999ABDSORで異常終了
2-2-3.OVT-APPLICATIONSのSTATUSを'9'に更新
【EXEC SQL UPDATE】
UPDATE OVT-APPLICATIONS SET STATUS = '9',
UPDATED-AT = CURRENT TIMESTAMP
WHERE APPL-ID = :WRK-SQL-APPL-ID
2-2-4.YEAR-MONTH抽出:APPL-DATE(1:6)
2-2-5.OVT-MONTHLYから減算(2210MONTHLYSUBSOR
【EXEC SQL UPDATE(減算)】
UPDATE OVT-MONTHLY SET
OVT-HOURS = OVT-HOURS - :WRK-SQL-OVT-HOURS,
OVT-COUNT = OVT-COUNT - 1,
UPDATED-AT = CURRENT TIMESTAMP
WHERE EMP-ID = :WRK-SQL-EMP-ID
AND YEAR-MONTH = :WRK-SQL-YEAR-MONTH
AND OVT-TYPE = :WRK-SQL-OVT-TYPE
リトライ制御:PERFORM TEST AFTER VARYING(最大3回)
SQLCODE≠0時はROLLBACKしてリトライ
全リトライ消費後もSQLCODE≠0の場合、
ERROR-LOG(カテゴリ=21)に出力し9999ABDSORで異常終了する
9100DBERRSORは通らない)
2-2-6.CUN-COMMITが閾値以上の場合、COMMIT実行(2300COMMITDBX
3.OVT-MONTHLY UPSERT処理(2110MONTHLYUPSOR
3-1.OVT-MONTHLYの存在確認SELECT
【EXEC SQL SELECT INTODB-OVT-HOURS, DB-OVT-COUNT)】
SELECT OVT-HOURS, OVT-COUNT
INTO :DB-OVT-HOURS, :DB-OVT-COUNT
FROM OVT-MONTHLY
WHERE EMP-ID = :WRK-SQL-EMP-ID
AND YEAR-MONTH = :WRK-SQL-YEAR-MONTH
AND OVT-TYPE = :WRK-SQL-OVT-TYPE
3-2.SQLCODE = 0(既存レコードあり):
【EXEC SQL UPDATE(加算)】
UPDATE OVT-MONTHLY SET
OVT-HOURS = OVT-HOURS + :WRK-SQL-OVT-HOURS,
OVT-COUNT = OVT-COUNT + 1,
UPDATED-AT = CURRENT TIMESTAMP
WHERE EMP-ID = :WRK-SQL-EMP-ID
AND YEAR-MONTH = :WRK-SQL-YEAR-MONTH
AND OVT-TYPE = :WRK-SQL-OVT-TYPE
3-3.SQLCODE NOT = 0(新規):
【EXEC SQL INSERT】
INSERT INTO OVT-MONTHLY
(EMP-ID, YEAR-MONTH, OVT-TYPE, OVT-HOURS, OVT-COUNT, UPDATED-AT)
VALUES
(:WRK-SQL-EMP-ID, :WRK-SQL-YEAR-MONTH, :WRK-SQL-OVT-TYPE,
:WRK-SQL-OVT-HOURS, 1, CURRENT TIMESTAMP)
3-4.SQLCODE異常時は9100DBERRSORへ
4.COMMIT処理(2300COMMITDBX
4-1.【EXEC SQL COMMIT】
COMMIT WORK
4-2.CUN-COMMITをZEROにリセット
5.終了処理(3000STPSOR
5-1.最終COMMIT2300COMMITDBX
5-2.入出力ファイルのクローズ
5-3.DB操作件数メッセージ出力
メッセージ番号:6(入力件数メッセージ)
PARM1'ZAN06R01' / PARM2CUN-R01INN
PARM1'ZAN06R02' / PARM2CUN-R02INN
PARM1'INS' / PARM2CUN-DBXINS
PARM1'UPD' / PARM2CUN-DBXUPD
PARM1'W01' / PARM2CUN-W01OUT
5-4.終了メッセージ出力
メッセージ番号:2(終了メッセージ)
6.DBエラー処理(9100DBERRSOR
6-1.ERROR-LOGにDBエラー情報出力(カテゴリ=30)
【COBOL STRING】
STRING 'DB ERROR SQLCODE=' SQLCODE ' APPL-ID='
WRK-SQL-APPL-ID INTO WRK-ERR-DETAIL
6-2.【EXEC SQL ROLLBACK】
ROLLBACK WORK
6-3.9999ABDSORで異常終了
```
---
## 新規カバレッジ構文
| # | 構文 | 用途 |
|---|------|------|
| 1 | EXEC SQL INSERT | OVT-APPLICATIONSへの新規登録 |
| 2 | EXEC SQL UPDATE | OVT-APPLICATIONSステータス更新・OVT-MONTHLY加減算 |
| 3 | EXEC SQL SELECT INTO | OVT-MONTHLY存在確認・OVT-APPLICATIONS検索 |
| 4 | EXEC SQL COMMIT | 定期的なCOMMIT発行 |
| 5 | EXEC SQL ROLLBACK | DBエラー時のROLLBACK |
| 6 | PERFORM VARYING | 月(1-12)のバリデーションループ |
| 7 | PERFORM TEST AFTER VARYING | 月次減算のリトライ制御 |
| 8 | COBOL MULTIPLY | 加班時間→分変換 |
## 出力レコード定義
### 出力ファイル1W01/ERROR-LOG
| No | 項目名 | 設定元 | 備考 |
|----|--------|--------|------|
| 1 | ERR-CATEGORY | WRK-ERR-CATEGORY | 20=孤立取消 / 30=DBエラー |
| 2 | ERR-DETAIL | WRK-ERR-DETAIL | エラー詳細メッセージ |