From f331c8fa2a5e9187d1993c93c655dd03b778a529 Mon Sep 17 00:00:00 2001 From: hangshuo652 Date: Sun, 9 Aug 2026 17:41:47 +0800 Subject: [PATCH 1/9] chore: ignore stray --gcow/ runtime output --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ecd10a3..4a2cfda 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ output_*/ logs/ --detailed/ --verbose/ +--gcow/ .work/ test_prog_*/ _test_flatfiles/ From 273a3f821174c0503b8078dbd60ae6c5048d9b6d Mon Sep 17 00:00:00 2001 From: hangshuo652 Date: Sun, 9 Aug 2026 17:43:00 +0800 Subject: [PATCH 2/9] feat: SQL between/hostvar-key alignment, class-condition parsing, gcov merge across scenario runs --- cobol_testgen/__init__.py | 1069 +++++++++++++++++++++++++++++- cobol_testgen/cond.py | 108 ++- cobol_testgen/core.py | 158 ++++- cobol_testgen/coverage.py | 96 ++- cobol_testgen/data_merger.py | 8 +- cobol_testgen/design.py | 211 +++++- cobol_testgen/design_mcdc.py | 177 ++++- cobol_testgen/file_io.py | 17 +- cobol_testgen/flatfile.py | 17 +- cobol_testgen/grammar.lark | 2 +- cobol_testgen/output.py | 5 +- cobol_testgen/pipeline_bridge.py | 71 +- cobol_testgen/read.py | 73 +- cobol_testgen/runner.py | 43 +- cobol_testgen/to_sql.py | 678 ++++++++++++++++++- config/program_schema.py | 9 + config/programs/JIN05UPD.yaml | 34 + config/programs/KIN06CLD.yaml | 16 + config/programs/KIN08DBU.yaml | 19 + config/programs/KIN09CSV.yaml | 24 + config/programs/KYU02REG.yaml | 32 + config/programs/KYU04CAL.yaml | 50 ++ config/programs/KYU05DED.yaml | 36 + config/programs/KYU06UPD.yaml | 29 + config/programs/SHA02MNC.yaml | 45 ++ config/programs/SHA03MNP.yaml | 32 + config/programs/SHA04TWO.yaml | 19 + config/programs/SHA06TWM.yaml | 30 + config/programs/SHA07KBR.yaml | 34 + orchestrator_db.py | 727 +++++++++++++++++--- runners/gixsql_runner.py | 192 +++++- 31 files changed, 3789 insertions(+), 272 deletions(-) create mode 100644 config/programs/JIN05UPD.yaml create mode 100644 config/programs/KYU02REG.yaml create mode 100644 config/programs/KYU04CAL.yaml create mode 100644 config/programs/KYU05DED.yaml create mode 100644 config/programs/KYU06UPD.yaml create mode 100644 config/programs/SHA02MNC.yaml create mode 100644 config/programs/SHA03MNP.yaml create mode 100644 config/programs/SHA04TWO.yaml create mode 100644 config/programs/SHA06TWM.yaml create mode 100644 config/programs/SHA07KBR.yaml diff --git a/cobol_testgen/__init__.py b/cobol_testgen/__init__.py index aaea8dd..d03bf79 100644 --- a/cobol_testgen/__init__.py +++ b/cobol_testgen/__init__.py @@ -22,7 +22,7 @@ CONFIG = { } 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 .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 @@ -291,6 +291,1027 @@ def _inject_empty_emp_rec(records, fields): 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 FD(open_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 = ` 解析判别常量值 + (如 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-ID,REC-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 LINES(TOP 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 上限エラー`),需超过上限条数的输入 + 记录才能覆盖 ELSE(SORT LIMIT OVER)分支(例:JIN06SRT 输入仅 6 条,上限 999)。 + + 本函数(通用・程序名硬编码なし): + 1) PROC 中检测 SORT + RELEASE + 同一段落内 `IF (<=|<) ` + 2) 解析 的常量值(字段 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 (<=|<) (同段落内必须有 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 FD(ASSIGN 名・记录布局) + 4) 按子程序 FD 布局写一条基础记录到 outdir/ + + 返回已生成的 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 <= <上限>)— 补充上限体积记录 + _inject_sort_limit_records(records, proc_div, fields_dict, term_types=term_types) if _HAVE_TOSQL: sql_meta = collect_sql_meta(assignments, declared_columns) @@ -736,6 +1771,11 @@ def main(): term_types=term_types, 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_records = 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) 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 @@ -1351,6 +2391,23 @@ def generate_data(cobol_source: str, structure: dict = None, if lhs in rec and rhs in rec and i < half: 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 diff --git a/cobol_testgen/cond.py b/cobol_testgen/cond.py index 6aeb006..500e8fe 100644 --- a/cobol_testgen/cond.py +++ b/cobol_testgen/cond.py @@ -98,10 +98,15 @@ def parse_single_condition(text, fields=None): if ' OF ' in text.upper(): text = text.split(' OF ')[0].strip() - # COBOL class condition: WS-KEY-DGT-N NUMERIC - if re.match(r'^[A-Z][A-Z0-9_-]*(?:\([^)]*\))?\s+(NUMERIC|ALPHABETIC|ALPHABETIC-UPPER|POSITIVE|NEGATIVE|ZERO)\s*$', text, re.IGNORECASE): - m = re.match(r'^([A-Z][A-Z0-9_-]*(?:\([^)]*\))?)\s+(NUMERIC|ALPHABETIC|ALPHABETIC-UPPER|POSITIVE|NEGATIVE|ZERO)\s*$', text, re.IGNORECASE) - return (m.group(1), '=', m.group(2).upper()) + # COBOL class condition: WS-KEY-DGT-N [IS] [NOT] NUMERIC/ALPHABETIC/... + # Return (field, 'IS', CLASS, want) — want=True → field must satisfy the class, + # want=False → field must NOT satisfy the class (IS NOT / NOT). + 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' 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 '): # After not_map normalization, NOT = has been converted to <> func_match = re.match( - r'^FUNCTION\s+(\w+)\(([^)]*)\)\s*(>=|<=|<>|>|<|=)\s*(.*)$', + r'^FUNCTION\s+(\w+)\s*\(([^)]*)\)\s*(>=|<=|<>|>|<|=)\s*(.*)$', normalized, re.IGNORECASE ) if func_match: @@ -244,6 +249,11 @@ def parse_compound_condition(text, fields=None): # Leaf condition parsed = parse_single_condition(text, fields) 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 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: ftype = field_info.get('type', 'unknown') digits = field_info.get('digits', 0) decimal = field_info.get('decimal', 0) 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': try: 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'): 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 operator in ('=', '=='): - return base_chr.ljust(length, base_chr) + return _exact elif operator in ('<>', '!='): - other = chr(65 + (ord(base_chr) - 64) % 26) - return other.ljust(length, other) + return _diff elif operator == '>': sv = str(value)[:length].ljust(length) chars = list(sv) @@ -403,10 +488,9 @@ def satisfying_value(field_info: dict, operator: str, value, want_true: bool) -> return ''.join(chars) else: if operator in ('=', '=='): - other = chr(65 + (ord(base_chr) - 64) % 26) - return other.ljust(length, other) + return _diff elif operator in ('<>', '!='): - return base_chr.ljust(length, base_chr) + return _exact elif operator in ('>', '<'): return str(value)[:length].ljust(length) diff --git a/cobol_testgen/core.py b/cobol_testgen/core.py index 5678e6e..c9bbc56 100644 --- a/cobol_testgen/core.py +++ b/cobol_testgen/core.py @@ -161,9 +161,17 @@ class _BrParser: return self.lines[self.pos].strip() return '' + def peek_next(self): + if self.pos + 1 < len(self.lines): + return self.lines[self.pos + 1].strip() + return '' + def clean(self): return self.peek().rstrip('.').strip() + def clean_next(self): + return self.peek_next().rstrip('.').strip() + def advance(self): self.pos += 1 @@ -217,6 +225,38 @@ class _BrParser: if perf_node: seq.add(perf_node) 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) if m_search: seq.add(self._parse_search(m_search)) @@ -277,6 +317,23 @@ class _BrParser: break self.advance() 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) if m_set_false: seq.add(self._parse_set_false(m_set_false.group(1))) @@ -796,6 +853,16 @@ class _BrParser: def _parse_perform(self): 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) if m: node = BrPerform('until', condition=m.group(1).strip()) @@ -965,6 +1032,28 @@ class _BrParser: self._inline_perform(node, target) return node + # PERFORM WITH TEST AFTER/BEFORE(UNTIL 在下一行) + 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() return None @@ -1189,24 +1278,24 @@ class _BrParser: _RE_SELECT_INTO = re.compile( 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_WHERE = re.compile(r'\bWHERE\b\s+(.*)', re.IGNORECASE) _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_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_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 ) @@ -1229,7 +1318,7 @@ class _BrParser: result = re.sub(r'\s+', ' ', 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.""" # 1) SELECT ... INTO ... FROM m = self._RE_SELECT_INTO.search(sql_text) @@ -1259,6 +1348,7 @@ class _BrParser: 'into_vars': into_vars, 'where': where_clause, 'sql_text': sql_text, + 'pos': pos, } for var in into_vars: @@ -1288,6 +1378,7 @@ class _BrParser: 'raw_values': values_str, 'host_vars': host_vars, 'sql_text': sql_text, + 'pos': pos, } synthetic = f'__SQL_INSERT_{table}' self.assignments.setdefault(synthetic, []).append(info) @@ -1307,6 +1398,7 @@ class _BrParser: 'where': where_clause, 'host_vars': [h.upper() for h in host_vars], 'sql_text': sql_text, + 'pos': pos, } synthetic = f'__SQL_DELETE_{table}' self.assignments.setdefault(synthetic, []).append(info) @@ -1314,7 +1406,7 @@ class _BrParser: # 4a) DECLARE CURSOR ... FOR SELECT ... FROM ... 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 ) if m: @@ -1336,6 +1428,7 @@ class _BrParser: 'into_vars': [], 'where': where_clause, 'sql_text': sql_text, + 'pos': pos, } synthetic = f'__SQL_CURSOR_{from_table}' self.assignments.setdefault(synthetic, []).append(info) @@ -1365,6 +1458,7 @@ class _BrParser: 'cursor_name': cursor_name, 'into_vars': into_vars, 'sql_text': sql_text, + 'pos': pos, } for var in into_vars: self.assignments.setdefault(var, []).append(info) @@ -1396,6 +1490,7 @@ class _BrParser: 'where': where_clause, 'host_vars': host_vars, 'sql_text': sql_text, + 'pos': pos, } synthetic = f'__SQL_UPDATE_{table}' self.assignments.setdefault(synthetic, []).append(info) @@ -1477,13 +1572,54 @@ def trace_to_root(field_name, assignments, fields, path_assign=None): var = next_var if next_var not in assignments: 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] else: break return var, chain +def _fold_constants(asgn, fields): + """将 2 源 compute 折叠为单源 compute + const,供链式反演使用。 + + 例如: T = A - CST(CST 为 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): op = operator @@ -1964,10 +2100,10 @@ def classify_field_roles(tree, assignments, fields, source=None, proc_text=None) # Phase 0: FD/OPEN 方向解析 fd_roles = {} 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_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(): if iname in file_sec: 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): 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) diff --git a/cobol_testgen/coverage.py b/cobol_testgen/coverage.py index 6348967..2cdd263 100644 --- a/cobol_testgen/coverage.py +++ b/cobol_testgen/coverage.py @@ -229,17 +229,25 @@ def _mark_if(dp, cons): simple = getattr(dp, 'parsed', None) if simple: - 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: + if len(simple) == 4: + # class condition (field, 'IS', CLASS, base_want): + # path want == base_want → T branch; == not base_want → F branch + field, op, val, base_want = simple + for c in cons: + if _match_constraint(c, (field, op, val)): + dp.active_branches.add('T' if c[3] == base_want else 'F') + 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') - elif _match_constraint(c, inv_simple): - dp.active_branches.add('F') elif dp.cond_tree and dp.cond_leaves: assignment = {} for leaf in dp.cond_leaves: @@ -411,17 +419,24 @@ def _mark_perform(dp, cons): simple = getattr(dp, 'parsed', None) if simple: - 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: + if len(simple) == 4: + # class condition (field, 'IS', CLASS, base_want): path want == base_want → Skip + field, op, val, base_want = simple + for c in cons: + if _match_constraint(c, (field, op, val)): + dp.active_branches.add('Skip' if c[3] == base_want else 'Enter') + 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') - elif _match_constraint(c, inv_simple): - dp.active_branches.add('Enter') elif dp.cond_tree and dp.cond_leaves: assignment = {} 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]): dp.source_line = i + 1 used_indices[dp.label] = i + found = True 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): @@ -494,7 +544,11 @@ def _normalize(text): def _build_search_patterns(dp): texts = [] 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': texts.append((r'\bEVALUATE\b', dp.label)) elif dp.kind == 'PERFORM': diff --git a/cobol_testgen/data_merger.py b/cobol_testgen/data_merger.py index ac2ff66..f5c8fef 100644 --- a/cobol_testgen/data_merger.py +++ b/cobol_testgen/data_merger.py @@ -31,16 +31,16 @@ def _dedup( def _hash(rec, keys): if keys: - return tuple(rec.get(k, "") for k in keys) - return tuple(sorted(rec.items())) + return tuple(str(rec.get(k, "")) for k in keys) + 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) if h not in seen: seen.add(h) result.append(rec) - for rec in main_records: + for rec in additional_records: h = _hash(rec, key_fields) if h not in seen: seen.add(h) diff --git a/cobol_testgen/design.py b/cobol_testgen/design.py index 2958b44..914c3dc 100644 --- a/cobol_testgen/design.py +++ b/cobol_testgen/design.py @@ -2,8 +2,9 @@ import re import logging +import os 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 logger = logging.getLogger(__name__) @@ -60,7 +61,7 @@ def _has_t_branch(cons): for c in cons: if len(c) >= 4 and c[0] == "__DP" and c[2] == "T": return True - if c[3]: + if len(c) >= 4 and c[3]: return True return False @@ -68,16 +69,16 @@ def _has_f_branch(cons): for c in cons: if len(c) >= 4 and c[0] == "__DP" and c[2] == "F": return True - if not c[3]: + if len(c) >= 4 and not c[3]: return True return False def _cap_paths(paths): if len(paths) > _MAX_PATHS: - special = [(i, p) for i, p in enumerate(paths) if 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)] - t_paths = [(i, p) for i, p in std if _has_t_branch(p)] - f_paths = [(i, p) for i, p in std if _has_f_branch(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[0])] + 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[0])] quota = _MAX_PATHS - len(special) if quota <= 0: return [p for _, p in special[:_MAX_PATHS]] @@ -210,7 +211,11 @@ def enum_paths(node, fields): continue for cp_cons, cp_assign in child_paths: 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: covered_sigs.add(sig) merged = {} @@ -223,12 +228,24 @@ def enum_paths(node, fields): if not any(_is_sentinel(c) for c in pc): new_active.append((pc, dict(pa))) break - paths = new_active + paths = _cap_paths(new_active) return paths elif isinstance(node, BrIf): parsed = parse_single_condition(node.condition, 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 paths = [] 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)) return paths # Fallback: parsed condition but non-field (e.g. arithmetic expr) - if parsed: + if parsed and len(parsed) == 3: field, op, val = parsed paths = [] 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'): # 尝试单条件(现有逻辑) 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 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) if val is None: return False + if operator == 'IS': + return evaluate_class_value(val, value) == want_true if operator == 'not_in': cases = value if isinstance(value, list) else [] 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 True else: - s_val = str(val).strip().upper() - s_target = str(value).strip().upper() - eq = s_val == s_target + s_val = str(val) + s_target = str(value) + # 图式常量: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 == '=': return eq == want_true elif operator == '<>': return (not eq) == want_true elif operator in ('>', '<', '>=', '<='): if operator == '>': - ok = s_val > s_target + ok = _sv > _tv elif operator == '<': - ok = s_val < s_target + ok = _sv < _tv elif operator == '>=': - ok = s_val >= s_target + ok = _sv >= _tv elif operator == '<=': - ok = s_val <= s_target + ok = _sv <= _tv return ok == want_true return True return False @@ -1226,6 +1255,19 @@ def apply_constraint(rec, field_name, operator, value, want_true, fields, assign # 如果当前值已满足该约束,跳过覆盖(保持先前约束的一致性) # 但零值时强制使用边界值(非 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): cur = str(rec.get(field_name, '')).strip('0') if (cur == '' or cur == '.') and ( @@ -1578,27 +1620,30 @@ def generate_records(path_infos, data_fields, base_assignments=None, file_sec=No pass if skip_impossible: continue - # Pass B.0: CALL 返回码一致性 — 将要求返回码非零的约束转为入参无效化 + # Pass B.0: CALL 返回码一致性 — 按调用级联顺序定位"首个失败"的校验入参。 + # 仅将导致该次校验失败的字段置为无效,其余级联字段修复为有效值, + # 使路径真正执行到目标校验分支(修复先前"全部入参无效化"导致提前分支底的问题)。 _b0_invalidated = set() new_cons = [] _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: - if len(c) == 4 and c[1] == '<>' and c[3] and c[0].endswith('RRC'): - rrc_field = c[0] - prefix = rrc_field[:-3] + if len(c) == 4 and c[1] == '<>' and c[0].endswith('RRC'): _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 new_cons.append(c) 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') if _emp_id_spaces and _status_0or1 and 'WRK-CSV-APPL-DATE' in rec: _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) kept_path_cons.append(path_cons) term_types.append(term_type) @@ -1861,15 +1910,105 @@ def _sync_unstring_targets_from_output(rec, base_assignments, data_fields): 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 返回码非零。""" + # 命令行馈入字段(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: 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 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: rec[field_name] = '9' * length 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 diff --git a/cobol_testgen/design_mcdc.py b/cobol_testgen/design_mcdc.py index 3f5dd26..9259529 100644 --- a/cobol_testgen/design_mcdc.py +++ b/cobol_testgen/design_mcdc.py @@ -35,6 +35,9 @@ def _invert_condition(parsed): """Invert a parsed condition (True ↔ False).""" if parsed is 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 inv_op = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}.get(op, op) 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) f_cons = list(path_cons) if parsed: - field, op, val = parsed - t_cons.append((field, op, val, True)) - f_cons.append((field, op, val, False)) + if len(parsed) == 4: + field, op, val, base_want = parsed + 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: - # Synthetic constraint for coverage matching - t_cons.append(("__DP", str(dp_id), "T", True)) - f_cons.append(("__DP", str(dp_id), "F", True)) + # Generate MC/DC leaf-level constraints for compound conditions + cond_tree = parse_compound_condition(node.condition, fields) + 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.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) if parsed: - field, op, val = parsed - body_cons = list(path_cons) + [(field, op, val, False)] + if len(parsed) == 4: + 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: # Synthetic constraint for coverage matching 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)) elif isinstance(node, BrSearch): + dp_id = _counter[0] + _counter[0] += 1 dp = { - "node": node, "kind": "SEARCH", + "node": node, "kind": "SEARCH", "id": dp_id, "access_constraints": list(path_cons), } result.append(dp) @@ -165,17 +204,37 @@ def _make_path_for_branch(dp, branch_idx, fields): dp_id = dp.get("id", 0) want_true = (branch_idx == dp.get("true_idx", 0)) if parsed is None: - # Use synthetic __DP constraint for coverage matching - label = "T" if want_true else "F" - constraints.append(("__DP", str(dp_id), label, True)) + # Generate MC/DC leaf-level constraints for compound conditions + cond_tree = parse_compound_condition(dp["node"].condition, fields) + 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"] body_seq = node.true_seq if branch_idx == 0 else node.false_seq 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)) + if len(parsed) == 4: + field, op, val, base_want = parsed + leaf_want = base_want if want_true else (not base_want) + constraints.append((field, op, val, leaf_want)) + 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"] body_seq = node.true_seq if branch_idx == 0 else node.false_seq return (constraints, {}) @@ -215,11 +274,38 @@ def _make_path_for_branch(dp, branch_idx, fields): label = "ENTER" if branch_idx == 0 else "SKIP" constraints.append(("__DP", str(dp_id), label, True)) return (constraints, {}) - field, op, val = parsed - if branch_idx == 0: - constraints.append((field, op, val, False)) + if len(parsed) == 4: + field, op, val, base_want = parsed + leaf_want = (not base_want) if branch_idx == 0 else base_want + constraints.append((field, op, val, leaf_want)) 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 ([], {}) @@ -245,12 +331,39 @@ def enum_paths(node, fields): kind = dp["kind"] if kind == "IF": - 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) + parsed = dp.get("parsed") + if parsed is None: + # Compound condition: add ALL MC/DC paths (one per leaf) + cond_tree = parse_compound_condition(dp["node"].condition, fields) + if cond_tree: + 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": node = dp["node"] @@ -275,6 +388,16 @@ def enum_paths(node, fields): if enter_path: paths.append(enter_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: paths = paths[:MAX_PATH] break diff --git a/cobol_testgen/file_io.py b/cobol_testgen/file_io.py index 227d555..486b6cf 100644 --- a/cobol_testgen/file_io.py +++ b/cobol_testgen/file_io.py @@ -29,7 +29,10 @@ def get_storage_length(field: dict) -> int: else: return 8 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: raise ValueError(f"Unsupported USAGE: {usage}") @@ -74,10 +77,12 @@ def pack_value(value: str, field: dict) -> bytes: fmt = fmt_map[size] if not signed: fmt = fmt.upper() - return struct.pack('<' + fmt, int_val) + # 本 GnuCOBOL(GC32-BDB-SP1、主机兼容)的 COMP 按大端存储。 + return struct.pack('>' + fmt, int_val) 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] if not signed: nibbles.append(0xF) @@ -112,7 +117,8 @@ def unpack_value(data: bytes, field: dict) -> str: fmt = fmt_map[size] if not signed: fmt = fmt.upper() - val = struct.unpack('<' + fmt, data)[0] + # 与 pack_value 一致:本 GnuCOBOL 的 COMP 为大端存储。 + val = struct.unpack('>' + fmt, data)[0] sign = '-' if val < 0 else '' 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' if signed and sign == 0xD: num_str = '-' + num_str - return num_str.zfill(digits) + # 补零长度含小数位 + return num_str.zfill(digits + pi.get('decimal', 0)) else: raise ValueError(f"Unsupported USAGE: {usage}") diff --git a/cobol_testgen/flatfile.py b/cobol_testgen/flatfile.py index dd2350c..a2ab90b 100644 --- a/cobol_testgen/flatfile.py +++ b/cobol_testgen/flatfile.py @@ -132,7 +132,10 @@ def _format_value(value: Any, field: dict) -> bytes: try: num = int(float(val)) if val else 0 except (ValueError, TypeError): - num = 0 + # 非数字値を 0 に静かに丸めると '00000000' が合成され、 + # 複数レコードで主キー衝突(DAILY_RECORDS INSERT エラー→早期 ABEND)を + # 引き起こす。SPACE を書くことでプログラムの空キー判定に委ねる。 + return (' ' * length).encode("ascii") num = abs(num) max_val = 10 ** length - 1 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 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: unique_ids = ["EMP00001", "EMP00002", "EMP00003", "EMP00004", "EMP00005"] # 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) lines.append("X UNKNOWN") - # End with RESET mode so 3000STPSOR runs the RESET path (DP#22-#23) - lines.append("M MODE=RESET") + # End with the configured final mode (default RESET → 3000STPSOR 実行 RESET path). + # 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 outpath = outdir / (prefix + sysin_filename) diff --git a/cobol_testgen/grammar.lark b/cobol_testgen/grammar.lark index 6fe4d5e..f06cf90 100644 --- a/cobol_testgen/grammar.lark +++ b/cobol_testgen/grammar.lark @@ -28,7 +28,7 @@ occurs_clause: "OCCURS" INT ("TO" INT)? ("TIME" "S"?)? ("DEPENDING" "ON" NAME)? key_clause: ("ASCENDING" | "DESCENDING") "KEY" "IS"? NAME (","? NAME)* indexed_clause: "INDEXED" "BY" NAME (","? NAME)* 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]+/ NAME: /[A-Z][A-Z0-9-]*/i PICTURE_STRING: /[0-9A-Z()+,\-*\/V\$]+/i diff --git a/cobol_testgen/output.py b/cobol_testgen/output.py index 6f6355b..57d6ee6 100644 --- a/cobol_testgen/output.py +++ b/cobol_testgen/output.py @@ -112,7 +112,7 @@ def output_json(records, outpath, roles=None, fd_fields=None, field_to_fd=None, entry = { 'input': inp, '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', } @@ -176,8 +176,7 @@ def output_input_files(records, outdir, stem, roles, fd_fields, field_to_fd, ope bin_path = outdir / assign_name name_to_field = { f['name']: f for f in data_fields - if not f.get('is_88') and not f.get('is_filler') - and f.get('pic') + if not f.get('is_88') and f.get('pic') } field_dicts = [] seen = set() diff --git a/cobol_testgen/pipeline_bridge.py b/cobol_testgen/pipeline_bridge.py index 864eca2..bd7e9f2 100644 --- a/cobol_testgen/pipeline_bridge.py +++ b/cobol_testgen/pipeline_bridge.py @@ -26,12 +26,36 @@ def build_branch_tree_fallback(proc_text, fields=None): pass # 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: + 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 # 2. Old parser with 3s timeout (fallback only) - old_tree, old_assigns = None, {} try: import threading r, e, d = [None], [None], [False] @@ -174,6 +198,49 @@ def _count_br_nodes(node) -> int: 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: result = {} for a in assigns_list: diff --git a/cobol_testgen/read.py b/cobol_testgen/read.py index 9ffadfe..80d3790 100644 --- a/cobol_testgen/read.py +++ b/cobol_testgen/read.py @@ -207,13 +207,13 @@ _RE_SQL_INC = re.compile( _BUILTIN_SQLCA = """\ 01 SQLCA. 05 SQLCAID PIC X(8). - 05 SQLCABC PIC S9(9) COMP. - 05 SQLCODE PIC S9(9) COMP. + 05 SQLCABC PIC S9(9) COMP-5. + 05 SQLCODE PIC S9(9) COMP-5. 05 SQLERRM. - 10 SQLERRML PIC S9(4) COMP. + 10 SQLERRML PIC S9(4) COMP-5. 10 SQLERRMC PIC X(70). 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. 10 SQLWARN0 PIC X. 10 SQLWARN1 PIC X. @@ -523,6 +523,11 @@ def parse_pic(pic_str: str) -> PicInfo: elif expanded[0] == 'A': info.type = 'alphabetic' 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', '*', '$', '+', '-'): info.type = 'numeric-edited' info.digits = expanded.count('9') @@ -597,15 +602,17 @@ def parse_file_control(source: str) -> dict: result = {} for sel_m in re.finditer( r'SELECT\s+(\w[\w-]*)\s+[^.]*?\bASSIGN\s+TO\s+' - r'(?:(["\'])(.*?)\2|(\w[\w-]*))' + r'(?:(["\'])(.*?)\2|EXTERNAL\s+(\w[\w-]*)|(\w[\w-]*))' r'[^.]*\.', fc, re.IGNORECASE ): name = sel_m.group(1).upper() if sel_m.group(2): assign_to = sel_m.group(3).upper() - else: + elif sel_m.group(4): assign_to = sel_m.group(4).upper() + else: + assign_to = sel_m.group(5).upper() clause = sel_m.group(0) org_m = re.search(r'ORGANIZATION\s+(LINE\s+)?SEQUENTIAL', clause, re.IGNORECASE) 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)): dirs[fname.upper()] = direction 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 与 SORT ... USING GIVING 子句, + 将 USING 文件识别为 INPUT、GIVING 文件识别为 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 diff --git a/cobol_testgen/runner.py b/cobol_testgen/runner.py index 7385d14..a434cbc 100644 --- a/cobol_testgen/runner.py +++ b/cobol_testgen/runner.py @@ -31,6 +31,7 @@ class GroupInfo: select_info: dict = field(default_factory=dict) overlap_mask: list[bool] = field(default_factory=list) multi_write_fds: set = field(default_factory=set) + command_args: list = field(default_factory=list) @dataclass @@ -335,7 +336,7 @@ def run_group(group: GroupInfo, exe_path: str, temp_dir: str, try: os.chdir(str(work_dir)) 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, ) except subprocess.TimeoutExpired: @@ -434,26 +435,36 @@ def run_all(program_name: str, outdir: str, temp_dir: str, gcov_root = work_dir / "gcov" 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(): - for assign in assign_names: - src = src_in_dir / assign - if src.exists(): - shutil.copy2(str(src), str(work_dir / assign)) + for src in sorted(src_in_dir.iterdir()): + if not src.is_file() or src.suffix.lower() == '.json': + continue + shutil.copy2(str(src), str(work_dir / src.name)) # ── 3b. 清理旧 gcda ── _clean_gcda(str(work_dir)) # ── 3c. 过滤 non-abend ── filtered_exp = [] + normal_recs = [] + abend_pairs = [] # (rec, command_args) for i, rec in enumerate(scene_expected): term = scene_terms[i] if i < len(scene_terms) else 'normal' if term != 'abend': 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( name=f"{program_name}_{scene_id}", - records=scene_recs, + records=normal_recs, expected_outputs=filtered_exp, expected_returncode=0, 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 '✗' 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. 出力保存 ── dst_out_dir.mkdir(parents=True, exist_ok=True) for fd_name in fd_field_dicts: @@ -579,6 +607,7 @@ def run_and_compare(program_name: str, outdir: str, expected_returncode=1, fd_field_dicts=fd_field_dicts, open_dir=open_dir, 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) if r.returncode != 0: diff --git a/cobol_testgen/to_sql.py b/cobol_testgen/to_sql.py index e1d21ff..7a44e02 100644 --- a/cobol_testgen/to_sql.py +++ b/cobol_testgen/to_sql.py @@ -1,4 +1,4 @@ -"""SQL层:WHERE约束解析 + DB输入行生成""" +"""SQL层:WHERE约束解析 + DB输入行生成""" import re import logging @@ -28,8 +28,97 @@ def _restore_strings(text: str, replacements: list) -> str: # ── Bracket-aware AND splitting ── -def _split_on_AND(text: str) -> list[str]: - """Split WHERE clause on AND, respecting parentheses.""" +_RE_BETWEEN_SPAN = re.compile(r'\bBETWEEN\b', re.IGNORECASE) + + +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 = [] current = [] depth = 0 @@ -52,13 +141,51 @@ def _split_on_AND(text: str) -> list[str]: current.append(token) if current: parts.append(' '.join(current).strip()) + if not keep_placeholders: + parts = [_restore_between_placeholders(p, spans) for p in 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 ── _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'(:\w[\w-]*(?::\w[\w-]*)?|__STR\d+__|[\w\d.-]+)', re.IGNORECASE @@ -70,7 +197,7 @@ _RE_IN_CLAUSE = 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 ) @@ -124,14 +251,15 @@ def _parse_where_condition(part: str, replacements: list) -> dict | None: # BETWEEN m = _RE_BETWEEN.match(part) if m: - col = m.group(1).upper() + subj = m.group(1).upper() neg = bool(m.group(2)) lo = m.group(3).strip() hi = m.group(4).strip() return { - 'col': col, 'type': 'between', 'neg': neg, - 'op': 'BETWEEN', - 'lo': lo.strip("'\""), 'hi': hi.strip("'\""), + 'type': 'between', 'neg': neg, 'op': 'BETWEEN', + 'subject': _parse_between_operand(subj, replacements), + 'lo': _parse_between_operand(lo, replacements), + 'hi': _parse_between_operand(hi, replacements), } # LIKE @@ -216,19 +344,28 @@ def sql_extract_constraints(where_clause: str, table: str, # Protect string literals cleaned, replacements = _protect_strings(where_clause) - # Split on AND - and_parts = _split_on_AND(cleaned) + # Protect BETWEEN spans, then split on AND + protected, spans = _scan_between_spans(cleaned) + and_parts = _split_on_AND(protected, spans=spans, keep_placeholders=True) constraints = [] for part in and_parts: part = part.strip() if not part: 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: # Map column to COBOL field - cobol_field = guess_cobol_field(cond['col'], table, declared_columns, column_map) - cond['cobol_field'] = cobol_field + col_name = cond.get('col') or (cond.get('subject', {}) or {}).get('name') + 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) else: 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 +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 ── _COLUMN_DEFAULTS = { @@ -251,8 +411,13 @@ _COLUMN_DEFAULTS = { def _format_db_value(col_info: dict, raw_val: str) -> str: db_type = col_info.get('db_type', 'CHAR') - formatter = _COLUMN_DEFAULTS.get(db_type, lambda _: str(raw_val)[:10]) - default = formatter(0) + if db_type in ('CHAR', 'VARCHAR'): + 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: return default if db_type in ('INTEGER', 'SMALLINT', 'DECIMAL'): @@ -261,10 +426,11 @@ def _format_db_value(col_info: dict, raw_val: str) -> str: except ValueError: return default return str(raw_val).ljust(len(default))[:len(default)] - - 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: unique = f"{path_index:03d}{hash(key_val) % 100000:05d}" seen_keys.add(unique) @@ -291,12 +457,18 @@ def collect_sql_meta(assignments: dict, declared_columns: dict, seen.add(key) where = asgn.get('where', '') table = asgn.get('table', '') + table = _norm_table(table) where_constraints = sql_extract_constraints( where, table, {}, column_map or {}, declared_columns ) meta = dict(asgn) + meta['table'] = table meta['where_constraints'] = where_constraints 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 @@ -311,6 +483,9 @@ def _path_has_sql_ok(path_cons: list) -> bool: sql_ok = False if pc[1] == '>' and pc[3]: sql_ok = False + # SQLCODE = (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 return sql_ok @@ -345,6 +520,367 @@ def _parse_select_columns(select_list: str) -> list[str]: 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( branch_paths: list[tuple[list, dict]], fields_dict: list[dict], @@ -352,6 +888,7 @@ def build_db_input( sql_meta: list[dict], declared_columns: dict, records: list[dict] = None, + insert_pk: dict[str, list[str]] = None, ) -> dict: """Generate DB input rows per branch path. Returns {table: [{col: val, ...}, ...]}. @@ -363,14 +900,42 @@ def build_db_input( seen_keys = {} seq_counter = itertools.count(1) + # 事务调度感知:分类每条记录运行时执行的 SQL(INSERT/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 for path_idx, (path_cons, path_assign) in enumerate(branch_paths): - # Skip paths where SQL fails (SQLCODE <> 0) - if not _path_has_sql_ok(path_cons): - continue + sql_ok = _path_has_sql_ok(path_cons) rec = records[path_idx] if records and path_idx < len(records) else {} + if path_idx == 0 and rec: + with open(r'C:\Users\marye\AppData\Local\Temp\opencode\build_db_input_debug.txt', 'w') as _f: + _f.write(f"rec keys count={len(rec)}\n") + _f.write(f"has_HV-ANNUAL-H={'HV-ANNUAL-H' in rec}\n") + _f.write(f"has_working_storage={'working_storage' in rec}\n") + _f.write(f"HV-ANNUAL-H via _rec_get={_rec_get(rec, 'HV-ANNUAL-H', 'NOT_FOUND')!r}\n") + if 'HV-ANNUAL-H' in rec: + _f.write(f"HV-ANNUAL-H value={rec['HV-ANNUAL-H']!r}\n") + _f.write(f"all keys sorted={sorted(rec.keys())}\n") + for sql in sql_meta: atype = sql.get('type', '') table = sql.get('table', '') @@ -383,12 +948,36 @@ def build_db_input( seen_keys[table] = set() 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 if atype in ('exec_sql_delete', 'exec_sql_update'): # 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: col_infos = _infer_columns_from_where(where_cons) row = {} @@ -397,17 +986,17 @@ def build_db_input( val = None for wc in where_cons: wc_col = wc.get('col', '').upper().split('.')[-1] - if wc_col != col_name: + if _norm_col(wc_col) != _norm_col(col_name): continue if wc['type'] == 'literal': val = wc.get('literal', '') break elif wc['type'] == 'host_var': hv = wc.get('host_var', '').upper() - val = str(rec.get(hv, '')) + val = _runtime_or_where_hostvar(rec, hv, assignments) break 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(): row[ci['name']] = _format_db_value(ci, val) if not row: @@ -416,8 +1005,10 @@ def build_db_input( continue # exec_sql_select (and any future read-only types) + if not sql_ok: + continue row = {} - col_infos = declared_columns.get(table, []) + col_infos = _declared_cols_for(declared_columns, table) if not col_infos: col_infos = _infer_columns_from_where(where_cons) into_vars = sql.get('into_vars', []) @@ -431,7 +1022,7 @@ def build_db_input( # Use SQL column names (not INTO var names) for row keys 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}) where_cols = set() @@ -439,35 +1030,48 @@ def build_db_input( col_name = col_info['name'] val = None 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', '') where_cols.add(col_name) break 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() for pc_field, pc_op, pc_val, pc_want in path_cons: if pc_field == hv: val = pc_val if pc_want else '' + where_cols.add(col_name) break - if val is None and hv in rec: - val = str(rec[hv]) + if val is None and _rec_has(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 if val is None: for iv, scola in into_to_col.items(): - if scola == col_name and iv in rec: - val = str(rec[iv]) + if _norm_col(scola) == _norm_col(col_name) and _rec_has(rec, iv): + val = _rec_get(rec, iv, '') break # Try COBOL field name mapping if val is None: cobol_field = guess_cobol_field(col_name, table, declared_columns) - if cobol_field in rec: - val = str(rec[cobol_field]) + if _rec_has(rec, cobol_field): + val = _rec_get(rec, cobol_field, '') if val is not None: row[col_name] = _format_db_value(col_info, val) else: + row[col_name] = _format_db_value(col_info, str(next(seq_counter))) if not row: @@ -478,6 +1082,8 @@ def build_db_input( 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]) + if drop_table is not None and table == drop_table and path_idx == last_sql_ok_path: + continue db_input[table].append(row) return db_input diff --git a/config/program_schema.py b/config/program_schema.py index 96d7a63..649fdad 100644 --- a/config/program_schema.py +++ b/config/program_schema.py @@ -32,6 +32,7 @@ class SysinDef: period: str | None = "202607" include_invalid_period: bool = False modes: list[str] = field(default_factory=lambda: ["NORMAL"]) + final_mode: str = "RESET" @dataclass @@ -42,6 +43,9 @@ class ScenarioDef: inject_duplicate_pk: bool = False row_overrides: dict[str, dict[str, str]] = field(default_factory=dict) 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 @@ -53,6 +57,7 @@ class ProgramSchema: db_name: str = "OVERTIME.DB" runs: list[ScenarioDef] = field(default_factory=list) coverage_dates: dict[str, list[dict[str, str]]] = field(default_factory=dict) + command_line: str | None = None @classmethod def from_yaml(cls, path: str | Path) -> ProgramSchema: @@ -76,6 +81,9 @@ class ProgramSchema: inject_duplicate_pk=r.get("inject_duplicate_pk", False), row_overrides=r.get("row_overrides", {}), 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( program_id=raw["program_id"], @@ -85,6 +93,7 @@ class ProgramSchema: db_name=raw.get("db_name", "OVERTIME.DB"), runs=runs, coverage_dates=raw.get("coverage_dates", {}), + command_line=raw.get("command_line"), ) diff --git a/config/programs/JIN05UPD.yaml b/config/programs/JIN05UPD.yaml new file mode 100644 index 0000000..6f803cb --- /dev/null +++ b/config/programs/JIN05UPD.yaml @@ -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 diff --git a/config/programs/KIN06CLD.yaml b/config/programs/KIN06CLD.yaml index 69852f6..9c36c18 100644 --- a/config/programs/KIN06CLD.yaml +++ b/config/programs/KIN06CLD.yaml @@ -23,6 +23,22 @@ db_tables: - name: STATUS 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: - SUB02MSG - SUB03END diff --git a/config/programs/KIN08DBU.yaml b/config/programs/KIN08DBU.yaml index 9929298..b1cc961 100644 --- a/config/programs/KIN08DBU.yaml +++ b/config/programs/KIN08DBU.yaml @@ -77,3 +77,22 @@ runs: include_invalid_period: true modes: ["NORMAL", "RESET"] 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] diff --git a/config/programs/KIN09CSV.yaml b/config/programs/KIN09CSV.yaml index 18f5be3..d2bd6e9 100644 --- a/config/programs/KIN09CSV.yaml +++ b/config/programs/KIN09CSV.yaml @@ -49,6 +49,30 @@ db_tables: - name: UPDATED_AT 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=MODE(VALUE1→WS-MODE)+ KEY2=YEARMONTH(VALUE2→WS-YEARMONTH) + command_line: "MODE=FULL,YEARMONTH=202607" + - id: missing_ym + # YEARMONTH 未指定 → SPACE → ABEND(SUB03END) + 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: - SUB02MSG - SUB03END diff --git a/config/programs/KYU02REG.yaml b/config/programs/KYU02REG.yaml new file mode 100644 index 0000000..a86ba25 --- /dev/null +++ b/config/programs/KYU02REG.yaml @@ -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 diff --git a/config/programs/KYU04CAL.yaml b/config/programs/KYU04CAL.yaml new file mode 100644 index 0000000..a224dd4 --- /dev/null +++ b/config/programs/KYU04CAL.yaml @@ -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 diff --git a/config/programs/KYU05DED.yaml b/config/programs/KYU05DED.yaml new file mode 100644 index 0000000..7c29d5b --- /dev/null +++ b/config/programs/KYU05DED.yaml @@ -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 diff --git a/config/programs/KYU06UPD.yaml b/config/programs/KYU06UPD.yaml new file mode 100644 index 0000000..20fa9ef --- /dev/null +++ b/config/programs/KYU06UPD.yaml @@ -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 diff --git a/config/programs/SHA02MNC.yaml b/config/programs/SHA02MNC.yaml new file mode 100644 index 0000000..4553457 --- /dev/null +++ b/config/programs/SHA02MNC.yaml @@ -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 \ No newline at end of file diff --git a/config/programs/SHA03MNP.yaml b/config/programs/SHA03MNP.yaml new file mode 100644 index 0000000..8281b84 --- /dev/null +++ b/config/programs/SHA03MNP.yaml @@ -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 \ No newline at end of file diff --git a/config/programs/SHA04TWO.yaml b/config/programs/SHA04TWO.yaml new file mode 100644 index 0000000..35c953a --- /dev/null +++ b/config/programs/SHA04TWO.yaml @@ -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 \ No newline at end of file diff --git a/config/programs/SHA06TWM.yaml b/config/programs/SHA06TWM.yaml new file mode 100644 index 0000000..6bb7405 --- /dev/null +++ b/config/programs/SHA06TWM.yaml @@ -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 \ No newline at end of file diff --git a/config/programs/SHA07KBR.yaml b/config/programs/SHA07KBR.yaml new file mode 100644 index 0000000..3ae4532 --- /dev/null +++ b/config/programs/SHA07KBR.yaml @@ -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 \ No newline at end of file diff --git a/orchestrator_db.py b/orchestrator_db.py index 7708ed2..ff0f9a1 100644 --- a/orchestrator_db.py +++ b/orchestrator_db.py @@ -34,6 +34,33 @@ from runners.gixsql_runner import GixsqlCobolRunner, GixsqlTableData 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 class DbPipelineResult: """DB 管线単体実行結果""" @@ -86,6 +113,7 @@ class GixsqlOrchestrator: self.java_input_path: Optional[Path] = None 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._sub_gcov_data: dict[str, dict[int, int]] = {} # per-subprogram gcov (kept separate from main) self.java_output_path: Optional[Path] = None self.generated_records: list[dict] = [] self.generated_structure: dict | None = None @@ -148,6 +176,20 @@ class GixsqlOrchestrator: copybook_dirs=[ascii_dir]) 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" extra_srcs = [] for sub in self.schema.subprograms: @@ -235,10 +277,27 @@ class GixsqlOrchestrator: # DB 初期行投入(DELETE/UPDATE が作用する行、SELECT が返す行) 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 で制御) if scenario is None or scenario.inject_duplicate_pk: 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 for i, rec in enumerate(recs): line = rec.get('R01LINE', '') @@ -253,10 +312,8 @@ class GixsqlOrchestrator: emp_id = rec.get('HV-EMP-ID', '') if not emp_id or emp_id == '00000000': emp_id = f"EMP{str(i).zfill(5)}" - # First record: empty EMP-ID to trigger R01EMP-ID = SPACE path (DP#12) if i == 0: rec['R01LINE'] = f"{' '*8},{parts[1]}" - rec['R01EMP-ID'] = ' ' * len(emp_id) else: rec['R01LINE'] = f"{emp_id.ljust(8)},{parts[1]}" rec['R01EMP-ID'] = emp_id @@ -286,20 +343,25 @@ class GixsqlOrchestrator: src_date = r.get('R01DATE', '') break 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)): rec = recs[j] if not dup_eid: continue rec['R01EMP-ID'] = dup_eid - # FIXED format: keep same YEAR_MONTH but different day - # to avoid PK conflict in DAILY_RECORDS INSERT. + # FIXED format: keep same YEAR_MONTH but uniquely different day + # to avoid PK conflict in DAILY_RECORDS INSERT (EMP_ID + TARGET_DATE). if dup_date and len(dup_date) >= 6: dup_ym = dup_date[:6] orig_date = rec.get('R01DATE', '') - if orig_date and len(orig_date) >= 8: - rec['R01DATE'] = dup_ym + orig_date[6:8] - else: - rec['R01DATE'] = dup_ym + '01' + orig_day = orig_date[6:8] if orig_date and len(orig_date) >= 8 else '' + day = orig_day if (orig_day and orig_day not in used_days) else f"{len(used_days)+1:02d}" + used_days.add(day) + rec['R01DATE'] = dup_ym + day # LINE SEQUENTIAL format: patch R01LINE line = rec.get('R01LINE', '') if line: @@ -307,6 +369,9 @@ class GixsqlOrchestrator: if len(parts) == 2: 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) ── # Normal scenario or legacy single-run: no modifications needed. # Collision scenario: INSERT duplicate, OVT-MONTHLY match, COMMIT threshold. @@ -367,6 +432,9 @@ class GixsqlOrchestrator: r02_recs[-1]['R02APPL-ID'] = 'ZZZZZZZZ' 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 "" output_root = self.work_dir / run_label if scenario else self.work_dir @@ -384,6 +452,7 @@ class GixsqlOrchestrator: "period": scenario.sysin.period, "include_invalid_period": scenario.sysin.include_invalid_period, "modes": scenario.sysin.modes, + "final_mode": scenario.sysin.final_mode, } sysin_path = write_sysin_file(recs, src_text, input_dir, 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 [] fdict = [] for f in data_fields: - fdict.append({ + _entry = { 'name': f.name, 'level': f.level, 'pic': f.pic, 'pic_info': { 'type': f.pic_info.type if f.pic_info else 'unknown', @@ -419,7 +488,11 @@ class GixsqlOrchestrator: 'occurs_depending': f.occurs_depending, 'value': f.value, 'values': f.values, '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) proc_div = extract_procedure_division(pp) branch_tree, assignments = build_branch_tree_fallback(proc_div, fdict) @@ -460,12 +533,14 @@ class GixsqlOrchestrator: # DB input for JSON 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) db_input = None if sql_meta: db_input = build_db_input( 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(シナリオ毎に分離) json_outdir = output_root / "main" / "json" @@ -551,14 +626,19 @@ class GixsqlOrchestrator: self.db_path.unlink() shutil.copy2(str(db_path), str(self.db_path)) db_path = self.db_path - # CONNECT TO 'data/kin.db' のパス解釈に備え CWD にもコピー - if scenario is not None: - cwd_data = cwd / "data" - cwd_data.mkdir(parents=True, exist_ok=True) - cwd_db = cwd_data / "kin.db" - if cwd_db.exists(): - cwd_db.unlink() - shutil.copy2(str(db_path), str(cwd_db)) + # CONNECT TO 'data/kin.db' のパス解釈に備え CWD にもコピー(単輪/多輪共通) + cwd_data = cwd / "data" + cwd_data.mkdir(parents=True, exist_ok=True) + cwd_db = cwd_data / "kin.db" + if cwd_db.exists(): + cwd_db.unlink() + 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 を削除(GnuCOBOL は累積書込みを行うため) @@ -569,15 +649,27 @@ class GixsqlOrchestrator: except PermissionError: 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 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( self.exe_path, cwd, db_path, input_dir=None, cobol_lib_path=str(cobol_bin) if cobol_bin.exists() else None, env_overrides=env_overrides, + command_args=command_args, ) log_dir = self.runtime_dir / "logs" @@ -642,12 +734,7 @@ class GixsqlOrchestrator: if not dst.exists(): shutil.copy2(str(f), str(dst)) - merged_data: dict[int, int] = {} - 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) + merged_data = _merge_run_dirs_gcov(gcov_dir, f"{self.program_id}_pp") logger.info(f" Merged gcov from {len(run_dirs)} runs ({len(merged_data)} lines)") return merged_data @@ -674,18 +761,16 @@ class GixsqlOrchestrator: # 1. Use pre-merged multi-run gcov data if available (skip gcov re-run) if self._multi_run_gcov_data is not None: gcov_data = self._multi_run_gcov_data - # Also merge subprogram gcov data from each scenario - from cobol_testgen.gcov import run_gcov as _run_gcov + # Subprogram gcov is kept separate: SUB*.cbl line numbers are + # 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" + self._sub_gcov_data = {} for sub in self.schema.subprograms: - sub_merged: dict[int, int] = {} - 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) + sub_merged = _merge_run_dirs_gcov(gcov_dir, sub) if sub_merged: - gcov_data.update(sub_merged) + self._sub_gcov_data[sub] = sub_merged else: # Single-run: collect .gcno/.gcda and run 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)) if not gcov_data: 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: sd = run_gcov(sub, str(gcov_dir)) if sd: - gcov_data.update(sd) + self._sub_gcov_data[sub] = sd # 4. Static branch tree from step2 st = self.generated_structure @@ -758,6 +845,7 @@ class GixsqlOrchestrator: }, 'section': f.section, 'occurs': f.occurs_count, 'occurs_depending': f.occurs_depending, + 'value': f.value, 'values': f.values, 'redefines': f.redefines, 'usage': f.usage, } if f.is_88: @@ -1011,7 +1099,7 @@ class GixsqlOrchestrator: # First pass: collect all SELECT/ASSIGN-TO mappings select_to_file: dict[str, str] = {} 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 ): sel_name = m.group(1) @@ -1078,6 +1166,58 @@ class GixsqlOrchestrator: conn.close() 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], scenario: ScenarioDef | None = None): """テストデータから DB 初期行を生成し挿入する。""" @@ -1094,7 +1234,7 @@ class GixsqlOrchestrator: data_fields = parse_data_division(data_div) if data_div else [] fields_dict = [] for f in data_fields: - fields_dict.append({ + _entry = { 'name': f.name, 'level': f.level, 'pic': f.pic, 'pic_info': { 'type': f.pic_info.type if f.pic_info else 'unknown', @@ -1107,7 +1247,11 @@ class GixsqlOrchestrator: 'occurs_depending': f.occurs_depending, 'value': f.value, 'values': f.values, '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) proc_div = extract_procedure_division(preprocessed) @@ -1127,10 +1271,12 @@ class GixsqlOrchestrator: logger.info(" No SQL metadata found, skipping DB population") return + declared_columns = self._merge_schema_columns(declared_columns) db_input = build_db_input( branch_paths, fields_dict, assignments, sql_meta, declared_columns, records=records, + insert_pk=self._insert_pk_map(), ) if not db_input: logger.info(" No DB input rows generated") @@ -1165,6 +1311,50 @@ class GixsqlOrchestrator: if i < len(holiday_overrides): 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) -- if scenario and scenario.row_overrides: for table_name, overrides in scenario.row_overrides.items(): @@ -1173,12 +1363,26 @@ class GixsqlOrchestrator: for col, val in overrides.items(): 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)) for table_name, rows in db_input.items(): if not rows: logger.info(f" Table {table_name}: 0 initial rows (will be created at runtime)") continue + # Normalize DB2 hyphenated identifiers -> underscores (schema uses underscores) + db_table = table_name.replace('-', '_') + # Debug 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 = {} try: pragma_cols = conn.execute( - f"PRAGMA table_info([{table_name}])" + f"PRAGMA table_info([{db_table}])" ).fetchall() valid_cols = {r[1].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: new_row = {} for k, v in row.items(): - if k.upper() in valid_cols: - new_row[k] = v + k_norm = k.replace('-', '_') + if k_norm.upper() in valid_cols: + new_row[k_norm] = v if new_row: remapped_rows.append(new_row) rows = remapped_rows @@ -1225,20 +1430,219 @@ class GixsqlOrchestrator: col_names = list(rows[0].keys()) placeholders = ", ".join("?" for _ 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]) logger.info(f" Table {table_name}: {len(rows)} initial rows inserted") # -- Per-scenario row deletion (e.g. empty cursor scenario) -- if scenario and scenario.delete_all_rows: 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})") + + # -- 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.close() 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/TO、REGION-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 の + 区间内値に設定し(AGE≈70 / DEP≈85 / 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。 + + 本関数:输出 FD(W01/W02 等)中 PIC 9 类型字段的 base 名(EMP-ID、 + CHG-DATE、CHG-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): - """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)) for table in self.schema.db_tables: pk_cols = [c.name for c in table.columns if c.primary_key] @@ -1246,18 +1650,18 @@ class GixsqlOrchestrator: continue col_names = [c.name for c in table.columns] try: - rows = conn.execute(f"SELECT * FROM [{table.name}] LIMIT 2").fetchall() - if len(rows) < 1: - # For empty tables, generate synthetic error rows from test record data - synthetic = self._make_synthetic_error_rows(table, records) - if synthetic: - rows = synthetic - else: + synthetic = self._make_synthetic_error_rows(table, records) + if synthetic: + rows = synthetic + else: + # fallback: 表已有行 + rows = conn.execute(f"SELECT * FROM [{table.name}] LIMIT 2").fetchall() + if not rows: continue quoted = ", ".join(f"[{c}]" for c in col_names) ph = ", ".join("?" for _ in col_names) 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) logger.info(f" SQL error test row injected into {table.name}") except Exception as e: @@ -1265,6 +1669,163 @@ class GixsqlOrchestrator: conn.commit() 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, max_seed: int = 1, 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)") 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 / R01DATE),YEAR_MONTH 由 R01DATE[:6] 推导,而非取值 + 尚未赋值的 WS 合成值(HV-* 在运行前是垃圾值,如 'A0000001')。 + """ if not records or len(records) < 2: return None pk_cols = [c.name for c in table.columns if c.primary_key] if not pk_cols: 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 = { - 'EMP_ID': ('HV-EMP-ID', 'R01EMP-ID', ''), - 'TARGET_DATE': ('HV-TARGET-DATE', ''), - 'YEAR_MONTH': ('HV-YEAR-MONTH', ''), - 'TIME_IN': ('HV-TIME-IN', ''), - 'TIME_OUT': ('HV-TIME-OUT', ''), - 'ANNUAL_LEAVE_H': ('HV-ANNUAL-H', ''), - 'PERSONAL_LEAVE_H': ('HV-PERSONAL-H', ''), - 'OFFICIAL_LEAVE_H': ('HV-OFFICIAL-H', ''), - 'SICK_LEAVE_H': ('HV-SICK-H', ''), - 'UNAPPROVED_ABSENT_H': ('HV-ABSENT-H', ''), + 'EMP_ID': ('R01EMP-ID', 'HV-EMP-ID', ''), + 'TARGET_DATE': ('R01DATE', 'HV-TARGET-DATE', ''), + 'YEAR_MONTH': ('R01DATE', 'HV-YEAR-MONTH', ''), + 'TIME_IN': ('R01TIME-IN', 'HV-TIME-IN', ''), + 'TIME_OUT': ('R01TIME-OUT', 'HV-TIME-OUT', ''), + 'ANNUAL_LEAVE_H': ('R01ANNUAL-H', 'HV-ANNUAL-H', ''), + 'PERSONAL_LEAVE_H': ('R01PERSONAL-H', 'HV-PERSONAL-H', ''), + 'OFFICIAL_LEAVE_H': ('R01OFFICIAL-H', 'HV-OFFICIAL-H', ''), + 'SICK_LEAVE_H': ('R01SICK-H', 'HV-SICK-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 = [] - for idx in range(min(2, len(records))): - rec = records[idx] + picked = 0 + 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 = [] for col in table.columns: val = None - if col.name in hv_map: - for key in hv_map[col.name]: - if key and key in rec: - val = rec[key] - break + if col.name == 'YEAR_MONTH': + val = r01date[:6] if len(r01date) >= 6 else '' + elif col.name in hv_map: + val = _first(rec, hv_map[col.name]) or None if val is None: 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)) + picked += 1 + if picked >= 2: + break return result if result else None diff --git a/runners/gixsql_runner.py b/runners/gixsql_runner.py index d3aa5e4..de38a98 100644 --- a/runners/gixsql_runner.py +++ b/runners/gixsql_runner.py @@ -13,6 +13,57 @@ from typing import Optional 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 ., 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 class GixsqlBuildResult: success: bool @@ -103,13 +154,13 @@ class GixsqlCobolRunner: return ( " 01 SQLCA.\n" " 05 SQLCAID PIC X(8).\n" - " 05 SQLCABC PIC S9(9) COMP.\n" - " 05 SQLCODE PIC S9(9) COMP.\n" + " 05 SQLCABC PIC S9(9) COMP-5.\n" + " 05 SQLCODE PIC S9(9) COMP-5.\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" " 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" " 10 SQLWARN0 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") + # 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 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) @@ -154,17 +209,10 @@ class GixsqlCobolRunner: r"CONNECT\s+TO\s+'[^']*'", f"CONNECT TO :{conn_var} USER :{usr_var}", inner, - flags=re.IGNORECASE + flags=re.IGNORECASE | re.DOTALL ) - # Short absolute path under C:\Temp\gix\ (no Chinese chars, fits col 72). - # The orchestrator creates the DB at the same path so they match. - 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" + # Orchestrator copies DB to CWD/kin. + return (f"MOVE 'sqlite://kin' TO {conn_var}\n" f" MOVE 'gix' TO {usr_var}\n" f" EXEC SQL\n" f" {new_inner.strip()}\n" @@ -185,9 +233,6 @@ class GixsqlCobolRunner: if hasattr(self, '_copybook_dirs') and 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 lines = [] for line in text.splitlines(keepends=True): @@ -246,8 +291,29 @@ class GixsqlCobolRunner: 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.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 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}") 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, copybook_dirs: list[str | Path] | None = None, extra_srcs: list[str | Path] | None = None) -> GixsqlBuildResult: @@ -282,6 +422,19 @@ class GixsqlCobolRunner: exe_dir = exe_path.parent 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 gixsql_k = [ "-K", "GIXSQLStartSQL", @@ -331,7 +484,8 @@ class GixsqlCobolRunner: input_dir: str | Path | None = None, timeout: int = 30, 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 プログラム実行""" exe_path = Path(exe_path) work_dir = Path(work_dir) @@ -388,6 +542,8 @@ class GixsqlCobolRunner: dst.write_bytes(f.read_bytes()) cmd = [str(exe_path)] + if command_args: + cmd.extend(command_args) logger.info(f" run: {' '.join(cmd)} (cwd={work_dir}, db={db_path})") try: r = subprocess.run(cmd, capture_output=True, timeout=timeout, From ff51bda96270b1f95cc44afa009ad9f328889193 Mon Sep 17 00:00:00 2001 From: hangshuo652 Date: Sun, 9 Aug 2026 17:43:16 +0800 Subject: [PATCH 3/9] test: cover between/hostvar/gcov-merge, class conditions, schema drop-tables, gixsql fixes --- test_tertiary.py | 63 ++++ tests/cobol_testgen/test_coverage_locator.py | 121 ++++++++ tests/cobol_testgen/test_fileio_pd_binary.py | 71 +++++ tests/cobol_testgen/test_kin07_coordinate.py | 216 ++++++++++++++ tests/cobol_testgen/test_merge_pipeline.py | 74 +++++ tests/cobol_testgen/test_read_into_layout.py | 138 +++++++++ tests/cobol_testgen/test_search_phase3.py | 108 +++++++ tests/cobol_testgen/test_skill_key_suffix.py | 148 ++++++++++ tests/cobol_testgen/test_sqlca_byteorder.py | 38 +++ tests/cobol_testgen/test_to_sql_between.py | 86 ++++++ .../test_to_sql_between_dbinput.py | 188 ++++++++++++ .../test_to_sql_dispatch_seed.py | 126 ++++++++ tests/cobol_testgen/test_to_sql_insert_pk.py | 177 +++++++++++ .../cobol_testgen/test_to_sql_schema_table.py | 135 +++++++++ .../test_to_sql_select_hostvar.py | 279 ++++++++++++++++++ .../config/test_program_schema_drop_tables.py | 37 +++ .../runners/test_gixsql_current_timestamp.py | 58 ++++ tests/runners/test_gixsql_schema_qualifier.py | 99 +++++++ tests/runners/test_sqlcode_normalize.py | 66 +++++ tests/test_kin08dbu_fixes.py | 169 +++++++++++ tests/test_kin09csv_fixes.py | 97 ++++++ tests/test_orchestrator_db_gcov_merge.py | 65 ++++ 22 files changed, 2559 insertions(+) create mode 100644 test_tertiary.py create mode 100644 tests/cobol_testgen/test_coverage_locator.py create mode 100644 tests/cobol_testgen/test_fileio_pd_binary.py create mode 100644 tests/cobol_testgen/test_kin07_coordinate.py create mode 100644 tests/cobol_testgen/test_merge_pipeline.py create mode 100644 tests/cobol_testgen/test_read_into_layout.py create mode 100644 tests/cobol_testgen/test_search_phase3.py create mode 100644 tests/cobol_testgen/test_skill_key_suffix.py create mode 100644 tests/cobol_testgen/test_sqlca_byteorder.py create mode 100644 tests/cobol_testgen/test_to_sql_between.py create mode 100644 tests/cobol_testgen/test_to_sql_between_dbinput.py create mode 100644 tests/cobol_testgen/test_to_sql_dispatch_seed.py create mode 100644 tests/cobol_testgen/test_to_sql_insert_pk.py create mode 100644 tests/cobol_testgen/test_to_sql_schema_table.py create mode 100644 tests/cobol_testgen/test_to_sql_select_hostvar.py create mode 100644 tests/config/test_program_schema_drop_tables.py create mode 100644 tests/runners/test_gixsql_current_timestamp.py create mode 100644 tests/runners/test_gixsql_schema_qualifier.py create mode 100644 tests/runners/test_sqlcode_normalize.py create mode 100644 tests/test_kin08dbu_fixes.py create mode 100644 tests/test_kin09csv_fixes.py create mode 100644 tests/test_orchestrator_db_gcov_merge.py diff --git a/test_tertiary.py b/test_tertiary.py new file mode 100644 index 0000000..1864015 --- /dev/null +++ b/test_tertiary.py @@ -0,0 +1,63 @@ +"""Quick unit test for _coordinate_tertiary_fd (new front-sequence design). + +与 tests/cobol_testgen/test_kin07_coordinate.py 对应,验证: +- 记录0 主+全部明细键一致(全匹配) +- rec1 主+明细1、rec2 主+明细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') diff --git a/tests/cobol_testgen/test_coverage_locator.py b/tests/cobol_testgen/test_coverage_locator.py new file mode 100644 index 0000000..0d34524 --- /dev/null +++ b/tests/cobol_testgen/test_coverage_locator.py @@ -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}" diff --git a/tests/cobol_testgen/test_fileio_pd_binary.py b/tests/cobol_testgen/test_fileio_pd_binary.py new file mode 100644 index 0000000..e104d8f --- /dev/null +++ b/tests/cobol_testgen/test_fileio_pd_binary.py @@ -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 用小端 '<',但本 GnuCOBOL(GC32-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' diff --git a/tests/cobol_testgen/test_kin07_coordinate.py b/tests/cobol_testgen/test_kin07_coordinate.py new file mode 100644 index 0000000..17fc068 --- /dev/null +++ b/tests/cobol_testgen/test_kin07_coordinate.py @@ -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} 长休暇结束时间' diff --git a/tests/cobol_testgen/test_merge_pipeline.py b/tests/cobol_testgen/test_merge_pipeline.py new file mode 100644 index 0000000..b5a3378 --- /dev/null +++ b/tests/cobol_testgen/test_merge_pipeline.py @@ -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 diff --git a/tests/cobol_testgen/test_read_into_layout.py b/tests/cobol_testgen/test_read_into_layout.py new file mode 100644 index 0000000..3590252 --- /dev/null +++ b/tests/cobol_testgen/test_read_into_layout.py @@ -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 / INTO 分行书写仍能解析为 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' diff --git a/tests/cobol_testgen/test_search_phase3.py b/tests/cobol_testgen/test_search_phase3.py new file mode 100644 index 0000000..d54b094 --- /dev/null +++ b/tests/cobol_testgen/test_search_phase3.py @@ -0,0 +1,108 @@ +"""SEARCH 分支路径生成 + 二元 COMPUTE 链反演(Phase 3 — TDD RED) + +目标:KYU05DED SEARCH WHEN 分支(#9)从不可达变为可达, +配合二元 `-` COMPUTE(WRK-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 源 `-` COMPUTE:SUBJ = GROSS - DED(DED 常量 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 diff --git a/tests/cobol_testgen/test_skill_key_suffix.py b/tests/cobol_testgen/test_skill_key_suffix.py new file mode 100644 index 0000000..bf46f40 --- /dev/null +++ b/tests/cobol_testgen/test_skill_key_suffix.py @@ -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(> ZERO)TRUE 分支不可达。 + +修复:增加"后缀匹配"层级——一方 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}' diff --git a/tests/cobol_testgen/test_sqlca_byteorder.py b/tests/cobol_testgen/test_sqlca_byteorder.py new file mode 100644 index 0000000..6a86202 --- /dev/null +++ b/tests/cobol_testgen/test_sqlca_byteorder.py @@ -0,0 +1,38 @@ +"""SQLCA 字节序对齐测试(T1)。 + +gixsql 以 native 小端 int 写入 SQLCA.SQLCODE。GnuCOBOL 的 COMP 按大端存储, +导致非零 SQLCODE 被错读(如 -1555 → -302383105)。必须改用 COMP-5(native), +否则 `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-5(native 字节序)""" + 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 diff --git a/tests/cobol_testgen/test_to_sql_between.py b/tests/cobol_testgen/test_to_sql_between.py new file mode 100644 index 0000000..586476e --- /dev/null +++ b/tests/cobol_testgen/test_to_sql_between.py @@ -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'] == '>' diff --git a/tests/cobol_testgen/test_to_sql_between_dbinput.py b/tests/cobol_testgen/test_to_sql_between_dbinput.py new file mode 100644 index 0000000..2a481c8 --- /dev/null +++ b/tests/cobol_testgen/test_to_sql_between_dbinput.py @@ -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 + diff --git a/tests/cobol_testgen/test_to_sql_dispatch_seed.py b/tests/cobol_testgen/test_to_sql_dispatch_seed.py new file mode 100644 index 0000000..0137488 --- /dev/null +++ b/tests/cobol_testgen/test_to_sql_dispatch_seed.py @@ -0,0 +1,126 @@ +"""build_db_input 事务调度感知种子测试(JIN05UPD 根因)。 + +复现根因:UPDATE/DELETE 种子对每个 sql_ok 路径都用该路径记录的主键建行, +不区分记录运行时是否真的执行 UPDATE。JIN05UPD 的 4 条 INSERT 记录主键因此 +全部被预置进 EMPLOYEE → 运行时 INSERT 全部 -803,INSERT 成功分支不可达。 +另外:非 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/B0000009(OTHER 记录)绝不能入表。 + """ + 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}' diff --git a/tests/cobol_testgen/test_to_sql_insert_pk.py b/tests/cobol_testgen/test_to_sql_insert_pk.py new file mode 100644 index 0000000..1f0df46 --- /dev/null +++ b/tests/cobol_testgen/test_to_sql_insert_pk.py @@ -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=0(INSERT 成功)的路径 → 不生成碰撞预置行""" + 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<>0(INSERT 失败)的路径 → 生成主键碰撞预置行""" + 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-MONTH(ACCEPT + + 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-ID);YEAR-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' diff --git a/tests/cobol_testgen/test_to_sql_schema_table.py b/tests/cobol_testgen/test_to_sql_schema_table.py new file mode 100644 index 0000000..4c9e1d6 --- /dev/null +++ b/tests/cobol_testgen/test_to_sql_schema_table.py @@ -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 不满足 WHERE,RATES 不装载, + 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" diff --git a/tests/cobol_testgen/test_to_sql_select_hostvar.py b/tests/cobol_testgen/test_to_sql_select_hostvar.py new file mode 100644 index 0000000..850c842 --- /dev/null +++ b/tests/cobol_testgen/test_to_sql_select_hostvar.py @@ -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(): + """两个 SELECT:EMP-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') diff --git a/tests/config/test_program_schema_drop_tables.py b/tests/config/test_program_schema_drop_tables.py new file mode 100644 index 0000000..d3f39d0 --- /dev/null +++ b/tests/config/test_program_schema_drop_tables.py @@ -0,0 +1,37 @@ +"""Scenario `drop_tables` 配置解析测试。 + +用于覆盖「表缺失导致 OPEN CURSOR 失败」的错误分支(如 SHA02MNC #5 T: +EMP-OPEN SQLCODE≠0)。机制通用:任一程序可在场景里声明要 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"] diff --git a/tests/runners/test_gixsql_current_timestamp.py b/tests/runners/test_gixsql_current_timestamp.py new file mode 100644 index 0000000..4907f20 --- /dev/null +++ b/tests/runners/test_gixsql_current_timestamp.py @@ -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 diff --git a/tests/runners/test_gixsql_schema_qualifier.py b/tests/runners/test_gixsql_schema_qualifier.py new file mode 100644 index 0000000..9a4b1bc --- /dev/null +++ b/tests/runners/test_gixsql_schema_qualifier.py @@ -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' → SQLCODE≠0 → 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 diff --git a/tests/runners/test_sqlcode_normalize.py b/tests/runners/test_sqlcode_normalize.py new file mode 100644 index 0000000..91b3293 --- /dev/null +++ b/tests/runners/test_sqlcode_normalize.py @@ -0,0 +1,66 @@ +"""SQLCODE 归一化注入测试(T3)。 + +gixsql+SQLite 对主键冲突返回 SQLCODE=-1555(SQLITE_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 diff --git a/tests/test_kin08dbu_fixes.py b/tests/test_kin08dbu_fixes.py new file mode 100644 index 0000000..f912ee1 --- /dev/null +++ b/tests/test_kin08dbu_fixes.py @@ -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)}' diff --git a/tests/test_kin09csv_fixes.py b/tests/test_kin09csv_fixes.py new file mode 100644 index 0000000..1038019 --- /dev/null +++ b/tests/test_kin09csv_fixes.py @@ -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}' diff --git a/tests/test_orchestrator_db_gcov_merge.py b/tests/test_orchestrator_db_gcov_merge.py new file mode 100644 index 0000000..79df104 --- /dev/null +++ b/tests/test_orchestrator_db_gcov_merge.py @@ -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 From f67a5bf7690c45c55d1e8ba144a34c668b8f563e Mon Sep 17 00:00:00 2001 From: hangshuo652 Date: Sun, 9 Aug 2026 17:43:28 +0800 Subject: [PATCH 4/9] =?UTF-8?q?docs:=20add=20detailed=20design=20documents?= =?UTF-8?q?=20(=E8=A9=B3=E7=B4=B0=E8=A8=AD=E8=A8=88=E6=9B=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 詳細設計書/COPY句定義書.md | 326 ++++++++++++++++++++++++++++++ 詳細設計書/DB定義書.md | 232 +++++++++++++++++++++ 詳細設計書/詳細設計書_KIN01INP.md | 153 ++++++++++++++ 詳細設計書/詳細設計書_KIN02UPD.md | 155 ++++++++++++++ 詳細設計書/詳細設計書_KIN03EXP.md | 170 ++++++++++++++++ 詳細設計書/詳細設計書_KIN04CHK.md | 155 ++++++++++++++ 詳細設計書/詳細設計書_KIN05MAT.md | 128 ++++++++++++ 詳細設計書/詳細設計書_KIN06CLD.md | 116 +++++++++++ 詳細設計書/詳細設計書_KIN07DAI.md | 167 +++++++++++++++ 詳細設計書/詳細設計書_KIN08DBU.md | 211 +++++++++++++++++++ 詳細設計書/詳細設計書_KIN09CSV.md | 207 +++++++++++++++++++ 詳細設計書/詳細設計書_SUB01DAT.md | 52 +++++ 詳細設計書/詳細設計書_SUB02MSG.md | 72 +++++++ 詳細設計書/詳細設計書_SUB03END.md | 51 +++++ 詳細設計書/詳細設計書_SUB04CHK.md | 115 +++++++++++ 詳細設計書/詳細設計書_SUB05TIM.md | 107 ++++++++++ 詳細設計書/詳細設計書_ZAN01CHK.md | 144 +++++++++++++ 詳細設計書/詳細設計書_ZAN02CHK.md | 123 +++++++++++ 詳細設計書/詳細設計書_ZAN03CHK.md | 122 +++++++++++ 詳細設計書/詳細設計書_ZAN04MAT.md | 144 +++++++++++++ 詳細設計書/詳細設計書_ZAN05CAL.md | 169 ++++++++++++++++ 詳細設計書/詳細設計書_ZAN06UPD.md | 238 ++++++++++++++++++++++ 22 files changed, 3357 insertions(+) create mode 100644 詳細設計書/COPY句定義書.md create mode 100644 詳細設計書/DB定義書.md create mode 100644 詳細設計書/詳細設計書_KIN01INP.md create mode 100644 詳細設計書/詳細設計書_KIN02UPD.md create mode 100644 詳細設計書/詳細設計書_KIN03EXP.md create mode 100644 詳細設計書/詳細設計書_KIN04CHK.md create mode 100644 詳細設計書/詳細設計書_KIN05MAT.md create mode 100644 詳細設計書/詳細設計書_KIN06CLD.md create mode 100644 詳細設計書/詳細設計書_KIN07DAI.md create mode 100644 詳細設計書/詳細設計書_KIN08DBU.md create mode 100644 詳細設計書/詳細設計書_KIN09CSV.md create mode 100644 詳細設計書/詳細設計書_SUB01DAT.md create mode 100644 詳細設計書/詳細設計書_SUB02MSG.md create mode 100644 詳細設計書/詳細設計書_SUB03END.md create mode 100644 詳細設計書/詳細設計書_SUB04CHK.md create mode 100644 詳細設計書/詳細設計書_SUB05TIM.md create mode 100644 詳細設計書/詳細設計書_ZAN01CHK.md create mode 100644 詳細設計書/詳細設計書_ZAN02CHK.md create mode 100644 詳細設計書/詳細設計書_ZAN03CHK.md create mode 100644 詳細設計書/詳細設計書_ZAN04MAT.md create mode 100644 詳細設計書/詳細設計書_ZAN05CAL.md create mode 100644 詳細設計書/詳細設計書_ZAN06UPD.md diff --git a/詳細設計書/COPY句定義書.md b/詳細設計書/COPY句定義書.md new file mode 100644 index 0000000..f88d557 --- /dev/null +++ b/詳細設計書/COPY句定義書.md @@ -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-SEQ(9(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.h(0.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-HOURS(9(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で使用予定(未実装) | diff --git a/詳細設計書/DB定義書.md b/詳細設計書/DB定義書.md new file mode 100644 index 0000000..58ddb85 --- /dev/null +++ b/詳細設計書/DB定義書.md @@ -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.50(50%控除) | + +### 備考 + +- 全社員共通の設定値 +- 現時点では参照プログラム未実装(設計上定義) + +--- + +# 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が月次集計後にUPSERT(MERGE) +- サブシステム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 diff --git a/詳細設計書/詳細設計書_KIN01INP.md b/詳細設計書/詳細設計書_KIN01INP.md new file mode 100644 index 0000000..2d5b054 --- /dev/null +++ b/詳細設計書/詳細設計書_KIN01INP.md @@ -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(終了メッセージ) +``` + +--- + +## 出力レコード定義 + +### 出力ファイル1(W01/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 | + +### 出力ファイル2(W02/ERROR-LOG) KIN05REC 200B VB + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | ERR-CATEGORY | '01'固定 | | +| 2 | ERR-DETAIL | STRING編集 | エラー内容(198B) | diff --git a/詳細設計書/詳細設計書_KIN02UPD.md b/詳細設計書/詳細設計書_KIN02UPD.md new file mode 100644 index 0000000..c447fb8 --- /dev/null +++ b/詳細設計書/詳細設計書_KIN02UPD.md @@ -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' + PARM2:SQLCODE + PARM3:WS-APPL-ID + 4-4.ABEND処理SUB呼出(9999ABDSOR) + ABENDコード:999 +``` + +--- + +## 出力レコード定義 + +### 出力ファイル1(W01/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テーブルに直接反映される。 diff --git a/詳細設計書/詳細設計書_KIN03EXP.md b/詳細設計書/詳細設計書_KIN03EXP.md new file mode 100644 index 0000000..853b1a3 --- /dev/null +++ b/詳細設計書/詳細設計書_KIN03EXP.md @@ -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.出力ファイルOPEN(W01) + 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(情報メッセージ) + PARM1:WRK-BFR-EMP-ID + PARM2:CUN-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(終了メッセージ) +``` + +--- + +## 出力レコード定義 + +### 出力ファイル1(W01/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 | diff --git a/詳細設計書/詳細設計書_KIN04CHK.md b/詳細設計書/詳細設計書_KIN04CHK.md new file mode 100644 index 0000000..dd6f452 --- /dev/null +++ b/詳細設計書/詳細設計書_KIN04CHK.md @@ -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(終了メッセージ) +``` + +--- + +## 出力レコード定義 + +### 出力ファイル1(W01/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 | + +### 出力ファイル2(W02/ERROR-LOG) KIN05REC 200B VB + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | ERR-CATEGORY | '02'固定 | 打刻エラー | +| 2 | ERR-DETAIL | STRING編集 | エラー内容(198B) | diff --git a/詳細設計書/詳細設計書_KIN05MAT.md b/詳細設計書/詳細設計書_KIN05MAT.md new file mode 100644 index 0000000..88e1ea2 --- /dev/null +++ b/詳細設計書/詳細設計書_KIN05MAT.md @@ -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分岐:R01keyR02key(休暇のみスキップ)。 | +| 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 | diff --git a/詳細設計書/詳細設計書_KIN06CLD.md b/詳細設計書/詳細設計書_KIN06CLD.md new file mode 100644 index 0000000..c0e80e3 --- /dev/null +++ b/詳細設計書/詳細設計書_KIN06CLD.md @@ -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-FILE)KIN06REC 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) | | diff --git a/詳細設計書/詳細設計書_KIN07DAI.md b/詳細設計書/詳細設計書_KIN07DAI.md new file mode 100644 index 0000000..ca59723 --- /dev/null +++ b/詳細設計書/詳細設計書_KIN07DAI.md @@ -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呼出し +``` + +--- + +## 出力レコード定義 + +### 出力ファイル1(W01: 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 | 予備領域 | + +### 出力ファイル2(W02: ERROR-LOG) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | WEERR-CATEGORY | '99'固定 | エラー区分(未使用だが予約) | +| 2 | WEERR-DETAIL | STRING編集 | エラー詳細(SUB05TIM異常時等) | diff --git a/詳細設計書/詳細設計書_KIN08DBU.md b/詳細設計書/詳細設計書_KIN08DBU.md new file mode 100644 index 0000000..28cf6df --- /dev/null +++ b/詳細設計書/詳細設計書_KIN08DBU.md @@ -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 | INSERT(MODE=NORMAL時) / DELETE+INSERT(MODE=RESET時) | +| 2 | MONTHLY_ABSENCE | SELECT COUNT(*) → IF >0 UPDATE ELSE INSERT(UPSERT) | +| 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.入力ファイルOPEN(DAILY-RECORD) + 1-10.出力ファイルOPEN(ABSENCE-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' + PARM2:CUN-DBXINS(INSERT件数) + 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-SUMMARY(OUTREC/KIN08REC)80B 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 | メッセージ編集 | diff --git a/詳細設計書/詳細設計書_KIN09CSV.md b/詳細設計書/詳細設計書_KIN09CSV.md new file mode 100644 index 0000000..046444c --- /dev/null +++ b/詳細設計書/詳細設計書_KIN09CSV.md @@ -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 ADVANCING(PAGE/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.出力ファイルOPEN(KIN09W01) + 1-7.ヘッダー行書出(WRITE AFTER ADVANCING PAGE) + 社員番号,日付,出勤時刻,退勤時刻,休暇種別, + 年休時間,事假時間,因公特批假時間,病欠時間,未申請欠勤時間 + +2.主処理(2000MAJSOR)(FETCH EOFまで下記を繰り返す) + 2-1.CURSOR FETCH(DAILY_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' + PARM2:CUN-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-OUTPUT(OUTREC/KIN09REC)200B 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 | 警告メッセージ表示 | diff --git a/詳細設計書/詳細設計書_SUB01DAT.md b/詳細設計書/詳細設計書_SUB01DAT.md new file mode 100644 index 0000000..8f32bee --- /dev/null +++ b/詳細設計書/詳細設計書_SUB01DAT.md @@ -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)。 +``` diff --git a/詳細設計書/詳細設計書_SUB02MSG.md b/詳細設計書/詳細設計書_SUB02MSG.md new file mode 100644 index 0000000..2407a62 --- /dev/null +++ b/詳細設計書/詳細設計書_SUB02MSG.md @@ -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)。 +``` diff --git a/詳細設計書/詳細設計書_SUB03END.md b/詳細設計書/詳細設計書_SUB03END.md new file mode 100644 index 0000000..d9d3297 --- /dev/null +++ b/詳細設計書/詳細設計書_SUB03END.md @@ -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. +``` diff --git a/詳細設計書/詳細設計書_SUB04CHK.md b/詳細設計書/詳細設計書_SUB04CHK.md new file mode 100644 index 0000000..a7d5333 --- /dev/null +++ b/詳細設計書/詳細設計書_SUB04CHK.md @@ -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へ)。 +``` diff --git a/詳細設計書/詳細設計書_SUB05TIM.md b/詳細設計書/詳細設計書_SUB05TIM.md new file mode 100644 index 0000000..4afbb16 --- /dev/null +++ b/詳細設計書/詳細設計書_SUB05TIM.md @@ -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へ)。 +``` diff --git a/詳細設計書/詳細設計書_ZAN01CHK.md b/詳細設計書/詳細設計書_ZAN01CHK.md new file mode 100644 index 0000000..9f737a4 --- /dev/null +++ b/詳細設計書/詳細設計書_ZAN01CHK.md @@ -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(終了メッセージ) +``` + +--- + +## 出力レコード定義 + +### 出力ファイル1(W01/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同項目を設定 | | + +### 出力ファイル2(W02/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同項目を設定 | | + +### 出力ファイル3(W03/ERROR-LOG) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | ERR-CATEGORY | 01で固定 | | +| 2 | ERR-DETAIL | STRINGで編集 | エラー内容 | diff --git a/詳細設計書/詳細設計書_ZAN02CHK.md b/詳細設計書/詳細設計書_ZAN02CHK.md new file mode 100644 index 0000000..9e9da40 --- /dev/null +++ b/詳細設計書/詳細設計書_ZAN02CHK.md @@ -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(終了メッセージ) +``` + +--- + +## 出力レコード定義 + +### 出力ファイル1(W01/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.同項目を設定 | | + +### 出力ファイル2(W02/ERROR-LOG) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | ERR-CATEGORY | 02で固定 | 重複エラー | +| 2 | ERR-DETAIL | STRINGで編集 | EMP-IDと日付を編集 | diff --git a/詳細設計書/詳細設計書_ZAN03CHK.md b/詳細設計書/詳細設計書_ZAN03CHK.md new file mode 100644 index 0000000..5394433 --- /dev/null +++ b/詳細設計書/詳細設計書_ZAN03CHK.md @@ -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(終了メッセージ) +``` + +--- + +## 出力レコード定義 + +### 出力ファイル1(W01/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.同項目を設定 | | + +### 出力ファイル2(W02/ERROR-LOG) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | ERR-CATEGORY | 03で固定 | 打刻不突合 | +| 2 | ERR-DETAIL | STRINGで編集 | 社員番号と日付を編集 | diff --git a/詳細設計書/詳細設計書_ZAN04MAT.md b/詳細設計書/詳細設計書_ZAN04MAT.md new file mode 100644 index 0000000..3d33b75 --- /dev/null +++ b/詳細設計書/詳細設計書_ZAN04MAT.md @@ -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 | ファイルR01(OVT-SORTED) | 申請番号(APPL-ID)で昇順ソート済、重複なし | +| 2 | ファイルR02(OVT-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(終了メッセージ) +``` + +--- + +## 出力レコード定義 + +### 出力ファイル1(W01/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 | 初期値 | | + +### 出力ファイル2(W02/OVT-DBCLEAN) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | APPL-ID | R02.APPL-ID | DB削除対象申請番号 | +| 2 | FILLER | 初期値 | | + +### 出力ファイル3(W03/ERROR-LOG) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | ERR-CATEGORY | 04で固定 | 取消マッチ監査証跡 | +| 2 | ERR-DETAIL | STRINGで編集 | CANCEL-MATCH: + 申請番号 + 社員番号 + 申請日 + 開始時刻 + 終了時刻 | diff --git a/詳細設計書/詳細設計書_ZAN05CAL.md b/詳細設計書/詳細設計書_ZAN05CAL.md new file mode 100644 index 0000000..15c1d65 --- /dev/null +++ b/詳細設計書/詳細設計書_ZAN05CAL.md @@ -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 | ファイルR01(OVT-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 2:0.1h単位切捨) + COMPUTE WRK-TEMP-HOURS = WRK-ACCUM-MIN / 60(切捨用にそのまま) + MOVE WRK-TEMP-HOURS TO T01TIMHRS + MOVE 2 TO T01TIMRRC(mode 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(終了メッセージ) +``` + +--- + +## 出力レコード定義 + +### 出力ファイル1(W01/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 | 初期値 | | diff --git a/詳細設計書/詳細設計書_ZAN06UPD.md b/詳細設計書/詳細設計書_ZAN06UPD.md new file mode 100644 index 0000000..40a5800 --- /dev/null +++ b/詳細設計書/詳細設計書_ZAN06UPD.md @@ -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 | ファイルR01(OVT-SUMMARY) | ZAN05CALの出力、APPL-ID順ソート済 | +| 2 | ファイルR02(OVT-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) | 対象年月 YYYYMM(PK) | +| 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.処理フェーズ1:OVT-SUMMARY(R01)を全件処理 + 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のUPSERT(2110MONTHLYUPSOR) + 2-1-5.CUN-COMMITが閾値以上の場合、COMMIT実行(2300COMMITDBX) + + 2-2.処理フェーズ2:OVT-DBCLEAN(R02)を全件処理 + 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 INTO(DB-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.最終COMMIT(2300COMMITDBX) + 5-2.入出力ファイルのクローズ + 5-3.DB操作件数メッセージ出力 + メッセージ番号:6(入力件数メッセージ) + PARM1:'ZAN06R01' / PARM2:CUN-R01INN + PARM1:'ZAN06R02' / PARM2:CUN-R02INN + PARM1:'INS' / PARM2:CUN-DBXINS + PARM1:'UPD' / PARM2:CUN-DBXUPD + PARM1:'W01' / PARM2:CUN-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 | 加班時間→分変換 | + + +## 出力レコード定義 + +### 出力ファイル1(W01/ERROR-LOG) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | ERR-CATEGORY | WRK-ERR-CATEGORY | 20=孤立取消 / 30=DBエラー | +| 2 | ERR-DETAIL | WRK-ERR-DETAIL | エラー詳細メッセージ | From d1f3d69445f740e6a71df0ef4c09c103cc7e819a Mon Sep 17 00:00:00 2001 From: hangshuo652 Date: Sat, 22 Aug 2026 18:05:24 +0800 Subject: [PATCH 5/9] docs: add V3 detailed design documents, AI usage log, and development paradigm --- CLAUDE.md | 40 + _AI_USAGE_LOG.md | 554 ++++++++++++ docs/detailed-design/00-overview.md | 324 +++++++ docs/detailed-design/01-cobol-testgen-core.md | 581 ++++++++++++ docs/detailed-design/02-orchestrator-db.md | 574 ++++++++++++ docs/detailed-design/03-runners.md | 412 +++++++++ .../detailed-design/04-hina-classification.md | 416 +++++++++ docs/detailed-design/05-agents-llm.md | 319 +++++++ docs/detailed-design/06-comparator.md | 358 ++++++++ docs/detailed-design/07-config-system.md | 474 ++++++++++ docs/detailed-design/08-data-flow.md | 825 ++++++++++++++++++ docs/detailed-design/append_doc.py | 26 + docs/development-paradigm.md | 345 ++++++++ 13 files changed, 5248 insertions(+) create mode 100644 _AI_USAGE_LOG.md create mode 100644 docs/detailed-design/00-overview.md create mode 100644 docs/detailed-design/01-cobol-testgen-core.md create mode 100644 docs/detailed-design/02-orchestrator-db.md create mode 100644 docs/detailed-design/03-runners.md create mode 100644 docs/detailed-design/04-hina-classification.md create mode 100644 docs/detailed-design/05-agents-llm.md create mode 100644 docs/detailed-design/06-comparator.md create mode 100644 docs/detailed-design/07-config-system.md create mode 100644 docs/detailed-design/08-data-flow.md create mode 100644 docs/detailed-design/append_doc.py create mode 100644 docs/development-paradigm.md diff --git a/CLAUDE.md b/CLAUDE.md index 5b21c8f..ae252f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,45 @@ 参见 `.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 电信程序) .claude/skills/ # 项目级 skills .code-review.yaml # code-review 配置 +_AI_USAGE_LOG.md # AI 使用日志(自动维护) ``` diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md new file mode 100644 index 0000000..a335aa5 --- /dev/null +++ b/_AI_USAGE_LOG.md @@ -0,0 +1,554 @@ +# AI 使用日志 + +> 本文件记录项目开发过程中 AI 辅助的所有代码修改,按时间倒序排列。 + +--- + +### 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.md(DB 管线文档) +- **涉及文件:** 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 出力 + DesignDataGenerator(25 个文件) +- **涉及文件:** 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/gcov)43/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.md(LLM代理模块,包含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 diff --git a/docs/detailed-design/00-overview.md b/docs/detailed-design/00-overview.md new file mode 100644 index 0000000..520026d --- /dev/null +++ b/docs/detailed-design/00-overview.md @@ -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 | +| 测试数据生成 | 基于分支覆盖的测试数据自动生成 | +| 双管道验证 | 支持非DB(flat file)和DB(SQLite)两种验证模式 | +| 覆盖率分析 | 静态分支覆盖 + 动态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')" +``` diff --git a/docs/detailed-design/01-cobol-testgen-core.md b/docs/detailed-design/01-cobol-testgen-core.md new file mode 100644 index 0000000..b73250a --- /dev/null +++ b/docs/detailed-design/01-cobol-testgen-core.md @@ -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, 叶条件不可匹配 diff --git a/docs/detailed-design/02-orchestrator-db.md b/docs/detailed-design/02-orchestrator-db.md new file mode 100644 index 0000000..741c9ab --- /dev/null +++ b/docs/detailed-design/02-orchestrator-db.md @@ -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 中介 JSON(Step 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-ID(AGG 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.db(CONNECT TO 路径) + |-- CWD/kin(gixsql 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 同步(共享 .gcno,COPY 不 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_path(W01 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 和 status(PASS/MISMATCH) +5. 返回 VerificationRun +``` + +**关键逻辑:** +- fields_mismatched == 0 时判定为 PASS +- 输出 Java 输出文件列表作为调试信息 +- 返回 VerificationRun 而非 DbPipelineResult + +**输入:** java_output_path, _current_db_path, schema.db_tables +**输出:** VerificationRun(status, 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 而非 MOVE(GnuCOBOL 累积写入特性) + +### 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 JSON(Java 中介数据) + +-- java_output/ <- Java 输出 + +-- run_{scenario}/ <- 多场景隔离目录 +``` diff --git a/docs/detailed-design/03-runners.md b/docs/detailed-design/03-runners.md new file mode 100644 index 0000000..438a711 --- /dev/null +++ b/docs/detailed-design/03-runners.md @@ -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 CobolRunner(cobol_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 GixsqlCobolRunner(gixsql_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 NativeJavaRunner(native_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 SparkJavaRunner(spark_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 DataWriter(data_writer.py) + +| 方法 | 签名 | 说明 | +|------|------|------| +| `write_cobol_binary` | `(cases, out)` | 将 TestCase 列表写为 COBOL 二进制格式(大端序 int64 / float64 / ASCII) | +| `write_spark_json` | `(cases, cfg, d)` | 写 Spark 输入 JSON(part-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 ... FROM(gixpp 要求 INTO 在前) + | 9. CURRENT TIMESTAMP -> CURRENT_TIMESTAMP(SQLite 后端兼容) + | 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_path(SQLite 数据库) + | + v 部署 DLL 到 exe_dir(libgixsql.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_path(JSON 文件) + | + v java -jar artifact(stdin 输入) + | + v 解析 stdout JSON 行 -> records 列表 + | + v RunResult(success, records, log) +``` + +- 超时 60 秒 +- 每行一个 JSON 对象 + +#### SparkJavaRunner + +``` +input_path(JSON 文件) + | + 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 程序超时 | 捕获 TimeoutExpired(300s) | `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 运行时需要多个 DLL(libgixsql.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-8,key 字段加序号后缀 | +| 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(运行时加载) | diff --git a/docs/detailed-design/04-hina-classification.md b/docs/detailed-design/04-hina-classification.md new file mode 100644 index 0000000..bd9daf1 --- /dev/null +++ b/docs/detailed-design/04-hina-classification.md @@ -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: 合并策略数据到测试记录 diff --git a/docs/detailed-design/05-agents-llm.md b/docs/detailed-design/05-agents-llm.md new file mode 100644 index 0000000..3bd4d07 --- /dev/null +++ b/docs/detailed-design/05-agents-llm.md @@ -0,0 +1,319 @@ +# 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. 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` 捕获所有解析异常,保证流水线不中断 + +## 4. 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` 捕获反序列化异常 + +## 5. 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 响应字符串,调用方负责解析 + +## 6. 式样书驱动测试数据生成器(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` 优先保留。 + +## 7. 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"]` + +## 8. 接口定义 + +### 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` | 字段比对结果 | + +## 9. 错误处理 + +### 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]`),超长设计书可能丢失信息 diff --git a/docs/detailed-design/06-comparator.md b/docs/detailed-design/06-comparator.md new file mode 100644 index 0000000..7f27e88 --- /dev/null +++ b/docs/detailed-design/06-comparator.md @@ -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-z(0x81-0xA9) +- 大写 A-Z(0xC1-0xE9) +- 数字 0-9(0xF0-0xF9) + +不可映射字符处理: +- 可打印 ASCII(32-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 的编码差异 diff --git a/docs/detailed-design/07-config-system.md b/docs/detailed-design/07-config-system.md new file mode 100644 index 0000000..576981d --- /dev/null +++ b/docs/detailed-design/07-config-system.md @@ -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 验证,依赖调用方保证格式正确 diff --git a/docs/detailed-design/08-data-flow.md b/docs/detailed-design/08-data-flow.md new file mode 100644 index 0000000..07fb5dc --- /dev/null +++ b/docs/detailed-design/08-data-flow.md @@ -0,0 +1,825 @@ +# 数据流设计文档 + +> 版本: v1.0 | 日期: 2026-08-22 +> 本文档描述 COBOL 迁移验证平台 V3 的完整数据流,覆盖非 DB(flat file)管道和 DB(SQL/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-ID(AGG 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.db(CONNECT 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 同步(共享 .gcno,COPY 不 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 和 status(PASS/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 | diff --git a/docs/detailed-design/append_doc.py b/docs/detailed-design/append_doc.py new file mode 100644 index 0000000..7305c75 --- /dev/null +++ b/docs/detailed-design/append_doc.py @@ -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") diff --git a/docs/development-paradigm.md b/docs/development-paradigm.md new file mode 100644 index 0000000..89ea2cf --- /dev/null +++ b/docs/development-paradigm.md @@ -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生成迁移方案和测试策略 + +**负责人:** AI(DeepSeek) + +**输入:** +- 需求分析报告 +- 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编码实现 + +**目标:** 根据审核通过的方案生成代码和测试数据 + +**负责人:** AI(DeepSeek) + +**输入:** +- 审核通过的迁移方案 +- 需求文档 +- COBOL源代码 + +**输出:** +- 迁移代码(Python/Java) +- 测试数据文件 +- 配置文件 +- 单元测试脚本 + +**质量标准:** +- 代码规范检查通过 +- 测试数据完整性验证 +- 配置文件格式正确 + +**活动:** +1. AI根据方案生成代码 +2. 创建测试数据和测试用例 +3. 编写配置文件 +4. 生成单元测试脚本 +5. 输出代码供测试验证 + +--- + +### 5. 测试验证 + +**目标:** 验证代码功能和测试覆盖率 + +**负责人:** 工具+人工 + +**输入:** +- 迁移代码 +- 测试数据 +- 测试脚本 + +**输出:** +- 测试报告(通过/失败) +- 覆盖率报告(分支/语句) +- 比对结果报告 + +**质量标准:** +- 测试通过率≥95% +- 分支覆盖率≥80% +- 无严重缺陷 + +**活动:** +1. 运行单元测试(pytest) +2. 执行集成测试 +3. 收集覆盖率数据(gcov) +4. 比对COBOL和Java输出 +5. 生成测试报告 + +**工具:** +- pytest(Python测试) +- 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模型 From 57db3a3ee60d5b410fba9332f496b8d796fb8fa2 Mon Sep 17 00:00:00 2001 From: hangshuo652 Date: Sat, 22 Aug 2026 21:57:35 +0800 Subject: [PATCH 6/9] docs: update README with detailed design documents index --- README.md | 11 +++++++++++ _AI_USAGE_LOG.md | 6 ++++++ 2 files changed, 17 insertions(+) diff --git a/README.md b/README.md index f607f65..ede34a5 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,17 @@ cobol_testgen runners comparator agents | `docs/v3-理解文档.md` | 系统架构、组件说明、数据流(中文,457 行) | | `docs/changelog-v1-to-v3.md` | V1→V3 演进记录 | | `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 设计规范 | | `CONTRIBUTING.md` | 贡献指南 | diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index a335aa5..2d6673f 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -4,6 +4,12 @@ --- +### 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个章节 From 459e05a4c0147afeed2bbffaa2ba203cc95f7c7a Mon Sep 17 00:00:00 2001 From: hangshuo652 Date: Sat, 22 Aug 2026 22:35:49 +0800 Subject: [PATCH 7/9] =?UTF-8?q?docs:=20=E5=AE=8C=E6=88=90=E7=BC=BA?= =?UTF-8?q?=E5=8F=A3=E5=88=86=E6=9E=90=E9=A1=B93-7=20-=20AGENTS.md?= =?UTF-8?q?=E3=80=81README=E5=AE=8C=E5=96=84=E3=80=81=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E3=80=81=E7=A4=BA=E4=BE=8B=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E3=80=81Agent=E6=9E=B6=E6=9E=84=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 145 +++++++++++++++++++++ README.md | 96 ++++++++++++++ docs/detailed-design/05-agents-llm.md | 159 ++++++++++++++++++++++- docs/test-report.md | 176 ++++++++++++++++++++++++++ sample/README.md | 84 ++++++++++++ sample/SIMPLE-output.json | 35 +++++ sample/simple.cbl | 16 +++ sample/simple.cpy | 4 + 8 files changed, 708 insertions(+), 7 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/test-report.md create mode 100644 sample/README.md create mode 100644 sample/SIMPLE-output.json create mode 100644 sample/simple.cbl create mode 100644 sample/simple.cpy diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..30aed73 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,145 @@ +# COBOL → Java/Spark 迁移验证平台 V3 + +AI 辅助的自动化测试工具,用于验证 COBOL 程序向 Java/Spark 迁移的正确性。支持非 DB(flat file)和 DB(EXEC 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 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 使用日志 | diff --git a/README.md b/README.md index ede34a5..081a903 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,104 @@ python diagnose_db2.py # DB 全流程 python diagnose_kind8dbrun.py # DB 编译运行 ``` +## 项目性质 + +本项目是 **AI 辅助的 COBOL 程序迁移验证工具**,用于验证 COBOL 程序向 Java/Spark 迁移的正确性。 + +### 核心功能 + +| 功能 | 说明 | +|------|------| +| COBOL 源码解析 | 自动解析 DATA DIVISION 和 PROCEDURE DIVISION | +| 测试数据生成 | 基于分支覆盖的测试数据自动生成 | +| 双管道验证 | 支持非 DB(flat file)和 DB(SQLite)两种验证模式 | +| 覆盖率分析 | 静态分支覆盖 + 动态 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` - **GnuCOBOL 3.2.0** (GC32-BDB-SP1,含 DB2/SQLite 支持) - **gixsql** (已 vendored 在 `gixsql/` 目录) +- **DeepSeek API** (可选,用于 LLM 路径生成) diff --git a/docs/detailed-design/05-agents-llm.md b/docs/detailed-design/05-agents-llm.md index 3bd4d07..a5928c2 100644 --- a/docs/detailed-design/05-agents-llm.md +++ b/docs/detailed-design/05-agents-llm.md @@ -24,7 +24,152 @@ LLM 代理模块(`agents/`)是 COBOL 迁移验证平台 V3 的智能分析 | `design_data.py` | 式样书驱动测试数据生成器 | `llm.py`, `design_data_input_parser` | | `design_data_input_parser.py` | 式样书 .md 解析器 | 标准库 `re`, `dataclasses` | -## 3. Agent1 解析代理(Agent1Parser) +## 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 职责 @@ -63,7 +208,7 @@ class Agent1Parser: - LLM 返回非法 JSON 时,`parse` 捕获异常,返回一个 `FieldTree(copybook_name="parse_error")` 空树 - 使用 bare `except` 捕获所有解析异常,保证流水线不中断 -## 4. Agent2 数据生成代理(Agent2Data) +## 5. Agent2 数据生成代理(Agent2Data) ### 4.1 职责 @@ -102,7 +247,7 @@ class Agent2Data: - LLM 调用失败或返回非法 JSON 时,生成一条兜底用例 `TestCase(id="TC-FALLBACK", fields={"BR-AMT": 0})` - 使用 bare `except` 捕获反序列化异常 -## 5. Agent3 诊断代理(Agent3Diagnostic) +## 6. Agent3 诊断代理(Agent3Diagnostic) ### 5.1 职责 @@ -135,7 +280,7 @@ class Agent3Diagnostic: - 依赖 `LLMClient.call()` 的重试机制 - 返回原始 LLM 响应字符串,调用方负责解析 -## 6. 式样书驱动测试数据生成器(DesignDataGenerator) +## 7. 式样书驱动测试数据生成器(DesignDataGenerator) ### 6.1 职责 @@ -207,7 +352,7 @@ class DesignDataGenerator: 支持按指定键字段去重,`additional_records` 优先保留。 -## 7. LLM 接口封装(LLMClient) +## 8. LLM 接口封装(LLMClient) ### 7.1 职责 @@ -258,7 +403,7 @@ Header: Authorization: Bearer {key} 响应解析路径:`response["choices"][0]["message"]["content"]` -## 8. 接口定义 +## 9. 接口定义 ### 8.1 模块公开 API @@ -291,7 +436,7 @@ Agent3Diagnostic.analyze(field_result) → str (JSON) | `SparkConfig` | `data.test_case` | Spark 生成配置 | | `FieldResult` | `data.diff_result` | 字段比对结果 | -## 9. 错误处理 +## 10. 错误处理 ### 9.1 错误策略 diff --git a/docs/test-report.md b/docs/test-report.md new file mode 100644 index 0000000..fdb1043 --- /dev/null +++ b/docs/test-report.md @@ -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. 补充边界值测试用例 diff --git a/sample/README.md b/sample/README.md new file mode 100644 index 0000000..ffc8b09 --- /dev/null +++ b/sample/README.md @@ -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 程序。 diff --git a/sample/SIMPLE-output.json b/sample/SIMPLE-output.json new file mode 100644 index 0000000..40a380a --- /dev/null +++ b/sample/SIMPLE-output.json @@ -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 + } +} diff --git a/sample/simple.cbl b/sample/simple.cbl new file mode 100644 index 0000000..ab03bd8 --- /dev/null +++ b/sample/simple.cbl @@ -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. diff --git a/sample/simple.cpy b/sample/simple.cpy new file mode 100644 index 0000000..fdb299a --- /dev/null +++ b/sample/simple.cpy @@ -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). From 5a968b4e3765a875966bcfd8a5dd8f766f1ad857 Mon Sep 17 00:00:00 2001 From: hangshuo652 Date: Sat, 22 Aug 2026 22:53:31 +0800 Subject: [PATCH 8/9] fix: remove hardcoded Windows temp path debug code from to_sql.py Removed debug file write at line 930 that hardcoded C:\Users\marye\AppData\Local\Temp\opencode\build_db_input_debug.txt All 36 to_sql tests pass (no regression) --- cobol_testgen/to_sql.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/cobol_testgen/to_sql.py b/cobol_testgen/to_sql.py index 7a44e02..9b11c62 100644 --- a/cobol_testgen/to_sql.py +++ b/cobol_testgen/to_sql.py @@ -926,16 +926,6 @@ def build_db_input( rec = records[path_idx] if records and path_idx < len(records) else {} - if path_idx == 0 and rec: - with open(r'C:\Users\marye\AppData\Local\Temp\opencode\build_db_input_debug.txt', 'w') as _f: - _f.write(f"rec keys count={len(rec)}\n") - _f.write(f"has_HV-ANNUAL-H={'HV-ANNUAL-H' in rec}\n") - _f.write(f"has_working_storage={'working_storage' in rec}\n") - _f.write(f"HV-ANNUAL-H via _rec_get={_rec_get(rec, 'HV-ANNUAL-H', 'NOT_FOUND')!r}\n") - if 'HV-ANNUAL-H' in rec: - _f.write(f"HV-ANNUAL-H value={rec['HV-ANNUAL-H']!r}\n") - _f.write(f"all keys sorted={sorted(rec.keys())}\n") - for sql in sql_meta: atype = sql.get('type', '') table = sql.get('table', '') From cbaf3deb3649f46e36a6cf1f302d21c55e4003c4 Mon Sep 17 00:00:00 2001 From: zhang_taoming <854317252@qq.com> Date: Mon, 24 Aug 2026 18:34:17 +0800 Subject: [PATCH 9/9] =?UTF-8?q?=E9=BB=91=E7=9B=92=EF=BC=8C=E7=99=BD?= =?UTF-8?q?=E7=9B=92=E5=A4=84=E7=90=86=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 132 +- black-box-data-create/.gitignore | 3 + black-box-data-create/AGENTS.md | 59 + black-box-data-create/README.md | 261 ++ black-box-data-create/agent/__init__.py | 57 + black-box-data-create/agent/api_client.py | 132 + black-box-data-create/agent/input_parser.py | 330 +++ black-box-data-create/agent/markdown_utils.py | 63 + black-box-data-create/agent/models.py | 89 + black-box-data-create/agent/output_writer.py | 75 + black-box-data-create/agent/prompt_builder.py | 155 ++ black-box-data-create/agent/rule_loader.py | 229 ++ .../plans/2026-07-12-testdata-agent-plan.md | 2310 +++++++++++++++++ .../specs/2026-07-12-testdata-agent-design.md | 319 +++ .../docs/未実装PGMパターン洗い出し_第一版.md | 184 ++ black-box-data-create/layout/DB定義書.md | 347 +++ .../layout/JSON格式说明v2.0.md | 435 ++++ .../layout/sample_ソース_SAN01MAT.cbl | 432 +++ .../layout/sample_詳細設計書_SAN01MAT.md | 141 + .../layout/ファイル定義書_xxxxxxx.md | 142 + .../layout/詳細設計書_XXXXXXXX.md | 145 ++ black-box-data-create/main.py | 67 + .../pgm_pattern/1-N+キーブレイク(異キー).md | 57 + .../rules/pgm_pattern/100分割.md | 34 + .../rules/pgm_pattern/25分割.md | 34 + .../rules/pgm_pattern/50分割.md | 34 + .../rules/pgm_pattern/ASCII→EBCDIC変換.md | 58 + .../rules/pgm_pattern/CSV→FB変換.md | 58 + .../rules/pgm_pattern/DB更新.md | 88 + .../rules/pgm_pattern/GETPUT(編集出力).md | 28 + .../rules/pgm_pattern/MERGE.md | 77 + .../rules/pgm_pattern/SELECT処理.md | 50 + .../rules/pgm_pattern/SORT.md | 64 + .../rules/pgm_pattern/SYSIN読込.md | 57 + .../rules/pgm_pattern/オンラインPGM.md | 53 + .../rules/pgm_pattern/キーブレイク(集約).md | 102 + .../rules/pgm_pattern/キーブレイク(集計).md | 102 + .../rules/pgm_pattern/キーブレイク(非集計).md | 73 + .../rules/pgm_pattern/サブプログラム.md | 60 + .../rules/pgm_pattern/マッチング(1-1).md | 172 ++ .../rules/pgm_pattern/マッチング(1-N).md | 195 ++ .../rules/pgm_pattern/マッチング(M-N).md | 102 + .../rules/pgm_pattern/マッチング(N-1).md | 188 ++ .../rules/pgm_pattern/ランキング生成.md | 57 + .../pgm_pattern/レイアウト編集のみ(GETPUT).md | 32 + .../rules/pgm_pattern/内部テーブル検索.md | 64 + .../rules/pgm_pattern/振り分け(EVALUATE).md | 68 + .../rules/pgm_pattern/振り分け(IF).md | 73 + .../pgm_pattern/項目チェック(半角20桁-4桁).md | 55 + .../pgm_pattern/項目チェック(重複含まず).md | 82 + .../2段階マッチング(1:1⇒1:1).md | 62 + .../2段階マッチング(M:N⇒M:N).md | 62 + .../2段階マッチング(N:1⇒N:1).md | 63 + .../rules/special_feature/条件分支.md | 27 + black-box-data-create/tests/__init__.py | 0 black-box-data-create/tests/conftest.py | 10 + .../tests/test_api_client.py | 68 + .../tests/test_copy_parser.py | 87 + .../tests/test_data/DB定義書.md | 347 +++ .../tests/test_data/ZAN01REC.cpy | 11 + .../tests/test_data/ZAN02REC.cpy | 12 + .../tests/test_data/ZAN04REC.cpy | 5 + .../tests/test_data/cpy/ZAN01REC.cpy | 11 + .../tests/test_data/cpy/ZAN04REC.cpy | 11 + .../tests/test_data/詳細設計書_ZAN04MAT.md | 144 + black-box-data-create/tests/test_db_parser.py | 53 + .../tests/test_input_parser.py | 149 ++ .../tests/test_integration.py | 77 + .../tests/test_markdown_utils.py | 65 + black-box-data-create/tests/test_models.py | 59 + .../tests/test_output_writer.py | 117 + .../tests/test_prompt_builder.py | 98 + .../tests/test_rule_loader.py | 278 ++ requirements.txt | 1 + run.py | 86 + 75 files changed, 10020 insertions(+), 107 deletions(-) create mode 100644 black-box-data-create/.gitignore create mode 100644 black-box-data-create/AGENTS.md create mode 100644 black-box-data-create/README.md create mode 100644 black-box-data-create/agent/__init__.py create mode 100644 black-box-data-create/agent/api_client.py create mode 100644 black-box-data-create/agent/input_parser.py create mode 100644 black-box-data-create/agent/markdown_utils.py create mode 100644 black-box-data-create/agent/models.py create mode 100644 black-box-data-create/agent/output_writer.py create mode 100644 black-box-data-create/agent/prompt_builder.py create mode 100644 black-box-data-create/agent/rule_loader.py create mode 100644 black-box-data-create/docs/superpowers/plans/2026-07-12-testdata-agent-plan.md create mode 100644 black-box-data-create/docs/superpowers/specs/2026-07-12-testdata-agent-design.md create mode 100644 black-box-data-create/docs/未実装PGMパターン洗い出し_第一版.md create mode 100644 black-box-data-create/layout/DB定義書.md create mode 100644 black-box-data-create/layout/JSON格式说明v2.0.md create mode 100644 black-box-data-create/layout/sample_ソース_SAN01MAT.cbl create mode 100644 black-box-data-create/layout/sample_詳細設計書_SAN01MAT.md create mode 100644 black-box-data-create/layout/ファイル定義書_xxxxxxx.md create mode 100644 black-box-data-create/layout/詳細設計書_XXXXXXXX.md create mode 100644 black-box-data-create/main.py create mode 100644 black-box-data-create/rules/pgm_pattern/1-N+キーブレイク(異キー).md create mode 100644 black-box-data-create/rules/pgm_pattern/100分割.md create mode 100644 black-box-data-create/rules/pgm_pattern/25分割.md create mode 100644 black-box-data-create/rules/pgm_pattern/50分割.md create mode 100644 black-box-data-create/rules/pgm_pattern/ASCII→EBCDIC変換.md create mode 100644 black-box-data-create/rules/pgm_pattern/CSV→FB変換.md create mode 100644 black-box-data-create/rules/pgm_pattern/DB更新.md create mode 100644 black-box-data-create/rules/pgm_pattern/GETPUT(編集出力).md create mode 100644 black-box-data-create/rules/pgm_pattern/MERGE.md create mode 100644 black-box-data-create/rules/pgm_pattern/SELECT処理.md create mode 100644 black-box-data-create/rules/pgm_pattern/SORT.md create mode 100644 black-box-data-create/rules/pgm_pattern/SYSIN読込.md create mode 100644 black-box-data-create/rules/pgm_pattern/オンラインPGM.md create mode 100644 black-box-data-create/rules/pgm_pattern/キーブレイク(集約).md create mode 100644 black-box-data-create/rules/pgm_pattern/キーブレイク(集計).md create mode 100644 black-box-data-create/rules/pgm_pattern/キーブレイク(非集計).md create mode 100644 black-box-data-create/rules/pgm_pattern/サブプログラム.md create mode 100644 black-box-data-create/rules/pgm_pattern/マッチング(1-1).md create mode 100644 black-box-data-create/rules/pgm_pattern/マッチング(1-N).md create mode 100644 black-box-data-create/rules/pgm_pattern/マッチング(M-N).md create mode 100644 black-box-data-create/rules/pgm_pattern/マッチング(N-1).md create mode 100644 black-box-data-create/rules/pgm_pattern/ランキング生成.md create mode 100644 black-box-data-create/rules/pgm_pattern/レイアウト編集のみ(GETPUT).md create mode 100644 black-box-data-create/rules/pgm_pattern/内部テーブル検索.md create mode 100644 black-box-data-create/rules/pgm_pattern/振り分け(EVALUATE).md create mode 100644 black-box-data-create/rules/pgm_pattern/振り分け(IF).md create mode 100644 black-box-data-create/rules/pgm_pattern/項目チェック(半角20桁-4桁).md create mode 100644 black-box-data-create/rules/pgm_pattern/項目チェック(重複含まず).md create mode 100644 black-box-data-create/rules/pgm_pattern/2段階マッチング(1:1⇒1:1).md create mode 100644 black-box-data-create/rules/pgm_pattern/2段階マッチング(M:N⇒M:N).md create mode 100644 black-box-data-create/rules/pgm_pattern/2段階マッチング(N:1⇒N:1).md create mode 100644 black-box-data-create/rules/special_feature/条件分支.md create mode 100644 black-box-data-create/tests/__init__.py create mode 100644 black-box-data-create/tests/conftest.py create mode 100644 black-box-data-create/tests/test_api_client.py create mode 100644 black-box-data-create/tests/test_copy_parser.py create mode 100644 black-box-data-create/tests/test_data/DB定義書.md create mode 100644 black-box-data-create/tests/test_data/ZAN01REC.cpy create mode 100644 black-box-data-create/tests/test_data/ZAN02REC.cpy create mode 100644 black-box-data-create/tests/test_data/ZAN04REC.cpy create mode 100644 black-box-data-create/tests/test_data/cpy/ZAN01REC.cpy create mode 100644 black-box-data-create/tests/test_data/cpy/ZAN04REC.cpy create mode 100644 black-box-data-create/tests/test_data/詳細設計書_ZAN04MAT.md create mode 100644 black-box-data-create/tests/test_db_parser.py create mode 100644 black-box-data-create/tests/test_input_parser.py create mode 100644 black-box-data-create/tests/test_integration.py create mode 100644 black-box-data-create/tests/test_markdown_utils.py create mode 100644 black-box-data-create/tests/test_models.py create mode 100644 black-box-data-create/tests/test_output_writer.py create mode 100644 black-box-data-create/tests/test_prompt_builder.py create mode 100644 black-box-data-create/tests/test_rule_loader.py create mode 100644 run.py diff --git a/README.md b/README.md index 081a903..f7214d7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,27 @@ python test-data/s30_db_e2e.py python -m cobol_testgen ../cobol-tna-system/src/KIN01INP.cbl ``` +## 全流程数据生成(run.py) + +先跑白盒 `cobol_testgen`(静态分析 + 测试数据),成功后跑黑盒 `black-box-data-create`(DeepSeek LLM 生成 JSON/SQL): + +```bash +python run.py \ + --design "D:\cobol-tna-system\詳細設計書\詳細設計書_ZAN04MAT.md" \ + --source "D:\cobol-tna-system\src\ZAN04MAT.cbl" \ + --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" \ + --cpy "D:\cobol-tna-system\cpy" \ + --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" \ + --output "D:\output" + +# 只查看将执行的命令,不真正运行 +python run.py --design ... --output ... --dry-run +``` + +- 参数 `--design / --source / --file-db-md / --cpy / --db-md / --output` 为必需项,`--api-key / --model / --rules / --max-tokens` 可选透传给黑盒。 +- 两步输出到同一 `--output` 根目录:白盒写 `output/<程序ID>/main/...`,黑盒写 `output/<程序ID>/g{N}/...`。 +- 任一步失败即停止并返回该步退出码。 + ## 架构 ``` @@ -43,17 +64,6 @@ cobol_testgen runners comparator agents | `docs/v3-理解文档.md` | 系统架构、组件说明、数据流(中文,457 行) | | `docs/changelog-v1-to-v3.md` | V1→V3 演进记录 | | `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 设计规范 | | `CONTRIBUTING.md` | 贡献指南 | @@ -72,106 +82,14 @@ python -m cobol_testgen --gcov runtime/ # 诊断脚本 python diagnose_db2.py # DB 全流程 python diagnose_kind8dbrun.py # DB 编译运行 + +# 黑盒数据生成 +python black-box-data-create/main.py --design "D:\xxxx\詳細設計書_xxxx.md" --source "D:\xxxx\xxxx.cbl" --file-db-md "D:\xxxx\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\xxxx\DB定義書.md" --output "D:\xxxx\output" + ``` -## 项目性质 - -本项目是 **AI 辅助的 COBOL 程序迁移验证工具**,用于验证 COBOL 程序向 Java/Spark 迁移的正确性。 - -### 核心功能 - -| 功能 | 说明 | -|------|------| -| COBOL 源码解析 | 自动解析 DATA DIVISION 和 PROCEDURE DIVISION | -| 测试数据生成 | 基于分支覆盖的测试数据自动生成 | -| 双管道验证 | 支持非 DB(flat file)和 DB(SQLite)两种验证模式 | -| 覆盖率分析 | 静态分支覆盖 + 动态 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` - **GnuCOBOL 3.2.0** (GC32-BDB-SP1,含 DB2/SQLite 支持) - **gixsql** (已 vendored 在 `gixsql/` 目录) -- **DeepSeek API** (可选,用于 LLM 路径生成) diff --git a/black-box-data-create/.gitignore b/black-box-data-create/.gitignore new file mode 100644 index 0000000..f849cca --- /dev/null +++ b/black-box-data-create/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +output/ diff --git a/black-box-data-create/AGENTS.md b/black-box-data-create/AGENTS.md new file mode 100644 index 0000000..e0b2463 --- /dev/null +++ b/black-box-data-create/AGENTS.md @@ -0,0 +1,59 @@ +# AGENTS.md + +## 项目 + +COBOL テストデータ生成 Agent。詳細設計書・COPYBOOK・ソースコードを解析し、DeepSeek API でテストデータ JSON/SQL を生成する。 + +- 言語: Python 3.9+ +- 依存: `requests>=2.28.0` +- テスト: `pytest` +- 入力元: `D:\cobol-tna-system` (COBOL プロジェクト) + +## コマンド + +```bash +# テスト実行 +python -m pytest tests/ -v + +# 単一プログラム実行 +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_XXX.md" \ + --source "D:\cobol-tna-system\src\XXX.cbl" \ + --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" \ + --cpy "D:\cobol-tna-system\cpy" \ + --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" \ + --output "output" +``` + +## アーキテクチャ + +パイプライン: `InputParser → RuleLoader → PromptBuilder → APIClient → OutputWriter` + +| モジュール | 役割 | +|-----------|------| +| `agent/models.py` | 全データクラス定義 | +| `agent/markdown_utils.py` | Markdown テーブル解析 | +| `agent/input_parser.py` | 設計書 + COPYBOOK + DB定義 解析 | +| `agent/rule_loader.py` | PGMパターン→ルール.md マッチング | +| `agent/prompt_builder.py` | API プロンプト組立 | +| `agent/api_client.py` | DeepSeek API + 3回リトライ | +| `agent/output_writer.py` | JSON/SQL ファイル出力 | +| `main.py` | CLI エントリポイント | + +## ルール追加方法 + +1. `rules/pgm_pattern/` に `.md` ファイルを追加 +2. `agent/rule_loader.py` の `PGM_PATTERN_MAP` にエントリ追加 + +ルールマッチング: まず `PGM_PATTERN_MAP` の完全一致 → 見つからなければルールファイル名(.md除く)が PGMパターン文字列に含まれるかチェック。半角/全角括弧違いに注意(`()` vs `()`)。 + +## よくある落とし穴 + +- **`—` (全角ダッシュ) のパース**: 設計書テーブルの `レコード長` 列などで `—` が使われると `int()` が失敗する。`input_parser.py` では `try/except` で 0 にフォールバック。 +- **同一COPYBOOKの複数REPLACING**: `ZAN01REC` が `R01`/`R02` 両方で使われる場合、`_extract_copy_replacing()` はリスト `[(copy_name, prefix), ...]` を返し、`_parse_copybooks()` が識別子でマッチする。 +- **PGMタイプ = サブ はスキップ**: `generate()` 関数で `meta.pgm_type == 'サブ'` の場合 `ValueError`。 +- **入力タイプ判定**: `使用ファイル一覧` の `I/O` 列に `'I'` を含む行の `媒体` 列を見る。`PS`=ファイル、`DB`=DB、両方=混合。 +- **APIキー**: コード内にハードコード (`sk-6156cccdc9c14d949cf5bfc5afc67a03`)。モデルは `deepseek-v4-flash`。 + +## 多keyテスト + +キーが複数項目のプログラムでは、ルールに「多keyテスト」が含まれる。基準データの各キー項目を1つだけ変えたN件の追加レコードを生成し、キー比較が正しいことを検証する。DB更新・マッチング・キーブレイク等で必須。 diff --git a/black-box-data-create/README.md b/black-box-data-create/README.md new file mode 100644 index 0000000..73cf1d9 --- /dev/null +++ b/black-box-data-create/README.md @@ -0,0 +1,261 @@ +# COBOL テストデータ生成 Agent + +COBOL プログラムの詳細設計書・COPYBOOK・ソースコードを解析し、DeepSeek v4 Flash API を使ってテストデータ(JSON/SQL)を自動生成する Python ツール。 + +## クイックスタート + +```bash +# インストール +pip install -r requirements.txt + +# テスト実行 +python -m pytest tests/ -v + +# 単一プログラムのテストデータ生成 +python main.py \ + --design "D:\cobol-tna-system\詳細設計書\詳細設計書_ZAN04MAT.md" \ + --source "D:\cobol-tna-system\src\ZAN04MAT.cbl" \ + --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" \ + --cpy "D:\cobol-tna-system\cpy" \ + --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" \ + --output "output" +``` + +| 引数 | 必須 | 説明 | +|------|------|------| +| `--design` | ✓ | 詳細設計書 .md のパス | +| `--source` | ✓ | COBOL ソース .cbl のパス | +| `--file-db-md` | ✓ | ファイル/DB 構造定義 .md のパス | +| `--cpy` | ✓ | COPYBOOK 格納ディレクトリ | +| `--db-md` | ✓ | DB 定義書 .md のパス | +| `--output` | | 出力ディレクトリ(デフォルト: `output`) | +| `--api-key` | | DeepSeek API Key | +| `--model` | | モデル名(デフォルト: `deepseek-v4-flash`) | +| `--rules` | | ルール格納ディレクトリ(デフォルト: `rules`) | + +### ライブラリとしての利用 + +```python +from agent import generate + +result = generate( + design_md="詳細設計書_ZAN04MAT.md", + source_cbl="src/ZAN04MAT.cbl", + file_db_md="COPY句定義書.md", + cpy_dir="cpy/", + db_md="DB定義書.md", + output_dir="output/" +) + +# result = {"output_files": {...}, "program_id": "ZAN04MAT", "groups": 5, "input_type": "file"} +``` + +## プロジェクト構造 + +``` +├── agent/ # コアモジュール +│ ├── __init__.py # generate() エントリポイント + 全エクスポート +│ ├── models.py # データクラス定義(ProgramMeta, FileInfo, CopyField 等) +│ ├── markdown_utils.py # Markdown テーブル解析ユーティリティ +│ ├── input_parser.py # 設計書・COPYBOOK・DB定義の解析 +│ ├── rule_loader.py # PGMパターン→ルールファイルのマッチング +│ ├── prompt_builder.py # DeepSeek API プロンプト組立 +│ ├── api_client.py # API 呼出 + 3回リトライ + JSON パース +│ └── output_writer.py # JSON / SQL ファイル出力 +├── rules/ # データ生成ルール(.md ファイル) +│ ├── pgm_pattern/ # PGMパターン別ルール +│ │ ├── マッチング(1-1).md +│ │ ├── マッチング(1-N).md +│ │ ├── マッチング(N-1).md +│ │ ├── DB更新.md +│ │ ├── 振り分け(IF).md +│ │ ├── 振り分け(EVALUATE).md +│ │ ├── 項目チェック(重複含まず).md +│ │ ├── 項目チェック(半角20桁-4桁).md +│ │ ├── レイアウト編集のみ(GETPUT).md +│ │ ├── キーブレイク(集計).md +│ │ ├── キーブレイク(集約).md +│ │ ├── SELECT処理.md +│ │ ├── MERGE.md +│ │ ├── 50分割.md +│ │ ├── CSV→FB変換.md +│ │ └── GETPUT(編集出力).md +│ └── special_feature/ # 特殊機能ルール +│ └── 条件分支.md +├── layout/ # ドキュメントテンプレート(参考) +├── tests/ # テストコード (48 tests) +│ └── test_data/ # テストフィクスチャ +├── main.py # CLI エントリポイント +├── requirements.txt # 依存関係 +├── AGENTS.md # AI エージェント向け指示書 +└── README.md +``` + +## 処理フロー + +``` +[詳細設計書.md] ──┐ +[ソース.cbl] ────┤ +[COPYBOOK .cpy] ─┤ +[DB定義書.md] ───┘ + │ + ▼ + InputParser ──→ ProgramMeta(全メタデータ) + │ + ▼ + RuleLoader ───→ ルール.md + 条件分支.md(該当時) + │ + ▼ + PromptBuilder ─→ API プロンプト + │ + ▼ + APIClient ────→ DeepSeek API(最大3回リトライ) + │ + ▼ + OutputWriter ──→ output/{プログラムID}/g{N}/{プログラムID}_g{N}.json + output/{プログラムID}/g{N}/{プログラムID}_g{N}.sql +``` + +## 出力形式 + +### 入力元がファイルの場合(JSON) + +```json +{ + "program": "ZAN04MAT", + "records": [ + { + "input": { + "ZAN04R01": { + "R01-APPL-ID": "A0000001", + "R01-EMP-ID": "00000001", + "R01-APPL-DATE": "20250101", + "R01-START-TIME": "0800", + "R01-END-TIME": "1700", + "R01-STATUS": "1", + "R01-OVT-TYPE": "O", + "R01-FILLER": " " + }, + "ZAN04R02": { + "R02-APPL-ID": "A0000002", + ... + } + } + } + ] +} +``` + +### 入力元が DB の場合(SQL) + +```sql +-- Group: 1 +-- 用途: 正常INSERT + 多keyテスト +INSERT INTO LEAVE_RECORDS (EMP_ID, LEAVE_TYPE, START_DATE, ...) VALUES +('00000001', '01', '20260101', ...), +('00000002', '02', '20260102', ...); +``` + +### 出力ディレクトリ構成 + +``` +output/ +└── ZAN04MAT/ + ├── g1/ + │ └── ZAN04MAT_g1.json + ├── g2/ + │ └── ZAN04MAT_g2.json + └── g3/ + └── ZAN04MAT_g3.json +``` + +## 対応 PGM パターン + +| パターン | ルールファイル | カバー数 | 多keyテスト | +|----------|---------------|----------|-------------| +| マッチング(1:1) | マッチング(1-1).md | 8 | ✓ | +| マッチング(1:N) | マッチング(1-N).md | 9 | ✓ | +| マッチング(N:1) | マッチング(N-1).md | 9 | ✓ | +| マッチング(M:N) | マッチング(M-N).md | 4 | ✓ | +| DB更新 | DB更新.md | 4 | ✓ | +| 振り分け(IF) | 振り分け(IF).md | 5 | — | +| 振り分け(EVALUATE) | 振り分け(EVALUATE).md | 5 | — | +| 項目チェック(重複含まず) | 項目チェック(重複含まず).md | 5 | ✓ | +| 項目チェック(半角20桁/4桁) | 項目チェック(半角20桁-4桁).md | 4 | ✓ | +| GETPUT | レイアウト編集のみ(GETPUT).md | 1 | — | +| キーブレイク(集計) | キーブレイク(集計).md | 7 | ✓ | +| キーブレイク(集約) | キーブレイク(集約).md | 7 | ✓ | +| SELECT処理 | SELECT処理.md | 3 | ✓ | +| MERGE | MERGE.md | 4 | ✓ | +| 50分割 | 50分割.md | 5 | — | +| CSV→FB変換 | CSV→FB変換.md | 4 | — | + +**合計: 24 プログラム対応** + +### 多keyテストとは + +キーが複数項目(例: A001, A002, A003)で構成される場合、基準データの各キー項目を 1 つだけ変えた N 件の追加データを各グループ末尾に生成する。これにより COBOL プログラムが正しい全キー項目で比較していることを検証する。 + +## ルール追加方法 + +1. `rules/pgm_pattern/` に新しい `.md` ファイルを作成 +2. `agent/rule_loader.py` の `PGM_PATTERN_MAP` にエントリを追加 +3. コード変更不要、ルール .md ファイル追加のみで拡張可能 + +ルールマッチング: まず `PGM_PATTERN_MAP` 完全一致 → なければルールファイル名(.md 除く)が PGM パターン文字列内に含まれるかチェック。 + +## 全プログラム実行コマンド + +```bash +# マッチング(1:1) +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_ZAN04MAT.md" --source "D:\cobol-tna-system\src\ZAN04MAT.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" + +# マッチング(1:N) +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KIN05MAT.md" --source "D:\cobol-tna-system\src\KIN05MAT.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KIN07DAI.md" --source "D:\cobol-tna-system\src\KIN07DAI.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" + +# マッチング(N:1) +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_ZAN03CHK.md" --source "D:\cobol-tna-system\src\ZAN03CHK.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" + +# DB更新 +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KIN02UPD.md" --source "D:\cobol-tna-system\src\KIN02UPD.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KIN08DBU.md" --source "D:\cobol-tna-system\src\KIN08DBU.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KYU02REG.md" --source "D:\cobol-tna-system\src\KYU02REG.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KYU06UPD.md" --source "D:\cobol-tna-system\src\KYU06UPD.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_ZAN06UPD.md" --source "D:\cobol-tna-system\src\ZAN06UPD.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" + +# 振り分け +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KIN01INP.md" --source "D:\cobol-tna-system\src\KIN01INP.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_ZAN01CHK.md" --source "D:\cobol-tna-system\src\ZAN01CHK.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" + +# 項目チェック +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_ZAN02CHK.md" --source "D:\cobol-tna-system\src\ZAN02CHK.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KIN04CHK.md" --source "D:\cobol-tna-system\src\KIN04CHK.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" + +# GETPUT +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KIN06CLD.md" --source "D:\cobol-tna-system\src\KIN06CLD.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KYU08PRI.md" --source "D:\cobol-tna-system\src\KYU08PRI.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KIN09CSV.md" --source "D:\cobol-tna-system\src\KIN09CSV.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" + +# キーブレイク(集計) +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KIN03EXP.md" --source "D:\cobol-tna-system\src\KIN03EXP.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_ZAN05CAL.md" --source "D:\cobol-tna-system\src\ZAN05CAL.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" + +# キーブレイク(集約) +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KYU03AGG.md" --source "D:\cobol-tna-system\src\KYU03AGG.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KYU04CAL.md" --source "D:\cobol-tna-system\src\KYU04CAL.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" + +# その他 +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KYU05DED.md" --source "D:\cobol-tna-system\src\KYU05DED.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KYU07DIV.md" --source "D:\cobol-tna-system\src\KYU07DIV.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KYU09MRG.md" --source "D:\cobol-tna-system\src\KYU09MRG.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +python main.py --design "D:\cobol-tna-system\詳細設計書\詳細設計書_KYU01CVT.md" --source "D:\cobol-tna-system\src\KYU01CVT.cbl" --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" --cpy "D:\cobol-tna-system\cpy" --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" --output "output" +``` + +## 注意事項 + +- **API Key**: DeepSeek API キーは `agent/__init__.py` にハードコードされています。本番利用時は環境変数などに変更してください。 +- **サブプログラム**: `PGMタイプ = サブ` のプログラムは処理対象外です。 +- **全角/半角**: 詳細設計書内の PGMパターン表記には全角括弧 `()` と半角括弧 `()` のバリエーションがあります。`rule_loader.py` で両方対応済みです。 +- **テスト**: 全 48 テストが `tests/test_data/` のフィクスチャを使って API を mock して動作します。 +- **所要時間**: 24 プログラム全実行で約 26 分(DeepSeek API 応答速度に依存)。 diff --git a/black-box-data-create/agent/__init__.py b/black-box-data-create/agent/__init__.py new file mode 100644 index 0000000..25c2104 --- /dev/null +++ b/black-box-data-create/agent/__init__.py @@ -0,0 +1,57 @@ +__version__ = "0.1.0" + +from agent.input_parser import InputParser +from agent.rule_loader import RuleLoader +from agent.prompt_builder import PromptBuilder +from agent.api_client import APIClient +from agent.output_writer import OutputWriter +from agent.models import ProgramMeta, FileInfo, CopyField, KeyInfo, TableColumn, TableInfo + + +def generate(design_md: str, source_cbl: str, file_db_md: str, + cpy_dir: str, db_md: str, output_dir: str = "output", + api_key: str = "sk-6156cccdc9c14d949cf5bfc5afc67a03", + api_model: str = "deepseek-v4-flash", + rules_dir: str = "rules", + max_tokens: int = 32768) -> dict: + """生成测试数据的主入口函数。""" + import os + + print(f"== 解析入力: {design_md}") + + parser = InputParser(design_md, source_cbl, file_db_md, cpy_dir, db_md) + meta = parser.run() + + if meta.pgm_type == 'サブ': + raise ValueError(f"程序 {meta.program_id} はサブプログラムです。主プログラムのみ処理対象です。") + + print(f" プログラムID: {meta.program_id}, パターン: {meta.pgm_pattern}, 入力タイプ: {meta.input_type}") + + # If rules_dir is relative, resolve from this file's location or cwd + if not os.path.isabs(rules_dir): + rules_dir = os.path.join(os.path.dirname(__file__), '..', rules_dir) + + loader = RuleLoader(rules_dir) + rules_text, group_descriptions, group_count = loader.load(meta) + + print(f" ルール読み込み完了, グループ数: {group_count}") + + builder = PromptBuilder() + prompt = builder.build(meta, rules_text, group_descriptions, group_count) + + client = APIClient(api_key=api_key, model=api_model, max_tokens=max_tokens) + print(f" API呼び出し中...") + result = client.generate(prompt) + print(f" API応答受信") + + writer = OutputWriter(output_dir) + output_files = writer.write(meta.program_id, result, meta.input_type) + + print(f" 出力完了: {len(output_files)} ファイル") + + return { + "output_files": output_files, + "program_id": meta.program_id, + "groups": group_count, + "input_type": meta.input_type, + } diff --git a/black-box-data-create/agent/api_client.py b/black-box-data-create/agent/api_client.py new file mode 100644 index 0000000..7b9697b --- /dev/null +++ b/black-box-data-create/agent/api_client.py @@ -0,0 +1,132 @@ +import json +import time +from typing import Dict, Any, Optional + +import requests + + +class APIClient: + """DeepSeek API 客户端,含重试逻辑。""" + + def __init__(self, api_key: str, model: str = 'deepseek-v4-flash', + base_url: str = 'https://api.deepseek.com/chat/completions', + max_retries: int = 3, timeout: int = 120, + max_tokens: int = 32768): + self.api_key = api_key + self.model = model + self.base_url = base_url + self.max_retries = max_retries + self.timeout = timeout + self.max_tokens = max_tokens + + def generate(self, prompt: str) -> Dict[str, Any]: + """发送 prompt 并返回 AI 生成的结果。""" + system_prompt = ( + "你是COBOL程序的测试数据生成专家。" + "请严格按照提供的规则,生成符合格式要求的测试数据。" + "输出必须是可被json.loads()直接解析的JSON,不要包裹在```json```代码块中。" + "不要在JSON前后添加任何说明文字。" + ) + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt}, + ] + + last_error = None + + for attempt in range(1, self.max_retries + 1): + try: + response = self._call_api(messages) + choice = response['choices'][0] + content = choice['message']['content'] + + if choice.get('finish_reason') == 'length': + # 输出被截断:追加"压缩输出"指示后重试,避免再次浪费全部 max_tokens + last_error = RuntimeError( + "API输出被截断(finish_reason=length),已追加压缩指示重试。" + ) + error_msg = ( + "前回の出力は長すぎて途中で切れました(finish_reason=length)。" + "レコード数・フィールド数を最小限にし、FILLERなどの冗長な項目は省略して、" + "必ず完結したJSONのみを出力してください。コードブロック(```)で囲まないでください。" + ) + messages.append({"role": "assistant", "content": content}) + messages.append({"role": "user", "content": error_msg}) + continue + + data = self._parse_json(content) + if data is not None: + return data + + last_error = RuntimeError("API返回内容无法解析为JSON") + error_msg = ( + f"前回の出力は有効なJSONではありませんでした。" + f"必ず有効なJSONのみを出力してください。" + f"コードブロック(```)で囲まないでください。" + ) + messages.append({"role": "assistant", "content": content}) + messages.append({"role": "user", "content": error_msg}) + + except requests.exceptions.RequestException as e: + last_error = e + if attempt < self.max_retries: + time.sleep(2 ** attempt) + continue + + raise RuntimeError( + f"API调用失败,已重试{self.max_retries}次。" + f"最后错误: {last_error}" + ) + + def _call_api(self, messages: list) -> dict: + """单次 API 调用。""" + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json', + } + payload = { + 'model': self.model, + # deepseek-v4 默认开启思考模式,会把 max_tokens 几乎全部消耗在 + # reasoning_content 上,导致 content 为空/截断。测试数据生成是 + # 结构化 JSON 输出,关闭思考模式后全部 token 用于正文。 + 'thinking': {'type': 'disabled'}, + 'messages': messages, + 'temperature': 0.3, + 'max_tokens': self.max_tokens, + } + + resp = requests.post( + self.base_url, + headers=headers, + json=payload, + timeout=self.timeout, + proxies={"http": None, "https": None}, + ) + resp.raise_for_status() + return resp.json() + + @staticmethod + def _parse_json(text: str) -> Optional[dict]: + """尝试从文本中提取 JSON。""" + text = text.strip() + + if text.startswith('```json'): + text = text[7:] + if text.startswith('```'): + text = text[3:] + if text.endswith('```'): + text = text[:-3] + text = text.strip() + + try: + return json.loads(text) + except json.JSONDecodeError: + start = text.find('{') + end = text.rfind('}') + if start >= 0 and end > start: + try: + return json.loads(text[start:end + 1]) + except json.JSONDecodeError: + pass + return None diff --git a/black-box-data-create/agent/input_parser.py b/black-box-data-create/agent/input_parser.py new file mode 100644 index 0000000..0a8401a --- /dev/null +++ b/black-box-data-create/agent/input_parser.py @@ -0,0 +1,330 @@ +# agent/input_parser.py +import re +import os +from typing import List, Dict, Optional, Tuple + +from agent.models import ( + FileInfo, KeyInfo, ModuleInfo, ProgramMeta, CopyField, TableColumn, TableInfo +) +from agent.markdown_utils import ( + extract_section, parse_table_rows, parse_table_from_section, find_row_by_key +) + + +class InputParser: + """解析 COBOL 程序的详细设计书和相关文件。""" + + def __init__(self, design_md_path: str, source_cbl_path: str, + file_db_md_path: str, cpy_dir: str, db_md_path: str): + self.design_md_path = design_md_path + self.source_cbl_path = source_cbl_path + self.file_db_md_path = file_db_md_path + self.cpy_dir = cpy_dir + self.db_md_path = db_md_path + self._design_text = '' + self._source_text = '' + + def run(self) -> ProgramMeta: + """执行完整解析,返回 ProgramMeta。""" + self._design_text = self._read_file(self.design_md_path) + self._source_text = self._read_file(self.source_cbl_path) + + meta = ProgramMeta( + program_id='', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', + copy_fields={}, db_tables={} + ) + + self._parse_basic_info(meta) + self._parse_use_files(meta) + self._parse_keys(meta) + self._parse_modules(meta) + self._parse_process_detail(meta) + self._parse_output_records(meta) + self._determine_input_type(meta) + self._parse_copybooks(meta) + self._parse_db_definition(meta) + + return meta + + def _parse_copybooks(self, meta: ProgramMeta): + replacing_list = self._extract_copy_replacing() + + for f in meta.files: + if not f.copy_group: + continue + + copy_file = os.path.join(self.cpy_dir, f.copy_group + '.cpy') + if not os.path.exists(copy_file): + continue + + prefix = f.identifier + for i, (cpy_name, rep_val) in enumerate(replacing_list): + if cpy_name == f.copy_group and rep_val == f.identifier: + prefix = rep_val + replacing_list.pop(i) + break + else: + for i, (cpy_name, rep_val) in enumerate(replacing_list): + if cpy_name == f.copy_group: + prefix = rep_val + replacing_list.pop(i) + break + + fields = self._parse_single_copybook(copy_file, prefix) + if f.identifier not in meta.copy_fields: + meta.copy_fields[f.identifier] = fields + + def _parse_db_definition(self, meta: ProgramMeta): + """解析 DB 定义书 .md,提取表结构信息。 + 只在输入类型涉及 DB 时调用。 + """ + if meta.input_type not in ('db', 'mixed'): + return + + db_text = self._read_file(self.db_md_path) + + sections = re.split(r'\n# ', db_text) + + for section in sections: + table_match = re.match(r'^([\w\-]+)', section) + if not table_match: + continue + table_name = table_match.group(1) + + section_with_header = '## ' + section + + db_info_text = extract_section(section_with_header, 'DB基本情報') + db_info_rows = parse_table_rows(db_info_text) + + db_id = '' + copy_id = '' + for row in db_info_rows: + db_id = row.get('DB ID', '') or db_id + copy_id = row.get('COPY ID', '') or copy_id + + column_text = extract_section(section_with_header, 'カラム定義') + column_rows = parse_table_rows(column_text) + + if not column_rows: + continue + + columns = [] + pk_columns = [] + for row in column_rows: + try: + no = int(row.get('No', '0')) + except ValueError: + continue + + is_pk = row.get('PK', '') == '✓' + nullable = row.get('NULL', 'NULL許可') == 'NULL許可' + + col = TableColumn( + no=no, + name_jp=row.get('項目名', ''), + name_en=row.get('項目名(英字名)', ''), + type=row.get('TYPE', ''), + max_len=row.get('最大長', ''), + decimal_digits=row.get('小数桁', ''), + byte_count=row.get('バイト数', ''), + nullable=nullable, + is_pk=is_pk + ) + columns.append(col) + if is_pk: + pk_columns.append(col.name_en) + + table = TableInfo( + table_name=table_name, + db_id=db_id, + copy_id=copy_id, + columns=columns, + pk_columns=pk_columns + ) + meta.db_tables[table_name] = table + + def _extract_copy_replacing(self) -> List[Tuple[str, str]]: + """从 COBOL 源码提取 COPY ... REPLACING ... 映射。 + Returns: [(copy_name, replacing_value), ...] 按出现顺序保存。 + """ + result = [] + pattern = r'COPY\s+(\S+)\s+REPLACING\s+==\(A\)==\s+BY\s+==(\S+)==' + for match in re.finditer(pattern, self._source_text, re.IGNORECASE): + result.append((match.group(1), match.group(2))) + return result + + def _parse_single_copybook(self, copy_path: str, prefix: str) -> List[CopyField]: + copy_text = self._read_file(copy_path) + fields = [] + + for line in copy_text.split('\n'): + line = line.strip() + if not line or line.startswith('*'): + continue + + match = re.match( + r'(\d{2})\s+\(A\)(\S+)\s+PIC\s+(.+?)\.', + line, re.IGNORECASE + ) + if not match: + continue + + level = int(match.group(1)) + raw_name = '(A)' + match.group(2) + pic_full = match.group(3).strip() + actual_name = prefix + '-' + match.group(2) + pic_bytes = self._calculate_pic_bytes(pic_full) + + fields.append(CopyField( + level=level, + name=actual_name, + raw_name=raw_name, + pic_type=pic_full, + pic_bytes=pic_bytes + )) + + return fields + + @staticmethod + def _calculate_pic_bytes(pic: str) -> int: + pic_upper = pic.upper().strip() + + if 'COMP-3' in pic_upper: + base = pic_upper.replace('COMP-3', '').strip() + total_digits = sum(int(num) for num in re.findall(r'9\((\d+)\)', base)) + if base.startswith('S'): + return (total_digits + 2) // 2 + else: + return (total_digits + 1) // 2 + + if 'COMP' in pic_upper or 'BINARY' in pic_upper: + base = re.sub(r'\s*(COMP|BINARY)\s*', '', pic_upper).strip() + total_digits = sum(int(num) for num in re.findall(r'9\((\d+)\)', base)) + if total_digits <= 4: + return 2 + elif total_digits <= 9: + return 4 + else: + return 8 + + total = 0 + for num in re.findall(r'X\((\d+)\)', pic_upper): + total += int(num) + for num in re.findall(r'G\((\d+)\)', pic_upper): + total += int(num) * 2 + for num in re.findall(r'9\((\d+)\)', pic_upper): + total += int(num) + if total > 0: + return total + + return 0 + + @staticmethod + def _read_file(path: str) -> str: + with open(path, 'r', encoding='utf-8') as f: + return f.read() + + def _parse_basic_info(self, meta: ProgramMeta): + rows = parse_table_from_section(self._design_text, '基本情報') + for row in rows: + item = row.get('項目', '') + value = row.get('内容', '') + if item == 'システム名': + meta.system_name = value + elif item == 'プログラムID': + meta.program_id = value + elif item == 'プログラム名': + meta.program_name = value + elif item == 'PGMタイプ': + meta.pgm_type = value + elif item == 'PGMパターン': + meta.pgm_pattern = value + elif item == '機能概要': + meta.summary_lines.append(value) + elif item == '': + if value: + meta.summary_lines.append(value) + + def _parse_use_files(self, meta: ProgramMeta): + rows = parse_table_from_section(self._design_text, '使用ファイル一覧') + for row in rows: + try: + no_str = row.get('NO', '0') + try: + no = int(no_str) if no_str and no_str.strip() not in ('', '—') else 0 + except ValueError: + no = 0 + rec_len_str = row.get('レコード長', '0') + try: + rec_len = int(rec_len_str) if rec_len_str and rec_len_str.strip() not in ('', '—') else 0 + except ValueError: + rec_len = 0 + f = FileInfo( + no=no, + file_db_name=row.get('使用ファイル/DB名', ''), + identifier=row.get('識別子', ''), + dd_name=row.get('DD名', ''), + io=row.get('I/O', ''), + copy_group=row.get('COPY群', ''), + format=row.get('形式', ''), + record_len=rec_len, + medium=row.get('媒体', ''), + remarks=row.get('備考', '') + ) + meta.files.append(f) + except (ValueError, KeyError): + continue + + def _parse_keys(self, meta: ProgramMeta): + rows = parse_table_from_section(self._design_text, 'キー項目一覧') + for row in rows: + try: + k = KeyInfo( + no=int(row.get('NO', '0')), + file_name=row.get('ファイル名', ''), + sort_condition=row.get('ソート条件(キー項目)', ''), + key_condition=row.get('キー条件(マッチング/キーブレイク)', '') + ) + meta.keys.append(k) + except (ValueError, KeyError): + continue + + def _parse_modules(self, meta: ProgramMeta): + rows = parse_table_from_section(self._design_text, '使用モジュール一覧') + for row in rows: + try: + m = ModuleInfo( + no=int(row.get('NO', '0')), + function=row.get('機能', ''), + program_id=row.get('プログラムID', ''), + copy_name=row.get('使用COPY名', '') + ) + meta.modules.append(m) + except (ValueError, KeyError): + continue + + def _parse_process_detail(self, meta: ProgramMeta): + meta.process_detail = extract_section(self._design_text, '処理詳細') + + def _parse_output_records(self, meta: ProgramMeta): + meta.output_records = extract_section(self._design_text, '出力レコード定義') + + def _determine_input_type(self, meta: ProgramMeta): + input_mediums = set() + for f in meta.files: + if 'I' in f.io: + input_mediums.add(f.medium) + + if not input_mediums: + meta.input_type = 'file' + elif input_mediums == {'PS'}: + meta.input_type = 'file' + elif input_mediums == {'DB'}: + meta.input_type = 'db' + else: + meta.input_type = 'mixed' diff --git a/black-box-data-create/agent/markdown_utils.py b/black-box-data-create/agent/markdown_utils.py new file mode 100644 index 0000000..f2812ba --- /dev/null +++ b/black-box-data-create/agent/markdown_utils.py @@ -0,0 +1,63 @@ +# agent/markdown_utils.py +import re +from typing import List, Dict, Optional + + +def extract_section(md_text: str, section_title: str) -> str: + """Extract content of a ## or ### section from markdown text. + Stops at the next heading of equal or higher level (fewer or equal #'s). + For ## sections, includes ### sub-sections. + """ + heading_re = rf'^(#{{2,3}})\s+{re.escape(section_title)}[^\S\n]*$' + h_match = re.search(heading_re, md_text, re.MULTILINE) + if not h_match: + return '' + + level = len(h_match.group(1)) + start = h_match.end() + + next_re = rf'^#{{1,{level}}}\s' + n_match = re.search(next_re, md_text[start:], re.MULTILINE) + end = start + n_match.start() if n_match else len(md_text) + + return md_text[start:end] + + +def parse_table_rows(text: str) -> List[Dict[str, str]]: + """Parse a markdown table from text, return list of row dicts. + Handles tables with exactly one header row and one separator row. + """ + lines = [] + for line in text.split('\n'): + stripped = line.strip() + if stripped.startswith('|') and stripped.endswith('|'): + if re.match(r'^\|[\s\-:]+\|', stripped): + continue + lines.append(stripped) + + if len(lines) < 1: + return [] + + headers = [cell.strip() for cell in lines[0].split('|')[1:-1]] + rows = [] + for line in lines[1:]: + cells = [cell.strip() for cell in line.split('|')[1:-1]] + if len(cells) == len(headers): + rows.append(dict(zip(headers, cells))) + return rows + + +def parse_table_from_section(md_text: str, section_title: str) -> List[Dict[str, str]]: + """Find a ### section and parse its first table.""" + section_text = extract_section(md_text, section_title) + if not section_text: + return [] + return parse_table_rows(section_text) + + +def find_row_by_key(rows: List[Dict[str, str]], key_col: str, key_value: str) -> Optional[Dict[str, str]]: + """Find a table row where a specific column matches the key value.""" + for row in rows: + if row.get(key_col, '').strip() == key_value: + return row + return None diff --git a/black-box-data-create/agent/models.py b/black-box-data-create/agent/models.py new file mode 100644 index 0000000..0d7caaa --- /dev/null +++ b/black-box-data-create/agent/models.py @@ -0,0 +1,89 @@ +from dataclasses import dataclass, field +from typing import List, Dict, Optional + + +@dataclass +class FileInfo: + """使用ファイル一覧 中的一行""" + no: int + file_db_name: str + identifier: str + dd_name: str + io: str + copy_group: str + format: str + record_len: int + medium: str + remarks: str + + +@dataclass +class CopyField: + """COPYBOOK 中的单个字段""" + level: int + name: str + raw_name: str + pic_type: str + pic_bytes: int + + +@dataclass +class KeyInfo: + """キー項目一覧 中的一行""" + no: int + file_name: str + sort_condition: str + key_condition: str + + +@dataclass +class ModuleInfo: + """使用モジュール一覧 中的一行""" + no: int + function: str + program_id: str + copy_name: str + + +@dataclass +class TableColumn: + """DB 表中的单个字段""" + no: int + name_jp: str + name_en: str + type: str + max_len: str + decimal_digits: str + byte_count: str + nullable: bool + is_pk: bool + + +@dataclass +class TableInfo: + """DB 表定义""" + table_name: str + db_id: str + copy_id: str + columns: List[TableColumn] + pk_columns: List[str] + + +@dataclass +class ProgramMeta: + """程序完整元数据""" + program_id: str + program_name: str + system_name: str + pgm_type: str + pgm_pattern: str + summary_lines: List[str] + prerequisites: List[Dict[str, str]] + files: List[FileInfo] + keys: List[KeyInfo] + modules: List[ModuleInfo] + process_detail: str + output_records: str + input_type: str + copy_fields: Dict[str, List[CopyField]] + db_tables: Dict[str, TableInfo] diff --git a/black-box-data-create/agent/output_writer.py b/black-box-data-create/agent/output_writer.py new file mode 100644 index 0000000..0d15c17 --- /dev/null +++ b/black-box-data-create/agent/output_writer.py @@ -0,0 +1,75 @@ +import json +import os +from typing import Dict, Any + + +class OutputWriter: + """将 AI 生成的数据写入文件系统。""" + + def __init__(self, output_dir: str): + self.output_dir = output_dir + + def write(self, program_id: str, ai_result: Dict[str, Any], + input_type: str) -> Dict[str, str]: + """写入所有组的输出文件。 + + Returns: + {group_folder: written_file_path} 映射 + """ + written = {} + groups = ai_result.get('groups', ai_result) + + for group_key in sorted(groups.keys()): + group_data = groups[group_key] + group_dir = os.path.join(self.output_dir, program_id, 'black_box', group_key) + os.makedirs(group_dir, exist_ok=True) + + if input_type in ('file', 'mixed'): + json_path = self._write_json(group_dir, program_id, group_key, group_data) + written[f"{group_key}/json"] = json_path + + if input_type in ('db', 'mixed'): + sql_path = self._write_sql(group_dir, program_id, group_key, group_data) + written[f"{group_key}/sql"] = sql_path + + summary_md = ai_result.get('summary_md') + if summary_md: + md_path = self._write_case_table(program_id, summary_md) + written["case表"] = md_path + + return written + + def _write_case_table(self, program_id: str, content: str) -> str: + """写入 case表.md(组与规则md的对应关系说明)。""" + black_box_dir = os.path.join(self.output_dir, program_id, 'black_box') + os.makedirs(black_box_dir, exist_ok=True) + + filepath = os.path.join(black_box_dir, 'case表.md') + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + + return filepath + + def _write_json(self, group_dir: str, program_id: str, + group_key: str, data: Any) -> str: + """写入 JSON 文件。""" + filename = f"{program_id}_{group_key}.json" + filepath = os.path.join(group_dir, filename) + + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + return filepath + + def _write_sql(self, group_dir: str, program_id: str, + group_key: str, data: Any) -> str: + """写入 SQL 文件。""" + filename = f"{program_id}_{group_key}.sql" + filepath = os.path.join(group_dir, filename) + + sql_content = data if isinstance(data, str) else data.get('sql', json.dumps(data, ensure_ascii=False)) + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(sql_content) + + return filepath diff --git a/black-box-data-create/agent/prompt_builder.py b/black-box-data-create/agent/prompt_builder.py new file mode 100644 index 0000000..bba29da --- /dev/null +++ b/black-box-data-create/agent/prompt_builder.py @@ -0,0 +1,155 @@ +from typing import List + +from agent.models import ProgramMeta + + +class PromptBuilder: + """将解析后的程序元数据和规则组装成 API prompt。""" + + def build(self, meta: ProgramMeta, rules_text: str, + group_descriptions: List[str], group_count: int) -> str: + """构建完整的 API prompt。""" + parts = [] + + parts.append(self._build_basic_info(meta)) + parts.append(self._build_process_detail(meta)) + parts.append(self._build_input_structures(meta)) + parts.append(self._build_output_records(meta)) + parts.append(rules_text) + parts.append(self._build_output_format(meta)) + parts.append(self._build_generation_instruction( + group_descriptions, group_count, meta.input_type + )) + + return '\n\n'.join(parts) + + def _build_basic_info(self, meta: ProgramMeta) -> str: + lines = [ + f"## プログラム基本情報", + f"- システム名: {meta.system_name}", + f"- プログラムID: {meta.program_id}", + f"- プログラム名: {meta.program_name}", + f"- PGMパターン: {meta.pgm_pattern}", + f"- 入力タイプ: {self._input_type_label(meta.input_type)}", + ] + if meta.summary_lines: + lines.append(f"- 機能概要: {' '.join(meta.summary_lines)}") + return '\n'.join(lines) + + def _build_process_detail(self, meta: ProgramMeta) -> str: + return f"## 処理詳細\n\n```\n{meta.process_detail}\n```" + + def _build_input_structures(self, meta: ProgramMeta) -> str: + parts = ["## 入力構造"] + + input_files = [f for f in meta.files if 'I' in f.io] + for f in input_files: + parts.append(f"### ファイル {f.identifier} (DD名: {f.dd_name}, COPY: {f.copy_group}, 媒体: {f.medium})") + fields = meta.copy_fields.get(f.identifier, []) + if fields: + parts.append("| 項目名 | PIC | バイト数 |") + parts.append("|--------|-----|----------|") + for cf in fields: + parts.append(f"| {cf.name} | {cf.pic_type} | {cf.pic_bytes} |") + else: + parts.append("(構造情報なし)") + + db_inputs = [f for f in meta.files if 'I' in f.io and f.medium == 'DB'] + if db_inputs or meta.db_tables: + parts.append("### DBテーブル構造") + for table_name, table in meta.db_tables.items(): + parts.append(f"**表名: {table_name}**") + pk_str = ', '.join(table.pk_columns) + parts.append(f"主キー: {pk_str}") + parts.append("| 項目名 | 英字名 | タイプ | 最大長 | KEY |") + parts.append("|--------|--------|--------|--------|-----|") + for col in table.columns: + key_mark = '✓' if col.is_pk else '' + parts.append(f"| {col.name_jp} | {col.name_en} | {col.type} | {col.max_len} | {key_mark} |") + + return '\n\n'.join(parts) + + def _build_output_records(self, meta: ProgramMeta) -> str: + return f"## 出力レコード定義\n\n```\n{meta.output_records}\n```" + + def _build_output_format(self, meta: ProgramMeta) -> str: + lines = [ + "## 出力形式", + "", + "### JSON形式(ファイル入力の場合)", + "", + "JSON構造:", + "```json", + "{", + ' "program": "{プログラムID}",', + ' "records": [', + ' { "input": { "FD名": { "項目名": "値", ... } } },', + ' ...', + ' ]', + "}", + "```", + "", + "### 項目値のルール(PIC → JSON値)", + "| PIC | JSON内表示 | 例 |", + "|-----|-----------|-----|", + "| PIC X(n) | 左詰め + スペース埋め | `\"A0000001\"` |", + "| PIC 9(n) | 右詰め + 先行ゼロ | `\"00000101\"` |", + "| PIC S9(n) | 符号 + 右詰め + 先行ゼロ | `\"+0000101\"` |", + "| PIC S9(n)V9(m) | 符号 + 右詰め + 小数点含む | `\"+001234567\"` |", + "| PIC S9(n) COMP | 通常の10進数文字列 | `\"300\"` |", + "| PIC S9(n) COMP-3 | 通常の10進数文字列 | `\"1234\"` |", + "| PIC 9(n) COMP-3 | 通常の10進数文字列 | `\"1234\"` |", + "| FILLER(純粋予約) | グループIDとレコード番号を含むパターン | `\"D000000...001\"` |", + "| FILLER(業務予約) | 全スペース(PIC X) または 全ゼロ(PIC 9) | |", + "", + "### SQL形式(DB入力の場合)", + "各グループ1つのSQLファイル: `{program}_g{groupId}.sql`", + "", + "SQL例:", + "```sql", + "-- Group: 1", + "INSERT INTO TABLE_NAME (COL1, COL2) VALUES", + "('val1', 'val2');", + "```", + "", + "### データ生成の注意", + "- 項目名に意味がある場合(DATE→日付、NAME→氏名)、実際の形式に合った値を生成すること", + "- 隣接するレコード間で、同じ項目に異なる値を設定すること", + ] + return '\n'.join(lines) + + def _build_generation_instruction(self, descriptions: List[str], + count: int, input_type: str) -> str: + lines = [ + "## 生成指示", + f"生成するグループ数: {count}", + "各グループの内容:", + ] + for i, desc in enumerate(descriptions, 1): + lines.append(f" - g{i}: {desc}") + + if input_type == 'file': + lines.append("出力: JSONファイル(1グループ=1JSONファイル)") + elif input_type == 'db': + lines.append("出力: SQL INSERTファイル(1グループ=1SQLファイル)") + else: + lines.append("出力: JSONファイル + SQL INSERTファイル") + + lines.append("") + lines.append("応答形式: JSONで返してください。最上位キー \"groups\" を使い、各グループを \"g1\", \"g2\" ... とします。") + lines.append("例: {\"groups\": {\"g1\": {\"program\": \"XXX\", \"records\": [...]}, \"g2\": {...}}}") + lines.append("") + lines.append("### case表.md 生成 (summary_md)") + lines.append("最上位に \"summary_md\" フィールドも追加してください。値は Markdown 文字列で、") + lines.append("各グループと規則mdの対応関係を説明する case表 です。") + lines.append("case表 には規則mdに定義された全グループ(g1〜gn)を列挙し、以下の列を含むMarkdownテーブルにしてください:") + lines.append("| 组 | 规则md中的定义/用途 | 本次是否生成 |") + lines.append("- 规则md中定义且本次已生成数据的组:状态列标注 已生成") + lines.append("- 规则md中定义、但本次判断没必要生成而省略的组:状态列标注 ✗") + lines.append("summary_md の最後にはルールファイル名(例: マッチング(1-1).md)を記載してください。") + return '\n'.join(lines) + + @staticmethod + def _input_type_label(t: str) -> str: + labels = {'file': 'ファイル', 'db': 'DB', 'mixed': '混合(ファイル+DB)'} + return labels.get(t, t) diff --git a/black-box-data-create/agent/rule_loader.py b/black-box-data-create/agent/rule_loader.py new file mode 100644 index 0000000..f7896a8 --- /dev/null +++ b/black-box-data-create/agent/rule_loader.py @@ -0,0 +1,229 @@ +import os +import re +from typing import List, Tuple, Optional + +from agent.models import ProgramMeta + + +PGM_PATTERN_MAP = { + 'マッチング(1:1)': 'マッチング(1-1).md', + 'マッチング(1:1)': 'マッチング(1-1).md', + 'マッチング(1:N)': 'マッチング(1-N).md', + 'マッチング(1:N)': 'マッチング(1-N).md', + 'マッチング(1:N) + EVALUATE 4パターン分岐': 'マッチング(1-N).md', + 'マッチング(N:1)': 'マッチング(N-1).md', + 'マッチング(N:1)': 'マッチング(N-1).md', + 'マッチング(M:N)': 'マッチング(M-N).md', + 'マッチングM:N⇒出力M件': 'マッチング(M-N).md', + 'マッチングM:N⇒出力N件': 'マッチング(M-N).md', + 'マッチングM:N⇒出力M×N件': 'マッチング(M-N).md', + '18(M:N→M件マッチング)': 'マッチング(M-N).md', + '19(M:N→N件マッチング+集計)': 'マッチング(M-N).md', + '20(M:N→M×N件直積出力)': 'マッチング(M-N).md', + 'レイアウト編集のみ(GETPUT)': 'レイアウト編集のみ(GETPUT).md', + 'レイアウト編集のみ(GETPUT)': 'レイアウト編集のみ(GETPUT).md', + 'レイアウト編集のみ': 'レイアウト編集のみ(GETPUT).md', + 'GETPUT(編集出力)': 'GETPUT(編集出力).md', + '項目チェック': '項目チェック(重複含まず).md', + '項目チェック(重複なし)': '項目チェック(重複含まず).md', + '項目チェック(重複なし)': '項目チェック(重複含まず).md', + '項目チェック(重複含まず)': '項目チェック(重複含まず).md', + '項目チェック(重複含む)': '項目チェック(重複含まず).md', + '項目チェック(半角20桁/4桁)': '項目チェック(半角20桁-4桁).md', + '27(半角20桁/4桁チェック)': '項目チェック(半角20桁-4桁).md', + '半角20桁/4桁': '項目チェック(半角20桁-4桁).md', + '振り分け': '振り分け(IF).md', + '振り分け(IF文)': '振り分け(IF).md', + '振り分け(IF文、EVALUATE文)': '振り分け(IF).md', + '振り分け(EVALUATE文)': '振り分け(EVALUATE).md', + '振り分け(EVALUATE文)': '振り分け(EVALUATE).md', + 'キーブレイク': 'キーブレイク(集計).md', + 'キーブレイク(集計)': 'キーブレイク(集計).md', + 'キーブレイク(集計)': 'キーブレイク(集計).md', + 'キーブレイク(集約)': 'キーブレイク(集約).md', + 'キーブレイク(集約)': 'キーブレイク(集約).md', + 'キーブレイク(集計、集約)': 'キーブレイク(集計).md', + 'キーブレイク(集計、集約の以外)': 'キーブレイク(非集計).md', + 'キーブレイク(集計集約以外)': 'キーブレイク(非集計).md', + 'キーブレイク(集計集約以外)': 'キーブレイク(非集計).md', + 'キーブレイク(非集計)': 'キーブレイク(非集計).md', + '1:Nキーブレイク(同キー集約)': 'キーブレイク(集計).md', + '1:N+キーブレイク(同キー)': 'キーブレイク(集計).md', + '1:N+同キーキーブレイク': 'キーブレイク(集計).md', + '32(1:N+同キーキーブレイク)': 'キーブレイク(集計).md', + '1:N+同キー': 'キーブレイク(集計).md', + '1:N+キーブレイク(異キー)': '1-N+キーブレイク(異キー).md', + '1:N+キーブレイク(異キー)': '1-N+キーブレイク(異キー).md', + '1:N+異キーキーブレイク': '1-N+キーブレイク(異キー).md', + '33(1:N+異キーキーブレイク)': '1-N+キーブレイク(異キー).md', + '1:N+異キー': '1-N+キーブレイク(異キー).md', + 'DB更新': 'DB更新.md', + 'DB更新 + SYSIN読込(P28)': 'DB更新.md', + 'SELECT処理': 'SELECT処理.md', + 'SELECT条件': 'SELECT処理.md', + 'SELECT条件': 'SELECT処理.md', + 'DB検索': 'SELECT処理.md', + 'DB検索': 'SELECT処理.md', + '50分割': '50分割.md', + '50分割': '50分割.md', + '25分割': '25分割.md', + '25分割': '25分割.md', + '11(25分割)': '25分割.md', + '100分割': '100分割.md', + '100分割': '100分割.md', + '12(100分割)': '100分割.md', + 'MERGE(複数ファイル結合)': 'MERGE.md', + 'MERGE(複数ファイル結合)': 'MERGE.md', + 'CSV→FB変換(改行あり)': 'CSV→FB変換.md', + 'CSV→FB変換(改行あり)': 'CSV→FB変換.md', + 'CSV→FB変換(改行なし)': 'CSV→FB変換.md', + 'CSV→FB変換(改行なし)': 'CSV→FB変換.md', + '2段階マッチング(1:1⇒1:1)': '2段階マッチング(1:1⇒1:1).md', + '2段階マッチング(1:1⇒1:1)': '2段階マッチング(1:1⇒1:1).md', + '2段階マッチング(N:1⇒N:1)': '2段階マッチング(N:1⇒N:1).md', + '2段階マッチング(N:1⇒N:1)': '2段階マッチング(N:1⇒N:1).md', + '2段階マッチング(M:N⇒M:N)': '2段階マッチング(M:N⇒M:N).md', + '2段階マッチング(M:N⇒M:N)': '2段階マッチング(M:N⇒M:N).md', + 'マッチング(1:1→1:1 2段階)': '2段階マッチング(1:1⇒1:1).md', + 'マッチング(N:1→N:1 2段階)': '2段階マッチング(N:1⇒N:1).md', + 'マッチング(M:N→M:N 2段階)': '2段階マッチング(M:N⇒M:N).md', + '内部テーブル検索': '内部テーブル検索.md', + '24(内部テーブル検索)': '内部テーブル検索.md', + 'サブプログラム使用': 'サブプログラム.md', + 'サブプログラム': 'サブプログラム.md', + 'サブPGM': 'サブプログラム.md', + '25(サブPGM)': 'サブプログラム.md', + 'SYSIN読込': 'SYSIN読込.md', + 'SYSIN読込': 'SYSIN読込.md', + '28(SYSIN読込)': 'SYSIN読込.md', + 'ASCII→EBCDIC変換': 'ASCII→EBCDIC変換.md', + 'ASCII→EBCDIC変換': 'ASCII→EBCDIC変換.md', + '29(ASCII→EBCDIC変換)': 'ASCII→EBCDIC変換.md', + 'SORT(INPUT/OUTPUT PROCEDURE)': 'SORT.md', + '34(SORT INPUT/OUTPUT PROCEDURE)': 'SORT.md', + 'SORT': 'SORT.md', + 'オンラインPGM': 'オンラインPGM.md', + 'オンラインPGM': 'オンラインPGM.md', + 'オンライン': 'オンラインPGM.md', + '編集出力(ランキング生成)': 'ランキング生成.md', + '36(ランキング生成)': 'ランキング生成.md', + 'ランキング生成': 'ランキング生成.md', + '編集出力(印刷制御)': 'GETPUT(編集出力).md', + '編集出力': 'GETPUT(編集出力).md', +} + +SPECIAL_FEATURE_CHECKS = [ + (['場合', 'EVALUATE', 'IF'], '条件分支.md'), +] + + +class RuleLoader: + """根据程序特征加载对应的数据生成规则。""" + + def __init__(self, rules_dir: str): + self.pgm_pattern_dir = os.path.join(rules_dir, 'pgm_pattern') + self.special_feature_dir = os.path.join(rules_dir, 'special_feature') + self.last_rule_file = None + + def load(self, meta: ProgramMeta) -> Tuple[str, List[str], int]: + """加载所有相关规则,返回 (合并后的规则文本, 组描述列表, 组数)。""" + pgm_rule = self._load_pgm_pattern_rule(meta.pgm_pattern) + if pgm_rule is None: + raise FileNotFoundError( + f"PGM模式 '{meta.pgm_pattern}' の規則ファイルが見つかりません。" + f"{self.pgm_pattern_dir} に .md ファイルを追加してください。" + ) + + group_descriptions, group_count = self._parse_group_info(pgm_rule) + + parts = [pgm_rule] + for keywords, rule_file in SPECIAL_FEATURE_CHECKS: + if self._detect_feature(meta.process_detail, keywords): + feature_rule = self._read_rule_file( + os.path.join(self.special_feature_dir, rule_file) + ) + if feature_rule: + parts.append(feature_rule) + + combined = '\n\n---\n\n'.join(parts) + return combined, group_descriptions, group_count + + def _load_pgm_pattern_rule(self, pgm_pattern: str) -> Optional[str]: + """根据 PGMパターン 加载对应的规则文件。""" + filename = PGM_PATTERN_MAP.get(pgm_pattern) + if filename: + path = os.path.join(self.pgm_pattern_dir, filename) + if os.path.exists(path): + self.last_rule_file = filename + return self._read_rule_file(path) + + if os.path.isdir(self.pgm_pattern_dir): + available = sorted(os.listdir(self.pgm_pattern_dir)) + # 对每个规则文件名(去掉.md),检查它是否出现在 PGMパターン 中 + # 半角/全角括弧に加え、'1:N' vs '1-N' のコロン/ハイフン差異も吸収する + pgm_lower = pgm_pattern.lower().replace(':', '-') + for fname in available: + if fname.endswith('.md'): + rule_name = fname[:-3].lower() + if rule_name in pgm_lower: + self.last_rule_file = fname + return self._read_rule_file(os.path.join(self.pgm_pattern_dir, fname)) + + return None + + @staticmethod + def _read_rule_file(path: str) -> str: + with open(path, 'r', encoding='utf-8') as f: + return f.read() + + @staticmethod + def _parse_group_info(rule_text: str) -> Tuple[List[str], int]: + """从规则文本中解析组信息。 + + 只统计表头首列为"组"的表格(生成的数据组), + 避免把记录明细表中的数字(如 record序号 / 第1条)误算成组号。 + """ + descriptions = [] + group_count = 0 + in_group_table = False + + for line in rule_text.split('\n'): + stripped = line.strip() + if not stripped.startswith('|'): + in_group_table = False + continue + + # 分隔行(|---|)跳过 + if re.match(r'^\|[\s\-:]+\|', stripped): + continue + + cells = [c.strip() for c in stripped.split('|')[1:-1]] + if not cells: + continue + + # 表头行:首列为"组"时进入组表格 + if cells[0] == '组': + in_group_table = True + continue + + if not in_group_table: + continue + + if len(cells) >= 2: + try: + group_num = int(cells[0]) + if group_num > group_count: + group_count = group_num + descriptions.append(cells[1]) + except ValueError: + pass + + return descriptions, group_count + + @staticmethod + def _detect_feature(process_detail: str, keywords: List[str]) -> bool: + """检测処理詳細中是否包含特定关键词。""" + for kw in keywords: + if kw in process_detail: + return True + return False diff --git a/black-box-data-create/docs/superpowers/plans/2026-07-12-testdata-agent-plan.md b/black-box-data-create/docs/superpowers/plans/2026-07-12-testdata-agent-plan.md new file mode 100644 index 0000000..cad7e2a --- /dev/null +++ b/black-box-data-create/docs/superpowers/plans/2026-07-12-testdata-agent-plan.md @@ -0,0 +1,2310 @@ +# 测试数据生成 Agent 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 基于 Python 构建一个测试数据生成 Agent,解析 COBOL 详细设计书和 COPYBOOK,通过 DeepSeek API 生成符合规范的测试数据 JSON/SQL 文件。 + +**Architecture:** 线性管道架构。InputParser 解析输入 → RuleLoader 匹配规则 → PromptBuilder 组装 prompt → APIClient 调用 AI → OutputWriter 保存结果。各模块通过 dataclass 传递结构化数据。 + +**Tech Stack:** Python 3.9+, requests, pytest + +--- + +### Task 1: 项目初始化 + +**Files:** +- Create: `D:\jcl-cobol-data-create\agent\__init__.py` +- Create: `D:\jcl-cobol-data-create\requirements.txt` +- Create: `D:\jcl-cobol-data-create\tests\__init__.py` +- Create: `D:\jcl-cobol-data-create\tests\conftest.py` + +- [ ] **Step 1: 创建 agent 包和 __init__.py** + +```python +# agent/__init__.py +__version__ = "0.1.0" +``` + +- [ ] **Step 2: 创建 requirements.txt** + +``` +requests>=2.28.0 +``` + +- [ ] **Step 3: 创建 tests 目录和 conftest.py** + +```python +# tests/__init__.py +# (空文件) +``` + +```python +# tests/conftest.py +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +@pytest.fixture +def data_dir(): + return os.path.join(os.path.dirname(__file__), 'test_data') +``` + +- [ ] **Step 4: 创建 tests/test_data 目录存放测试夹具** + +Run: `New-Item -ItemType Directory -Path "D:\jcl-cobol-data-create\tests\test_data" -Force` + +- [ ] **Step 5: 运行测试验证环境** + +Run: `python -m pytest tests/ -v` +Expected: 0 tests collected, no errors. + +--- + +### Task 2: 数据模型定义 + +**Files:** +- Create: `D:\jcl-cobol-data-create\agent\models.py` +- Create: `D:\jcl-cobol-data-create\tests\test_models.py` + +- [ ] **Step 1: 定义数据模型** + +```python +# agent/models.py +from dataclasses import dataclass, field +from typing import List, Dict, Optional + + +@dataclass +class FileInfo: + """使用ファイル一覧 中的一行""" + no: int + file_db_name: str # 使用ファイル/DB名 + identifier: str # 識別子 (R01, W01, etc.) + dd_name: str # DD名 + io: str # I/O (I, O, I/U/D) + copy_group: str # COPY群 + format: str # 形式 (FB, VB) + record_len: int # レコード長 + medium: str # 媒体 (PS, DB) + remarks: str # 備考 + + +@dataclass +class CopyField: + """COPYBOOK 中的单个字段""" + level: int # 级别 (03, 05, ...) + name: str # 替换后的实际字段名 (如 R01-APPL-ID) + raw_name: str # 原始占位符名 (如 (A)APPL-ID) + pic_type: str # PIC 定义 (如 "X(008)", "9(008)", "S9(009) COMP-3") + pic_bytes: int # 字节数 + + +@dataclass +class KeyInfo: + """キー項目一覧 中的一行""" + no: int + file_name: str # ファイル名 + sort_condition: str # ソート条件(キー項目) + key_condition: str # キー条件(マッチング/キーブレイク) + + +@dataclass +class ModuleInfo: + """使用モジュール一覧 中的一行""" + no: int + function: str # 機能 + program_id: str # プログラムID + copy_name: str # 使用COPY名 + + +@dataclass +class TableColumn: + """DB 表中的单个字段""" + no: int + name_jp: str # 项目名(日文) + name_en: str # 项目名(英文) + type: str # 类型 (CHAR, DECIMAL, etc.) + max_len: str # 最大长 + decimal_digits: str # 小数桁 + byte_count: str # バイト数 + nullable: bool # NULL 许可 + is_pk: bool # 是否主键 + + +@dataclass +class TableInfo: + """DB 表定义""" + table_name: str # DB名 (如 LEAVE_RECORDS) + db_id: str # DB ID (如 EMP_MASTER) + copy_id: str # COPY ID + columns: List[TableColumn] + pk_columns: List[str] # 主键字段名列表 + + +@dataclass +class ProgramMeta: + """程序完整元数据""" + program_id: str + program_name: str + system_name: str + pgm_type: str # PGMタイプ (メイン/サブ) + pgm_pattern: str # PGMパターン + summary_lines: List[str] # 機能概要 (多行) + prerequisites: List[Dict[str, str]] # 前提条件 + files: List[FileInfo] + keys: List[KeyInfo] + modules: List[ModuleInfo] + process_detail: str # 処理詳細 全文 (从详细设计书获取) + output_records: str # 出力レコード定義 全文 + input_type: str # "file", "db", "mixed" + # 由 COPYBOOK 解析填充 + copy_fields: Dict[str, List[CopyField]] # 識別子 → 字段列表 + # DB 相关 + db_tables: Dict[str, TableInfo] # DB表名 → 表定义 +``` + +- [ ] **Step 2: 写测试验证数据模型可实例化** + +```python +# tests/test_models.py +from agent.models import FileInfo, CopyField, ProgramMeta, KeyInfo, TableColumn, TableInfo + + +def test_file_info(): + f = FileInfo( + no=1, file_db_name="OVT-SORTED", identifier="R01", + dd_name="ZAN04R01", io="I", copy_group="ZAN01REC", + format="FB", record_len=80, medium="PS", remarks="有効申請" + ) + assert f.identifier == "R01" + assert f.medium == "PS" + + +def test_copy_field(): + cf = CopyField(level=3, name="R01-APPL-ID", raw_name="(A)APPL-ID", + pic_type="X(008)", pic_bytes=8) + assert cf.name == "R01-APPL-ID" + assert cf.pic_bytes == 8 + + +def test_program_meta_defaults(): + meta = ProgramMeta( + program_id="TEST", program_name="", system_name="", + pgm_type="", pgm_pattern="", + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail="", output_records="", + input_type="file", + copy_fields={}, db_tables={} + ) + assert meta.program_id == "TEST" + assert meta.input_type == "file" + + +def test_table_info(): + col = TableColumn(no=1, name_jp="社員番号", name_en="EMP_ID", + type="CHAR", max_len="8", decimal_digits="", + byte_count="8", nullable=False, is_pk=True) + table = TableInfo(table_name="EMP_MASTER", db_id="EMP_MASTER", + copy_id="", columns=[col], pk_columns=["EMP_ID"]) + assert table.pk_columns == ["EMP_ID"] + assert len(table.columns) == 1 +``` + +- [ ] **Step 3: 运行测试** + +Run: `python -m pytest tests/test_models.py -v` +Expected: 4 tests PASS + +--- + +### Task 3: InputParser — 详细设计书解析 + +**Files:** +- Create: `D:\jcl-cobol-data-create\agent\markdown_utils.py` +- Create: `D:\jcl-cobol-data-create\agent\input_parser.py` +- Create: `D:\jcl-cobol-data-create\tests\test_data\sample_design_ZAN04MAT.md` +- Create: `D:\jcl-cobol-data-create\tests\test_markdown_utils.py` +- Create: `D:\jcl-cobol-data-create\tests\test_input_parser.py` + +- [ ] **Step 1: 创建 markdown 工具函数** + +```python +# agent/markdown_utils.py +import re +from typing import List, Dict, Optional + + +def extract_section(md_text: str, section_title: str) -> str: + """Extract content of a ### section from markdown text. + Returns content between this header and the next ## or ### header, or end of file. + """ + pattern = rf'###\s+{re.escape(section_title)}\s*\n(.*?)(?=\n##|\n###|\Z)' + match = re.search(pattern, md_text, re.DOTALL) + return match.group(1) if match else '' + + +def parse_table_rows(text: str) -> List[Dict[str, str]]: + """Parse a markdown table from text, return list of row dicts. + Handles tables with exactly one header row and one separator row. + """ + lines = [] + for line in text.split('\n'): + stripped = line.strip() + if stripped.startswith('|') and stripped.endswith('|'): + # Skip separator rows like |---|------|---| + if re.match(r'^\|[\s\-:]+\|', stripped): + continue + lines.append(stripped) + + if len(lines) < 1: + return [] + + headers = [cell.strip() for cell in lines[0].split('|')[1:-1]] + rows = [] + for line in lines[1:]: + cells = [cell.strip() for cell in line.split('|')[1:-1]] + if len(cells) == len(headers): + rows.append(dict(zip(headers, cells))) + return rows + + +def parse_table_from_section(md_text: str, section_title: str) -> List[Dict[str, str]]: + """Find a ### section and parse its first table.""" + section_text = extract_section(md_text, section_title) + if not section_text: + return [] + return parse_table_rows(section_text) + + +def find_row_by_key(rows: List[Dict[str, str]], key_col: str, key_value: str) -> Optional[Dict[str, str]]: + """Find a table row where a specific column matches the key value.""" + for row in rows: + if row.get(key_col, '').strip() == key_value: + return row + return None +``` + +- [ ] **Step 2: 写 markdown_utils 的测试** + +```python +# tests/test_markdown_utils.py +from agent.markdown_utils import extract_section, parse_table_rows, parse_table_from_section, find_row_by_key + +SAMPLE_MD = """ +### 基本情報 + +| # | 項目 | 内容 | +|---|------|------| +| 1 | システム名 | 残業統計管理システム | +| 4 | PGMパターン | マッチング(1:1) | +| 5 | 機能概要 | 取消マッチング処理 | + +### 使用ファイル一覧 + +| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 媒体 | 備考 | +|----|------------------|--------|------|-----|--------|------|------| +| 1 | OVT-SORTED | R01 | ZAN04R01 | I | ZAN01REC | PS | | +| 2 | ERROR-LOG | W01 | ZAN04W01 | O | ZAN05REC | PS | | + +### 出力レコード定義 + +### 出力ファイル1(W01/OVT-MATCHED) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | APPL-ID | R01.APPL-ID | | +| 2 | EMP-ID | R01.EMP-ID | | +""" + + +def test_extract_section(): + result = extract_section(SAMPLE_MD, "基本情報") + assert "残業統計管理システム" in result + assert "使用ファイル一覧" not in result + + +def test_extract_section_not_found(): + result = extract_section(SAMPLE_MD, "存在しないセクション") + assert result == '' + + +def test_parse_table_rows(): + rows = parse_table_from_section(SAMPLE_MD, "基本情報") + assert len(rows) >= 3 + assert rows[0]['項目'] == 'システム名' + + +def test_find_row_by_key(): + rows = parse_table_from_section(SAMPLE_MD, "基本情報") + row = find_row_by_key(rows, '項目', 'PGMパターン') + assert row is not None + assert row['内容'] == 'マッチング(1:1)' + + +def test_parse_use_file_table(): + rows = parse_table_from_section(SAMPLE_MD, "使用ファイル一覧") + assert len(rows) == 2 + assert rows[0]['識別子'] == 'R01' + assert rows[0]['DD名'] == 'ZAN04R01' + + +def test_extract_output_records_section(): + result = extract_section(SAMPLE_MD, "出力レコード定義") + assert "出力ファイル1" in result + assert "W01/OVT-MATCHED" in result +``` + +- [ ] **Step 3: 运行测试确认 markdown_utils 通过** + +Run: `python -m pytest tests/test_markdown_utils.py -v` +Expected: all PASS + +- [ ] **Step 4: 创建测试用详细设计书 fixture** + +使用实际项目中 `詳細設計書_ZAN04MAT.md` 的副本作为测试数据。 + +Run: `Copy-Item "D:\cobol-tna-system\詳細設計書\詳細設計書_ZAN04MAT.md" "D:\jcl-cobol-data-create\tests\test_data\詳細設計書_ZAN04MAT.md"` + +- [ ] **Step 5: 实现 InputParser 设计书部分** + +```python +# agent/input_parser.py +import re +import os +from typing import List, Dict, Optional, Tuple + +from agent.models import ( + FileInfo, KeyInfo, ModuleInfo, ProgramMeta, CopyField, TableColumn, TableInfo +) +from agent.markdown_utils import ( + extract_section, parse_table_rows, parse_table_from_section, find_row_by_key +) + + +class InputParser: + """解析 COBOL 程序的详细设计书和相关文件。""" + + def __init__(self, design_md_path: str, source_cbl_path: str, + file_db_md_path: str, cpy_dir: str, db_md_path: str): + self.design_md_path = design_md_path + self.source_cbl_path = source_cbl_path + self.file_db_md_path = file_db_md_path + self.cpy_dir = cpy_dir + self.db_md_path = db_md_path + self._design_text = '' + self._source_text = '' + + def run(self) -> ProgramMeta: + """执行完整解析,返回 ProgramMeta。""" + self._design_text = self._read_file(self.design_md_path) + self._source_text = self._read_file(self.source_cbl_path) + + meta = ProgramMeta( + program_id='', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', + copy_fields={}, db_tables={} + ) + + self._parse_basic_info(meta) + self._parse_use_files(meta) + self._parse_keys(meta) + self._parse_modules(meta) + self._parse_process_detail(meta) + self._parse_output_records(meta) + self._determine_input_type(meta) + self._parse_copybooks(meta) + self._parse_db_definition(meta) + + return meta + + @staticmethod + def _read_file(path: str) -> str: + with open(path, 'r', encoding='utf-8') as f: + return f.read() + + def _parse_basic_info(self, meta: ProgramMeta): + rows = parse_table_from_section(self._design_text, '基本情報') + for row in rows: + item = row.get('項目', '') + value = row.get('内容', '') + if item == 'システム名': + meta.system_name = value + elif item == 'プログラムID': + meta.program_id = value + elif item == 'プログラム名': + meta.program_name = value + elif item == 'PGMタイプ': + meta.pgm_type = value + elif item == 'PGMパターン': + meta.pgm_pattern = value + elif item == '機能概要': + meta.summary_lines.append(value) + elif item == '': + # 多行功能概要用空 # 列标识续行 + if value: + meta.summary_lines.append(value) + + def _parse_use_files(self, meta: ProgramMeta): + rows = parse_table_from_section(self._design_text, '使用ファイル一覧') + for row in rows: + try: + no = int(row.get('NO', '0')) + rec_len_str = row.get('レコード長', '0') + rec_len = int(rec_len_str) if rec_len_str and rec_len_str != '' else 0 + f = FileInfo( + no=no, + file_db_name=row.get('使用ファイル/DB名', ''), + identifier=row.get('識別子', ''), + dd_name=row.get('DD名', ''), + io=row.get('I/O', ''), + copy_group=row.get('COPY群', ''), + format=row.get('形式', ''), + record_len=rec_len, + medium=row.get('媒体', ''), + remarks=row.get('備考', '') + ) + meta.files.append(f) + except (ValueError, KeyError): + continue + + def _parse_keys(self, meta: ProgramMeta): + rows = parse_table_from_section(self._design_text, 'キー項目一覧') + for row in rows: + try: + k = KeyInfo( + no=int(row.get('NO', '0')), + file_name=row.get('ファイル名', ''), + sort_condition=row.get('ソート条件(キー項目)', ''), + key_condition=row.get('キー条件(マッチング/キーブレイク)', '') + ) + meta.keys.append(k) + except (ValueError, KeyError): + continue + + def _parse_modules(self, meta: ProgramMeta): + rows = parse_table_from_section(self._design_text, '使用モジュール一覧') + for row in rows: + try: + m = ModuleInfo( + no=int(row.get('NO', '0')), + function=row.get('機能', ''), + program_id=row.get('プログラムID', ''), + copy_name=row.get('使用COPY名', '') + ) + meta.modules.append(m) + except (ValueError, KeyError): + continue + + def _parse_process_detail(self, meta: ProgramMeta): + meta.process_detail = extract_section(self._design_text, '処理詳細') + + def _parse_output_records(self, meta: ProgramMeta): + meta.output_records = extract_section(self._design_text, '出力レコード定義') + + def _determine_input_type(self, meta: ProgramMeta): + """Determine input type based on input files' medium. + Only looks at files with I/O containing 'I' (input). + """ + input_mediums = set() + for f in meta.files: + if 'I' in f.io: + input_mediums.add(f.medium) + + if not input_mediums: + meta.input_type = 'file' + elif input_mediums == {'PS'}: + meta.input_type = 'file' + elif input_mediums == {'DB'}: + meta.input_type = 'db' + else: + meta.input_type = 'mixed' + + def _parse_copybooks(self, meta: ProgramMeta): + """TODO: 在 Task 4 中实现""" + pass + + def _parse_db_definition(self, meta: ProgramMeta): + """TODO: 在 Task 5 中实现""" + pass +``` + +- [ ] **Step 6: 写 InputParser 设计书解析部分的测试** + +```python +# tests/test_input_parser.py +import os +from agent.input_parser import InputParser + +FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'test_data') + + +def test_parse_design_basic_info(): + parser = InputParser( + design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'), + source_cbl_path='dummy.cbl', + file_db_md_path='dummy.md', + cpy_dir='dummy_cpy', + db_md_path='dummy_db.md' + ) + parser._design_text = parser._read_file(parser.design_md_path) + + from agent.models import ProgramMeta + meta = ProgramMeta(program_id='', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={}) + + parser._parse_basic_info(meta) + assert meta.program_id == 'ZAN04MAT' + assert meta.pgm_pattern == 'マッチング(1:1)' + assert meta.pgm_type == 'メイン' + assert '残業統計管理システム' in meta.system_name + + +def test_parse_use_files(): + parser = InputParser( + design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'), + source_cbl_path='dummy.cbl', file_db_md_path='dummy.md', + cpy_dir='dummy_cpy', db_md_path='dummy_db.md' + ) + parser._design_text = parser._read_file(parser.design_md_path) + + from agent.models import ProgramMeta + meta = ProgramMeta(program_id='', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={}) + + parser._parse_use_files(meta) + assert len(meta.files) == 5 + r01 = [f for f in meta.files if f.identifier == 'R01'][0] + assert r01.file_db_name == 'OVT-SORTED' + assert r01.copy_group == 'ZAN01REC' + assert r01.medium == 'PS' + assert r01.io == 'I' + + +def test_determine_input_type_file(): + parser = InputParser( + design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'), + source_cbl_path='dummy.cbl', file_db_md_path='dummy.md', + cpy_dir='dummy_cpy', db_md_path='dummy_db.md' + ) + + from agent.models import ProgramMeta, FileInfo + meta = ProgramMeta(program_id='ZAN04MAT', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={}) + meta.files = [ + FileInfo(no=1, file_db_name='F1', identifier='R01', dd_name='DD1', + io='I', copy_group='C1', format='FB', record_len=80, + medium='PS', remarks=''), + FileInfo(no=2, file_db_name='F2', identifier='W01', dd_name='DD2', + io='O', copy_group='C2', format='FB', record_len=80, + medium='PS', remarks=''), + ] + parser._determine_input_type(meta) + assert meta.input_type == 'file' + + +def test_determine_input_type_mixed(): + parser = InputParser( + design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'), + source_cbl_path='dummy.cbl', file_db_md_path='dummy.md', + cpy_dir='dummy_cpy', db_md_path='dummy_db.md' + ) + + from agent.models import ProgramMeta, FileInfo + meta = ProgramMeta(program_id='TEST', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={}) + meta.files = [ + FileInfo(no=1, file_db_name='F1', identifier='R01', dd_name='DD1', + io='I', copy_group='C1', format='FB', record_len=80, + medium='PS', remarks=''), + FileInfo(no=2, file_db_name='DB1', identifier='DB', dd_name='', + io='I', copy_group='', format='', record_len=0, + medium='DB', remarks=''), + ] + parser._determine_input_type(meta) + assert meta.input_type == 'mixed' + + +def test_parse_process_detail(): + parser = InputParser( + design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'), + source_cbl_path='dummy.cbl', file_db_md_path='dummy.md', + cpy_dir='dummy_cpy', db_md_path='dummy_db.md' + ) + parser._design_text = parser._read_file(parser.design_md_path) + + from agent.models import ProgramMeta + meta = ProgramMeta(program_id='', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={}) + + parser._parse_process_detail(meta) + assert '1000ITTSOR' in meta.process_detail + assert 'マッチの場合' in meta.process_detail + + +def test_parse_output_records(): + parser = InputParser( + design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'), + source_cbl_path='dummy.cbl', file_db_md_path='dummy.md', + cpy_dir='dummy_cpy', db_md_path='dummy_db.md' + ) + parser._design_text = parser._read_file(parser.design_md_path) + + from agent.models import ProgramMeta + meta = ProgramMeta(program_id='', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={}) + + parser._parse_output_records(meta) + assert 'OVT-MATCHED' in meta.output_records + assert 'OVT-DBCLEAN' in meta.output_records +``` + +- [ ] **Step 7: 运行测试** + +Run: `python -m pytest tests/test_input_parser.py -v` +Expected: all PASS + +--- + +### Task 4: InputParser — COPYBOOK 解析 + +**Files:** +- Modify: `D:\jcl-cobol-data-create\agent\input_parser.py` (添加 `_parse_copybooks`) +- Create: `D:\jcl-cobol-data-create\tests\test_copy_parser.py` + +- [ ] **Step 1: 添加 COPYBOOK 解析逻辑到 InputParser** + +在 `agent/input_parser.py` 的 `_parse_copybooks` 方法中追加实现,替换之前的 `pass`: + +```python +def _parse_copybooks(self, meta: ProgramMeta): + """解析 COPYBOOK 文件,应用 REPLACING 替换。""" + # Step 1: 从源码提取 COPY REPLACING 映射 + replacing_map = self._extract_copy_replacing() + + # Step 2: 遍历使用ファイル一覧 中的文件,找到对应的 COPYBOOK + for f in meta.files: + if not f.copy_group: + continue + + copy_file = os.path.join(self.cpy_dir, f.copy_group + '.cpy') + if not os.path.exists(copy_file): + continue + + # 从 REPLACING 映射获取替换前缀 + prefix = replacing_map.get(f.copy_group, f.identifier) + + fields = self._parse_single_copybook(copy_file, prefix) + if f.identifier not in meta.copy_fields: + meta.copy_fields[f.identifier] = fields + + +def _extract_copy_replacing(self) -> Dict[str, str]: + """从 COBOL 源码提取 COPY ... REPLACING ... 映射。 + Returns: {copy_name: replacing_value} e.g. {'ZAN01REC': 'R01'} + """ + result = {} + # 匹配模式: COPY ZAN01REC REPLACING ==(A)== BY ==R01==. + pattern = r'COPY\s+(\S+)\s+REPLACING\s+==\(A\)==\s+BY\s+==(\S+)==' + for match in re.finditer(pattern, self._source_text, re.IGNORECASE): + copy_name = match.group(1) + replacing_value = match.group(2) + result[copy_name] = replacing_value + return result + + +def _parse_single_copybook(self, copy_path: str, prefix: str) -> List[CopyField]: + """解析单个 COPYBOOK 文件,将 (A) 占位符替换为 'prefix-' 前缀。 + e.g. (A)APPL-ID + prefix='R01' → R01-APPL-ID + """ + copy_text = self._read_file(copy_path) + fields = [] + + for line in copy_text.split('\n'): + line = line.strip() + if not line or line.startswith('*'): + continue + + # 匹配: 03 (A)FIELD-NAME PIC X(008). + # 或: 03 (A)FIELD-NAME PIC S9(009) COMP-3. + match = re.match( + r'(\d{2})\s+\(A\)(\S+)\s+PIC\s+(.+?)\.', + line, re.IGNORECASE + ) + if not match: + continue + + level = int(match.group(1)) + raw_name = '(A)' + match.group(2) + pic_full = match.group(3).strip() + + # 替换占位符 + actual_name = prefix + '-' + match.group(2) + + pic_bytes = self._calculate_pic_bytes(pic_full) + + fields.append(CopyField( + level=level, + name=actual_name, + raw_name=raw_name, + pic_type=pic_full, + pic_bytes=pic_bytes + )) + + return fields + + +@staticmethod +def _calculate_pic_bytes(pic: str) -> int: + """Calculate byte size from PIC definition. + Handles: X(n), 9(n), S9(n)V9(m), COMP-3 variants, COMP variants. + """ + pic_upper = pic.upper().strip() + + # COMP-3 packed decimal: S9(n)V9(m) COMP-3 or S9(n) COMP-3 or 9(n) COMP-3 + if 'COMP-3' in pic_upper: + # Remove COMP-3 suffix + base = pic_upper.replace('COMP-3', '').strip() + # Extract digits: count all 9s + total_digits = sum(int(num) for num in re.findall(r'9\((\d+)\)', base)) + if base.startswith('S'): + return (total_digits + 2) // 2 + else: + return (total_digits + 1) // 2 + + # COMP / BINARY: S9(n) COMP, S9(n)V9(m) COMP + if 'COMP' in pic_upper or 'BINARY' in pic_upper: + base = re.sub(r'\s*(COMP|BINARY)\s*', '', pic_upper).strip() + total_digits = sum(int(num) for num in re.findall(r'9\((\d+)\)', base)) + if total_digits <= 4: + return 2 + elif total_digits <= 9: + return 4 + else: + return 8 + + # Simple PIC: X(n), 9(n), S9(n), S9(n)V9(m) + total = 0 + # Match X(n) + for num in re.findall(r'X\((\d+)\)', pic_upper): + total += int(num) + # Match G(n) (double-byte) + for num in re.findall(r'G\((\d+)\)', pic_upper): + total += int(num) * 2 + # Match 9(n) + for num in re.findall(r'9\((\d+)\)', pic_upper): + total += int(num) + if total > 0: + return total + + return 0 +``` + +- [ ] **Step 2: 创建 COPYBOOK 解析测试的 fixture** + +将实际 COPYBOOK 文件复制到测试目录: + +```powershell +Copy-Item "D:\cobol-tna-system\cpy\ZAN01REC.cpy" "D:\jcl-cobol-data-create\tests\test_data\ZAN01REC.cpy" +Copy-Item "D:\cobol-tna-system\cpy\ZAN02REC.cpy" "D:\jcl-cobol-data-create\tests\test_data\ZAN02REC.cpy" +Copy-Item "D:\cobol-tna-system\cpy\ZAN04REC.cpy" "D:\jcl-cobol-data-create\tests\test_data\ZAN04REC.cpy" +``` + +- [ ] **Step 3: 写 COPYBOOK 解析测试** + +```python +# tests/test_copy_parser.py +import os +import tempfile +from agent.input_parser import InputParser + +FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'test_data') + + +def test_parse_single_copybook(): + parser = InputParser( + design_md_path='dummy.md', source_cbl_path='dummy.cbl', + file_db_md_path='dummy.md', cpy_dir=FIXTURE_DIR, + db_md_path='dummy_db.md' + ) + fields = parser._parse_single_copybook( + os.path.join(FIXTURE_DIR, 'ZAN01REC.cpy'), + prefix='R01' + ) + + assert len(fields) == 8 + + # 第一个字段: (A)APPL-ID → R01-APPL-ID, PIC X(008) + f0 = fields[0] + assert f0.name == 'R01-APPL-ID' + assert f0.raw_name == '(A)APPL-ID' + assert 'X' in f0.pic_type + assert f0.pic_bytes == 8 + + # EMP-ID: PIC 9(008) + f1 = fields[1] + assert f1.name == 'R01-EMP-ID' + assert '9' in f1.pic_type + assert f1.pic_bytes == 8 + + # START-TIME: PIC 9(004) + f3 = fields[3] + assert f3.name == 'R01-START-TIME' + assert f3.pic_bytes == 4 + + # FILLER + f7 = fields[7] + assert 'FILLER' in f7.name + assert f7.pic_bytes == 46 + + +def test_extract_copy_replacing(): + source_text = """ + FD R01INNFIL. + 01 R01INNREC. + COPY ZAN01REC REPLACING ==(A)== BY ==R01==. + FD R02INNFIL. + 01 R02INNREC. + COPY ZAN04REC REPLACING ==(A)== BY ==R02==. + """ + + parser = InputParser( + design_md_path='dummy.md', source_cbl_path='dummy.cbl', + file_db_md_path='dummy.md', cpy_dir=FIXTURE_DIR, + db_md_path='dummy_db.md' + ) + parser._source_text = source_text + + result = parser._extract_copy_replacing() + assert result['ZAN01REC'] == 'R01' + assert result['ZAN04REC'] == 'R02' + + +def test_calculate_pic_bytes_x(): + assert InputParser._calculate_pic_bytes('X(008)') == 8 + assert InputParser._calculate_pic_bytes('X(10)') == 10 + + +def test_calculate_pic_bytes_9(): + assert InputParser._calculate_pic_bytes('9(008)') == 8 + assert InputParser._calculate_pic_bytes('9(004)') == 4 + + +def test_calculate_pic_bytes_comp3(): + # S9(009) COMP-3: 9 digits → (9+2)//2 = 5 + assert InputParser._calculate_pic_bytes('S9(009) COMP-3') == 5 + # S9(7) COMP-3: 7 digits → (7+2)//2 = 4 + assert InputParser._calculate_pic_bytes('S9(7) COMP-3') == 4 + # 9(7) COMP-3: 7 digits unsigned → (7+1)//2 = 4 + assert InputParser._calculate_pic_bytes('9(7) COMP-3') == 4 + + +def test_calculate_pic_bytes_comp(): + # S9(4) COMP: <=4 → 2 bytes + assert InputParser._calculate_pic_bytes('S9(4) COMP') == 2 + # S9(9) COMP: <=9 → 4 bytes + assert InputParser._calculate_pic_bytes('S9(9) COMP') == 4 + + +def test_calculate_pic_bytes_decimal(): + # S9(7)V9(2): 7+2=9 bytes + assert InputParser._calculate_pic_bytes('S9(7)V9(2)') == 9 +``` + +- [ ] **Step 4: 运行测试** + +Run: `python -m pytest tests/test_copy_parser.py -v` +Expected: all PASS + +--- + +### Task 5: InputParser — DB 定义解析 + +**Files:** +- Modify: `D:\jcl-cobol-data-create\agent\input_parser.py` (添加 `_parse_db_definition`) +- Create: `D:\jcl-cobol-data-create\tests\test_db_parser.py` + +- [ ] **Step 1: 添加 DB 定义解析逻辑** + +在 `agent/input_parser.py` 的 `_parse_db_definition` 方法中追加实现: + +```python +def _parse_db_definition(self, meta: ProgramMeta): + """解析 DB 定义书 .md,提取表结构信息。 + 只在输入类型涉及 DB 时调用。 + """ + if meta.input_type not in ('db', 'mixed'): + return + + db_text = self._read_file(self.db_md_path) + + # 按 ## 切分每个表定义 + sections = re.split(r'\n## ', db_text) + + for section in sections: + # 提取表名(## 后的第一部分) + table_match = re.match(r'^([\w\-]+)', section) + if not table_match: + continue + table_name = table_match.group(1) + + # 查找 "DB基本情報" 子节 + db_info_text = extract_section('## ' + section, 'DB基本情報') + db_info_rows = parse_table_rows(db_info_text) + + db_id = '' + copy_id = '' + for row in db_info_rows: + db_id = row.get('DB ID', '') or db_id + copy_id = row.get('COPY ID', '') or copy_id + + # 查找 "カラム定義" 子节 + column_text = extract_section('## ' + section, 'カラム定義') + column_rows = parse_table_rows(column_text) + + columns = [] + pk_columns = [] + for row in column_rows: + try: + no = int(row.get('No', '0')) + except ValueError: + continue + + is_pk = row.get('PK', '') == '✓' + nullable = row.get('NULL', 'NULL許可') == 'NULL許可' + + col = TableColumn( + no=no, + name_jp=row.get('項目名', ''), + name_en=row.get('項目名(英字名)', ''), + type=row.get('TYPE', ''), + max_len=row.get('最大長', ''), + decimal_digits=row.get('小数桁', ''), + byte_count=row.get('バイト数', ''), + nullable=nullable, + is_pk=is_pk + ) + columns.append(col) + if is_pk: + pk_columns.append(col.name_en) + + table = TableInfo( + table_name=table_name, + db_id=db_id, + copy_id=copy_id, + columns=columns, + pk_columns=pk_columns + ) + meta.db_tables[table_name] = table +``` + +- [ ] **Step 2: 创建 DB 测试 fixture** + +```powershell +Copy-Item "D:\cobol-tna-system\詳細設計書\DB定義書.md" "D:\jcl-cobol-data-create\tests\test_data\DB定義書.md" +``` + +- [ ] **Step 3: 写 DB 解析测试** + +```python +# tests/test_db_parser.py +import os +from agent.input_parser import InputParser +from agent.models import ProgramMeta, FileInfo + +FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'test_data') + + +def test_parse_db_definition(): + parser = InputParser( + design_md_path='dummy.md', source_cbl_path='dummy.cbl', + file_db_md_path='dummy.md', cpy_dir='dummy_cpy', + db_md_path=os.path.join(FIXTURE_DIR, 'DB定義書.md') + ) + + meta = ProgramMeta( + program_id='TEST', program_name='テスト', system_name='', + pgm_type='メイン', pgm_pattern='DB更新', + summary_lines=[], prerequisites=[], files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='mixed', # DB 类型才会触发解析 + copy_fields={}, db_tables={} + ) + + parser._parse_db_definition(meta) + + assert len(meta.db_tables) > 0 + assert 'EMP_MASTER' in meta.db_tables + + emp = meta.db_tables['EMP_MASTER'] + assert emp.db_id == 'EMP_MASTER' + assert len(emp.columns) >= 3 + assert emp.pk_columns == ['EMP_ID'] + + +def test_parse_db_skipped_for_file_type(): + parser = InputParser( + design_md_path='dummy.md', source_cbl_path='dummy.cbl', + file_db_md_path='dummy.md', cpy_dir='dummy_cpy', + db_md_path=os.path.join(FIXTURE_DIR, 'DB定義書.md') + ) + + meta = ProgramMeta( + program_id='TEST', program_name='テスト', system_name='', + pgm_type='メイン', pgm_pattern='マッチング(1:1)', + summary_lines=[], prerequisites=[], files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', # 文件类型,跳过 DB 解析 + copy_fields={}, db_tables={} + ) + + parser._parse_db_definition(meta) + assert len(meta.db_tables) == 0 +``` + +- [ ] **Step 4: 运行测试** + +Run: `python -m pytest tests/test_db_parser.py -v` +Expected: all PASS + +--- + +### Task 6: RuleLoader — 规则匹配与特殊功能检测 + +**Files:** +- Create: `D:\jcl-cobol-data-create\agent\rule_loader.py` +- Create: `D:\jcl-cobol-data-create\tests\test_rule_loader.py` + +- [ ] **Step 1: 实现 RuleLoader** + +```python +# agent/rule_loader.py +import os +import re +from typing import List, Tuple, Optional + +from agent.models import ProgramMeta + + +# PGMパターン → 规则文件名 映射表 +PGM_PATTERN_MAP = { + 'マッチング(1:1)': 'マッチング(1-1).md', + 'マッチング(1:N)': 'マッチング(1-N).md', + 'マッチング(N:1)': 'マッチング(N-1).md', + 'マッチング(M:N)': 'マッチング(M-N).md', + 'レイアウト編集のみ(GETPUT)': 'レイアウト編集のみ(GETPUT).md', + 'レイアウト編集のみ': 'レイアウト編集のみ(GETPUT).md', + '項目チェック': '項目チェック.md', + '振り分け': '振り分け.md', + '振り分け(IF文、EVALUATE文)': '振り分け.md', + 'キーブレイク': 'キーブレイク.md', + 'キーブレイク(集計、集約)': 'キーブレイク(集計、集約).md', + 'キーブレイク(集計、集約の以外)': 'キーブレイク.md', + 'DB更新': 'DB更新.md', +} + +# 特殊功能检测:关键词集合 → 规则文件名 +SPECIAL_FEATURE_CHECKS = [ + (['場合', 'EVALUATE', 'IF'], '条件分支.md'), +] + + +class RuleLoader: + """根据程序特征加载对应的数据生成规则。""" + + def __init__(self, rules_dir: str): + """ + Args: + rules_dir: 规则根目录,包含 pgm_pattern/ 和 special_feature/ 子目录 + """ + self.pgm_pattern_dir = os.path.join(rules_dir, 'pgm_pattern') + self.special_feature_dir = os.path.join(rules_dir, 'special_feature') + + def load(self, meta: ProgramMeta) -> Tuple[str, List[str], int]: + """加载所有相关规则,返回 (合并后的规则文本, 组描述列表, 组数)。 + + Returns: + combined_rules: 合并后的规则文本 + group_descriptions: 各组的用途描述列表 + group_count: 组数 + """ + # 1. 加载 PGM 模式规则 + pgm_rule = self._load_pgm_pattern_rule(meta.pgm_pattern) + if pgm_rule is None: + raise FileNotFoundError( + f"PGM模式 '{meta.pgm_pattern}' 的规则文件未找到。" + f"请在 {self.pgm_pattern_dir} 中添加对应的 .md 文件。" + ) + + # 2. 解析组信息 + group_descriptions, group_count = self._parse_group_info(pgm_rule) + + # 3. 检测特殊功能 + parts = [pgm_rule] + for keywords, rule_file in SPECIAL_FEATURE_CHECKS: + if self._detect_feature(meta.process_detail, keywords): + feature_rule = self._read_rule_file( + os.path.join(self.special_feature_dir, rule_file) + ) + if feature_rule: + parts.append(feature_rule) + + combined = '\n\n---\n\n'.join(parts) + return combined, group_descriptions, group_count + + def _load_pgm_pattern_rule(self, pgm_pattern: str) -> Optional[str]: + """根据 PGMパターン 加载对应的规则文件。""" + # 先精确匹配 + filename = PGM_PATTERN_MAP.get(pgm_pattern) + if filename: + path = os.path.join(self.pgm_pattern_dir, filename) + if os.path.exists(path): + return self._read_rule_file(path) + + # 尝试模糊匹配:检查文件名是否包含关键字 + for fname in os.listdir(self.pgm_pattern_dir): + if fname.endswith('.md'): + # 尝试模糊匹配 + keyword = pgm_pattern.split('(')[0].strip() + if keyword in fname: + return self._read_rule_file(os.path.join(self.pgm_pattern_dir, fname)) + + return None + + @staticmethod + def _read_rule_file(path: str) -> str: + with open(path, 'r', encoding='utf-8') as f: + return f.read() + + @staticmethod + def _parse_group_info(rule_text: str) -> Tuple[List[str], int]: + """从规则文本中解析组信息。 + 查找表格中 '组' 列对应的行,提取组号和用途。 + """ + descriptions = [] + group_count = 0 + + # 匹配规则中的组描述表格 + # 格式: | 组 | 用途 | ... | + for line in rule_text.split('\n'): + line = line.strip() + if line.startswith('|') and not re.match(r'^\|[\s\-:]+\|', line): + cells = [c.strip() for c in line.split('|')[1:-1]] + if len(cells) >= 2: + try: + group_num = int(cells[0]) + if group_num > group_count: + group_count = group_num + descriptions.append(cells[1]) + except ValueError: + pass + + return descriptions, group_count + + @staticmethod + def _detect_feature(process_detail: str, keywords: List[str]) -> bool: + """检测処理詳細中是否包含特定关键词。""" + for kw in keywords: + if kw in process_detail: + return True + return False +``` + +- [ ] **Step 2: 写 RuleLoader 测试** + +```python +# tests/test_rule_loader.py +import os +from agent.rule_loader import RuleLoader, PGM_PATTERN_MAP +from agent.models import ProgramMeta + +RULES_DIR = os.path.join(os.path.dirname(__file__), '..', 'rules') + + +def test_pgm_pattern_map_has_known_patterns(): + assert 'マッチング(1:1)' in PGM_PATTERN_MAP + assert 'マッチング(1:N)' in PGM_PATTERN_MAP + assert 'DB更新' in PGM_PATTERN_MAP + + +def test_load_matching_1_1_rule(): + loader = RuleLoader(RULES_DIR) + meta = ProgramMeta( + program_id='TEST', program_name='', system_name='', + pgm_type='メイン', pgm_pattern='マッチング(1:1)', + summary_lines=[], prerequisites=[], files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={} + ) + combined, descriptions, count = loader.load(meta) + + assert count > 0 + assert len(combined) > 0 + assert 'マッチング(1:1)' in combined + assert '数据生成步骤' in combined + + +def test_load_matching_1_n_rule(): + loader = RuleLoader(RULES_DIR) + meta = ProgramMeta( + program_id='TEST', program_name='', system_name='', + pgm_type='メイン', pgm_pattern='マッチング(1:N)', + summary_lines=[], prerequisites=[], files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={} + ) + combined, descriptions, count = loader.load(meta) + + assert count > 0 + assert len(combined) > 0 + assert 'マッチング(1:N)' in combined + + +def test_load_nonexistent_pattern_raises(): + loader = RuleLoader(RULES_DIR) + meta = ProgramMeta( + program_id='TEST', program_name='', system_name='', + pgm_type='メイン', pgm_pattern='不存在的パターン', + summary_lines=[], prerequisites=[], files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={} + ) + try: + loader.load(meta) + assert False, 'Should have raised FileNotFoundError' + except FileNotFoundError: + pass + + +def test_detect_conditional_branch(): + process = """ + 2-1-2.ロジック分岐判定(EVALUATE) + 2-1-2-1.STATUS='1'の場合 + INSERT処理 + """ + assert RuleLoader._detect_feature(process, ['場合', 'EVALUATE', 'IF']) is True + + +def test_no_conditional_branch(): + process = """ + 2-1.マッチの場合 + R01をW01に出力 + """ + # '場合' 也匹配了! 日文中 'の場合' 表示 "在...情况下",视为条件分支 + assert RuleLoader._detect_feature(process, ['場合', 'EVALUATE', 'IF']) is True + + +def test_rule_with_conditional_feature(): + loader = RuleLoader(RULES_DIR) + meta = ProgramMeta( + program_id='TEST', program_name='', system_name='', + pgm_type='メイン', pgm_pattern='マッチング(1:1)', + summary_lines=[], prerequisites=[], files=[], keys=[], modules=[], + process_detail='2-1-2.EVALUATEで分岐する。STATUS=1の場合INSERTする。', + output_records='', + input_type='file', copy_fields={}, db_tables={} + ) + combined, descriptions, count = loader.load(meta) + + assert count > 0 + # 条件分支规则应该被包含 + assert '分支' in combined or '条件分支' in combined or '条件判断' in combined +``` + +- [ ] **Step 3: 运行测试** + +Run: `python -m pytest tests/test_rule_loader.py -v` +Expected: all PASS + +--- + +### Task 7: PromptBuilder — Prompt 组装 + +**Files:** +- Create: `D:\jcl-cobol-data-create\agent\prompt_builder.py` +- Create: `D:\jcl-cobol-data-create\tests\test_prompt_builder.py` + +- [ ] **Step 1: 实现 PromptBuilder** + +```python +# agent/prompt_builder.py +from typing import List + +from agent.models import ProgramMeta, CopyField, TableInfo + + +class PromptBuilder: + """将解析后的程序元数据和规则组装成 API prompt。""" + + def build(self, meta: ProgramMeta, rules_text: str, + group_descriptions: List[str], group_count: int) -> str: + """构建完整的 API prompt。""" + parts = [] + + # 1. 程序基本情報 + parts.append(self._build_basic_info(meta)) + + # 2. 処理詳細 + parts.append(self._build_process_detail(meta)) + + # 3. 入力構造 (文件 + DB) + parts.append(self._build_input_structures(meta)) + + # 4. 出力レコード定義 + parts.append(self._build_output_records(meta)) + + # 5. データ生成ルール + parts.append(rules_text) + + # 6. 出力格式指示 + parts.append(self._build_output_format(meta)) + + # 7. 生成指示 + parts.append(self._build_generation_instruction( + group_descriptions, group_count, meta.input_type + )) + + return '\n\n'.join(parts) + + def _build_basic_info(self, meta: ProgramMeta) -> str: + lines = [ + f"## 程序基本情報", + f"- システム名: {meta.system_name}", + f"- プログラムID: {meta.program_id}", + f"- プログラム名: {meta.program_name}", + f"- PGMパターン: {meta.pgm_pattern}", + f"- 入力タイプ: {self._input_type_label(meta.input_type)}", + ] + if meta.summary_lines: + lines.append(f"- 機能概要: {' '.join(meta.summary_lines)}") + return '\n'.join(lines) + + def _build_process_detail(self, meta: ProgramMeta) -> str: + return f"## 処理詳細\n\n```\n{meta.process_detail}\n```" + + def _build_input_structures(self, meta: ProgramMeta) -> str: + parts = ["## 入力構造"] + + # 文件输入结构 + input_files = [f for f in meta.files if 'I' in f.io] + for f in input_files: + parts.append(f"### ファイル {f.identifier} (DD名: {f.dd_name}, COPY: {f.copy_group}, 媒体: {f.medium})") + fields = meta.copy_fields.get(f.identifier, []) + if fields: + parts.append("| 項目名 | PIC | バイト数 |") + parts.append("|--------|-----|----------|") + for cf in fields: + parts.append(f"| {cf.name} | {cf.pic_type} | {cf.pic_bytes} |") + else: + parts.append("(構造情報なし)") + + # DB 表结构 + db_inputs = [f for f in meta.files if 'I' in f.io and f.medium == 'DB'] + if db_inputs or meta.db_tables: + parts.append("### DBテーブル構造") + for table_name, table in meta.db_tables.items(): + parts.append(f"**表名: {table_name}**") + parts.append(f"主キー: {', '.join(table.pk_columns)}") + parts.append("| 項目名 | 英字名 | タイプ | 最大長 | KEY |") + parts.append("|--------|--------|--------|--------|-----|") + for col in table.columns: + key_mark = '✓' if col.is_pk else '' + parts.append(f"| {col.name_jp} | {col.name_en} | {col.type} | {col.max_len} | {key_mark} |") + + return '\n\n'.join(parts) + + def _build_output_records(self, meta: ProgramMeta) -> str: + return f"## 出力レコード定義\n\n```\n{meta.output_records}\n```" + + def _build_output_format(self, meta: ProgramMeta) -> str: + lines = [ + "## 出力形式", + "", + "### JSON形式(ファイル入力の場合)", + f"各グループ1つのJSONファイル: `{{program}}_{{group}}.json`", + "", + "JSON構造:", + "```json", + "{", + ' "program": "{プログラムID}",', + ' "records": [', + ' { "input": { "FD名": { "項目名": "値", ... } } },', + ' ...', + ' ]', + "}", + "```", + "", + "### 項目値のルール(PIC → JSON値)", + "| PIC | JSON内表示 | 例 |", + "|-----|-----------|-----|", + "| PIC X(n) | 左詰め + スペース埋め | `\"A0000001\"` |", + "| PIC 9(n) | 右詰め + 先行ゼロ | `\"00000101\"` |", + "| PIC S9(n) | 符号 + 右詰め + 先行ゼロ | `\"+0000101\"` |", + "| PIC S9(n)V9(m) | 符号 + 右詰め + 小数点含む | `\"+001234567\"` |", + "| PIC S9(n) COMP | 通常の10進数文字列 | `\"300\"` |", + "| PIC S9(n) COMP-3 | 通常の10進数文字列 | `\"1234\"` |", + "| PIC 9(n) COMP-3 | 通常の10進数文字列 | `\"1234\"` |", + "| FILLER(純粋予約) | グループIDとレコード番号を含む識別可能なパターン | `\"D000000...001\"` |", + "| FILLER(業務予約) | 全スペース(PIC X) または 全ゼロ(PIC 9) | |", + "", + "### SQL形式(DB入力の場合)", + "各グループ1つのSQLファイル: `{program}_g{groupId}.sql`", + "", + "SQL例:", + "```sql", + "-- Group: 1", + "INSERT INTO TABLE_NAME (COL1, COL2) VALUES", + "('val1', 'val2');", + "```", + "", + "### データ生成の注意", + "- 項目名に意味がある場合(DATE→日付、NAME→氏名、ADDRESS→住所等)、実際の形式に合った値を生成すること", + "- 隣接するレコード間で、できるだけ同じ項目に異なる値を設定すること", + ] + return '\n'.join(lines) + + def _build_generation_instruction(self, descriptions: List[str], + count: int, input_type: str) -> str: + lines = [ + "## 生成指示", + f"生成するグループ数: {count}", + "各グループの内容:", + ] + for i, desc in enumerate(descriptions, 1): + lines.append(f" - g{i}: {desc}") + + if input_type == 'file': + lines.append("出力: JSONファイル") + elif input_type == 'db': + lines.append("出力: SQL INSERTファイル") + else: + lines.append("出力: JSONファイル + SQL INSERTファイル(混合)") + + lines.append("") + lines.append("すべてのグループのデータを一度にJSONで返してください。") + lines.append("出力形式: { \"groups\": { \"g1\": { \"type\": \"json\"|... }, ... } }") + return '\n'.join(lines) + + @staticmethod + def _input_type_label(t: str) -> str: + labels = {'file': 'ファイル', 'db': 'DB', 'mixed': '混合(ファイル+DB)'} + return labels.get(t, t) +``` + +- [ ] **Step 2: 写 PromptBuilder 测试** + +```python +# tests/test_prompt_builder.py +from agent.prompt_builder import PromptBuilder +from agent.models import ProgramMeta, FileInfo, CopyField + + +def test_build_basic_prompt(): + meta = ProgramMeta( + program_id='ZAN04MAT', program_name='取消マッチング処理', + system_name='残業統計管理システム', + pgm_type='メイン', pgm_pattern='マッチング(1:1)', + summary_lines=['取消申請のマッチング'], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='1.初期処理...\n2.主処理...', + output_records='### 出力ファイル1...', + input_type='file', copy_fields={}, db_tables={} + ) + + builder = PromptBuilder() + prompt = builder.build( + meta=meta, + rules_text='# マッチング(1:1) データ生成規則\n...', + group_descriptions=['両端不一致', '逆方向両端不一致', '中間不一致'], + group_count=3 + ) + + assert 'ZAN04MAT' in prompt + assert 'マッチング(1:1)' in prompt + assert '1.初期処理' in prompt + assert '生成するグループ数: 3' in prompt + assert 'g1: 両端不一致' in prompt + assert 'g2: 逆方向両端不一致' in prompt + assert 'g3: 中間不一致' in prompt + assert 'JSON形式' in prompt + + +def test_build_with_copy_fields(): + fields = { + 'R01': [ + CopyField(level=3, name='R01-APPL-ID', raw_name='(A)APPL-ID', + pic_type='X(008)', pic_bytes=8), + CopyField(level=3, name='R01-EMP-ID', raw_name='(A)EMP-ID', + pic_type='9(008)', pic_bytes=8), + ] + } + + meta = ProgramMeta( + program_id='TEST', program_name='', system_name='', + pgm_type='メイン', pgm_pattern='マッチング(1:1)', + summary_lines=[], prerequisites=[], + files=[ + FileInfo(no=1, file_db_name='INPUT-FILE', identifier='R01', + dd_name='TESTR01', io='I', copy_group='TESTREC', + format='FB', record_len=80, medium='PS', remarks='') + ], + keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields=fields, db_tables={} + ) + + builder = PromptBuilder() + prompt = builder.build( + meta=meta, + rules_text='# 規則', + group_descriptions=['テスト'], + group_count=1 + ) + + assert 'R01-APPL-ID' in prompt + assert 'X(008)' in prompt + assert '8' in prompt + assert 'R01-EMP-ID' in prompt + + +def test_build_db_prompt(): + meta = ProgramMeta( + program_id='TESTDB', program_name='DB更新', system_name='', + pgm_type='メイン', pgm_pattern='DB更新', + summary_lines=[], prerequisites=[], + files=[ + FileInfo(no=1, file_db_name='INPUT-FILE', identifier='R01', + dd_name='TESTR01', io='I', copy_group='TESTREC', + format='FB', record_len=80, medium='DB', remarks='') + ], + keys=[], modules=[], + process_detail='', output_records='', + input_type='db', copy_fields={}, db_tables={} + ) + + builder = PromptBuilder() + prompt = builder.build( + meta=meta, + rules_text='# DB更新規則', + group_descriptions=['テスト'], + group_count=1 + ) + + assert 'DB' in prompt or 'SQL' in prompt + assert 'INSERT' in prompt +``` + +- [ ] **Step 3: 运行测试** + +Run: `python -m pytest tests/test_prompt_builder.py -v` +Expected: all PASS + +--- + +### Task 8: APIClient — API 调用与重试 + +**Files:** +- Create: `D:\jcl-cobol-data-create\agent\api_client.py` +- Create: `D:\jcl-cobol-data-create\tests\test_api_client.py` + +- [ ] **Step 1: 实现 APIClient** + +```python +# agent/api_client.py +import json +import time +from typing import Dict, Any, Optional + +import requests + + +class APIClient: + """DeepSeek API 客户端,含重试逻辑。""" + + def __init__(self, api_key: str, model: str = 'deepseek-v4-flash', + base_url: str = 'https://api.deepseek.com/chat/completions', + max_retries: int = 3, timeout: int = 120): + self.api_key = api_key + self.model = model + self.base_url = base_url + self.max_retries = max_retries + self.timeout = timeout + + def generate(self, prompt: str) -> Dict[str, Any]: + """发送 prompt 并返回 AI 生成的结果。 + + Returns: + AI 返回的 JSON 字典 + Raises: + RuntimeError: 超过最大重试次数后仍然失败 + """ + system_prompt = ( + "你是COBOL程序的测试数据生成专家。" + "请严格按照提供的规则,生成符合格式要求的测试数据。" + "输出必须是可被json.loads()直接解析的JSON,不要包裹在```json```代码块中。" + "不要在JSON前后添加任何说明文字。" + ) + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt}, + ] + + last_error = None + + for attempt in range(1, self.max_retries + 1): + try: + response = self._call_api(messages) + + # 提取 AI 返回的文本 + content = response['choices'][0]['message']['content'] + + # 尝试解析 JSON + data = self._parse_json(content) + if data is not None: + return data + + # JSON 解析失败,反馈给 AI 重试 + error_msg = ( + f"前回の出力は有効なJSONではありませんでした。" + f"必ず有効なJSONのみを出力してください。" + f"コードブロック(```)で囲まないでください。" + ) + messages.append({"role": "assistant", "content": content}) + messages.append({"role": "user", "content": error_msg}) + + except requests.exceptions.RequestException as e: + last_error = e + if attempt < self.max_retries: + time.sleep(2 ** attempt) # 指数退避 + continue + + raise RuntimeError( + f"API调用失败,已重试{self.max_retries}次。" + f"最后错误: {last_error}" + ) + + def _call_api(self, messages: list) -> dict: + """单次 API 调用。""" + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json', + } + payload = { + 'model': self.model, + 'messages': messages, + 'temperature': 0.3, + 'max_tokens': 8192, + } + + resp = requests.post( + self.base_url, + headers=headers, + json=payload, + timeout=self.timeout + ) + resp.raise_for_status() + return resp.json() + + @staticmethod + def _parse_json(text: str) -> Optional[dict]: + """尝试从文本中提取 JSON。""" + text = text.strip() + + # 去除可能的 markdown 代码块包裹 + if text.startswith('```json'): + text = text[7:] + if text.startswith('```'): + text = text[3:] + if text.endswith('```'): + text = text[:-3] + text = text.strip() + + try: + return json.loads(text) + except json.JSONDecodeError: + # 尝试找到第一个 { 和最后一个 } + start = text.find('{') + end = text.rfind('}') + if start >= 0 and end > start: + try: + return json.loads(text[start:end + 1]) + except json.JSONDecodeError: + pass + return None +``` + +- [ ] **Step 2: 写 APIClient 测试(mock API)** + +```python +# tests/test_api_client.py +import json +from unittest.mock import patch, MagicMock +from agent.api_client import APIClient + + +def test_parse_json_valid(): + text = '{"groups": {"g1": {"records": []}}}' + result = APIClient._parse_json(text) + assert result is not None + assert 'groups' in result + + +def test_parse_json_with_markdown_wrapper(): + text = '```json\n{"key": "value"}\n```' + result = APIClient._parse_json(text) + assert result is not None + assert result['key'] == 'value' + + +def test_parse_json_with_text_before(): + text = '少し説明があります。\n{"key": "value"}\n以上です。' + result = APIClient._parse_json(text) + assert result is not None + assert result['key'] == 'value' + + +def test_parse_json_invalid(): + text = 'これは有効なJSONではありません。' + result = APIClient._parse_json(text) + assert result is None + + +@patch('agent.api_client.requests.post') +def test_generate_success(mock_post): + mock_response = MagicMock() + mock_response.json.return_value = { + 'choices': [{'message': {'content': '{"groups": {"g1": {"type": "json"}}}'}}] + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + client = APIClient(api_key='test-key') + result = client.generate("テストプロンプト") + + assert result['groups']['g1']['type'] == 'json' + + +@patch('agent.api_client.requests.post') +def test_generate_retry_on_json_error(mock_post): + # 第一次返回无效 JSON,第二次返回有效 JSON + bad_response = MagicMock() + bad_response.json.return_value = { + 'choices': [{'message': {'content': '無効な応答'}}] + } + bad_response.raise_for_status = MagicMock() + + good_response = MagicMock() + good_response.json.return_value = { + 'choices': [{'message': {'content': '{"result": "ok"}'}}] + } + good_response.raise_for_status = MagicMock() + + mock_post.side_effect = [bad_response, good_response] + + client = APIClient(api_key='test-key', max_retries=3) + result = client.generate("テスト") + + assert result['result'] == 'ok' + assert mock_post.call_count == 2 # 调用了2次 +``` + +- [ ] **Step 3: 运行测试** + +Run: `python -m pytest tests/test_api_client.py -v` +Expected: all PASS + +--- + +### Task 9: OutputWriter — 输出写入 + +**Files:** +- Create: `D:\jcl-cobol-data-create\agent\output_writer.py` +- Create: `D:\jcl-cobol-data-create\tests\test_output_writer.py` + +- [ ] **Step 1: 实现 OutputWriter** + +```python +# agent/output_writer.py +import json +import os +from typing import Dict, Any + + +class OutputWriter: + """将 AI 生成的数据写入文件系统。""" + + def __init__(self, output_dir: str): + self.output_dir = output_dir + + def write(self, program_id: str, ai_result: Dict[str, Any], + input_type: str) -> Dict[str, str]: + """写入所有组的输出文件。 + + Args: + program_id: 程序ID + ai_result: AI 返回的 JSON 数据,格式: {"groups": {"g1": {...}, ...}} + input_type: "file", "db", "mixed" + + Returns: + {group_folder: written_file_path} 映射 + """ + written = {} + groups = ai_result.get('groups', ai_result) # 兼容 {"records": [...]} 直接格式 + + for group_key in sorted(groups.keys()): + group_data = groups[group_key] + group_dir = os.path.join(self.output_dir, program_id, group_key) + os.makedirs(group_dir, exist_ok=True) + + if input_type in ('file', 'mixed'): + json_path = self._write_json(group_dir, program_id, group_key, group_data) + written[f"{group_key}/json"] = json_path + + if input_type in ('db', 'mixed'): + sql_path = self._write_sql(group_dir, program_id, group_key, group_data) + written[f"{group_key}/sql"] = sql_path + + return written + + def _write_json(self, group_dir: str, program_id: str, + group_key: str, data: Any) -> str: + """写入 JSON 文件。""" + filename = f"{program_id}_{group_key}.json" + filepath = os.path.join(group_dir, filename) + + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + return filepath + + def _write_sql(self, group_dir: str, program_id: str, + group_key: str, data: Any) -> str: + """写入 SQL 文件。""" + filename = f"{program_id}_{group_key}.sql" + filepath = os.path.join(group_dir, filename) + + # 如果 data 中有 sql 字段,直接使用;否则将整个 data 写入 + sql_content = data if isinstance(data, str) else data.get('sql', json.dumps(data, ensure_ascii=False)) + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(sql_content) + + return filepath +``` + +- [ ] **Step 2: 写 OutputWriter 测试** + +```python +# tests/test_output_writer.py +import json +import os +import tempfile +from agent.output_writer import OutputWriter + + +def test_write_json_files(): + with tempfile.TemporaryDirectory() as tmpdir: + writer = OutputWriter(tmpdir) + + ai_result = { + "groups": { + "g1": { + "program": "TEST", + "records": [ + {"input": {"R01": {"R01-FIELD": "A001"}}} + ] + } + } + } + + written = writer.write("TESTPGM", ai_result, input_type="file") + + assert len(written) == 1 + json_path = written["g1/json"] + assert os.path.exists(json_path) + + with open(json_path, 'r', encoding='utf-8') as f: + content = json.load(f) + assert content['program'] == 'TEST' + assert len(content['records']) == 1 + + +def test_write_mixed_type(): + with tempfile.TemporaryDirectory() as tmpdir: + writer = OutputWriter(tmpdir) + + ai_result = { + "groups": { + "g1": { + "program": "MIXED", + "records": [{"input": {"R01": {"F1": "X"}}}], + "sql": "INSERT INTO T VALUES ('x');" + } + } + } + + written = writer.write("MIXEDPGM", ai_result, input_type="mixed") + + assert len(written) == 2 + assert os.path.exists(written["g1/json"]) + assert os.path.exists(written["g1/sql"]) + + +def test_write_multiple_groups(): + with tempfile.TemporaryDirectory() as tmpdir: + writer = OutputWriter(tmpdir) + + ai_result = { + "groups": { + "g1": {"records": [{}]}, + "g2": {"records": [{}]}, + "g3": {"records": [{}]}, + } + } + + written = writer.write("MULTI", ai_result, input_type="file") + + assert len(written) == 3 + for g in ['g1', 'g2', 'g3']: + assert f'{g}/json' in written + assert os.path.exists(written[f'{g}/json']) + + +def test_directory_structure(): + with tempfile.TemporaryDirectory() as tmpdir: + writer = OutputWriter(tmpdir) + ai_result = {"groups": {"g1": {"records": [{}]}}} + writer.write("MYPROG", ai_result, input_type="file") + + expected_dir = os.path.join(tmpdir, "MYPROG", "g1") + assert os.path.isdir(expected_dir) + assert os.path.isfile(os.path.join(expected_dir, "MYPROG_g1.json")) +``` + +- [ ] **Step 3: 运行测试** + +Run: `python -m pytest tests/test_output_writer.py -v` +Expected: all PASS + +--- + +### Task 10: main.py — CLI 入口与集成测试 + +**Files:** +- Create: `D:\jcl-cobol-data-create\main.py` +- Create: `D:\jcl-cobol-data-create\tests\test_integration.py` + +- [ ] **Step 1: 确认 agent/__init__.py 导出 generate 函数** + +更新 `D:\jcl-cobol-data-create\agent\__init__.py`: + +```python +# agent/__init__.py +__version__ = "0.1.0" + +from agent.input_parser import InputParser +from agent.rule_loader import RuleLoader +from agent.prompt_builder import PromptBuilder +from agent.api_client import APIClient +from agent.output_writer import OutputWriter +from agent.models import ProgramMeta, FileInfo, CopyField, KeyInfo, TableColumn, TableInfo + + +def generate(design_md: str, source_cbl: str, file_db_md: str, + cpy_dir: str, db_md: str, output_dir: str = "output", + api_key: str = "sk-6156cccdc9c14d949cf5bfc5afc67a03", + api_model: str = "deepseek-v4-flash", + rules_dir: str = "rules") -> dict: + """生成测试数据的主入口函数。 + + Args: + design_md: 詳細設計書 .md 路径 + source_cbl: COBOL 源码 .cbl 路径 + file_db_md: 文件/DB 构造 .md 路径 + cpy_dir: COPYBOOK 存放目录路径 + db_md: DB 定义书 .md 路径 + output_dir: 输出目录 + api_key: DeepSeek API key + api_model: 模型名称 + rules_dir: 规则目录路径 + + Returns: + {"output_files": {"g1/json": "path", ...}, "program_id": "...", "groups": N} + """ + print(f"== 解析入力: {design_md}") + + # 1. 解析输入 + parser = InputParser(design_md, source_cbl, file_db_md, cpy_dir, db_md) + meta = parser.run() + + if meta.pgm_type == 'サブ': + raise ValueError(f"程序 {meta.program_id} はサブプログラムです。主プログラムのみ処理対象です。") + + print(f" プログラムID: {meta.program_id}, パターン: {meta.pgm_pattern}, 入力タイプ: {meta.input_type}") + + # 2. 加载规则 + loader = RuleLoader(rules_dir) + rules_text, group_descriptions, group_count = loader.load(meta) + + print(f" ルール読み込み完了, グループ数: {group_count}") + + # 3. 构建 prompt + builder = PromptBuilder() + prompt = builder.build(meta, rules_text, group_descriptions, group_count) + + # 4. 调用 API + client = APIClient(api_key=api_key, model=api_model) + print(f" API呼び出し中...") + result = client.generate(prompt) + print(f" API応答受信") + + # 5. 写入输出 + writer = OutputWriter(output_dir) + output_files = writer.write(meta.program_id, result, meta.input_type) + + print(f" 出力完了: {len(output_files)} ファイル") + + return { + "output_files": output_files, + "program_id": meta.program_id, + "groups": group_count, + "input_type": meta.input_type, + } +``` + +- [ ] **Step 2: 创建 main.py CLI 入口** + +```python +# main.py +#!/usr/bin/env python +""" +COBOL テストデータ生成 Agent + +使用方法: + python main.py \\ + --design "D:\\cobol-tna-system\\詳細設計書\\詳細設計書_ZAN04MAT.md" \\ + --source "D:\\cobol-tna-system\\src\\ZAN04MAT.cbl" \\ + --file-db-md "D:\\cobol-tna-system\\詳細設計書\\COPY句定義書.md" \\ + --cpy "D:\\cobol-tna-system\\cpy" \\ + --db-md "D:\\cobol-tna-system\\詳細設計書\\DB定義書.md" \\ + --output "output" +""" +import argparse +import os +import sys + +from agent import generate + +DEFAULT_RULES_DIR = os.path.join(os.path.dirname(__file__), 'rules') + + +def main(): + parser = argparse.ArgumentParser( + description='COBOLテストデータ生成Agent', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +例: + python main.py --design 詳細設計書_ZAN04MAT.md --source ZAN04MAT.cbl \\ + --cpy cpy/ --db-md DB定義書.md --output output/ + """ + ) + + parser.add_argument('--design', required=True, + help='詳細設計書 .md のパス') + parser.add_argument('--source', required=True, + help='COBOL ソース .cbl のパス') + parser.add_argument('--file-db-md', required=True, + help='ファイル/DB 構造 .md のパス') + parser.add_argument('--cpy', required=True, + help='COPYBOOK 格納ディレクトリ') + parser.add_argument('--db-md', required=True, + help='DB 定義書 .md のパス') + parser.add_argument('--output', default='output', + help='出力ディレクトリ (デフォルト: output)') + parser.add_argument('--api-key', default='sk-6156cccdc9c14d949cf5bfc5afc67a03', + help='DeepSeek API Key') + parser.add_argument('--model', default='deepseek-v4-flash', + help='API モデル名') + parser.add_argument('--rules', default=DEFAULT_RULES_DIR, + help='ルール格納ディレクトリ (デフォルト: rules/)') + + args = parser.parse_args() + + # 验证输入文件存在 + for name, path in [('--design', args.design), ('--source', args.source)]: + if not os.path.exists(path): + print(f"エラー: {name} のファイルが見つかりません: {path}", file=sys.stderr) + sys.exit(1) + + result = generate( + design_md=args.design, + source_cbl=args.source, + file_db_md=args.file_db_md, + cpy_dir=args.cpy, + db_md=args.db_md, + output_dir=args.output, + api_key=args.api_key, + api_model=args.model, + rules_dir=args.rules, + ) + + print(f"\n== 完了 ==") + print(f"プログラムID: {result['program_id']}") + print(f"グループ数: {result['groups']}") + print(f"入力タイプ: {result['input_type']}") + print(f"出力ファイル:") + for key, path in sorted(result['output_files'].items()): + print(f" {key}: {path}") + + +if __name__ == '__main__': + main() +``` + +- [ ] **Step 3: 写集成测试(跳过实际 API 调用)** + +```python +# tests/test_integration.py +import os +import tempfile +from unittest.mock import patch, MagicMock +from agent import generate + +FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'test_data') + + +@patch('agent.api_client.requests.post') +def test_full_pipeline_mock_api(mock_post): + """完整流水线测试,API 调用使用 mock。""" + # Mock API response + mock_response = MagicMock() + mock_response.json.return_value = { + 'choices': [{'message': {'content': '''{ + "groups": { + "g1": { + "program": "ZAN04MAT", + "records": [ + { + "input": { + "R01INNFIL": { + "R01-APPL-ID": "A0000001", + "R01-EMP-ID": "00000101", + "R01-APPL-DATE": "20260101", + "R01-START-TIME": "0900", + "R01-END-TIME": "1800", + "R01-STATUS": "0", + "R01-OVT-TYPE": "W", + "R01-FILLER": "D000000000000000000000000000000000000000000001" + } + } + } + ] + } + } + }'''}}] + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + cpy_dir = FIXTURE_DIR + output_dir = tempfile.mkdtemp() + rules_dir = os.path.join(os.path.dirname(__file__), '..', 'rules') + + design_md = os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md') + source_cbl = os.path.join(FIXTURE_DIR, '..', '..', '..', + 'cobol-tna-system', 'src', 'ZAN04MAT.cbl') + + # 如果 source_cbl 不存在,创建一个最小副本 + if not os.path.exists(source_cbl): + source_cbl = 'dummy.cbl' + with open(source_cbl, 'w', encoding='utf-8') as f: + f.write("COPY ZAN01REC REPLACING ==(A)== BY ==R01==.\n") + + try: + result = generate( + design_md=design_md, + source_cbl=source_cbl, + file_db_md='dummy.md', + cpy_dir=cpy_dir, + db_md='dummy_db.md', + output_dir=output_dir, + api_key='test-key', + rules_dir=rules_dir, + ) + + assert result['program_id'] == 'ZAN04MAT' + assert result['groups'] > 0 + assert len(result['output_files']) > 0 + + # 验证输出文件存在 + for path in result['output_files'].values(): + assert os.path.exists(path), f"输出文件不存在: {path}" + + finally: + if source_cbl == 'dummy.cbl': + os.remove(source_cbl) +``` + +- [ ] **Step 4: 运行集成测试** + +Run: `python -m pytest tests/test_integration.py -v` +Expected: PASS + +- [ ] **Step 5: 运行全部测试** + +Run: `python -m pytest tests/ -v` +Expected: all tests PASS + +--- + +### Task 11: 首次实际运行验证 + +- [ ] **Step 1: 用 ZAN04MAT 实际运行** + +```powershell +python main.py ` + --design "D:\cobol-tna-system\詳細設計書\詳細設計書_ZAN04MAT.md" ` + --source "D:\cobol-tna-system\src\ZAN04MAT.cbl" ` + --file-db-md "D:\cobol-tna-system\詳細設計書\COPY句定義書.md" ` + --cpy "D:\cobol-tna-system\cpy" ` + --db-md "D:\cobol-tna-system\詳細設計書\DB定義書.md" ` + --output "D:\jcl-cobol-data-create\output" +``` + +- [ ] **Step 2: 验证输出** + +确认 `output/ZAN04MAT/` 下生成了 g1、g2、g3 三个文件夹,每个文件夹中有对应的 JSON 文件。 + +- [ ] **Step 3: 验证 JSON 格式** + +检查 JSON 文件是否符合 `JSON格式说明v2.0.md` 规范。 diff --git a/black-box-data-create/docs/superpowers/specs/2026-07-12-testdata-agent-design.md b/black-box-data-create/docs/superpowers/specs/2026-07-12-testdata-agent-design.md new file mode 100644 index 0000000..7870694 --- /dev/null +++ b/black-box-data-create/docs/superpowers/specs/2026-07-12-testdata-agent-design.md @@ -0,0 +1,319 @@ +# COBOL 测试数据生成 Agent 设计文档 + +## 背景 + +旧 COBOL 项目通过 Java 重写。需要基于 Python 新建一个测试数据生成 Agent,根据详细设计书自动生成 COBOL 程序的测试数据(JSON/SQL),通过 DeepSeek v4 Flash API 由 AI 生成具体数据内容。 + +--- + +## 架构概览 + +采用**线性管道架构**,模块化设计,便于后续追加规则和维护: + +``` +InputParser → RuleLoader → PromptBuilder → APIClient → OutputWriter +``` + +- **Python 侧**:负责解析结构化信息、建立映射关系、组装 prompt +- **AI 侧**:接收 prompt,根据规则生成具体的测试数据 JSON/SQL + +--- + +## 目录结构 + +``` +D:\jcl-cobol-data-create/ +├── agent/ +│ ├── __init__.py +│ ├── input_parser.py # 输入解析器 +│ ├── rule_loader.py # 规则加载器 +│ ├── prompt_builder.py # Prompt 构建器 +│ ├── api_client.py # API 客户端(DeepSeek) +│ └── output_writer.py # 输出写入器 +├── rules/ +│ ├── pgm_pattern/ # PGM模式规则 .md +│ │ ├── マッチング(1-1).md +│ │ ├── マッチング(1-N).md +│ │ └── ...(后续追加,无需改代码) +│ └── special_feature/ # 特殊功能规则 .md +│ └── 条件分支.md +├── layout/ # 文档模板(参考用) +│ └── JSON格式说明v2.0.md +├── output/ # 生成输出(运行时自动创建) +│ └── {プログラムID}/ +│ ├── g1/ +│ └── g2/ +├── main.py # CLI 入口 + generate() 库函数 +├── requirements.txt +└── README.md +``` + +--- + +## 模块详细设计 + +### 1. InputParser(输入解析器) + +**职责**:解析所有输入文件,提取结构化数据。 + +**输入**: +- `a` 詳細設計書 .md +- `b` ソース .cbl 文本文件 +- `c` 文件/DB 构造 .md(文件定義書 + COPY句定義書) +- `d` COPY 存放路径 +- `e` DB 结构 .md + +**输出**:结构化的程序元数据对象,包含: + +#### 1.1 基本情報 +- プログラムID, プログラム名, PGMタイプ(メイン/サブ), PGMパターン +- 解析方法:定位 `# 行` 列 = `PGMパターン` 的行,取 `内容` 列的值 + +#### 1.2 使用ファイル一覧 +- 每个文件的:識別子, DD名, I/O, COPY群, 媒体(PS=文件, DB=DB) +- 判定输入类型:遍历 I/O="I" 的行,根据 `媒体` 列判定 + - 全是 PS → 文件输入 + - 包含 DB → DB 输入 + - 都有 → 混合输入 + +#### 1.3 COPYBOOK 解析 + REPLACING 映射 +- 从 .cbl 源码的 FD 块中提取 `COPY xxx REPLACING ==(A)== BY ==識別子==` +- 建立映射:`識別子(R01) → COPY名(ZAN01REC) → 替换前缀(R01-)` +- 读取 COPYBOOK 文件,应用 `(A)` → 前缀替换,得到实际字段名和 PIC 定义 +- 输出示例:`R01-APPL-ID | X(8) | 8` + +#### 1.4 キー項目一覧 +- 排序条件、匹配键信息 + +#### 1.5 処理詳細 +- 完整原文(不做解析,保留原样) + +#### 1.6 出力レコード定義 +- 输出文件的项目定义和设定元信息 + +#### 1.7 DB 定义(如涉及) +- 从 DB 定义书 .md 中提取表的字段定义、类型、PK 信息 + +--- + +### 2. RuleLoader(规则加载器) + +**职责**:根据程序特征匹配对应的数据生成规则。 + +#### 2.1 PGM 模式规则匹配 +PGMパターン 值到规则文件名的映射: + +| PGMパターン | 规则文件 | +|------------|---------| +| マッチング(1:1) | マッチング(1-1).md | +| マッチング(1:N) | マッチング(1-N).md | +| マッチング(M:N) | (待添加) | +| レイアウト編集のみ(GETPUT) | (待添加) | +| 項目チェック | (待添加) | +| 振り分け | (待添加) | +| キーブレイク | (待添加) | +| キーブレイク(集計、集約) | (待添加) | +| DB更新 | (待添加) | + +- 未找到对应规则 → 报错并列出缺失的模式名 +- 读取匹配到的 .md 全文 +- 从规则中解析:组数、每组用途、每组的数据生成方法 + +#### 2.2 特殊功能检测 +扫描 `処理詳細` 文本,根据预设关键词自动检测: + +| 特殊功能 | 检测关键词 | 规则文件 | +|---------|-----------|---------| +| 条件分支 | `場合`, `EVALUATE`, `IF` | 条件分支.md | + +- 扩展方式:在检测表中追加行,同时在 `rules/special_feature/` 下添加对应 .md + +--- + +### 3. PromptBuilder(Prompt 构建器) + +**职责**:将前两个模块的所有信息组装成结构化 API prompt。 + +**Prompt 结构**: + +``` +## 程序基本情報 +- 程序ID: ZAN04MAT +- 程序名: 取消マッチング処理 +- PGMパターン: マッチング(1:1) +- 输入类型: 文件 + +## 処理詳細 +(詳細設計書の処理詳細全文) + +## 入力ファイル/DB 構造 +### 文件R01 (DD名: ZAN04R01, COPY: ZAN01REC) +| 字段名 | PIC | 字节数 | +| R01-APPL-ID | X(8) | 8 | +| R01-EMP-ID | X(8) | 8 | +... + +## DBテーブル構造(如有) +### 表名: LEAVE_RECORDS +| 字段名 | 类型 | 最大长 | KEY | +... +- 主键: APPLICATION_ID + +## 出力レコード定義 +(詳細設計書の出力レコード定義全文) + +## データ生成ルール +(匹配到的PGM模式规则 .md 全文) + +## 特殊機能ルール(如有) +(条件分支.md 全文) + +## 出力形式 +- 文件输入类型 → JSON格式(遵循以下规则) +- DB输入类型 → SQL INSERT语句 + +### 字段值规则(PIC → JSON表示) +| PIC | JSON表示 | 例 | +|-----|---------|-----| +| PIC X(n) | 左对齐 + 空格填充 | "A0000001" | +| PIC 9(n) | 右对齐 + 前补零 | "00000101" | +| PIC S9(n) COMP-3 | 十进制数字 | "1234" | +| PIC S9(n) COMP | 十进制数字 | "300" | +| FILLER(纯保留) | 有辨识性模式 | "D000...001" | +| FILLER(业务保留) | 全空格或全零 | | + +- COMP/COMP-3 类型字段输出**普通十进制数字符串**,不做特殊转换 +- 字段名含语义关键词时(如 DATE="日期"、NAME="姓名")生成符合实际含义的值 + +## 生成指示 +- 需要生成的グループ数: 3 +- 各グループの内容: + - g1: 两端不匹配 + - g2: 反向两端不匹配 + - g3: 中间不匹配 +- 出力: JSON(文件输入) +``` + +--- + +### 4. APIClient(API 客户端) + +**职责**:调用 DeepSeek API,含重试逻辑。 + +**API 配置**: +- 地址:https://api.deepseek.com/chat/completions +- 模型:`deepseek-v4-flash` +- API Key:`sk-6156cccdc9c14d949cf5bfc5afc67a03` +- timeout:120 秒 + +**请求结构**: +```json +{ + "model": "deepseek-v4-flash", + "messages": [ + { + "role": "system", + "content": "你是COBOL程序测试数据生成专家。请严格按照规则生成测试数据。输出必须是可直接解析的JSON,不要包在markdown代码块中。" + }, + { + "role": "user", + "content": "" + } + ], + "temperature": 0.3, + "max_tokens": 8192 +} +``` + +**重试逻辑**(最多 3 次): +``` +for i in 1..3: + 发送请求 + if 网络错误 || API返回错误: + continue # 重试 + if JSON解析失败: + 将错误信息追加到prompt中,重试 + if 成功: + break +``` +- 3 次全部失败 → 报错退出 + +--- + +### 5. OutputWriter(输出写入器) + +**职责**:将 AI 返回的 JSON/SQL 保存为文件。 + +**输出规则**: +- 文件输入 → `output/{プログラムID}/g{组号}/{プログラムID}_g{组号}.json` +- DB 输入 → `output/{プログラムID}/g{组号}/{プログラムID}_g{组号}.sql` +- 混合输入 → 同文件夹下同时输出 `.json` 和 `.sql` + +**输出格式**: +- JSON:遵循 `JSON格式说明v2.0.md` 规范,每个文件内含 `records` 数组 +- SQL:标准 INSERT 语句 + +``` +output/ZAN04MAT/ +├── g1/ +│ └── ZAN04MAT_g1.json +├── g2/ +│ └── ZAN04MAT_g2.json +└── g3/ + └── ZAN04MAT_g3.json +``` + +**验证**:写入前校验 JSON 合法性,不合法时返回错误给 APIClient 触发重试。 + +--- + +### 6. main.py(入口) + +**CLI 调用**: +```bash +python main.py \ + --design "詳細設計書_ZAN04MAT.md" \ + --source "src/ZAN04MAT.cbl" \ + --cpy "cpy/" \ + --db-def "DB定義書.md" \ + --output "output/" +``` + +**库调用**: +```python +from agent import generate + +result = generate( + design_md="詳細設計書_ZAN04MAT.md", + source_cbl="src/ZAN04MAT.cbl", + cpy_dir="cpy/", + db_def_md="DB定義書.md", + output_dir="output/" +) +``` + +--- + +## 依赖 + +- Python 3.9+ +- `requests` — HTTP 客户端 +- `openai` 或直接用 `requests` 调用 DeepSeek 兼容 API + +``` +requests>=2.28.0 +``` + +--- + +## 扩展指南 + +### 追加 PGM 模式规则 +1. 在 `rules/pgm_pattern/` 下创建新的 .md 文件 +2. 文件名与 PGMパターン 值按约定匹配 +3. 无需修改 Python 代码 + +### 追加特殊功能检测 +1. 在 RuleLoader 的检测映射表中追加一行 `(关键词列表, 对应规则文件)` +2. 在 `rules/special_feature/` 下创建对应 .md +3. 需要改一行 Python 代码(添加映射条目) diff --git a/black-box-data-create/docs/未実装PGMパターン洗い出し_第一版.md b/black-box-data-create/docs/未実装PGMパターン洗い出し_第一版.md new file mode 100644 index 0000000..d320b97 --- /dev/null +++ b/black-box-data-create/docs/未実装PGMパターン洗い出し_第一版.md @@ -0,0 +1,184 @@ +# 未実装PGMパターン洗い出し(第一版) + +> **実装状況(2026-08-04 更新)**:本一覧の ❌/⚠️ 項目はすべて実装済み。 +> 新規規則ファイル:`25分割.md`・`100分割.md`・`2段階マッチング.md`・`内部テーブル検索.md`・`サブプログラム.md`・`ASCII→EBCDIC変換.md`・`キーブレイク(非集計).md`・`1-N+キーブレイク(異キー).md`・`SORT.md`・`オンラインPGM.md`・`SYSIN読込.md`・`ランキング生成.md`(分類表外のNo.36) +> `agent/rule_loader.py` の PGM_PATTERN_MAP に全タイプの正式名称・詳細設計書表記のエイリアスを追加。`CSV→FB変換.md`(改行なし)を内容補強、`項目チェック` は 重複含まず/半角20桁-4桁 に分割。 +> テスト:`tests/test_rule_loader.py` に35タイプ全量+詳細設計書表記の解決テストを追加し、全101件パス。 + +> 目的:`rules/pgm_pattern` に未実装のプログラムタイプを洗い出す。 +> 出典:`D:\cobol-tna-system\品質管理\程序分类说明.md`(HINA基准33类型+追加2类型=計35类型) +> テスト基準:`D:\cobol-tna-system\品質管理\测试基准说明.md` §2.2 类型别测试基准 +> 現状確認:`D:\jcl-cobol-data-create\rules\pgm_pattern`(16ファイル)+ `agent/rule_loader.py` の PGM_PATTERN_MAP + +## 凡例 + +| 記号 | 意味 | +|------|------| +| ✅ | 実装済み(規則ファイル+マッピングあり) | +| ⚠️ | 部分実装/内容は既存規則でカバーできるがマッピング・内容が不足 | +| ❌ | 未実装(規則ファイルなし) | + +## 一、35类型总体状态 + +| No. | 类型(日) | 类型(中) | 分類 | 状態 | 現状規則 | +|-----|-----------|-----------|------|:---:|---------| +| 01 | マッチング(1:1) | 1:1匹配 | 匹配 | ✅ | マッチング(1-1).md | +| 02 | マッチング(1:N) | 1:N匹配 | 匹配 | ✅ | マッチング(1-N).md | +| 03 | マッチング(N:1) | N:1匹配 | 匹配 | ✅ | マッチング(N-1).md | +| 04 | レイアウト編集のみ(GETPUT) | 编辑输出 | 编辑 | ✅ | レイアウト編集のみ(GETPUT).md/GETPUT(編集出力).md | +| 05 | 振り分け(IF文) | IF分支 | 分支 | ✅ | 振り分け.md | +| 06 | 振り分け(EVALUATE文) | EVALUATE分支 | 分支 | ✅ | 振り分け.md(+special_feature/条件分支.md) | +| 07 | キーブレイク(集計) | key切汇总 | key切 | ✅ | キーブレイク(集計).md | +| 08 | キーブレイク(集約) | key切聚合 | key切 | ✅ | キーブレイク(集約).md | +| 09 | DB更新 | 数据库更新 | 数据库 | ✅ | DB更新.md | +| 10 | 50分割 | 50分割 | 分割 | ✅ | 50分割.md | +| 11 | 25分割 | 25分割 | 分割 | ❌ | なし(50分割.mdは注記のみ) | +| 12 | 100分割 | 100分割 | 分割 | ❌ | なし | +| 13 | 項目チェック(重複含まず) | 字段校验(不含重复) | 校验 | ✅ | 項目チェック(重複含まず).md | +| 14 | オンラインPGM | online程序 | online | ❌ | なし | +| 15 | CSV→FB変換(改行なし) | CSV→FB(无换行) | 文件转换 | ⚠️ | CSV→FB変換.md(无换行STRING合并の明示なし) | +| 16 | 2段階マッチング(1:1⇒1:1) | 二级1:1→1:1 | 匹配 | ❌ | なし | +| 17 | 2段階マッチング(N:1⇒N:1) | 二级N:1→N:1 | 匹配 | ❌ | なし | +| 18 | マッチングM:N⇒出力M件 | 组合M:N→M条 | 匹配 | ⚠️ | 内容はマッチング(M-N).mdに有/名称マッピング不足 | +| 19 | マッチングM:N⇒出力N件 | 组合M:N→N条 | 匹配 | ⚠️ | 内容はマッチング(M-N).mdに有/名称マッピング不足 | +| 20 | マッチングM:N⇒出力M×N件 | 组合M:N→M×N条 | 匹配 | ⚠️ | 内容はマッチング(M-N).mdに有/名称マッピング不足 | +| 21 | CSV→FB変換(改行あり) | CSV→FB(有换行) | 文件转换 | ✅ | CSV→FB変換.md | +| 22 | 2段階マッチング(M:N⇒M:N) | 二级M:N→M:N | 匹配 | ❌ | なし | +| 23 | SELECT条件 | SELECT条件 | 数据库 | ✅ | SELECT処理.md | +| 24 | 内部テーブル検索 | 内部表检索 | 内部处理 | ❌ | なし | +| 25 | サブプログラム使用 | 子程序调用 | 内部处理 | ❌ | なし(PGMタイプ=サブは generate() で ValueError) | +| 26 | DB検索 | 数据库检索 | 数据库 | ⚠️ | SELECT処理.md で内容はほぼカバー/名称マッピングなし | +| 27 | 項目チェック(半角20桁/4桁) | 半角校验 | 校验 | ✅ | 項目チェック(半角20桁-4桁).md | +| 28 | SYSIN読込 | SYSIN读取 | 内部处理 | ⚠️ | 独立規則なし(DB更新.md に紐づくが内容はSYSIN未対応) | +| 29 | ASCII→EBCDIC変換 | ASCII→EBCDIC转换 | 文件转换 | ❌ | なし | +| 30 | キーブレイク(集計集約以外) | key切非汇总 | key切 | ❌ | なし(MAPは集計.mdへの仮対応のみ) | +| 31 | 項目チェック(重複含む) | 字段校验(含重复) | 校验 | ✅ | 項目チェック(重複含まず).md(重複チェック手順あり) | +| 32 | 1:N+キーブレイク(同キー) | 1:N+同key切 | 混合 | ✅ | キーブレイク(集計).md | +| 33 | 1:N+キーブレイク(異キー) | 1:N+异key切 | 混合 | ❌ | なし | +| 34 | SORT(INPUT/OUTPUT PROCEDURE) | 排序处理 | 排序 | ❌ | なし | +| 35 | MERGE(複数ファイル結合) | 合并处理 | 排序 | ✅ | MERGE.md | + +**集計:✅ 16種 / ⚠️ 7種 / ❌ 12種(計35種)** + +--- + +## 二、未実装类型(12種)明细 + +### 2.1 分割系 + +| No. | 类型 | 对应测试基准(§2.2) | 现状与课题 | +|-----|------|----------------------|-----------| +| 11 | 25分割 | S-N004(整数倍)、S-A001/A002、S-R001/R002 | 規則ファイルなし。50分割.md の注記「25/100も同じ考え方」のみで、分割数がハードコードされている。 | +| 12 | 100分割 | S-N005(整数倍)、S-A001/A002、S-R001/R002 | 同上。 | + +**建议**:50分割.md を「分割数パラメータ化」した共通規則(`分割処理.md`)に改修するか、`25分割.md`/`100分割.md` を新規作成し PGM_PATTERN_MAP に追加。 + +### 2.2 匹配系(2段階) + +| No. | 类型 | 对应测试基准(§2.2) | 现状与课题 | +|-----|------|----------------------|-----------| +| 16 | 2段階マッチング(1:1⇒1:1) | AM-N001、AM-A001/A002、AM-R001 | 中間ファイルを挟む2段階マッチングの規則なし。 | +| 17 | 2段階マッチング(N:1⇒N:1) | AM-N002、AM-A001/A002、AM-R001 | 同上。 | +| 22 | 2段階マッチング(M:N⇒M:N) | AM-N008、AM-A001/A002、AM-R001 | 同上。 | + +**建议**:`2段階マッチング.md` を新規作成(または16/17/22を1規則でパターン別グループ化)。中間ファイルOPEN失敗・段間キー不一致・各段の出力件数確認を含める。 + +### 2.3 内部处理系 + +| No. | 类型 | 对应测试基准(§2.2) | 现状与课题 | +|-----|------|----------------------|-----------| +| 24 | 内部テーブル検索 | T-N001〜T-N007、T-A001〜T-A003、T-R001 | SEARCH/SEARCH ALL/OCCURS DEPENDING ON/INDEX越界の規則なし。KIN01INP では振り分け内部の一部として使われたのみ。 | +| 25 | サブプログラム使用 | C-N001〜C-N009、C-A001〜C-A004、C-R001/R002 | 規則なし。さらに `agent/models.py`/`main.py` の generate() で PGMタイプ=サブ は ValueError でスキップされるため、先にこの制約を外す必要あり。 | + +**建议**:`内部テーブル検索.md`・`サブプログラム.md` を新規作成。サブプログラムはCALLER側(CALL)とCALLEE側(LINKAGE/EXIT PROGRAM)の両ロールを定義。 + +### 2.4 文件转换系 + +| No. | 类型 | 对应测试基准(§2.2) | 现状与课题 | +|-----|------|----------------------|-----------| +| 29 | ASCII→EBCDIC変換 | AE-N001〜AE-N003、AE-A001〜AE-A003、AE-R001 | 変換規則なし。SHA01CVT(詳細設計書あり)が該当。 | + +**建议**:`ASCII-EBCDIC変換.md` を新規作成。変換表にない文字・往復一致性・レコード長一致を含める。 + +### 2.5 key切系 + +| No. | 类型 | 对应测试基准(§2.2) | 现状与课题 | +|-----|------|----------------------|-----------| +| 30 | キーブレイク(集計集約以外) | KB-N003(鍵変化でマークのみ)、KB-N004/N005/N006、KB-R001 | 非集計型(鍵切替時の変化検知)の規則なし。PGM_PATTERN_MAP の `'キーブレイク(集計、集約の以外)' → キーブレイク(集計).md` は内容が対応していない仮マッピング。 | + +**建议**:`キーブレイク(非集計).md` を新規作成し、仮マッピングを置き換える。 + +### 2.6 混合系 + +| No. | 类型 | 对应测试基准(§2.2) | 现状与课题 | +|-----|------|----------------------|-----------| +| 33 | 1:N+キーブレイク(異キー) | AM-N007 | 同キー(No.32)は実装済みだが、異キー版の規則なし。SHA07KBR(詳細設計書あり)が該当。 | + +**建议**:`1-N+キーブレイク(異キー).md` を新規作成済み(No.33)。No.32(同キー)は キーブレイク(集計).md にマッピング。 + +### 2.7 排序系 + +| No. | 类型 | 对应测试基准(§2.2) | 现状与课题 | +|-----|------|----------------------|-----------| +| 34 | SORT(INPUT/OUTPUT PROCEDURE) | SR-N001〜SR-N010、SR-A001〜SR-A003、SR-R001〜SR-R003 | SORT文の規則なし。SHA08SRT・JIN06SRT(詳細設計書あり)が該当。 | + +**建议**:`SORT.md` を新規作成。SORT ON KEY/INPUT PROCEDURE(RELEASE)/OUTPUT PROCEDURE(RETURN)/RC確認を含める。 + +### 2.8 online系 + +| No. | 类型 | 对应测试基准(§2.2) | 现状与课题 | +|-----|------|----------------------|-----------| +| 14 | オンラインPGM | OL-N001〜OL-N004、OL-A001〜OL-A003、OL-R001 | CICS系(DFHCOMMAREA/MAP)の規則なし。現システムに該当プログラムなし(将来拡張用)。 | + +**建议**:`オンラインPGM.md` を新規作成(または保留)。DFHCOMMAREA長不一致・ROLLBACK・応答時間を含める。 + +--- + +## 三、部分実装/マッピング不足(7種) + +| No. | 类型 | 不足内容 | 対応方針 | +|-----|------|---------|---------| +| 15 | CSV→FB変換(改行なし) | 規則はfallbackでマッチするが、无换行(STRING合并、CF-N001)が明示されていない | CSV→FB変換.md に「改行なし(STRING合并)」グループを明示追加 | +| 18 | マッチングM:N⇒出力M件 | 内容はマッチング(M-N).md 组3にあるが、設計書のパターン名(例:`18(M:N→M件マッチング)`)がマッチしない | PGM_PATTERN_MAP にエイリアス追加 | +| 19 | マッチングM:N⇒出力N件 | 内容は组4にあるが、名称マッチしない | 同上 | +| 20 | マッチングM:N⇒出力M×N件 | 内容は组2(直積)にあるが、名称マッチしない | 同上 | +| 26 | DB検索 | SELECT処理.md で DB-N001/N002/N006 はほぼカバー | PGM_PATTERN_MAP に `DB検索` → SELECT処理.md のエイリアス追加 | +| 27 | 項目チェック(半角20桁/4桁) | 半角20桁/4桁境界(VF-N005/N006/A001/A002)の専用規則なし | 項目チェック(半角20桁-4桁).md を新規作成済み | +| 28 | SYSIN読込 | KIN08DBU は DB更新.md に紐づくが、SYSIN読込自体(SY-N001〜SY-R001)の規則がない | `SYSIN読込.md` を新規作成、または DB更新.md にSYSINグループを追加 | + +--- + +## 四、実装済み类型(16種)一覧 + +| No. | 类型 | 規則ファイル | +|-----|------|-------------| +| 01 | マッチング(1:1) | マッチング(1-1).md | +| 02 | マッチング(1:N) | マッチング(1-N).md | +| 03 | マッチング(N:1) | マッチング(N-1).md | +| 04 | レイアウト編集のみ(GETPUT) | レイアウト編集のみ(GETPUT).md/GETPUT(編集出力).md | +| 05 | 振り分け(IF文) | 振り分け.md | +| 06 | 振り分け(EVALUATE文) | 振り分け.md(+special_feature/条件分支.md) | +| 07 | キーブレイク(集計) | キーブレイク(集計).md | +| 08 | キーブレイク(集約) | キーブレイク(集約).md | +| 09 | DB更新 | DB更新.md | +| 10 | 50分割 | 50分割.md | +| 13 | 項目チェック(重複含まず) | 項目チェック(重複含まず).md | +| 21 | CSV→FB変換(改行あり) | CSV→FB変換.md | +| 23 | SELECT条件 | SELECT処理.md | +| 31 | 項目チェック(重複含む) | 項目チェック(重複含まず).md | +| 32 | 1:N+キーブレイク(同キー) | キーブレイク(集計).md | +| 35 | MERGE(複数ファイル結合) | MERGE.md | + +--- + +## 五、実装優先度(案) + +| 優先度 | 类型 | 理由 | +|:---:|------|------| +| P1 | 11・12(25/100分割)、34(SORT) | 詳細設計書あり(SHA09S25/SHA10S10/SHA08SRT/JIN06SRT)、且つ25/100は50分割の応用でコスト低 | +| P1 | 16・17・22(2段階マッチング)、33(1:N+異キー) | 詳細設計書あり(SHA04TWO/SHA05TWN/SHA06TWM/SHA07KBR)、マッチング系の拡張 | +| P2 | 24(内部テーブル検索)、25(サブプログラム) | 内部処理系。25は generate() の制約解除が必要 | +| P2 | 29(ASCII→EBCDIC) | 詳細設計書あり(SHA01CVT)、専用変換ロジック | +| P2 | 30(キーブレイク非集計) | 仮マッピングの是正を兼ねる | +| P3 | 15・18・19・20・26・27・28 | 内容は既存規則でほぼカバー、エイリアス/グループ追加で対応可能 | +| P3 | 14(オンライン) | 現システムに該当プログラムなし、保留可 | diff --git a/black-box-data-create/layout/DB定義書.md b/black-box-data-create/layout/DB定義書.md new file mode 100644 index 0000000..34a4ecd --- /dev/null +++ b/black-box-data-create/layout/DB定義書.md @@ -0,0 +1,347 @@ +# DB定義書 + +## 変更履歴 + +| No | 変更内容 | 担当者 | 変更日 | 承認者 | 備考 | +|----|---------|--------|--------|--------|------| +| 1 | 新規作成(全8テーブル) | AI | 2026/06/23 | | サブシステムA(6) + B(2) | +| 2 | SALARYDB 4テーブル追加 | AI | 2026/07/08 | | サブシステムC(給与計算) | + +--- + +# 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.50(50%控除) | + +### 備考 + +- 全社員共通の設定値 +- 現時点では参照プログラム未実装(設計上定義) + +--- + +# 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が月次集計後にUPSERT(MERGE) +- サブシステム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 + +--- + +# EMP-MASTER(SALARYDB) — 社員マスタ(給与計算用) + +## Layout(テーブル定義) + +### DB基本情報 + +| DB名 | DB ID | COPY ID | INDEX | +|------|-------|---------|-------| +| 給与計算DB | EMP-MASTER | KYU01REC | PK: EMP-ID | + +### カラム定義 + +| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 | +|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------| +| 1 | 社員番号 | EMP-ID | CHAR | 8 | | 8 | NOT NULL | | ✓ | | | | | +| 2 | 氏名 | EMP-NAME | VARCHAR | 40 | | 40 | NOT NULL | | | | | | | +| 3 | 部署コード | DEPT-CODE | CHAR | 2 | | 2 | NOT NULL | | | | | | | +| 4 | 地域コード | REGION-CODE | CHAR | 2 | | 2 | NOT NULL | | | | | | | +| 5 | 職種コード | CATEGORY-CODE | CHAR | 3 | | 3 | NOT NULL | | | | | | | +| 6 | 基本給 | BASE-SALARY | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | +| 7 | 時給 | HOURLY-RATE | DECIMAL | 7 | 0 | 4 | NOT NULL | | | | | | | +| 8 | 扶養人数 | DEPENDENT-COUNT | SMALLINT | — | | 2 | NOT NULL | | | | | | | +| 9 | ステータス | STATUS | CHAR | 1 | | 1 | NOT NULL | | | | | | '0'=在籍, '9'=退職 | +| 10 | 更新日時 | UPDATED-AT | TIMESTAMP | — | | 10 | NOT NULL | CURRENT TIMESTAMP | | | | | | + +### 備考 + +- KYU02REGがINSERT/UPSERTを実行 +- KYU04CALがSELECTで参照 + +--- + +# TAX-TABLE(SALARYDB) — 所得税率テーブル + +## Layout(テーブル定義) + +### DB基本情報 + +| DB名 | DB ID | COPY ID | INDEX | +|------|-------|---------|-------| +| 給与計算DB | TAX-TABLE | — | PK: (TAX-FROM, TAX-TO) | + +### カラム定義 + +| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 | +|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------| +| 1 | 課税下限 | TAX-FROM | DECIMAL | 9 | 0 | 5 | NOT NULL | | ✓ | | | | | +| 2 | 課税上限 | TAX-TO | DECIMAL | 9 | 0 | 5 | NOT NULL | | ✓ | | | | | +| 3 | 税率 | TAX-RATE | DECIMAL | 5 | 4 | 3 | NOT NULL | | | | | | | +| 4 | 控除額 | DEDUCTION | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | + +### 備考 + +- KYU05DEDがSELECT ... BETWEENで参照 +- 課税所得に対する超過累進税率 + +--- + +# INSURANCE-TABLE(SALARYDB) — 社会保険料率テーブル + +## Layout(テーブル定義) + +### DB基本情報 + +| DB名 | DB ID | COPY ID | INDEX | +|------|-------|---------|-------| +| 給与計算DB | INSURANCE-TABLE | — | PK: (INS-INCOME-FROM, INS-INCOME-TO) | + +### カラム定義 + +| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 | +|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------| +| 1 | 所得下限 | INS-INCOME-FROM | DECIMAL | 9 | 0 | 5 | NOT NULL | | ✓ | | | | | +| 2 | 所得上限 | INS-INCOME-TO | DECIMAL | 9 | 0 | 5 | NOT NULL | | ✓ | | | | | +| 3 | 健康保険料率 | INS-HEALTH-RATE | DECIMAL | 7 | 6 | 4 | NOT NULL | | | | | | | +| 4 | 厚生年金保険料率 | INS-PENSION-RATE | DECIMAL | 7 | 6 | 4 | NOT NULL | | | | | | | +| 5 | 更新日時 | UPDATED-AT | TIMESTAMP | — | | 10 | NOT NULL | CURRENT TIMESTAMP | | | | | | + +### 備考 + +- KYU05DEDが内部テーブル(SEARCH ALL)に取込んで参照 +- 被保険者負担分のみ + +--- + +# SALARY-RESULTS(SALARYDB) — 給与計算結果テーブル + +## Layout(テーブル定義) + +### DB基本情報 + +| DB名 | DB ID | COPY ID | INDEX | +|------|-------|---------|-------| +| 給与計算DB | SALARY-RESULTS | — | 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 | 総支給額 | GROSS-PAYMENT | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | +| 4 | 源泉所得税 | INCOME-TAX | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | +| 5 | 社会保険料 | INSURANCE | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | +| 6 | 住民税 | RESIDENT-TAX | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | +| 7 | 差引支給額 | NET-PAYMENT | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | +| 8 | 更新日時 | UPDATED-AT | TIMESTAMP | — | | 10 | NOT NULL | CURRENT TIMESTAMP | | | | | | + +### 備考 + +- KYU06UPDがINSERT/UPSERTを実行 diff --git a/black-box-data-create/layout/JSON格式说明v2.0.md b/black-box-data-create/layout/JSON格式说明v2.0.md new file mode 100644 index 0000000..9063a7d --- /dev/null +++ b/black-box-data-create/layout/JSON格式说明v2.0.md @@ -0,0 +1,435 @@ +# cobol_testgen v4 生成 JSON — 格式说明 v2.0 + +## 修订履历 + +| 版本 | 日期 | 变更内容 | +|------|------|---------| +| v2.0 | | 明确1个json文件=1组数据(group)的命名规则,格式本身与v1相同 | + +## 说明 + +v2.0 **格式本身与v1完全一致**,仅补充: +1. 一组数据单独放一个json文件 +2. 一个文件内的`records`数组包含该组的全部处理单元 +3. 命名规则追加 `_g{groupId}` 标识组号 + +--- + +## 完整规格 JSON(`output/json/{プログラム名}_g{groupId}.json`) + +### 最外层结构 + +```json +{ + "program": "SAN01MAT", + "records": [ + { + "input": { ... } + } + ] +} +``` + +**1条record = 1次处理单元**。例如匹配(1:1)时: +- 匹配成功:record内同时包含两个输入FD +- R01-only:record内只含R01的FD +- R02-only:record内只含R02的FD + +多个record按处理顺序排列,组成一组完整的测试数据。 + +--- + +## 例1:单文件CSV输入(1组1条记录) + +文件 `ZAN01CHK_g1.json`: + +```json +{ + "program": "ZAN01CHK", + "records": [ + { + "input": { + "R01INNFIL": { + "R01LINE": "A0000000, , , , ,0, , " + } + } + } + ] +} +``` + +## 例2:单文件二进制输入(1组多条记录) + +文件 `ZAN02CHK_g1.json`: + +```json +{ + "program": "ZAN02CHK", + "records": [ + { + "input": { + "R01INNFIL": { + "R01APPL-ID": "A0000001", + "R01EMP-ID": "00000101", + "R01APPL-DATE": "20000101", + "R01START-TIME":"0201", + "R01END-TIME": "0301", + "R01STATUS": "B", + "R01OVT-TYPE": "C", + "R01FILLER": "D000000000000000000000000000000000000000000001" + } + } + }, + { + "input": { + "R01INNFIL": { + "R01APPL-ID": "A0000002", + "R01EMP-ID": "00000102", + "R01APPL-DATE": "20000102", + "R01START-TIME":"0202", + "R01END-TIME": "0302", + "R01STATUS": "C", + "R01OVT-TYPE": "D", + "R01FILLER": "D000000000000000000000000000000000000000000002" + } + } + } + ] +} +``` + +## 例3:多文件输入(与v1相同) + +文件 `ZAN03CHK_g1.json`: + +```json +{ + "program": "ZAN03CHK", + "records": [ + { + "input": { + "R01INNFIL": { + "R01APPL-ID": "A0000001", + "R01EMP-ID": "00000101", + "R01APPL-DATE": "20000101", + "R01START-TIME":"0201", + "R01END-TIME": "0301", + "R01STATUS": "B", + "R01OVT-TYPE": "C", + "R01FILLER": "D000000000000000000000000000000000000000000001" + }, + "R02INNFIL": { + "R02EMP-ID": "00000401", + "R02DATE": "20000101", + "R02TIME-IN": "0501", + "R02TIME-OUT": "0601", + "R02FILLER": "E0000000000000000000000000000000000000000000000000000001" + }, + "R03INNFIL": { + "R03HOLIDAY-DATE": "20000101", + "R03HOLIDAY-FLG": "F", + "R03FILLER": "G0000000000000000000000000000000000000000000000000000000000000000000001" + } + } + } + ] +} +``` + +## 例4:二文件匹配(マッチング1:1) + +v1中ZAN04MAT示例是1条record = 1次匹配处理。 + +对于匹配(1:1)测试,**1组数据 = 1个json文件**。1组内包含多条record(R01-only、R02-only、匹配),按处理顺序排列。 + +### 文件1:`SAN01MAT_g1.json` + +两端不匹配:先处理R01-only(a001) → 3次匹配(a002/b002, a003/b003, a004/b004) → 最后R02-only(a005)。 + +```json +{ + "program": "SAN01MAT", + "records": [ + { + "input": { + "R01INNFIL": { + "R01A001": "a001", + "R01A002": "b001", + "R01A003": "c001", + "R01DATA": "c00000000011" + } + } + }, + { + "input": { + "R01INNFIL": { + "R01A001": "a002", + "R01A002": "b002", + "R01A003": "c002", + "R01DATA": "c00000000012" + }, + "R02INNFIL": { + "R02A001": "a002", + "R02A002": "b002", + "R02A003": "c002", + "R02DATA": "c00000000012" + } + } + }, + { + "input": { + "R01INNFIL": { + "R01A001": "a003", + "R01A002": "b003", + "R01A003": "c003", + "R01DATA": "c00000000013" + }, + "R02INNFIL": { + "R02A001": "a003", + "R02A002": "b003", + "R02A003": "c003", + "R02DATA": "c00000000013" + } + } + }, + { + "input": { + "R01INNFIL": { + "R01A001": "a004", + "R01A002": "b004", + "R01A003": "c004", + "R01DATA": "c00000000014" + }, + "R02INNFIL": { + "R02A001": "a004", + "R02A002": "b004", + "R02A003": "c004", + "R02DATA": "c00000000014" + } + } + }, + { + "input": { + "R02INNFIL": { + "R02A001": "a005", + "R02A002": "b005", + "R02A003": "c005", + "R02DATA": "c00000000015" + } + } + } + ] +} +``` + +### 文件2:`SAN01MAT_g2.json` + +反向两端不匹配:先处理R02-only(a001) → 3次匹配 → 最后R01-only(a005)。 + +```json +{ + "program": "SAN01MAT", + "records": [ + { + "input": { + "R02INNFIL": { + "R02A001": "a001", + "R02A002": "b001", + "R02A003": "c001", + "R02DATA": "c00000000011" + } + } + }, + { + "input": { + "R01INNFIL": { + "R01A001": "a002", + "R01A002": "b002", + "R01A003": "c002", + "R01DATA": "c00000000012" + }, + "R02INNFIL": { + "R02A001": "a002", + "R02A002": "b002", + "R02A003": "c002", + "R02DATA": "c00000000012" + } + } + }, + { + "input": { + "R01INNFIL": { + "R01A001": "a003", + "R01A002": "b003", + "R01A003": "c003", + "R01DATA": "c00000000013" + }, + "R02INNFIL": { + "R02A001": "a003", + "R02A002": "b003", + "R02A003": "c003", + "R02DATA": "c00000000013" + } + } + }, + { + "input": { + "R01INNFIL": { + "R01A001": "a004", + "R01A002": "b004", + "R01A003": "c004", + "R01DATA": "c00000000014" + }, + "R02INNFIL": { + "R02A001": "a004", + "R02A002": "b004", + "R02A003": "c004", + "R02DATA": "c00000000014" + } + } + }, + { + "input": { + "R01INNFIL": { + "R01A001": "a005", + "R01A002": "b005", + "R01A003": "c005", + "R01DATA": "c00000000015" + } + } + } + ] +} +``` + +### 文件3:`SAN01MAT_g3.json` + +中间不匹配:先匹配(a001) → R01-only(a003) → 匹配(a004) → R02-only(a002) → 匹配(a005)。 + +```json +{ + "program": "SAN01MAT", + "records": [ + { + "input": { + "R01INNFIL": { + "R01A001": "a001", + "R01A002": "b001", + "R01A003": "c001", + "R01DATA": "c00000000011" + }, + "R02INNFIL": { + "R02A001": "a001", + "R02A002": "b001", + "R02A003": "c001", + "R02DATA": "c00000000011" + } + } + }, + { + "input": { + "R01INNFIL": { + "R01A001": "a003", + "R01A002": "b003", + "R01A003": "c003", + "R01DATA": "c00000000013" + } + } + }, + { + "input": { + "R01INNFIL": { + "R01A001": "a004", + "R01A002": "b004", + "R01A003": "c004", + "R01DATA": "c00000000014" + }, + "R02INNFIL": { + "R02A001": "a004", + "R02A002": "b004", + "R02A003": "c004", + "R02DATA": "c00000000014" + } + } + }, + { + "input": { + "R02INNFIL": { + "R02A001": "a002", + "R02A002": "b002", + "R02A003": "c002", + "R02DATA": "c00000000012" + } + } + }, + { + "input": { + "R01INNFIL": { + "R01A001": "a005", + "R01A002": "b005", + "R01A003": "c005", + "R01DATA": "c00000000015" + }, + "R02INNFIL": { + "R02A001": "a005", + "R02A002": "b005", + "R02A003": "c005", + "R02DATA": "c00000000015" + } + } + } + ] +} +``` + +--- + +## DB类型时的输出 + +输入类型为DB时,每组生成一个独立的SQL insert文件。 + +| 文件种类 | 命名规则 | 例 | +|---------|--------|-----| +| Insert SQL(一组) | `{プログラムID}_g{groupId}.sql` | `SAN01MAT_g1.sql` | + +例(组1): + +```sql +-- Group: 1 +-- 用途: 员工主表基础数据 +INSERT INTO EMPLOYEE (EMP_ID, EMP_NAME, BIRTH_DATE, SALARY) VALUES +('0000000001', '测试太郎', '1990-04-01', 350000.00), +('0000000002', '测试花子', '1995-07-15', 420000.00); +``` + +--- + +## 字段值规则 + +| PIC | JSON内表示 | 例 | +|-----|-----------|-----| +| `PIC X(n)` | 左对齐 + 空格填充 | `"A0000001"` (8桁) | +| `PIC 9(n)` | 右对齐 + 前补零 | `"00000101"` (8桁) | +| `PIC S9(n)` | 符号 + 右对齐 + 前补零 | `"+0000101"` | +| `PIC S9(n)V9(m)` | 符号 + 右对齐 + 含小数点 | `"+001234567"` (V9(2)时实际为 +0012345.67) | +| `PIC S9(n) COMP` | 十进制数字字符串(含符号) | `"300"`、`"-100000"` | +| `PIC S9(n) COMP-3` | 十进制数字字符串(含符号) | `"1234"`、`"-5678"` | +| `PIC 9(n) COMP-3` | 十进制数字字符串(无符号) | `"1234"` | +| FILLER(纯保留) | 字段名为`FILLER`的未使用区域 | 含有组ID和记录编号的可辨识模式 `"D000000...001"` | +| FILLER(业务保留) | 字段名有具体名称但实际未使用的保留区 | PIC X(n)填全空格、PIC 9(n)填全零 | + +--- + +## 输出目录结构 + +``` +output/ +├── json/ # 完整JSON(每组一个文件,输入类型=文件时输出) +│ ├── SAN01MAT_g1.json +│ ├── SAN01MAT_g2.json +│ └── SAN01MAT_g3.json +└── db/ # DB insert SQL(每组一个文件,输入类型=DB时输出) + ├── SAN01MAT_g1.sql + ├── SAN01MAT_g2.sql + └── SAN01MAT_g3.sql +``` diff --git a/black-box-data-create/layout/sample_ソース_SAN01MAT.cbl b/black-box-data-create/layout/sample_ソース_SAN01MAT.cbl new file mode 100644 index 0000000..5bc28a5 --- /dev/null +++ b/black-box-data-create/layout/sample_ソース_SAN01MAT.cbl @@ -0,0 +1,432 @@ + IDENTIFICATION DIVISION. + PROGRAM-ID. SAN01MAT. + ***************************************************************** + * システム名 : サンプルシステム * + * プログラムID : SAN01MAT * + * プログラム名 : XXXXXX処理 * + * 作成日 : YYYY-MM-DD * + * 処理概要 : マッチング(1:1)を行う。 * + * マッチの場合、ファイルW01を出力する。 * + * ファイルR01のみの場合ファイルW02を出力する。* + * ファイルR02のみの場合ファイルW03を出力する。* + * * + ***************************************************************** + * 更新履歴 * + *---------------------------------------------------------------* + * 更新日付 担当者 更新内容 * + *---------------------------------------------------------------* + * YY-MM-DD @@@ 新規作成 * + * * + ***************************************************************** + ENVIRONMENT DIVISION. + CONFIGURATION SECTION. + SOURCE-COMPUTER. IBM-ZSERIES. + OBJECT-COMPUTER. IBM-ZSERIES. + * + INPUT-OUTPUT SECTION. + FILE-CONTROL. + SELECT R01INNFIL ASSIGN TO SAN01R01. + SELECT R02INNFIL ASSIGN TO SAN01R02. + SELECT W010UTFIL ASSIGN TO SAN01W01. + SELECT W020UTFIL ASSIGN TO SAN01W02. + SELECT W030UTFIL ASSIGN TO SAN01W03. + * + DATA DIVISION. + FILE SECTION. + * + ***************************************************************** + * ##RO1## * + ***************************************************************** + FD R01INNFIL + LABEL RECORD IS STANDARD + BLOCK CONTAINS 0 + RECORDING MODE IS F. + 01 RO1INNREC. + COPY BBBBBFC REPLACING ==(A)== BY ==R01==. + * + ***************************************************************** + * ##RO2## * + ***************************************************************** + FD R02INNFIL + LABEL RECORD IS STANDARD + BLOCK CONTAINS 0 + RECORDING MODE IS F. + 01 RO2INNREC. + COPY BBBBBFC REPLACING ==(A)== BY ==R02==. + * + ***************************************************************** + * ##WO1## * + ***************************************************************** + FD W010UTFIL + LABEL RECORD IS STANDARD + BLOCK CONTAINS 0 + RECORDING MODE IS F. + 01 WO10UTREC. + COPY BBBBBFC REPLACING ==(A)== BY ==WO1==. + * + ***************************************************************** + * ##WO2## * + ***************************************************************** + FD W020UTFIL + LABEL RECORD IS STANDARD + BLOCK CONTAINS 0 + RECORDING MODE IS F. + 01 WO20UTREC. + COPY BBBBBFC REPLACING ==(A)== BY ==WO2==. + * + ***************************************************************** + * ##W03## * + ***************************************************************** + FD W030UTFIL + LABEL RECORD IS STANDARD + BLOCK CONTAINS 0 + RECORDING MODE IS F. + 01 WO30UTREC. + COPY BBBBBFC REPLACING ==(A)== BY ==WO3==. + * + WORKING-STORAGE SECTION. + * + ***************************************************************** + * SYSIN領域 * + ***************************************************************** + *01 SYSARA. + * + ***************************************************************** + * コンスタント領域 * + ***************************************************************** + 01 CNSARA. + 03 CNS-PRGIDX PIC X(008) VALUE 'SAN01MAT'. + 03 CNS-UNYSUTKES-1 PIC X(001) VALUE '1'. + 03 CNS-CFKFCA PIC X(001) VALUE 'N'. + 03 CNS-MSGSTR PIC 9(003) VALUE 001. + 03 CNS-MSGFIN PIC 9(003) VALUE 002. + 03 CNS-MSGSUBEEK PIC 9(003) VALUE 005. + 03 CNS-MSGIINKES PIC 9(003) VALUE 006. + 03 CNS-MSGOUTKES PIC 9(003) VALUE 007. + 03 CNS-MSGKEYINF PIC 9(003) VALUE 033. + 03 CNS-KN0002 PIC 9(001) VALUE 2. + 03 CNS-ABD999 PIC 9(003) VALUE 999. + * + ***************************************************************** + * フラグ領域 * + ***************************************************************** + *01 FLGARA. + * + ***************************************************************** + * カウンタ領域 * + ***************************************************************** + 01 CUNARA. + 03 CUN-RO1INN PIC S9(009) COMP-3 VALUE ZERO. + 03 CUN-R02INN PIC S9(009) COMP-3 VALUE ZERO. + 03 CUN-WO1OUT PIC S9(009) COMP-3 VALUE ZERO. + 03 CUN-WO2OUT PIC S9(009) COMP-3 VALUE ZERO. + 03 CUN-WO3OUT PIC S9(009) COMP-3 VALUE ZERO. + * + ***************************************************************** + * 作業領域 * + ***************************************************************** + 01 WRKARA. + *** 運用日付 + 03 WRK-U06 PIC 9(008). + 03 WRK-U06ARA REDEFINES WRK-U06. + 05 WRK-UYOYMX PIC 9(006). + 05 WRK-UYOHII PIC 9(002). + *** マッチングキー + 03 WRK-R01KEY. + 05 WRK-R01KEY001 PIC X(010). + 05 WRK-R01KEY002 PIC X(010). + 05 WRK-R01KEY003 PIC X(010). + 03 WRK-R02KEY. + 05 WRK-R02KEY001 PIC X(010). + 05 WRK-R02KEY002 PIC X(010). + 05 WRK-R02KEY003 PIC X(010). + * + ***************************************************************** + * サブプログラム連絡領域 * + ***************************************************************** + *** 運用日付取得 + COPY D01010AC. + *** 入力件数表示/入力レコードシーケンスチェックSR用 + COPY S01MSGAC. + *** メッセージ編集出力SR用 + COPY MSG000AC. + * + PROCEDURE DIVISION. + ***************************************************************** + * サブモジュールNO: (0.0) * + * サブモジュール名: 制御処理 * + * 処理概要 : メインコントロール処理 * + ***************************************************************** + 0000MAJCOLSOR SECTION. + * + *** 初期処理 + PERFORM 1000ITTSOR. + * + *** メイン処理 + PERFORM 2000MAJSOR + UNTIL WRK-R01KEY = HIGH-VALUE + AND WRK-R02KEY = HIGH-VALUE. + * + *** 終了処理 + PERFORM 3000STPSOR. + * + 0000MAJCOLSOREXT. + GOBACK. + ***************************************************************** + * サブモジュールNO: (1.0) * + * サブモジュール名: 初期処理 * + * 処理概要 : 開始メッセージ出力・各種初期化処理 * + ***************************************************************** + 1000ITTSOR SECTION. + * + *** 開始メッセージ出力 + INITIALIZE M00MHOPAR. + MOVE CNS-MSGSTR TO M00MSGCOD. + PERFORM 4000MSGOUTSOR. + * + *** コンバイル日時出力 + INITIALIZE M00MHOPAR. + MOVE CNS-MSGKEYINF TO M00MSGCOD. + MOVE FUNCTION WHEN-COMPILED TO M00UMKDATS22(1). + MOVE 'COMPILED' TO M00UMKDATS22(2). + PERFORM 4000MSGOUTSOR. + * + *** ワークエリア初期化 + INITIALIZE WRKARA. + * + *** 運用日付取得 + INITIALIZE D01UBSPAR. + CALL 'DAT01S0' USING D01UBSPAR. + IF D01FKICOD = ZERO + MOVE D01U06 TO WRK-U06 + ELSE + INITIALIZE M00MHOPAR + MOVE CNS-MSGSUBEEK TO M00MSGCOD + MOVE 'DAT01SO' TO M00UMKDATS22(1) + MOVE D01FKICOD TO M00UMKDATS22(2) + PERFORM 4000MSGOUTSOR + PERFORM 9999ABDSOR + END-IF. + * + *** 入出力ファイルOPEN + OPEN INPUT R01INNFIL + R02INNFIL + OUTPUT W010UTFIL + W020UTFIL + W030UTFIL. + * + *** #R01#を読み込み + PERFORM 1100R01INNSOR. + *** #R02#を読み込み + PERFORM 1200R02INNSOR. + * + 1000ITTSOREXT. + EXIT. + ***************************************************************** + * サブモジュールNO:(1.1) * + * サブモジュール名:##RO1##読込処理 * + * 処理概要 :レコード読込・キー設定・SEQチェック処理 * + ***************************************************************** + 1100R01INNSOR SECTION. + * + READ R01INNFIL + AT END + MOVE HIGH-VALUE TO WRK-R01KEY + NOT AT END + ADD 1 TO CUN-RO1INN + MOVE R01A001 TO WRK-R01KEY001 + MOVE R01A002 TO WRK-R01KEY002 + MOVE R01A003 TO WRK-R01KEY003 + *** 入力件数表示/シーケンスチェック処理 + MOVE 'SAN01R01' TO S01DDM. + MOVE CNS-UNYSUTKES-1 TO SO1HYOKESKJCKUB + MOVE CNS-CFKFCA TO S01JKDKIYFLG + MOVE WRK-R01KEY TO SO1SEQCECKOM + COPY CHK01SRP REPLACING ==(A)==BY ==S01==. + END-READ. + * + 1100R01INNSOREXT. + EXIT. + ***************************************************************** + * サブモジュールNO:(1.2) * + * サブモジュール名:##RO2##読込処理 * + * 処理概要 :レコード読込・キー設定・SEQチェック処理 * + ***************************************************************** + 1200R02INNSOR SECTION. + * + READ R02INNFIL + AT END + MOVE HIGH-VALUE TO WRK-R02KEY + NOT AT END + ADD 1 TO CUN-R02INN + MOVE R02A001 TO WRK-R02KEY001 + MOVE R02A002 TO WRK-R02KEY002 + MOVE R02A003 TO WRK-R02KEY003 + *** 入力件数表示/シーケンスチェック処理 + MOVE 'SAN01R02' TO S01DDM. + MOVE CNS-UNYSUTKES-1 TO SO1HYOKESKJCKUB + MOVE CNS-CFKFCA TO S01JKDKIYFLG + MOVE WRK-R02KEY TO SO1SEQCECKOM + COPY CHK01SRP REPLACING ==(A)== BY ==S01==. + END-READ. + * + 1200R02INNSOREXT. + EXIT. + ***************************************************************** + * サブプログラムNO:(2.0) * + * サブプログラム名:主処理 * + * 処理概要 :マッチング(1:1)を行う * + ***************************************************************** + 2000MAJSOR SECTION. + * + EVALUATE TRUE + *** マッチ + WHEN WRK-R01KEY = WRK-R02KEY + *** #WO1#を編集出力処理 + PERFORM 2100W010UTSOR + * + *** #RO1#を読み込み + PERFORM 1100R01INNSOR + *** #RO2#を読み込み + PERFORM 1200R02INNSOR + * + *** アンマッチ(##R01##のみ) + WHEN WRK-R01KEY < WRK-R02KEY + *** #WO2#を編集出力処理 + PERFORM 2200W020UTSOR + * + *** #RO1#を読み込み + PERFORM 1100R01INNSOR + * + *** アンマッチ(##R02##のみ) + WHEN WRK-R01KEY > WRK-R02KEY + *** #WO3#を編集出力処理 + PERFORM 2300W030UTSOR + * + *** #R02#を読み込み + PERFORM 1200R02INNSOR + END-EVALUATE. + * + 2000MAJSOREXT. + EXIT. + ***************************************************************** + * サブモジュールNO:(2.1) * + * サブモジュール名:##WO1##編集出力処理 * + * 処理概要 :レコード編集・出力処理 * + ***************************************************************** + 2100W010UTSOR SECTION. + * + MOVE RO1INNREC TO W010UTREC. + MOVE R02INNREC TO W010UTREC. + * + WRITE WO1OUTREC. + ADD 1 TO CUN-WO1OUT. + + 2100W010UTSOREXT. + EXIT. + ***************************************************************** + * サブプログラムNO:(2.2) * + * サブプログラム名:##WO2#を編集出力処理 * + * 処理概要 :レコード編集・出力処理 * + ***************************************************************** + 2200W020UTSOR SECTION. + * + MOVE RO1INNREC TO W020UTREC. + * + WRITE WO2OUTREC. + ADD 1 TO CUN-WO2OUT. + * + 2200W020UTSOREXT. + EXIT. + ***************************************************************** + * サブプログラムNO:(2.3) * + * サブプログラム名:##WO3#を編集出力処理 * + * 処理概要 :レコード編集・出力処理 * + ***************************************************************** + 2300W030UTSOR SECTION. + * + MOVE R02INNREC TO W03OUTREC. + * + WRITE WO3OUTREC. + ADD 1 TO CUN-WO3OUT. + * + 2300W030UTSOREXT. + EXIT. + ***************************************************************** + * サブモジュールNO:(3.0) * + * サブモジュール名:終了処理 * + * 処理概要 :ファイルクローズ・件数と終了メッセージ出力 * + ***************************************************************** + 3000STPSOR SECTION. + * + *** 入出力ファイルCLOSE + CLOSE R01INNFIL + R02INNFIL + W010UTFIL + W020UTFIL + W030UTFIL. + * + *** 入出力ファイル件数出力 + INITIALIZE M00MHOPAR. + MOVE CNS-MSGIINKES TO M00MSGCOD. + MOVE 'SAN01R01' TO M00UMKDATS22(1). + MOVE CUN-RO1INN TO M00UMKDATS22(2). + PERFORM 4000MSGOUTSOR. + * + INITIALIZE M00MHOPAR. + MOVE CNS-MSGIINKES TO M00MSGCOD. + MOVE 'SAN01R02' TO M00UMKDATS22(1). + MOVE CUN-R02INN TO M00UMKDATS22(2). + PERFORM 4000MSGOUTSOR. + * + INITIALIZE M00MHOPAR. + MOVE CNS-MSGIINKES TO M00MSGCOD. + MOVE 'SAN01W01' TO M00UMKDATS22(1). + MOVE CUN-WO1OUT TO M00UMKDATS22(2). + PERFORM 4000MSGOUTSOR. + * + INITIALIZE M00MHOPAR. + MOVE CNS-MSGIINKES TO M00MSGCOD. + MOVE 'SAN01W02' TO M00UMKDATS22(1). + MOVE CUN-WO2OUT TO M00UMKDATS22(2). + PERFORM 4000MSGOUTSOR. + * + INITIALIZE M00MHOPAR. + MOVE CNS-MSGIINKES TO M00MSGCOD. + MOVE 'SAN01W03' TO M00UMKDATS22(1). + MOVE CUN-WO3OUT TO M00UMKDATS22(2). + PERFORM 4000MSGOUTSOR. + * + *** 終了メッセージ出力 + INITIALIZE M00MHOPAR. + MOVE CNS-MSGFIN TO M00MSGCOD. + PERFORM 4000MSGOUTSOR. + * + 3000STPSOREXT. + EXIT. + ***************************************************************** + * サブプログラムNO:(4.0) * + * サブプログラム名:メッセージ編集出力処理 * + * 処理概要 :メッセージ編集出力サブPGM呼出 * + ***************************************************************** + 4000MSGOUTSOR SECTION. + * + MOVE CNS-KN0002 TO M00KNOCOD + MOVE CNS-KN0002 TO M00SYUCOD. + MOVE CNS-PRGIDX TO M00PRGIDX. + CALL 'MSG0SRP' USING M00MSGPAR. + * + 4000MSGOUTSOREXT. + EXIT. + ***************************************************************** + * サブプログラムNO:(9.9) * + * サブプログラム名:ABEND処理 * + * 処理概要 :ABENDサブPGM呼出 * + ***************************************************************** + 9999ABDSOR SECTION. + * + MOVE CNS-ABD999 TO M00ABDCOD. + CALL 'END01TO' USING M00MSGPAR. + * + 9999ABDSOREXT. + EXIT. diff --git a/black-box-data-create/layout/sample_詳細設計書_SAN01MAT.md b/black-box-data-create/layout/sample_詳細設計書_SAN01MAT.md new file mode 100644 index 0000000..5549762 --- /dev/null +++ b/black-box-data-create/layout/sample_詳細設計書_SAN01MAT.md @@ -0,0 +1,141 @@ +# 詳細設計書 + +## 基本情報 + +| # | 項目 | 内容 | +|---|------|------| +| 1 | システム名 | サンプルシステム | +| 2 | プログラムID | SAN01MAT | +| 3 | プログラム名 | XXXXXX処理 | +| 4 | PGMパターン | マッチング(1:1) | +| 5 | 機能概要 | マッチング(1:1)を行う。 | +| 6 | | マッチの場合、ファイルW01を出力する。 | +| 7 | | ファイルR01のみの場合ファイルW02を出力する。 | +| 8 | | ファイルR02のみの場合ファイルW03を出力する。 | + +※PGMパターン:マッチング(1:1、1:N、M:N)、レイアウト編集のみ(GETPUT)、項目チェック、振り分け、キーブレイク、キーブレイク(集計、集約)、DB更新 + +### 前提条件 + +| NO | 対象ファイル | 条件 | +|----|-------------|------| +| 1 | ファイルR01 | キー項目A001>A002>A003で昇順でソート | +| 2 | ファイルR02 | キー項目A001>A002>A003で昇順でソート | + +### 使用ファイル一覧 + +| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 | +|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------| +| 1 | ファイルR01 | R01 | SAN01R01 | I | BBBBBFC | FB | | 120 | PS | | +| 2 | ファイルR02 | R02 | SAN01R02 | I | BBBBBFC | FB | | 120 | PS | | +| 3 | ファイルW01 | W01 | SAN01W01 | O | BBBBBFC | FB | | 120 | PS | | +| 4 | ファイルW02 | W02 | SAN01W02 | O | BBBBBFC | FB | | 120 | PS | | +| 5 | ファイルW03 | W03 | SAN01W03 | O | BBBBBFC | FB | | 120 | PS | | + +### キー項目一覧 + +| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) | +|----|-----------|---------------------|-------------------------------------------| +| 1 | ファイルR01 | A001>A002>A003(重複NG) | A001>A002>A003 | +| 2 | ファイルR02 | A001>A002>A003(重複NG) | A001>A002>A003 | + +### 使用モジュール一覧 + +| NO | 機能 | プログラムID | 使用COPY名 | +|----|------|-------------|-----------| +| 1 | 入力件数表示/シーケンスチェック処理SUB | CHK01SRP | S01MSGAC | +| 2 | 運用日取得SUB | DAT01S0 | D01010AC | +| 3 | メッセージ編集出力処理SUB | MSG0SRP | MSG000AC | +| 4 | ABEND処理SUB | END01TO | MSG000AC | + +--- + +## 処理詳細 + +``` +1.初期処理(1000ITTSOR) + 1-1.開始メッセージ出力 + 【メッセージ編集】 + メッセージ番号:1(開始メッセージ) + 1-2.コンパイル日時出力 + 【メッセージ編集】 + メッセージ番号:33(コンパイル日時) + PARM1:コンパイル日時 + PARM2:'COMPILED' + 1-3.ワークエリアの初期化 + 1-4.運用日取得SUB(DAT01S0)により運用日を取得する。 + 復帰コード≠ZEROの場合、メッセージを出力し、ABEND処理SUBを呼び出し異常終了する。 + 【メッセージ編集】 + メッセージ番号:5(サブエラー) + PARM1:'DAT01SO' + PARM2:復帰コード + 【ABEND処理SUB】 + ABENDコード:999 + 1-5.使用ファイルのオーブン + 1-6.R01を読み込む。(1100R01INNSOR)(1回目) + 1-7.R02を読み込む。(1200R02INNSOR)(1回目) + +2.主処理(2000MAJSOR)(R01、R02を全て読み終えるまで下記を繰り返す) + 2-1.マッチの場合 + 2-1-1.R01をW01にそのまま出力する。(2100W010UTSOR) + *出力編集(W01)を参照 + 2-1-2.R01を読み込む。(1100R01INNSOR)(2件目以降) + 2-1-3.R02を読み込む。(1200R02INNSOR)(2件目以降) + 2-2.R01のみの場合 + 2-2-1.R01をW02にそのまま出力する。(2200W020UTSOR) + *出力編集(W02)を参照 + 2-2-2.R01を読み込む。(1100R01INNSOR)(2件目以降) + 2-3.R02のみの場合 + 2-3-1.R02をW03にそのまま出力する。(2300W030UTSOR) + *出力編集(W03)を参照 + 2-3-2.R02を読み込む。(1200R02INNSOR)(2件目以降) + +3.終了処理(3000STPSOR) + 3-1.入出力ファイルのクローズ + 3-2.入出力件数出力メッセージ出力 + 【入力メッセージ編集】 + メッセージ番号:6(入力件数メッセージ) + PARM1:当該入力ファイルのDD名 + PARM2:当該入力ファイルの件数 + 【出力メッセージ編集】 + メッセージ番号:7(出力件数メッセージ) + PARM1:当該出力ファイルのDD名 + PARM2:当該出力ファイルの件数 + 3-3.終了メッセージ出力 + 【メッセージ編集】 + メッセージ番号:2(終了メッセージ) +``` + +--- + +## 出力レコード定義 + +### 出力ファイル1(W01) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | A001 | R01.同項目を設定 | | +| 2 | A002 | R01.同項目を設定 | | +| 3 | A003 | R01.同項目を設定 | | +| 4 | … | | | +| 5 | A020 | R01.同項目を設定 | | + +### 出力ファイル2(W02) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | A001 | R01.同項目を設定 | | +| 2 | A002 | R01.同項目を設定 | | +| 3 | A003 | R01.同項目を設定 | | +| 4 | … | | | +| 5 | A020 | R01.同項目を設定 | | + +### 出力ファイル3(W03) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | A001 | R02.同項目を設定 | | +| 2 | A002 | R02.同項目を設定 | | +| 3 | A003 | R02.同項目を設定 | | +| 4 | … | | | +| 5 | A020 | R02.同項目を設定 | | diff --git a/black-box-data-create/layout/ファイル定義書_xxxxxxx.md b/black-box-data-create/layout/ファイル定義書_xxxxxxx.md new file mode 100644 index 0000000..25de2e3 --- /dev/null +++ b/black-box-data-create/layout/ファイル定義書_xxxxxxx.md @@ -0,0 +1,142 @@ +# ファイル定義書 + +## 変更履歴 + +| No | 変更内容 | 担当者 | 変更日 | 承認者 | 備考 | +|----|---------|--------|--------|--------|------| +| 1 | 新規作成 | AI | YYYY/MM/DD | | | + +## Layout(レコードレイアウト) + +### ファイル基本情報 + +| ファイル名 | COPY ID | 媒体 | レコード形式 | レコード長 | +|-----------|---------|------|-------------|-----------| +| | | PS | FB | | + +### レコード定義 + +| No | レベル | 項目名 | 項目名(英字名) | 属性 | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 | +|----|--------|--------|---------------|------|---------|--------|---------|-----------|-------------|------| +| 1 | | | | | | | | | | | +| 2 | | | | | | | | | | | +| 3 | | | | | | | | | | | +| 4 | | | | | | | | | | | + +--- + +## 記載説明 + +| 項目 | 説明 | +|------|------| +| 媒体 | ファイル(PS)の場合は"PS"と記録 | +| レコード形式 | 固定長(FB)または可変長(VB)と記録 | +| レコード長 | レコード長は全項目のバイト数を合計して記録。FDのRECORD SIZEと一致させること。REDEFINES項目は合計に含まない。 | +| レベル | レベル番号の定義:01, 03, 05, 07, 09... と飛び飛びのレベル番号とする。同じレベル番号は兄弟関係となる。 | +| 属性(PIC TYPE) | 属性(TYPE)とバイト数を記入するものとする。原則PIC記法で記録。COBOL記述に沿ったシートの記載内容に準拠。 | + +## COBOLデータ型一覧 + +| No | COBOL記述 | 略記法 | バイト数 | 説明 | 日本語説明 | 例 | バイト数(例) | +|----|-----------|--------|---------|------|-----------|-----|-------------| +| 1 | PIC X(n) | X(n) | n | 英数字 | 固定長英数字 | X(10) | 10 | +| 2 | PIC G(n) | G(n) | n×2 | DBCS文字 | ダブルバイト文字(日本語) | G(20) | 40 | +| 3 | PIC 9(n) | 9(n) | n | 数字・10進(符号無) | 符号無し整数 | 9(7) | 7 | +| 4 | PIC S9(n) | S9(n) | n | 数字・10進(符号有) | 符号付き整数 | S9(7) | 7 | +| 5 | PIC S9(n)V9(m) | S9(n)V9(m) | n+m | 数字・10進(符号有・小数) | 暗黙小数点付き(DISPLAY) | S9(7)V9(2) | 9 | +| 6 | PIC S9(n) COMP-3 | S9(n) COMP-3 | INT((n+2)/2) | パック10進(符号有) | 内部10進数 | S9(7) COMP-3 | 4 | +| 7 | PIC 9(n) COMP-3 | 9(n) COMP-3 | INT((n+1)/2) | パック10進(符号無) | 内部10進数(符号無) | 9(7) COMP-3 | 4 | +| 8 | PIC S9(n)V9(m) COMP-3 | S9(n)V9(m) COMP-3 | INT((n+m+2)/2) | パック10進(符号有・小数) | 暗黙小数点付きCOMP-3 | S9(7)V9(2) COMP-3 | 5 | +| 9 | PIC S9(4) COMP | S9(4) COMP | 2 | 2進(2バイト) | 半語長バイナリ | S9(4) COMP | 2 | +| 10 | PIC S9(9) COMP | S9(9) COMP | 4 | 2進(4バイト) | 全語長バイナリ | S9(9) COMP | 4 | +| 11 | PIC S9(18) COMP | S9(18) COMP | 8 | 2進(8バイト) | 2語長バイナリ | S9(18) COMP | 8 | +| 12 | PIC S9(n)V9(m) COMP | S9(n)V9(m) COMP | n+m≦4→2, ≦9→4, ≦18→8 | 2進(符号有・小数) | 暗黙小数点付きCOMP | S9(9)V9(2) COMP | 8 | +| 13 | PIC S9(n) BINARY | S9(n) BINARY | n≦4→2, n≦9→4, n≦18→8 | 2進(バイナリ) | COMPと同一 | S9(15) BINARY | 8 | +| 14 | ZZZ9 | ZZZ9 | Zの数+9の数 | 数字編集パターン | ゼロ抑制編集 | ZZZZ9 | 5 | + +## COBOL記述例 + +### 例1: 基本レイアウト + +COBOL定義: +``` +01 TEST-RECORD. + 03 FIELD-1 PIC X(10). + 03 FIELD-2 PIC S9(7) COMP-3. + 03 FIELD-3 PIC 9(4). + 03 FIELD-4 PIC S9(9) COMP. + 03 FILLER PIC X(5). +``` + +FD記述: + +| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 | +|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------| +| 1 | 01 | テストレコード | TEST-RECORD | | | | 1 | | | | +| 2 | 03 | フィールド1 | FIELD-1 | X | 10 | | 1 | | TEST-RECORD | | +| 3 | 03 | フィールド2 | FIELD-2 | S9(7) COMP-3 | 5 | | 11 | | TEST-RECORD | | +| 4 | 03 | フィールド3 | FIELD-3 | 9(4) | 4 | | 16 | | TEST-RECORD | | +| 5 | 03 | 予約 | FILLER | X(5) | 5 | | 20 | | TEST-RECORD | | + +### 例2: REDEFINESを使用する場合 + +COBOL定義: +``` +01 REDEF-REC. + 03 BASE-FIELD PIC X(10). + 03 REDEF-FIELD REDEFINES BASE-FIELD. + 05 INFO-1 PIC X(5). + 05 INFO-2 PIC X(5). +``` + +FD記述: + +| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 | +|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------| +| 1 | 01 | REDEFINESレコード | REDEF-REC | | | | 1 | | | | +| 2 | 03 | ベースフィールド | BASE-FIELD | X(10) | 10 | | 1 | | REDEF-REC | | +| 3 | 03 | 再定義フィールド | REDEF-FIELD | | | | 11 | BASE-FIELD | REDEF-REC | | +| 4 | 05 | 情報1 | INFO-1 | 9(5) | 5 | | 11 | | REDEF-REC | | +| 5 | 05 | 情報2 | INFO-2 | 9(5) | 5 | | 16 | | REDEF-REC | | + +### 例3: OCCURSを使用する場合 + +COBOL定義: +``` +01 OCCURS-REC. + 03 OCCURS-ITEM OCCURS 3. + 05 SUB-1 PIC X(5). + 03 AFTER-ITEM PIC 9(3). +``` + +FD記述: + +| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 | +|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------| +| 1 | 01 | OCCURSレコード | OCCURS-REC | | | | 1 | | | | +| 2 | 03 | OCCURS項目 | OCCURS-ITEM | | | 3 | 1 | | OCCURS-REC | | +| 3 | 05 | サブ項目1 | SUB-1 | X(5) | 5 | | 1 | | OCCURS-REC | | +| 4 | 03 | OCCURS後 | AFTER-ITEM | 9(3) | 3 | | 16 | | OCCURS-REC | | + +### 例4: OCCURSの多重入れ子 + +COBOL定義: +``` +01 OCCURS-MULTI-REC. + 03 GRP-ITEM OCCURS 3. + 05 SUB1 PIC X(5). + 05 SUB2 OCCURS 4. + 07 SUB2-DATA PIC 9(3). + 03 AFTER-ITEM PIC 9(3). +``` + +FD記述: + +| No | レベル | 項目名 | 項目名(英字名) | 属性(PIC TYPE) | バイト数 | OCCURS | 開始位置 | REDEFINES | 所属グループ | 備考 | +|----|--------|--------|---------------|----------------|---------|--------|---------|-----------|-------------|------| +| 1 | 01 | OCCURS多重入れ子レコード | OCCURS-MULTI-REC | | | | 1 | | | | +| 2 | 03 | グループ項目 | GRP-ITEM | | | 3 | 1 | | OCCURS-MULTI-REC | | +| 3 | 05 | サブ項目1 | SUB-1 | X(5) | 5 | | 1 | | OCCURS-MULTI-REC | | +| 4 | 05 | サブ項目2 | SUB-2 | | | 4 | 6 | | OCCURS-MULTI-REC | | +| 5 | 07 | サブ項目2データ | SUB2-DATA | 9(3) | 3 | | 6 | | OCCURS-MULTI-REC | | +| 6 | 03 | OCCURS後 | AFTER-ITEM | 9(3) | 3 | | 52 | | OCCURS-MULTI-REC | | diff --git a/black-box-data-create/layout/詳細設計書_XXXXXXXX.md b/black-box-data-create/layout/詳細設計書_XXXXXXXX.md new file mode 100644 index 0000000..a36a817 --- /dev/null +++ b/black-box-data-create/layout/詳細設計書_XXXXXXXX.md @@ -0,0 +1,145 @@ +# 詳細設計書 + +## 基本情報 + +| # | 項目 | 内容 | +|---|------|------| +| 1 | システム名 | SSSSシステム | +| 2 | プログラムID | SSSnnFFF | +| 3 | プログラム名 | XXXXXX処理 | +| 4 | PGMパターン | | +| 5 | 機能概要 | | +| 6 | | | +| 7 | | | +| 8 | | | + +※PGMパターン:マッチング(1:1、1:N、M:N)、レイアウト編集のみ(GETPUT)、項目チェック、振り分け、キーブレイク、キーブレイク(集計、集約)、DB更新 + +### 前提条件 + +| NO | 対象ファイル | 条件 | +|----|-------------|------| +| 1 | | | +| 2 | | | + +### 使用ファイル一覧 + +| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 形式 | ブロック | レコード長 | 媒体 | 備考 | +|----|------------------|--------|------|-----|--------|------|---------|-----------|------|------| +| 1 | | | | | | | | | | | +| 2 | | | | | | | | | | | +| 3 | | | | | | | | | | | +| 4 | | | | | | | | | | | +| 5 | | | | | | | | | | | + +### キー項目一覧 + +| NO | ファイル名 | ソート条件(キー項目) | キー条件(マッチング/キーブレイク) | +|----|-----------|---------------------|-------------------------------------------| +| 1 | | | | +| 2 | | | | + +### 使用モジュール一覧 + +| NO | 機能 | プログラムID | 使用COPY名 | +|----|------|-------------|-----------| +| 1 | | | | +| 2 | | | | +| 3 | | | | +| 4 | | | | + +--- + +## 処理詳細 + +``` +大項目(1桁):「1.」「2.」「3.」のいずれか + 中項目(2桁):対応するパラグラフ名を括弧書きで併記 + 小項目(3桁):個別処理の説明 + 詳細:メッセージ番号、PARM、条件分岐、ABENDコード等 +``` + +### 処理詳細フォーマット(記入例) + +``` +1.初期処理 (1000ITTSOR) + 1-1.開始メッセージ出力 + メッセージ番号: 1(開始メッセージ) + 1-2.コンパイル日時出力 + メッセージ番号: 33(コンパイル日時) + PARM1: コンパイル日時 + PARM2: 'COMPILED' + 1-3.ワークエリア初期化 + 1-4.サブプログラム呼出(各種初期設定) + 戻りコードがZEROの場合、正常継続。 + それ以外の場合、メッセージ出力後、ABEND処理へ遷移。 + メッセージ番号: 5(サブエラー) + PARM1: 呼出プログラム名 + PARM2: 戻りコード + ABENDコード: 999 + 1-5.入出力ファイルOPEN + 1-6.入力ファイル1を読み込み。 + 1-7.入力ファイル2を読み込み。 + +2.主処理 (2000MAJSOR)(全入力ファイルを読み切り終了するまで繰り返し) + 2-1.条件Aの場合 + 2-1-1.出力ファイル1に出力する。 + 出力後、出力ファイル1のカウンタを加算 + 2-1-2.入力ファイル1を読み込み。 + 2-2.条件Bの場合 + 2-2-1.出力ファイル2に出力する。 + 出力後、出力ファイル2のカウンタを加算 + 2-2-2.入力ファイル2を読み込み。 + 2-3.条件Cの場合 + 2-3-1.出力ファイル3に出力する。 + 出力後、出力ファイル3のカウンタを加算 + 2-3-2.入力ファイル2を読み込み。 + +3.終了処理 (3000STPSOR) + 3-1.入出力ファイルCLOSE + 3-2.入出力ファイル件数出力 + メッセージ番号: 6(入力件数) + PARM1: 該当ファイルDD名 + PARM2: 該当ファイル件数 + メッセージ番号: 7(出力件数) + PARM1: 該当ファイルDD名 + PARM2: 該当ファイル件数 + 3-3.終了メッセージ出力 + メッセージ番号: 2(終了メッセージ) +``` + +--- + +## 出力レコード定義 + +出力ファイルごとに以下の形式で記述する。 + +### 出力ファイル1(例: W01) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | | | | +| 2 | | | | +| 3 | | | | +| 4 | | | | +| 5 | | | | + +### 出力ファイル2(例: W02) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | | | | +| 2 | | | | +| 3 | | | | +| 4 | | | | +| 5 | | | | + +### 出力ファイル3(例: W03) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | | | | +| 2 | | | | +| 3 | | | | +| 4 | | | | +| 5 | | | | diff --git a/black-box-data-create/main.py b/black-box-data-create/main.py new file mode 100644 index 0000000..17bacab --- /dev/null +++ b/black-box-data-create/main.py @@ -0,0 +1,67 @@ +# main.py +#!/usr/bin/env python +""" +COBOL テストデータ生成 Agent + +使用方法: + python main.py --design 詳細設計書_ZAN04MAT.md --source ZAN04MAT.cbl \ + --file-db-md COPY句定義書.md --cpy cpy/ --db-md DB定義書.md --output output/ +""" +import argparse +import os +import sys + +from agent import generate + +DEFAULT_RULES_DIR = os.path.join(os.path.dirname(__file__), 'rules') + + +def main(): + parser = argparse.ArgumentParser( + description='COBOLテストデータ生成Agent' + ) + + parser.add_argument('--design', required=True, help='詳細設計書 .md のパス') + parser.add_argument('--source', required=True, help='COBOL ソース .cbl のパス') + parser.add_argument('--file-db-md', required=True, help='ファイル/DB 構造 .md のパス') + parser.add_argument('--cpy', required=True, help='COPYBOOK 格納ディレクトリ') + parser.add_argument('--db-md', required=True, help='DB 定義書 .md のパス') + parser.add_argument('--output', default='output', help='出力ディレクトリ') + parser.add_argument('--api-key', default='sk-6156cccdc9c14d949cf5bfc5afc67a03', + help='DeepSeek API Key') + parser.add_argument('--model', default='deepseek-v4-flash', help='API モデル名') + parser.add_argument('--rules', default=DEFAULT_RULES_DIR, help='ルール格納ディレクトリ') + parser.add_argument('--max-tokens', type=int, default=32768, + help='API 生成トークン上限') + + args = parser.parse_args() + + for name, path in [('--design', args.design), ('--source', args.source)]: + if not os.path.exists(path): + print(f"エラー: {name} のファイルが見つかりません: {path}", file=sys.stderr) + sys.exit(1) + + result = generate( + design_md=args.design, + source_cbl=args.source, + file_db_md=args.file_db_md, + cpy_dir=args.cpy, + db_md=args.db_md, + output_dir=args.output, + api_key=args.api_key, + api_model=args.model, + rules_dir=args.rules, + max_tokens=args.max_tokens, + ) + + print(f"\n== 完了 ==") + print(f"プログラムID: {result['program_id']}") + print(f"グループ数: {result['groups']}") + print(f"入力タイプ: {result['input_type']}") + print(f"出力ファイル:") + for key, path in sorted(result['output_files'].items()): + print(f" {key}: {path}") + + +if __name__ == '__main__': + main() diff --git a/black-box-data-create/rules/pgm_pattern/1-N+キーブレイク(異キー).md b/black-box-data-create/rules/pgm_pattern/1-N+キーブレイク(異キー).md new file mode 100644 index 0000000..836a653 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/1-N+キーブレイク(異キー).md @@ -0,0 +1,57 @@ +# 1:N+キーブレイク(異キー) データ生成規則 + +## 概要 + +1:Nマッチングの処理中に、マッチしたグループ内でキーが異なるタイミング(異キーブレイク)を検知して処理を分岐する混合型プログラムのテストデータ。 + +本规则定义4组数据,覆盖基本异key切、多组/单件组、異常系、件数確認。 + +## 输入 + +- 詳細設計書のキー項目一覧(マッチキー、ブレイクキー) +- 各入力ファイルのCOPYBOOKフィールド定義 +- キー条件:マッチキー・ブレイクキーとも昇順ソート済み + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 基本异key切(AM-N007) | 1:Nマッチ内でブレイクキーが変わるグループを複数含むデータ | +| 2 | 多组/单件组 | グループ数が複数、1件のみのグループを含むデータ | +| 3 | 異常系(KB-A001/KB-A002) | 前ブレイクキー未初期化、累加器/カウンタ溢出 | +| 4 | 件数確認(AM-R001/KB-R001) | マッチ件数とグループ数の整合確認データ | + +### 组1:基本异key切(AM-N007) + +マッチキー a001 に対し R02 が3件(ブレイクキー b001,b002,b003)。1:Nマッチしながらブレイクキーが変わるたびに処理が分岐することを確認。 + +### 组2:多组/单件组 + +マッチキー a001(3件)・a002(1件)・a003(2件)。複数グループと単件グループの両方を含むデータ。 + +### 组3:異常系(KB-A001/KB-A002) + +- 先頭レコードのブレイクキーが通常値(WS-PREV-KEY未初期化時の誤中断確認) +- 集約対象(件数・合計)がPIC定義を超えるデータ(SIZE ERROR確認) + +### 组4:件数確認(AM-R001/KB-R001) + +マッチ件数(正常+剩余)の合計が入力件数と一致し、グループ数=キー変化回数+1 となるデータ。 + +## 多keyテスト + +マッチキーまたはブレイクキーが複数項目の場合、各組末尾に多keyテストを追加(既存マッチング規則と同じ手順)。 + +## データ生成手順 + +1. マッチキー・ブレイクキーの特定 +2. 组1/组2: 正常系データ +3. 组3: 異常系データ +4. 各組末尾に多keyテスト +5. 生成後自检:マッチ件数合計とグループ数の整合確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的4组一致(g1〜g4),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/100分割.md b/black-box-data-create/rules/pgm_pattern/100分割.md new file mode 100644 index 0000000..b6438f2 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/100分割.md @@ -0,0 +1,34 @@ +# 100分割 データ生成規則 + +## 概要 + +入力ファイルのレコードを100件ずつ分割し、複数の出力ファイルに振り分けるプログラムのテストデータ。 + +本规则定义5组数据,覆盖分割数的边界场景(整数倍、余数、不足、最小、分割数>记录数)。 + +## 输入 + +- 入力ファイルのCOPYBOOKフィールド定義 + +## 生成データ組 + +| 组 | 用途 | 记录数 | 说明 | +|----|------|--------|------| +| 1 | 正好100件 | 100 | 1ファイルに100件| +| 2 | 超过100件(余1件) | 101 | 100件ファイル+余1件ファイル| +| 3 | 不足100件 | 99 | 最終ファイルが99件| +| 4 | 分割数=1 | 1 | 1件入力→1ファイルのみ出力 | +| 5 | 分割数>记录数 | 3 | 出力ファイルは少数 | + +## データ生成手順 + +1. 入力ファイルのフィールド定義を取得 +2. 各組の记录数に従いレコードを生成 +3. 各フィールドはPIC定義に従い実在しそうな値を生成し、隣接レコード間で同じ項目に異なる値を設定 +4. 生成後自检(S-R001/S-R002):出力ファイル命名规则通りであること、各ファイル件数の合計=入力件数であることを確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的5组一致(g1〜g5),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/25分割.md b/black-box-data-create/rules/pgm_pattern/25分割.md new file mode 100644 index 0000000..995894c --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/25分割.md @@ -0,0 +1,34 @@ +# 25分割 データ生成規則 + +## 概要 + +入力ファイルのレコードを25件ずつ分割し、複数の出力ファイルに振り分けるプログラムのテストデータ。 + +本规则定义5组数据,覆盖分割数的边界场景(整数倍、余数、不足、最小、分割数>记录数)。 + +## 输入 + +- 入力ファイルのCOPYBOOKフィールド定義 + +## 生成データ組 + +| 组 | 用途 | 记录数 | 说明 | +|----|------|--------|------| +| 1 | 正好25件 | 25 | 1ファイルに25件 | +| 2 | 超过25件(余1件) | 26 | 25件ファイル+余1件ファイル | +| 3 | 不足25件 | 24 | 最終ファイルが24件 | +| 4 | 分割数=1 | 1 | 1件入力→1ファイルのみ出力 | +| 5 | 分割数>记录数 | 3 | 出力ファイルは少数 | + +## データ生成手順 + +1. 入力ファイルのフィールド定義を取得 +2. 各組の记录数に従いレコードを生成 +3. 各フィールドはPIC定義に従い実在しそうな値を生成し、隣接レコード間で同じ項目に異なる値を設定 +4. 生成後自检(S-R001/S-R002):出力ファイル命名规则通りであること、各ファイル件数の合計=入力件数であることを確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的5组一致(g1〜g5),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/50分割.md b/black-box-data-create/rules/pgm_pattern/50分割.md new file mode 100644 index 0000000..98250ae --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/50分割.md @@ -0,0 +1,34 @@ +# 50分割 データ生成規則 + +## 概要 + +入力ファイルのレコードを50件ずつ分割し、複数の出力ファイルに振り分けるプログラムのテストデータ。 + +本规则定义5组数据,覆盖分割数的边界场景(正好、余数、不足、最小、分割数>记录数)。 + +## 输入 + +- 入力ファイルのCOPYBOOKフィールド定義 + +## 生成データ組 + +| 组 | 用途 | 记录数 | 说明 | +|----|------|--------|------| +| 1 | 正好50件 | 50 | 1ファイルに50件 | +| 2 | 超过50件(余1件) | 51 | 50件ファイル+余1件ファイル | +| 3 | 不足50件 | 49 | 最終ファイルが49件 | +| 4 | 分割数=1 | 1 | 1件入力→1ファイルのみ出力 | +| 5 | 分割数>记录数 | 3 | 出力ファイルは少数 | + +## データ生成手順 + +1. 入力ファイルのフィールド定義を取得 +2. 各組の记录数に従いレコードを生成 +3. 各フィールドはPIC定義に従い実在しそうな値を生成し、隣接レコード間で同じ項目に異なる値を設定 +4. 生成後自检(S-R001/S-R002):出力ファイル命名规则通りであること、各ファイル件数の合計=入力件数であることを確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的5组一致(g1〜g5),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/ASCII→EBCDIC変換.md b/black-box-data-create/rules/pgm_pattern/ASCII→EBCDIC変換.md new file mode 100644 index 0000000..421a3f4 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/ASCII→EBCDIC変換.md @@ -0,0 +1,58 @@ +# ASCII→EBCDIC変換 データ生成規則 + +## 概要 + +ASCIIコードの入力データを変換表によりEBCDICコードに変換して出力するプログラムのテストデータ。 + +本规则定义5组数据,覆盖全文字変換、逆方向、制御文字、異常系、レコード長確認。 + +## 输入 + +- 詳細設計書の変換表定義(コード対応表) +- 入力・出力レコード定義 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | ASCII→EBCDIC 全文字(AE-N001) | 印字可能文字全種を含むデータ | +| 2 | EBCDIC→ASCII 逆方向(AE-N002) | 逆変換の一致確認データ | +| 3 | 制御文字保持(AE-N003) | 改行・タブ等の制御文字 | +| 4 | 異常系(AE-A001〜AE-A003) | 変換表にない文字、往復不一致、全角/半角混在 | +| 5 | レコード長確認(AE-R001) | 変換前後でレコード長が一致するデータ | + +### 组1:ASCII→EBCDIC 全文字(AE-N001) + +英大文字・英小文字・数字・記号など、印字可能文字全種を含むデータ。変換表の全エントリが正しく変換されることを確認。 + +### 组2:EBCDIC→ASCII 逆方向(AE-N002) + +逆方向の変換データ。組1の変換結果を逆変換したときに元の値と一致することを確認。 + +### 组3:制御文字保持(AE-N003) + +改行・タブなどの制御文字を含むデータ。変換後も制御文字が保持されることを確認。 + +### 组4:異常系(AE-A001〜AE-A003) + +- 変換表にない文字(未定義コード)→ 代替文字またはエラー処理 +- 往復一致しない文字(ASCII→EBCDIC→ASCII で値が変わる) +- 全角文字がPIC X領域に入った場合の文字化け + +### 组5:レコード長確認(AE-R001) + +変換前後のレコード長が一致することを確認するデータ(固定長前提)。 + +## データ生成手順 + +1. 変換表とレコード定義の特定 +2. 组1〜组3: 正常系データ +3. 组4: 異常系データ +4. 组5: レコード長確認データ +5. 生成後自检(AE-R001):変換後レコード長=変換前レコード長 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的5组一致(g1〜g5),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/CSV→FB変換.md b/black-box-data-create/rules/pgm_pattern/CSV→FB変換.md new file mode 100644 index 0000000..4b64504 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/CSV→FB変換.md @@ -0,0 +1,58 @@ +# CSV→FB変換 データ生成規則 + +## 概要 + +CSV形式の入力ファイルを読み取り、固定長(FB)レコードに変換して出力するプログラムのテストデータ。 +CSVのカンマ区切り分解、引用符内改行処理、項目チェックを含む。改行なし(STRING合并、CF-N001)と改行あり(改行展開、CF-N002)の両方の変換方式に対応する。 + +本规则定义4组数据,覆盖正常、异常、引用符・改行、空項目・最大長。 + +## 输入 + +- 入力CSVの項目定義(処理詳細から特定) +- 出力COPYBOOKのフィールド定義 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 正常データ | 全項目が正しいCSVレコード 3〜5件。異なる値のバリエーションを確保。改行なし変換(STRING合并、CF-N001)のケースを含む | +| 2 | 異常データ | 項目数不足、項目数過剰、不正な値、改行位置がRECORD長超過(CF-A001)、引用符未閉じ(CF-A002)のレコード | +| 3 | 引用符・改行(CF-N002〜N004) | 改行展開(1行入力→複数行出力)、項目内カンマ引用符付き、引用符内改行保持 | +| 4 | 空項目・最大長(CF-N005/N006) | 連続カンマ→空文字列処理、各項目がPIC定義ギリギリの最大長 | + +### 组1:正常データ + +全項目が正しいCSVレコード。値をPIC定義に従い生成し、隣接レコード間で異なる値のバリエーションを確保。 + +### 组2:異常データ + +- 項目数不足・項目数過剰 +- 不正な値(フォーマット違反) +- 改行位置がRECORD長超過(CF-A001: 改行展開後1行が定義長を超える) +- 引用符未閉じ(CF-A002: 閉じ"がない→残り全部1項目) + +### 组3:引用符・改行(CF-N002〜N004) + +- 有换行(改行展开): 1行入力→複数行出力(改行位置) +- 項目内カンマ引用符付き(CF-N003) +- 引用符付き文字列(CF-N004): "..."内のカンマ/改行を保持 + +### 组4:空項目・最大長(CF-N005/N006) + +- 空項目連続カンマ→空文字列処理(CF-N005) +- 全項目最大長(CF-N006): 各項目がPIC定義ギリギリ + +## データ生成手順 + +1. CSVの項目数と項目名を処理詳細から特定 +2. 正常データ: カンマ区切りCSV行文字列を生成。値はPIC定義に従う +3. 異常データ: 項目数不一致、空値、不正フォーマット、改行位置超過、引用符未閉じを含む +4. 引用符・改行データ: 引用符内カンマ・改行、改行展開を含む +5. 空項目・最大長データ: 連続カンマと各項目最大長を含む + +## 出力 + +入力はCSV文字列(カンマ区切り)として1フィールドに格納。每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的4组一致(g1〜g4),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/DB更新.md b/black-box-data-create/rules/pgm_pattern/DB更新.md new file mode 100644 index 0000000..dda2fe3 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/DB更新.md @@ -0,0 +1,88 @@ +# DB更新 データ生成規則 + +## 概要 + +入力ファイルのレコードをDBテーブルにINSERT/UPDATE/DELETEするプログラムのテストデータを生成する。 +出力はSQL INSERTファイル。 + +本规则定义4组数据:组1正常INSERT、组2 INSERT+UPDATE、组3 DELETE対象(无DELETE则以UPDATE/INSERT代替)、组4重复INSERT异常(DB-A001)。 + +## 输入 + +- 詳細設計書の処理詳細(SQL操作と分岐条件) +- 入力ファイルのCOPYBOOKフィールド定義 +- DBテーブル定義と主キー +- キー項目一覧 + +## 生成データ組 + +| 组 | 用途 | 内容 | 条件 | +|----|------|------|------| +| 1 | 正常INSERT | 5件以上の新規INSERT + 末尾に多keyテスト | 必須 | +| 2 | INSERT+UPDATE | 3件以上の重複主キーレコード(UPDATEトリガー用)+ 末尾に多keyテスト | 必須 | +| 3 | DELETE対象 | 2件以上のDELETE対象レコード + 末尾に多keyテスト | 処理詳細にDELETE操作がある場合はDELETE対象、ない場合はUPDATE/INSERT対象で代替 | +| 4 | 重复INSERT异常(DB-A001) | 既存主キーを再度INSERTするレコード(一意キー違反→SQLCODEエラー確認)+ 末尾に多keyテスト | 必須 | + +## 多keyテスト(各組の末尾に追加) + +キーが複数項目(例: A001, A002, A003)で構成される場合、以下のテストデータを追加する(キー項目が1つの場合は追加しない)。 + +**手順:** +1. 該当グループ内から1件のレコードを選び、基準データとする +2. キー項目がN個ある場合、N件の追加レコードを生成する +3. 各追加レコードは「1つのキー項目のみ基準データと異なり、残りのキー項目は基準データと完全一致」とする +4. 非キー項目(DATA部分)は基準データと同じ値でよい + +**例: キーが (A001, A002, A003) の場合、基準データ (a001, b001, c001) に対し:** +- 追加1: (a002, b001, c001) → A001のみ異なる → INSERTされるべき(別レコード) +- 追加2: (a001, b002, c001) → A002のみ異なる → INSERTされるべき(別レコード) +- 追加3: (a001, b001, c002) → A003のみ異なる → INSERTされるべき(別レコード) + +これにより、プログラムが正しいキー項目で比較していることを検証する。 + +## データ生成手順 + +1. **SQL操作の特定**: 処理詳細からINSERT/UPDATE/DELETE/UPSERTの有無を確認する +2. **キー項目の特定**: キー項目一覧からDBテーブルの主キーを特定する +3. **フィールド値生成**: 入力COPYBOOKの各フィールドに対しPIC定義に従い値を生成: + - PIC X(n): 左詰め・スペース埋め + - PIC 9(n): 右詰め・先行ゼロ + - PIC S9(n) COMP / COMP-3: 通常の10進数文字列(符号付き・なし) + - DATE / 日付系フィールド: 実在しそうな日付(YYYYMMDD形式) + - TIME / 時刻系フィールド: 実在しそうな時刻(HHMM形式) + - ID / 番号系フィールド: 連番 + - NAME系フィールド: 実在しそうな氏名 + - 金額 / SALARY系: 現実的な金額 +4. **組1 (正常INSERT)**: + - 全レコードの主キーを重複なしで生成 + - 各レコードの非キーフィールドは異なる値を設定 + - 末尾に多keyテストデータを追加 +5. **組2 (INSERT+UPDATE)**: + - 一部のレコードの主キーを組1と重複させる + - 重複レコードの非キーフィールド値は組1と異なる値にする + - 末尾に多keyテストデータを追加 +6. **組3 (DELETE対象)**: + - STATUS='9'などの削除フラグ付きデータ(処理詳細にDELETE操作がない場合はUPDATE/INSERT対象データで代替) + - 末尾に多keyテストデータを追加 +7. **組4 (重复INSERT异常)**: + - 組1で既に存在する主キーを再度INSERTするレコードを生成 + - 期待: 一意キー違反→SQLCODEエラー/設計書どおりのエラー処理 + - 末尾に多keyテストデータを追加 + +## 出力形式 + +各組1つのSQLファイル: `{プログラムID}_g{groupId}.sql` + +```sql +-- Group: 1 +-- 用途: 正常INSERT + 多keyテスト +INSERT INTO TABLE_NAME (COL1, COL2, ...) VALUES +('val1', 'val2', ...), +('val3', 'val4', ...); +``` + +**注意:** +- 出力は純粋なSQL INSERT文のみ(コードブロックで囲まない) +- 複数行を1つのINSERT文にまとめる(VALUESの後にカンマ区切りで複数行) + +组数约束:输出组数必须与本规则定义的4组一致(g1〜g4),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/GETPUT(編集出力).md b/black-box-data-create/rules/pgm_pattern/GETPUT(編集出力).md new file mode 100644 index 0000000..8ab739e --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/GETPUT(編集出力).md @@ -0,0 +1,28 @@ +# GETPUT(編集出力)データ生成規則 + +## 概要 + +DBまたはファイルからデータを読み取り、編集・整形して出力するプログラムのテストデータ。 +レイアウト編集のみ(GETPUT)と同様のシンプルなREAD→編集→WRITEパターン。 + +## 入力 + +- 入力ソースのフィールド定義(COPYBOOKまたはDBテーブル定義) +- 出力レコード定義 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 基本データ | 通常値2〜5件。各フィールドに有効な値を設定。異なる値のバリエーションを確保 | + +## データ生成手順 + +1. 入力フィールドを特定 +2. 各フィールドのPIC定義に従い値を生成 +3. 実在しそうな値(日付・時刻・金額・氏名等)を生成 +4. レコード間で異なる値を設定 + +## 出力 + +各組1 JSONファイル。DBからの入力の場合はSQLファイルも出力。 diff --git a/black-box-data-create/rules/pgm_pattern/MERGE.md b/black-box-data-create/rules/pgm_pattern/MERGE.md new file mode 100644 index 0000000..74eafd9 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/MERGE.md @@ -0,0 +1,77 @@ +# MERGE(複数ファイル結合) データ生成規則 + +## 概要 + +複数の入力ファイルをキーでソート・マージし、統合された1つの出力ファイルを生成するプログラムのテストデータ。 + +本规则定义4组数据,覆盖全文件合并、键边界、重复键、未排序输入。 + +## 输入 + +- 詳細設計書のキー項目一覧(マージキー) +- 各入力ファイルのCOPYBOOKフィールド定義 +- キー条件:正常系は昇順ソート済み入力、異常系(组4)は故意に乱序 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 全ファイルカバー | 全入力ファイルのデータが出力に反映されるデータ + 末尾多keyテスト | +| 2 | キー境界 | 同一キー値が複数ファイルに分散しているケース + 末尾多keyテスト | +| 3 | 重复键(MR-N003) | 同一キーが複数ファイルに同値で出現するケース(同キー順序保持確認)+ 末尾多keyテスト | +| 4 | 未排序输入(MR-A001) | 入力ファイルが昇順でないデータ(誤マージ確認) | + +### 组1:全ファイルカバー(MR-N001) + +R01: a001→a003→a004 +R02: a001→a002→a003 + +マージ後キー順序(期待): a001→a002→a003→a004 + +### 组2:キー境界(MR-N002) + +R01: a001→a002→a003 +R02: a002 + +同一キーa002が両ファイルに存在(境界でのマージ順序確認)。 + +### 组3:重复键(MR-N003) + +R01: a001→a002→a002→a003 +R02: a002→a002→a004 + +同一キーa002が両ファイルに計4件(同キーの順序保持を確認)。 + +### 组4:未排序输入(MR-A001) + +R01: a001→a003→a002→a004(a003先于a002,违反升序) +R02: a001→a002→a003→a004(正常升序) + +说明:昇順前提が破られた入力でプログラムの誤マージ/未期待動作を確認する(不要求"正确合并")。 + +## 多keyテスト + +キーが複数項目の場合、各組末尾に追加。各キー項目のみ異なるN件を生成し、正しいキーでマージされることを検証。 + +需要注意当key为n个项目组合而成时,需要在各組末尾追加n条数据。以基准数据的所有key为准,只有当前第n个key与基准不一致。保证key的比较没有问题。 + +例(n=3,基准 (a001, b001, c001)): +- 追加1: (a002, b001, c001) → 仅第1个key不同 +- 追加2: (a001, b002, c001) → 仅第2个key不同 +- 追加3: (a001, b001, c002) → 仅第3个key不同 + +n为实际解析出的key项目数(例中n=3仅为示意,追加条数必须等于实际key项目数)。键项目数n=1时不追加多key测试数据。追加的每条记录必须恰好只改变一个key项目,且不得与既有记录完全重复。 + +## データ生成手順 + +1. マージキー項目を特定(キー項目一覧から) +2. 各組のキー構成に従いレコードを生成(键值升序,组4除外) +3. 非キーフィールドはPIC定義に従い実在しそうな値を生成し、隣接レコード間で異なる値を設定 +4. 組1〜組3末尾に多keyテストデータを追加 +5. 生成後自检(MR-R001相当):マージ後件数 = 全入力ファイル件数の合計 を確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的4组一致(g1〜g4),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/SELECT処理.md b/black-box-data-create/rules/pgm_pattern/SELECT処理.md new file mode 100644 index 0000000..8eab76b --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/SELECT処理.md @@ -0,0 +1,50 @@ +# SELECT処理 データ生成規則 + +## 概要 + +DBテーブルから条件付きSELECTでデータを取得し、取得結果に基づいて処理を行うプログラムのテストデータ。入力はDB(SELECT結果)であり、出力はSQL INSERTファイル。 + +本规则定义3组数据,覆盖基本匹配/非匹配、0件、NULL处理。 + +## 输入 + +- 詳細設計書の処理詳細(SELECT文のWHERE条件) +- DBテーブル定義(テーブル名、カラム、主キー) + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 基本データ | SELECT条件にマッチするデータ + マッチしないデータ + 末尾多keyテスト | +| 2 | SELECT 0件(DB-N006) | 条件に合うレコードが0件になるデータ + 末尾多keyテスト | +| 3 | NULL処理(DB-A003) | NULL項目を含むデータ(MOVE/FETCH時のNULL扱い確認)+ 末尾多keyテスト | + +## 多keyテスト + +WHERE条件に複数キーが関わる場合、基準データに対し各キー項目のみ異なるN件を生成。マッチするパターンとマッチしないパターンの両方をカバー。 + +需要注意当key为n个项目组合而成时,需要追加n条数据。以基准数据的所有key为准,只有当前第n个key与基准不一致。保证key的比较没有问题。 + +例(n=3,基准 (a001, b001, c001)): +- 追加1: (a002, b001, c001) → 仅第1个key不同 +- 追加2: (a001, b002, c001) → 仅第2个key不同 +- 追加3: (a001, b001, c002) → 仅第3个key不同 + +n为实际解析出的key项目数(例中n=3仅为示意,追加条数必须等于实际key项目数)。键项目数n=1时不追加多key测试数据。追加的每条记录必须恰好只改变一个key项目,且不得与既有记录完全重复。 + +## データ生成手順 + +1. SELECT文のWHERE条件を抽出 +2. 条件にマッチするレコードを生成(组1) +3. 条件にマッチしないレコード(境界値)を生成(组1) +4. 条件に合うレコードが存在しないデータを生成(组2) +5. NULL項目を含むデータを生成(组3) +6. 各組末尾に多keyテストデータを追加 + +注:存在しないテーブルへのSELECT(DB-A002)はSQL実行環境で確認する。 + +## 出力 + +每组1个SQL INSERT文件:`{プログラムID}_g{groupId}.sql` + +组数约束:输出组数必须与本规则定义的3组一致(g1〜g3),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/SORT.md b/black-box-data-create/rules/pgm_pattern/SORT.md new file mode 100644 index 0000000..bc23139 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/SORT.md @@ -0,0 +1,64 @@ +# SORT データ生成規則 + +## 概要 + +SORT文(USING/GIVING、INPUT PROCEDURE、OUTPUT PROCEDURE)によりデータを並べ替えるプログラムのテストデータ。 + +本规则定义6组数据,覆盖升序、降序/多键、重复键/0条/1条、INPUT PROCEDURE、OUTPUT PROCEDURE、異常系。 + +## 输入 + +- 詳細設計書の処理詳細(SORTキー、昇順/降順、PROCEDUREの有無) +- 入出力ファイルのCOPYBOOKフィールド定義 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 简单升序(SR-N001) | SORT USING/GIVING 昇順、複数レコード | +| 2 | 降序+多键(SR-N002/SR-N003) | DESCENDING指定、第1キー+第2キーの複合キー | +| 3 | 重复键/边界(SR-N004〜SR-N006) | 同キー順序保持、0件、1件 | +| 4 | INPUT PROCEDURE(SR-N007/SR-N009) | ソート前の選別・編集(RELEASE送出) | +| 5 | OUTPUT PROCEDURE(SR-N008/SR-N010) | ソート後の集計・レポート(RETURN受取) | +| 6 | 異常系(SR-A002/SR-A003) | INPUT内でRELEASE漏れ(データ消失)、OUTPUT内でRETURN漏れ(無限ループ) | + +### 组1:简单升序(SR-N001) + +キー: c003→a001→b002(任意順の入力)→ 昇順 a001→b002→c003 に並ぶデータ。 + +### 组2:降序+多键(SR-N002/SR-N003) + +- 降順(DESCENDING)データ +- 第1キー+第2キー(例: 部署コード昇順+社員番号昇順)の複合キーデータ + +### 组3:重复键/边界(SR-N004〜SR-N006) + +- 同一キーが複数件あるデータ(安定ソートで入力順が維持されること) +- 0件のデータ(空ファイル) +- 1件のデータ + +### 组4:INPUT PROCEDURE(SR-N007/SR-N009) + +ソート前に条件で選別するデータ(例: ステータス='1'のみRELEASE)。選別漏れがないことを確認。 + +### 组5:OUTPUT PROCEDURE(SR-N008/SR-N010) + +ソート後にRETURNで受け取り集計・レポート出力するデータ。グループ集計が正しいことを確認。 + +### 组6:異常系(SR-A002/SR-A003) + +- INPUT PROCEDURE内でRELEASE漏れが起きるケース(データ消失の検証) +- OUTPUT PROCEDURE内でRETURN漏れが起きるケース(無限ループの検証) + +## データ生成手順 + +1. SORTキー・昇降順・PROCEDURE有無の特定 +2. 组1〜组5: 正常系データ +3. 组6: 異常系データ +4. 生成後自检(SR-R001):RC=0で正常終了。RC=16/20は環境依存(SR-R002/R003) + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的6组一致(g1〜g6),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/SYSIN読込.md b/black-box-data-create/rules/pgm_pattern/SYSIN読込.md new file mode 100644 index 0000000..c176e17 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/SYSIN読込.md @@ -0,0 +1,57 @@ +# SYSIN読込 データ生成規則 + +## 概要 + +JCLのSYSIN(制御カード)を読み込み、パラメータを解析して処理を分岐するプログラムのテストデータ。 + +本规则定义5组数据,覆盖正常参数、多卡连续、终了处理、異常系、解析結果確認。 + +## 输入 + +- 詳細設計書の処理詳細(SYSINカードのレイアウト、カード種別) +- パラメータ項目定義 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 正常参数读取(SY-N001) | 標準パラメータカード→正しく解析 | +| 2 | 多卡连续(SY-N002) | 複数カードを順次読み取り | +| 3 | 终了处理(SY-N003) | SYSINを読み終えた後の正常継続 | +| 4 | 異常系(SY-A001〜SY-A003) | フォーマット不正、0件(デフォルト処理)、OPEN失敗(環境テスト) | +| 5 | 解析結果確認(SY-R001) | 解析後のパラメータ値が期待どおり | + +### 组1:正常参数读取(SY-N001) + +標準的なパラメータカード1枚のデータ(例: 'T' カード)。カード種別の識別とパラメータ解析が正しいことを確認。 + +### 组2:多卡连续(SY-N002) + +複数カード(例: 'T' カード+'P' カード+'M' カード)を順次読み取るデータ。全カードが正しく処理されることを確認。 + +### 组3:终了处理(SY-N003) + +SYSINを読み終えた(EOF)後に正常に後続処理へ進むデータ。ファイル終了時の処理漏れがないことを確認。 + +### 组4:異常系(SY-A001〜SY-A003) + +- カードのフォーマット不正(項目数不足・不正値)→ エラー処理/ABEND +- SYSIN 0件(カードなし)→ デフォルト値で処理 +- SYSINファイル不存在(OPEN STATUS 35)→ 環境テスト項目 + +### 组5:解析結果確認(SY-R001) + +解析後のパラメータ値が期待どおりであることを確認するデータ(カード種別ごとの分岐結果を含む)。 + +## データ生成手順 + +1. カードレイアウト・カード種別の特定 +2. 组1〜组3: 正常系データ +3. 组4: 異常系データ +4. 组5: 解析結果の自检 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的5组一致(g1〜g5),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/オンラインPGM.md b/black-box-data-create/rules/pgm_pattern/オンラインPGM.md new file mode 100644 index 0000000..35f39b6 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/オンラインPGM.md @@ -0,0 +1,53 @@ +# オンラインPGM データ生成規則 + +## 概要 + +CICSオンライン(DFHCOMMAREA・MAP送受信)で画面入出力とDBアクセスを行うプログラムのテストデータ。 + +※ 現システム(バッチ中心)には該当プログラムがないため、将来のオンライン追加用に用意する。 + +本规则定义4组数据,覆盖COMMAREA受信、MAP入出力/画面遷移、異常系、応答確認。 + +## 输入 + +- 詳細設計書の処理詳細(DFHCOMMAREA項目、MAP名、画面遷移) +- 連絡領域(COMMAREA)のレイアウト定義 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | COMMAREA受信+MAP入出力(OL-N001/N002) | 渡されたCOMMAREAを正しく受信、SEND/RECEIVE MAP | +| 2 | 画面入力→DB検索→遷移(OL-N003/N004) | 画面入力値をキーにDB検索→結果表示、画面A→B→Cのデータ引継ぎ | +| 3 | 異常系(OL-A001/OL-A002) | COMMAREA長不一致、トランザクションABEND→ROLLBACK | +| 4 | 応答確認(OL-R001) | 応答時間測定用の件数バリエーション | + +### 组1:COMMAREA受信+MAP入出力(OL-N001/N002) + +CALL側から渡されたDFHCOMMAREAの内容を正しく受信するデータ。SEND MAP / RECEIVE MAP の入出力パターンをカバー。 + +### 组2:画面入力→DB検索→遷移(OL-N003/OL-N004) + +画面入力値をキーにDB検索し結果を表示するデータ。複数画面(A→B→C)間でデータが引き継がれるケースを含む。 + +### 组3:異常系(OL-A001/OL-A002) + +- DFHCOMMAREA長がCALL側と受信側で異なるデータ +- トランザクション内で異常が発生しROLLBACKされるケース + +### 组4:応答確認(OL-R001) + +応答時間測定用に件数を変えたデータ(少量・大量のバリエーション)。 + +## データ生成手順 + +1. COMMAREA・MAP定義の特定 +2. 组1/组2: 正常系データ +3. 组3: 異常系データ +4. 组4: 応答時間確認データ + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的4组一致(g1〜g4),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/キーブレイク(集約).md b/black-box-data-create/rules/pgm_pattern/キーブレイク(集約).md new file mode 100644 index 0000000..eadcfcd --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/キーブレイク(集約).md @@ -0,0 +1,102 @@ +# キーブレイク(集約) データ生成規則 + +## 概要 + +キー項目でソート済みの入力ファイルを読み取り、キーブレイク時に集約レコード(件数・最大・最小など)を出力するプログラムのテストデータを生成する。 + +本规则定义7组数据,覆盖测试基准说明 §2.2「key切测试(汇总 / 聚合 / 非汇总)」中聚合型相关的 KB-N002、KB-N004、KB-N005、KB-N006、KB-A001、KB-A002、KB-R001。汇总型(ADD累加)は「キーブレイク(集計).md」、非汇总型(键变化做标记)は「キーブレイク(非集計).md」を参照。 + +## 输入 + +- 詳細設計書のキー項目一覧(ソートキー) +- 入力ファイルのCOPYBOOKフィールド定義 +- 集約対象フィールド(処理詳細から件数・最大・最小の算出対象を特定) +- キー条件:键值升序,键项目为复合键时按多keyテスト追加 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 基本聚合(KB-N002) | 同键多件→件数/最大/最小が正しいデータ(複数グループ、各グループ複数レコード)+ 末尾に多keyテスト | +| 2 | 单键组(KB-N004) | 全件同一キー→キーブレイク1回のデータ + 末尾に多keyテスト | +| 3 | 多键组(KB-N005) | N個の異なるキー→Nグループ出力のデータ + 末尾に多keyテスト | +| 4 | 单条记录(KB-N006) | 1件1グループ(自身が集約値)のデータ + 末尾に多keyテスト | +| 5 | 前键值未初始化(KB-A001) | 先頭レコードのキーが通常値(非スペース)のデータ。WS-PREV-KEYが初期化されていない場合に先頭で誤中断しないか確認 | +| 6 | 累加器溢出(KB-A002) | 集約対象フィールドの合計がPIC定義を超えるデータ(SIZE ERROR/エラー処理確認) | +| 7 | 各组小计=合计(KB-R001) | 各グループの集約値の合計と全体合計が一致することを確認するデータ | + +### 组1:基本聚合(KB-N002) + +キー: a001(3件)→a002(2件)→a003(1件)(3グループ以上、各グループ内複数レコード、キー昇順) + +同键内の件数・最大・最小などが設計書どおり算出されることを確認する。 + +### 组2:单键组(KB-N004) + +キー: a001(5件)(全件同一キー) + +全体が1つのキーグループ→キーブレイク1回のみ。集約値(件数・最大・最小)が全件から算出されることを確認する。 + +### 组3:多键组(KB-N005) + +キー: a001(2件)→a002(1件)→a003(3件)(N個の異なるキー) + +キー変化N-1回→Nグループ出力されることを確認する。 + +### 组4:单条记录(KB-N006) + +キー: a001(1件)→a002(3件)→a003(1件)(単一レコードグループを含む) + +1件1グループの場合、そのレコード自身が集約値(件数1・値そのもの)になることを確認する。 + +### 组5:前键值未初始化(KB-A001) + +キー: a001(2件)→a002(2件)(先頭レコードのキーが通常値) + +期待:先頭で誤中断せず、1グループ目は1回の中断のみ(前鍵値フィールドが正しく初期化されていること)。 + +### 组6:累加器溢出(KB-A002) + +集約対象フィールドの合計がPIC定義を超えるデータ(例: PIC 9(3)金額 999 × 2件 → 合計1,998でオーバーフロー)。 + +期待:SIZE ERROR/設計書どおりのエラー処理が発生することを確認。 + +### 组7:各组小计=合计(KB-R001) + +各グループの集約値の合計と全体合計が一致することを確認するデータ(组1〜组4相当のグループ構成)。 + +## 多keyテスト(各組の末尾に追加) + +キーが複数項目で構成される場合: +1. 該当グループ内から1件のレコードを選び基準データとする +2. N個のキー項目に対し、それぞれ1項目のみ異なるN件の追加レコードを生成 +3. これらのレコードは別グループとして扱われるべき(集約値が別になる) + +需要注意当key为n个项目组合而成时,需要在各組末尾追加n条数据。以基准数据的所有key为准,只有当前第n个key与基准不一致。保证key的比较没有问题。 + +例(n=3,基准 (a001, b001, c001)): +- 追加1: (a002, b001, c001) → 仅第1个key不同 +- 追加2: (a001, b002, c001) → 仅第2个key不同 +- 追加3: (a001, b001, c002) → 仅第3个key不同 + +n为实际解析出的key项目数(例中n=3仅为示意,追加条数必须等于实际key项目数)。键项目数n=1时不追加多key测试数据。追加的每条记录必须恰好只改变一个key项目,且不得与既有记录完全重复。 + +## データ生成手順 + +1. キー項目を特定(キー項目一覧のソート条件から) +2. 集約フィールドを特定(処理詳細から件数・最大・最小の算出対象を抽出) +3. 组1: 3グループ以上、各グループ内複数レコード +4. 组2: 全件同一キー +5. 组3: N個の異なるキー +6. 组4: 単一レコードグループを含む +7. 组5: 先頭キーが通常値のデータ +8. 组6: 集約合計がPIC定義を超えるデータ +9. 组7: グループ小計と全体合計の整合確認データ +10. 各組末尾に多keyテストデータを追加 +11. 生成後自检(KB-R001):各グループの集約値の合計 = 全体合計 を確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的7组一致(g1〜g7),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/キーブレイク(集計).md b/black-box-data-create/rules/pgm_pattern/キーブレイク(集計).md new file mode 100644 index 0000000..d7842cb --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/キーブレイク(集計).md @@ -0,0 +1,102 @@ +# キーブレイク(集計) データ生成規則 + +## 概要 + +キー項目でソート済みの入力ファイルを読み取り、キーが変わるタイミング(キーブレイク)で集計値(ADD累加による金額合計等)を計算し、1グループにつき1件の集計レコードを出力するプログラムのテストデータを生成する。 + +本规则定义7组数据,覆盖测试基准说明 §2.2「key切测试(汇总 / 聚合 / 非汇总)」中汇总型相关的 KB-N001、KB-N004、KB-N005、KB-N006、KB-A001、KB-A002、KB-R001。集約型(件数・最大・最小)は「キーブレイク(集約).md」、非汇总型(键变化做标记)は「キーブレイク(非集計).md」を参照。 + +## 输入 + +- 詳細設計書のキー項目一覧(ソートキー) +- 入力ファイルのCOPYBOOKフィールド定義 +- 集計対象フィールド(処理詳細からADD/COMPUTE対象を特定) +- キー条件:键值升序,键项目为复合键时按多keyテスト追加 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 基本汇总(KB-N001) | 同键多件→金額合計が正しいデータ(複数グループ、各グループ複数レコード)+ 末尾に多keyテスト | +| 2 | 单键组(KB-N004) | 全件同一キー→キーブレイク1回のデータ + 末尾に多keyテスト | +| 3 | 多键组(KB-N005) | N個の異なるキー→Nグループ出力のデータ + 末尾に多keyテスト | +| 4 | 单条记录(KB-N006) | 1件1グループ(自身が集計値)のデータ + 末尾に多keyテスト | +| 5 | 前键值未初始化(KB-A001) | 先頭レコードのキーが通常値(非スペース)のデータ。WS-PREV-KEYが初期化されていない場合に先頭で誤中断しないか確認 | +| 6 | 累加器溢出(KB-A002) | 集計値(金額合計)がPIC定義を超えるデータ(SIZE ERROR/エラー処理確認) | +| 7 | 各组小计=合计(KB-R001) | 各グループの集計値の合計と全体合計が一致することを確認するデータ | + +### 组1:基本汇总(KB-N001) + +キー: a001(3件)→a002(2件)→a003(1件)(3グループ以上、各グループ内複数レコード、キー昇順) + +同键内の集計対象フィールド(例: 金額)をADD累加し、グループごとの合計が正しいことを確認する。 + +### 组2:单键组(KB-N004) + +キー: a001(5件)(全件同一キー) + +全体が1つのキーグループ→キーブレイク1回のみ。合計が全件の累加値と一致することを確認する。 + +### 组3:多键组(KB-N005) + +キー: a001(2件)→a002(1件)→a003(3件)(N個の異なるキー) + +キー変化N-1回→Nグループ出力されることを確認する。 + +### 组4:单条记录(KB-N006) + +キー: a001(1件)→a002(3件)→a003(1件)(単一レコードグループを含む) + +1件1グループの場合、そのレコード自身が集計値になることを確認する。 + +### 组5:前键值未初始化(KB-A001) + +キー: a001(2件)→a002(2件)(先頭レコードのキーが通常値) + +期待:先頭で誤中断せず、1グループ目は1回の中断のみ(前鍵値フィールドが正しく初期化されていること)。 + +### 组6:累加器溢出(KB-A002) + +集計対象フィールドの合計がPIC定義を超えるデータ(例: PIC 9(3)金額 999 × 2件 → 合計1,998でオーバーフロー)。 + +期待:SIZE ERROR/設計書どおりのエラー処理が発生することを確認。 + +### 组7:各组小计=合计(KB-R001) + +各グループの集計値の合計と全体合計が一致することを確認するデータ(组1〜组4相当のグループ構成)。 + +## 多keyテスト(各組の末尾に追加) + +キーが複数項目で構成される場合: +1. 該当グループ内から1件のレコードを選び基準データとする +2. N個のキー項目に対し、それぞれ1項目のみ異なるN件の追加レコードを生成 +3. これらのレコードは別グループとして扱われるべき(集計値が別になる) + +需要注意当key为n个项目组合而成时,需要在各組末尾追加n条数据。以基准数据的所有key为准,只有当前第n个key与基准不一致。保证key的比较没有问题。 + +例(n=3,基准 (a001, b001, c001)): +- 追加1: (a002, b001, c001) → 仅第1个key不同 +- 追加2: (a001, b002, c001) → 仅第2个key不同 +- 追加3: (a001, b001, c002) → 仅第3个key不同 + +n为实际解析出的key项目数(例中n=3仅为示意,追加条数必须等于实际key项目数)。键项目数n=1时不追加多key测试数据。追加的每条记录必须恰好只改变一个key项目,且不得与既有记录完全重复。 + +## データ生成手順 + +1. キー項目を特定(キー項目一覧のソート条件から) +2. 集計フィールドを特定(処理詳細からADD/COMPUTE対象を抽出) +3. 组1: 3グループ以上、各グループ2〜5レコード、キー昇順 +4. 组2: 全件同一キー +5. 组3: N個の異なるキー +6. 组4: 単一レコードグループを含む +7. 组5: 先頭キーが通常値のデータ +8. 组6: 集計値がPIC定義を超えるデータ +9. 组7: グループ小計と全体合計の整合確認データ +10. 各組末尾に多keyテストデータを追加 +11. 生成後自检(KB-R001):各グループの集計値の合計 = 全体合計 を確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的7组一致(g1〜g7),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/キーブレイク(非集計).md b/black-box-data-create/rules/pgm_pattern/キーブレイク(非集計).md new file mode 100644 index 0000000..dca60d4 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/キーブレイク(非集計).md @@ -0,0 +1,73 @@ +# キーブレイク(非集計) データ生成規則 + +## 概要 + +キーが変わるタイミングで集計値を持たずにキー変化の検知・フラグ設定・レコード出力のみを行うプログラム(集計・集約以外のキーブレイク)のテストデータ。 + +本规则定义5组数据,覆盖键变化检测、单键组、多键组、单条记录、前键值未初始化。 + +## 输入 + +- 詳細設計書のキー項目一覧(ソートキー) +- 入力ファイルのCOPYBOOKフィールド定義 +- キー条件:键值升序,键项目为复合键时按多keyテスト追加 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 键变化检测(KB-N003) | 複数グループでキー切替時のみ変化検知・マーク出力 | +| 2 | 单键组(KB-N004) | 全件同一キー→キーブレイク1回 | +| 3 | 多键组(KB-N005) | N個の異なるキー→Nグループ | +| 4 | 单条记录(KB-N006) | 1件1グループ(自身がグループ) | +| 5 | 前键值未初始化(KB-A001) | 先頭キーが通常値(非スペース)のデータ | + +### 组1:键变化检测(KB-N003) + +キー: a001(3件)→a002(2件)→a003(1件)。キー切替のタイミングで変化検知・マークが正しく出力されることを確認。 + +### 组2:单键组(KB-N004) + +キー: a001(5件)(全件同一キー)。キーブレイクが1回だけ発生することを確認。 + +### 组3:多键组(KB-N005) + +キー: a001→a002→a003→a004→a005(5件すべて異なるキー)。キー変化4回→5グループの出力を確認。 + +### 组4:单条记录(KB-N006) + +キー: a001(1件)(1件1グループ)。単独レコードが正しく1グループとして処理されることを確認。 + +### 组5:前键值未初始化(KB-A001) + +キー: a001(2件)→a002(2件)(先頭キーが通常値)。WS-PREV-KEYが初期化されていない場合に先頭で誤中断しないことを確認。 + +## 多keyテスト + +キーが複数項目の場合、各組末尾に追加。1レコードを基準に、各キー項目のみ異なるN件を生成。 + +需要注意当key为n个项目组合而成时,需要在各組末尾追加n条数据。以基准数据的所有key为准,只有当前第n个key与基准不一致。保证key的比较没有问题。 + +例(n=3,基准 (a001, b001, c001)): +- 追加1: (a002, b001, c001) → 仅第1个key不同 +- 追加2: (a001, b002, c001) → 仅第2个key不同 +- 追加3: (a001, b001, c002) → 仅第3个key不同 + +n为实际解析出的key项目数(例中n=3仅为示意,追加条数必须等于实际key项目数)。键项目数n=1时不追加多key测试数据。 + +## データ生成手順 + +1. キー項目の特定 +2. 组1: 複数グループの変化検知データ +3. 组2: 単一キーグループデータ +4. 组3: 全キー異なるデータ +5. 组4: 単件データ +6. 组5: 先頭キー通常値データ +7. 各組末尾に多keyテスト +8. 生成後自检:グループ数=キー変化回数+1 を確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的5组一致(g1〜g5),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/サブプログラム.md b/black-box-data-create/rules/pgm_pattern/サブプログラム.md new file mode 100644 index 0000000..556933b --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/サブプログラム.md @@ -0,0 +1,60 @@ +# サブプログラム データ生成規則 + +## 概要 + +サブプログラムをCALLで呼び出すメインプログラムのテストデータ。CALL文のバリエーション、引数渡し、RETURN-CODE、CALL先の状態保持を検証する。 + +※ 本規則はCALLER側(PGMタイプ=メイン)を対象とする。Callee単体(PGMタイプ=サブ)は generate() の対象外(AGENTS.md参照)。 + +本规则定义5组数据,覆盖基本CALL、动态CALL、返回处理、状态保持、异常系。 + +## 输入 + +- 詳細設計書の処理詳細(CALL文、引数、RETURN-CODE判定) +- CALL先サブプログラムのLINKAGE定義 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | CALL 字面量+参数传递(C-N001/N002) | CALL 'SUBPGM' USING ...、CALL側とLINKAGE側のパラメータ対応 | +| 2 | 动态CALL+RETURN-CODE(C-N003/N004) | CALL WS-PGM-NAME USING ...、RETURN-CODEの設定・検査 | +| 3 | 返回处理(C-N005/N006) | EXIT PROGRAMによる正常復帰、GOBACK(RETURN-CODE付き) | +| 4 | 状态保持(C-N007/C-N008) | IS INITIAL指定プログラム(毎CALL初期化)/非指定(値保持) | +| 5 | 异常系(C-A001〜C-A003) | CALL先不存在(ABEND)、パラメータ長不一致、CANCEL後再CALL | + +### 组1:CALL 字面量+参数传递(C-N001/N002) + +CALL 'SUB01DAT' USING ... のように字面量でCALLするデータ。CALL側のUSING引数とLINKAGE側のレイアウトが対応すること。 + +### 组2:动态CALL+RETURN-CODE(C-N003/N004) + +CALL WS-PGM-NAME USING ... のように変数名でCALLするデータ。サブプログラム側でRETURN-CODEを設定し、CALL側で検査するケースを含む。 + +### 组3:返回处理(C-N005/C-N006) + +EXIT PROGRAMで正常復帰するケースと、GOBACK(RETURN-CODE付き)で復帰するケースのデータ。 + +### 组4:状态保持(C-N007/C-N008) + +- IS INITIAL指定のサブプログラム: CALL毎にWSが初期化されることを確認するデータ +- 非IS INITIALのサブプログラム: CALL跨ぎでWS値が保持されることを確認するデータ + +### 组5:异常系(C-A001〜C-A003) + +- CALL先プログラムが存在しない(未ロード→ABEND) +- CALL USINGのパラメータ長がLINKAGE側と不一致 +- CANCEL後に再CALL(INITIAL状態に戻ること) + +## データ生成手順 + +1. CALL文・引数・RETURN-CODE判定の特定 +2. 组1〜组4: 正常系データ +3. 组5: 異常系データ +4. 生成後自检(C-R001/C-R002):CALL前の正常終了、CALL後のRETURN-CODE確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的5组一致(g1〜g5),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/マッチング(1-1).md b/black-box-data-create/rules/pgm_pattern/マッチング(1-1).md new file mode 100644 index 0000000..d8d059d --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/マッチング(1-1).md @@ -0,0 +1,172 @@ +# マッチング(1:1) 数据生成规则 + +## 概要 + +2个输入文件按指定键进行1:1匹配,输出匹配成功/仅R01/仅R02三种结果。 + +本规则定义8组数据:组1〜组4为正常系(覆盖MT-N001完全匹配、MT-N004主件剩余、MT-N005从件剩余),组5〜组8为异常系(覆盖MT-N006主键重复、MT-N007键值乱序,R01/R02两侧均测)。 + +## 输入 + +- 前提条件:从详细设计书解析匹配键(A001>A002>A003...) +- 输入文件:2个(R01、R02) +- 键条件:组1〜组4(正常系)键值唯一(重複NG)且升序排序 +- 组5(异常系):R01内故意生成重复键值 +- 组6(异常系):R01故意违反升序前提 +- 组7(异常系):R02内故意生成重复键值 +- 组8(异常系):R02故意违反升序前提 + +## 生成的数据组 + +| 组 | 用途 | R01记录数 | R02记录数 | 说明 | +|----|------|-----------|-----------|------| +| 1 | 两端不匹配 | 4 | 4 | 第1条R01-only、最后1条R02-only、中间3条以上匹配 | +| 2 | 反向两端不匹配 | 4 | 4 | 第1条R02-only、最后1条R01-only、中间3条以上匹配 | +| 3 | 中间不匹配 | 4 | 4 | 首尾匹配、中间有R01-only和R02-only各1条 | +| 4 | 完全匹配 | 4 | 4 | 双方各N件全部匹配成功,无剩余(MT-N001) | +| 5 | 主键重复-R01(异常) | 4 | 3 | R01内同一键出现2次(MT-N006) | +| 6 | 键值乱序-R01(异常) | 4 | 4 | R01违反升序前提(MT-N007) | +| 7 | 主键重复-R02(异常) | 3 | 4 | R02内同一键出现2次(MT-N006) | +| 8 | 键值乱序-R02(异常) | 4 | 4 | R02违反升序前提(MT-N007) | + +### 组1:两端不匹配 + +R01键:a001(R01-only) → a002 → a003 → a004 +R02键:a002 → a003 → a004 → a005(R02-only) + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001) | R01-only | +| 第2条 | R01(a002) + R02(a002) | 匹配 | +| 第3条 | R01(a003) + R02(a003) | 匹配 | +| 第4条 | R01(a004) + R02(a004) | 匹配 | +| 第5条 | R02(a005) | R02-only | + +组1:需要注意当key为n个项目组合而成时,在R01的最后需要加上n条数据。和R02(a005)的所有key相比,只有当前第n个key与R02(a005)的key不一致。保证key的比较没有问题。 + +例(n=3): +| R01 key1 | R01 key2 | R01 key3 | R02 key1 | R02 key2 | R02 key3 | 说明 | +|----------|----------|----------|----------|----------|----------|------| +| a004 | b005 | c005 | a005 | b005 | c005 | 仅第1个key不一致 | +| a005 | b004 | c005 | a005 | b005 | c005 | 仅第2个key不一致 | +| a005 | b005 | c00c | a005 | b005 | c005 | 仅第3个key不一致 | + +n为实际解析出的key项目数(例中n=3仅为示意,追加条数必须等于实际key项目数,严禁按示例机械生成3条)。键项目数n=1时不追加多key测试数据。追加的每条记录必须恰好只改变一个key项目,且不得与R01既有记录完全重复。 + +### 组2:反向两端不匹配 + +R01键:a002 → a003 → a004 → a005(R01-only) +R02键:a001(R02-only) → a002 → a003 → a004 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R02(a001) | R02-only | +| 第2条 | R01(a002) + R02(a002) | 匹配 | +| 第3条 | R01(a003) + R02(a003) | 匹配 | +| 第4条 | R01(a004) + R02(a004) | 匹配 | +| 第5条 | R01(a005) | R01-only | + +### 组3:中间不匹配 + +R01键:a001 → a003(R01-only) → a004 → a005 +R02键:a001 → a002(R02-only) → a004 → a005 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001) + R02(a001) | 匹配 | +| 第2条 | R02(a002) | R02-only | +| 第3条 | R01(a003) | R01-only | +| 第4条 | R01(a004) + R02(a004) | 匹配 | +| 第5条 | R01(a005) + R02(a005) | 匹配 | + +### 组4:完全匹配(MT-N001) + +R01键:a001 → a002 → a003 → a004 +R02键:a001 → a002 → a003 → a004 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001) + R02(a001) | 匹配 | +| 第2条 | R01(a002) + R02(a002) | 匹配 | +| 第3条 | R01(a003) + R02(a003) | 匹配 | +| 第4条 | R01(a004) + R02(a004) | 匹配 | + +### 组5:主键重复-R01(异常系,MT-N006) + +R01键:a001 → a002 → a002(重复) → a003 +R02键:a001 → a002 → a003 + +说明:R01中键a002出现2次,违反"键值唯一"前提。期望程序检出重复键并执行设计书规定的错误处理(错误输出/异常结束),不得静默处理。 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001) + R02(a001) | 匹配 | +| 第2条 | R01(a002) + R02(a002) | 匹配 | +| 第3条 | R01(a002) | 重复键(第2次出现) | +| 第4条 | R01(a003) + R02(a003) | 匹配 | + +### 组6:键值乱序-R01(异常系,MT-N007) + +R01键:a001 → a003 → a002 → a004(a003先于a002,违反升序) +R02键:a001 → a002 → a003 → a004(正常升序) + +说明:R01违反升序前提,程序可能产生误匹配或未预期结果。本组用于确认程序对乱序输入的行为(按设计书确认期望结果),不要求生成"正确匹配"。 + +records数组(按文件读取顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001) + R02(a001) | 匹配 | +| 第2条 | R01(a003) + R02(a002) | 乱序错位(键不一致) | +| 第3条 | R01(a002) + R02(a003) | 乱序错位(键不一致) | +| 第4条 | R01(a004) + R02(a004) | 匹配 | + +### 组7:主键重复-R02(异常系,MT-N006) + +R01键:a001 → a002 → a003 +R02键:a001 → a002 → a002(重复) → a003 + +说明:R02中键a002出现2次,违反"键值唯一"前提。期望程序检出重复键并执行设计书规定的错误处理(错误输出/异常结束),不得静默处理。 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001) + R02(a001) | 匹配 | +| 第2条 | R01(a002) + R02(a002) | 匹配 | +| 第3条 | R02(a002) | 重复键(第2次出现) | +| 第4条 | R01(a003) + R02(a003) | 匹配 | + +### 组8:键值乱序-R02(异常系,MT-N007) + +R01键:a001 → a002 → a003 → a004(正常升序) +R02键:a001 → a003 → a002 → a004(a003先于a002,违反升序) + +说明:R02违反升序前提,程序可能产生误匹配或未预期结果。本组用于确认程序对乱序输入的行为(按设计书确认期望结果),不要求生成"正确匹配"。 + +records数组(按文件读取顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001) + R02(a001) | 匹配 | +| 第2条 | R01(a002) + R02(a003) | 乱序错位(键不一致) | +| 第3条 | R01(a003) + R02(a002) | 乱序错位(键不一致) | +| 第4条 | R01(a004) + R02(a004) | 匹配 | + +## 数据生成步骤 + +1. 从前提条件解析匹配键字段(如 A001>A002>A003) +2. 从COPYBOOK获取各字段的PIC定义 +3. 生成键值:键值以 a001/b001/c001 格式递增。组1〜组4保证键值唯一且升序;组5/组7生成重复键;组6/组8故意乱序 +4. 非键字段的数据生成:根据字段名关键词生成符合实际的数据,同时尽量保证每条相邻的数据的相同项目不一致。 +5. 按各组规则组装records数组 +6. 组号从1开始连续编号 +7. 生成后自检(MT-R001):统计每组匹配件数M、R01-only件数r1、R02-only件数r2,校验 M + r1 = R01输入件数 且 M + r2 = R02输入件数(组6/组8乱序组因匹配结果不确定,仅确认records数组包含R01与R02的全部输入记录) + +## 输出 + +每个组输出一个json文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的8组一致(g1〜g8),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。每组生成的records必须与对应组的定义完全一致。 diff --git a/black-box-data-create/rules/pgm_pattern/マッチング(1-N).md b/black-box-data-create/rules/pgm_pattern/マッチング(1-N).md new file mode 100644 index 0000000..9c9de64 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/マッチング(1-N).md @@ -0,0 +1,195 @@ +# マッチング(1:N) 数据生成规则 + +## 概要 + +2个输入文件,R01为主件(1侧),R02为从件(N侧)。1件R01可对应多件R02(1:N)。 + +本规则定义9组数据:组1〜组5为正常系(覆盖MT-N001完全匹配、MT-N002的1:N匹配、MT-N004主件剩余、MT-N005从件剩余、MT-N009极端不平衡),组6〜组9为异常系(覆盖MT-N006主键重复R01/R02、MT-N007键值乱序R01/R02)。 + +## 输入 + +- 前提条件:从详细设计书解析匹配键(A001>A002>A003...) +- 输入文件:2个(R01主件、R02从件) +- 键条件:组1〜组5(正常系)键值唯一(主件重複NG)且升序排序 +- 组6(异常系):R01内故意生成重复键值 +- 组7/组8(异常系):R01/R02故意违反升序前提 +- 组9(异常系):R02内故意生成重复键值 + +## 生成的数据组 + +| 组 | 用途 | R01记录数 | R02记录数 | 说明 | +|----|------|-----------|-----------|------| +| 1 | 1:1匹配 | 4 | 4 | 双方各N件全部1:1匹配(MT-N001),R01末尾追加n条多key变体(仅一个key不同→R01-only,验证键比较) | +| 2 | 1:N匹配 | 3 | 5 | R01一件对应R02多件,同key多条(MT-N002) | +| 3 | 主件剩余-R01 | 4 | 2 | 从件读完主件剩余→W03(MT-N004) | +| 4 | 从件剩余-R02 | 2 | 4 | 主件读完从件剩余→W02(MT-N005) | +| 5 | 极端不平衡1:N | 1 | 5 | R01=1条、R02=N条同key(MT-N009) | +| 6 | 主键重复-R01(异常) | 4 | 3 | R01内同一键出现2次(MT-N006) | +| 7 | 键值乱序-R01(异常) | 4 | 4 | R01违反升序前提(MT-N007) | +| 8 | 键值乱序-R02(异常) | 4 | 4 | R02违反升序前提(MT-N007) | +| 9 | 主键重复-R02(异常) | 3 | 4 | R02内同一键出现2次(MT-N006) | + +### 组1:1:1匹配(MT-N001) + +R01: a001→a002→a003→a004 +R02: a001→a002→a003→a004 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配 | +| 第3条 | R01(a003)+R02(a003) | 匹配 | +| 第4条 | R01(a004)+R02(a004) | 匹配 | + +组1:需要注意当key为n个项目组合而成时,在R01的最后需要加上n条数据。和R02(a004)的所有key相比,只有当前第n个key与R02(a004)的key不一致。保证key的比较没有问题。 + +例(n=3): +| R01 key1 | R01 key2 | R01 key3 | R02 key1 | R02 key2 | R02 key3 | 说明 | +|----------|----------|----------|----------|----------|----------|------| +| a003 | b004 | c004 | a004 | b004 | c004 | 仅第1个key不一致 | +| a004 | b003 | c004 | a004 | b004 | c004 | 仅第2个key不一致 | +| a004 | b004 | c003 | a004 | b004 | c004 | 仅第3个key不一致 | + + +n为实际解析出的key项目数(例中n=3仅为示意,追加条数必须等于实际key项目数,严禁按示例机械生成3条)。键项目数n=1时不追加多key测试数据。追加的每条记录必须恰好只改变一个key项目,且不得与R01既有记录完全重复。这n条追加记录与R02的任何key都不完全一致,作为R01-only追加在R01末尾(不影响前面全部匹配)。 + +### 组2:1:N匹配(MT-N002) + +R01: a001→a002→a003 +R02: a001→a002→a002→a002→a003(a002同key 3条) + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配(第1件) | +| 第3条 | R01(a002)+R02(a002) | 匹配(第2件) | +| 第4条 | R01(a002)+R02(a002) | 匹配(第3件) | +| 第5条 | R01(a003)+R02(a003) | 匹配 | + +### 组3:主件剩余-R01(MT-N004) + +R01: a001→a002→a003→a004 +R02: a001→a002 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配 | +| 第3条 | R01(a003) | R01-only(主件剩余→W03) | +| 第4条 | R01(a004) | R01-only(主件剩余→W03) | + +组3:需要注意当key为n个项目组合而成时,在R01的最后需要加上n条数据。和R02(a002)的所有key相比,只有当前第n个key与R02(a002)的key不一致。保证key的比较没有问题。 + +n为实际解析出的key项目数(追加条数必须等于实际key项目数)。键项目数n=1时不追加多key测试数据。追加的每条记录必须恰好只改变一个key项目,且不得与R01既有记录完全重复。 + +例(n=3): +| R01 key1 | R01 key2 | R01 key3 | R02 key1 | R02 key2 | R02 key3 | 说明 | +|----------|----------|----------|----------|----------|----------|------| +| a002 | b002 | c003 | a002 | b002 | c002 | 仅第3个key不一致 | +| a002 | b003 | c002 | a002 | b002 | c002 | 仅第2个key不一致 | +| a003 | b002 | c002 | a002 | b002 | c002 | 仅第1个key不一致 | + +### 组4:从件剩余-R02(MT-N005) + +R01: a001→a002 +R02: a001→a002→a003→a004 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配 | +| 第3条 | R02(a003) | R02-only(从件剩余→W02) | +| 第4条 | R02(a004) | R02-only(从件剩余→W02) | + +### 组5:极端不平衡 1:N(MT-N009) + +R01: a001(1条) +R02: a001→a001→a001→a001→a001(5条同key) + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配(第1件) | +| 第2条 | R01(a001)+R02(a001) | 匹配(第2件) | +| 第3条 | R01(a001)+R02(a001) | 匹配(第3件) | +| 第4条 | R01(a001)+R02(a001) | 匹配(第4件) | +| 第5条 | R01(a001)+R02(a001) | 匹配(第5件) | + +### 组6:主键重复-R01(异常系,MT-N006) + +R01: a001→a002→a002(重复) →a003 +R02: a001→a002→a003 + +说明:R01中键a002出现2次,违反"键值唯一"前提(1:N程序中主件键值必须唯一)。期望程序检出重复键并执行设计书规定的错误处理(错误输出/异常结束),不得静默处理。 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配 | +| 第3条 | R01(a002) | 重复键(第2次出现) | +| 第4条 | R01(a003)+R02(a003) | 匹配 | + +### 组7:键值乱序-R01(异常系,MT-N007) + +R01: a001→a003→a002→a004(a003先于a002,违反升序) +R02: a001→a002→a003→a004(正常升序) + +说明:R01违反升序前提,程序可能产生误匹配或未预期结果。本组用于确认程序对乱序输入的行为(按设计书确认期望结果),不要求生成"正确匹配"。 + +records数组(按文件读取顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a003)+R02(a002) | 乱序错位(键不一致) | +| 第3条 | R01(a002)+R02(a003) | 乱序错位(键不一致) | +| 第4条 | R01(a004)+R02(a004) | 匹配 | +### 组8:键值乱序-R02(异常系,MT-N007) + +R01: a001→a002→a003→a004(正常升序) +R02: a001→a003→a002→a004(a003先于a002,违反升序) + +说明:R02违反升序前提,程序可能产生误匹配或未预期结果。本组用于确认程序对乱序输入的行为(按设计书确认期望结果),不要求生成"正确匹配"。 + +records数组(按文件读取顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a003) | 乱序错位(键不一致) | +| 第3条 | R01(a003)+R02(a002) | 乱序错位(键不一致) | +| 第4条 | R01(a004)+R02(a004) | 匹配 | + +### 组9:主键重复-R02(异常系,MT-N006) + +R01: a001→a002→a003 +R02: a001→a002→a002(重复) →a003 + +说明:1:N程序中R02(从件)同key多条属于正常(N件匹配),本组验证从件重复键时程序的处理(R01一条对应R02两条)。若设计书对R02也要求键值唯一(重複NG),则本组用于验证重复键检出/错误处理,不得静默处理。 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配(第1件) | +| 第3条 | R01(a002)+R02(a002) | 同key第2条(N件处理/重复检出) | +| 第4条 | R01(a003)+R02(a003) | 匹配 | + +## 数据生成步骤 + +1. 从前提条件解析匹配键字段(如 A001>A002>A003) +2. 从COPYBOOK获取各字段的PIC定义 +3. 生成键值:键值以 a001/b001/c001 格式递增。组1〜组5保证键值唯一且升序;组6/组9生成重复键;组7/组8故意乱序 +4. 非键字段的数据生成:根据字段名关键词生成符合实际的数据,同时尽量保证每条相邻的数据的相同项目不一致。 +5. 按各组规则组装records数组 +6. 组号从1开始连续编号 +7. 生成后自检(MT-R001):统计每组匹配件数m1(R01中被匹配的件数)、m2(R02中被匹配的件数)、R01-only件数r1、R02-only件数r2,校验 m1 + r1 = R01输入件数 且 m2 + r2 = R02输入件数(组7/组8乱序组因匹配结果不确定,仅确认records数组包含R01与R02的全部输入记录) + +## 输出 + +每个组输出一个json文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的9组一致(g1〜g9),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。每组生成的records必须与对应组的定义完全一致。 diff --git a/black-box-data-create/rules/pgm_pattern/マッチング(M-N).md b/black-box-data-create/rules/pgm_pattern/マッチング(M-N).md new file mode 100644 index 0000000..9c6fd2f --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/マッチング(M-N).md @@ -0,0 +1,102 @@ +# マッチング(M:N) 数据生成规则 + +## 概要 + +R01(主件)与R02(从件)按指定键进行M:N匹配:同一键下R01有M件、R02有N件时,程序按设计书输出M件、N件或M×N件(笛卡尔积)。 + +本规则定义4组数据:组1为完全匹配(MT-N001),组2〜组4覆盖M:N的三种输出形态(AM-N005笛卡尔积、AM-N003输出M件、AM-N004输出N件)。 + +## 输入 + +- 前提条件:从详细设计书解析匹配键(A001>A002>A003...) +- 输入文件:2个(R01、R02) +- 键条件:各组键值升序;同一键下R01/R02按组定义生成M条/N条同key数据 + +## 生成的数据组 + +| 组 | 用途 | R01记录数 | R02记录数 | 说明 | +|----|------|-----------|-----------|------| +| 1 | 完全匹配 | 4 | 4 | 双方各N件全部1:1匹配,无剩余(MT-N001) | +| 2 | M:N笛卡尔积 | 3 | 3 | 同key下R01×2、R02×2→M×N=4件(AM-N005) | +| 3 | M:N→M件 | 3 | 2 | 同key下R01×2、R02×1→输出M件(AM-N003) | +| 4 | M:N→N件 | 2 | 3 | 同key下R01×1、R02×2→输出N件(AM-N004) | + +### 组1:完全匹配(MT-N001) + +R01: a001→a002→a003→a004 +R02: a001→a002→a003→a004 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配 | +| 第3条 | R01(a003)+R02(a003) | 匹配 | +| 第4条 | R01(a004)+R02(a004) | 匹配 | + +### 组2:M:N笛卡尔积(AM-N005) + +R01: a001→a001→a002(a001×2) +R02: a001→a001→a002(a001×2) + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 组合1 | +| 第2条 | R01(a001)+R02(a001) | 组合2 | +| 第3条 | R01(a001)+R02(a001) | 组合3 | +| 第4条 | R01(a001)+R02(a001) | 组合4 | +| 第5条 | R01(a002)+R02(a002) | 匹配 | + +### 组3:M:N→M件(AM-N003) + +R01: a001→a001→a002(a001×2) +R02: a001→a002 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配(主件第1条) | +| 第2条 | R01(a001)+R02(a001) | 匹配(主件第2条)→输出M件 | +| 第3条 | R01(a002)+R02(a002) | 匹配 | + +### 组4:M:N→N件(AM-N004) + +R01: a001→a002 +R02: a001→a001→a002(a001×2) + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配(从件第1条) | +| 第2条 | R01(a001)+R02(a001) | 匹配(从件第2条)→输出N件 | +| 第3条 | R01(a002)+R02(a002) | 匹配 | + +## 多keyテスト + +需要注意当key为n个项目组合而成时,需要在末尾追加n条数据。以基准数据的所有key为准,只有当前第n个key与基准不一致。保证key的比较没有问题。 + +例(n=3,基准 a002 = (a002, b002, c002)): +| 追加 key1 | 追加 key2 | 追加 key3 | 基准 key1 | 基准 key2 | 基准 key3 | 说明 | +|----------|----------|----------|----------|----------|----------|------| +| a001 | b002 | c002 | a002 | b002 | c002 | 仅第1个key不一致 | +| a002 | b001 | c002 | a002 | b002 | c002 | 仅第2个key不一致 | +| a002 | b002 | c001 | a002 | b002 | c002 | 仅第3个key不一致 | + +n为实际解析出的key项目数(例中n=3仅为示意,追加条数必须等于实际key项目数)。键项目数n=1时不追加多key测试数据。追加的每条记录必须恰好只改变一个key项目,且不得与既有记录完全重复。 + +## 数据生成步骤 + +1. 从前提条件解析匹配键字段(如 A001>A002>A003) +2. 从COPYBOOK获取各字段的PIC定义 +3. 生成键值:键值以 a001/b001/c001 格式递增,各组按定义生成M/N条同key数据,键值升序 +4. 非键字段的数据生成:根据字段名关键词生成符合实际的数据,同时尽量保证每条相邻的数据的相同项目不一致 +5. 按各组规则组装records数组 +6. 组号从1开始连续编号 +7. 生成后自检(MT-R001):统计每组匹配件数m1(R01中被匹配的件数)、m2(R02中被匹配的件数)、R01-only件数r1、R02-only件数r2,校验 m1 + r1 = R01输入件数 且 m2 + r2 = R02输入件数 + +## 输出 + +每个组输出一个json文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的4组一致(g1〜g4),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。每组生成的records必须与对应组的定义完全一致。 diff --git a/black-box-data-create/rules/pgm_pattern/マッチング(N-1).md b/black-box-data-create/rules/pgm_pattern/マッチング(N-1).md new file mode 100644 index 0000000..f321b87 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/マッチング(N-1).md @@ -0,0 +1,188 @@ +# マッチング(N:1) 数据生成规则 + +## 概要 + +2个输入文件,R01为主件(N侧),R02为从件(1侧)。N件R01可对应1件R02(N:1)。 + +本规则定义9组数据:组1〜组5为正常系(覆盖MT-N001完全匹配、MT-N003的N:1匹配、MT-N004主件剩余、MT-N005从件剩余、MT-N010极端不平衡N:1),组6〜组9为异常系(覆盖MT-N006主键重复R01/R02、MT-N007键值乱序R01/R02)。 + +## 输入 + +- 前提条件:从详细设计书解析匹配键(A001>A002>A003...) +- 输入文件:2个(R01主件、R02从件) +- 键条件:组1/组3/组4(正常系)键值唯一且升序;组2/组5はR01同key多条(N件匹配)を意図的に生成、R02側は键值唯一 +- 组6(异常系):R01内故意生成重复键值(設計書が主件キーの一意性を要求する場合) +- 组7/组8(异常系):R01/R02故意违反升序前提 +- 组9(异常系):R02内故意生成重复键值(1侧・从件键值唯一違反) + +## 生成的数据组 + +| 组 | 用途 | R01记录数 | R02记录数 | 说明 | +|----|------|-----------|-----------|------| +| 1 | 1:1匹配 | 4 | 4 | 双方各N件全部1:1匹配(MT-N001),R01末尾追加n条多key变体(仅一个key不同→R01-only,验证键比较) | +| 2 | N:1匹配 | 5 | 3 | R02一件对应R01多件,R01同key多条(MT-N003) | +| 3 | 主件剩余-R01 | 4 | 2 | 从件读完主件剩余→W03(MT-N004) | +| 4 | 从件剩余-R02 | 2 | 4 | 主件读完从件剩余→W02(MT-N005) | +| 5 | 极端不平衡N:1 | 5 | 1 | R01=N条同key、R02=1条(MT-N010) | +| 6 | 主键重复-R01(异常) | 4 | 3 | R01内同一键出现2次(MT-N006) | +| 7 | 键值乱序-R01(异常) | 4 | 4 | R01违反升序前提(MT-N007) | +| 8 | 键值乱序-R02(异常) | 4 | 4 | R02违反升序前提(MT-N007) | +| 9 | 主键重复-R02(异常) | 3 | 4 | R02内同一键出现2次(MT-N006) | + +### 组1:1:1匹配(MT-N001) + +R01: a001→a002→a003→a004 +R02: a001→a002→a003→a004 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配 | +| 第3条 | R01(a003)+R02(a003) | 匹配 | +| 第4条 | R01(a004)+R02(a004) | 匹配 | + +组1:需要注意当key为n个项目组合而成时,在R01的最后需要加上n条数据。和R02(a004)的所有key相比,只有当前第n个key与R02(a004)的key不一致。保证key的比较没有问题。 + +例(n=3): +| R01 key1 | R01 key2 | R01 key3 | R02 key1 | R02 key2 | R02 key3 | 说明 | +|----------|----------|----------|----------|----------|----------|------| +| a004 | b003 | c004 | a004 | b004 | c004 | 仅第2个key不一致 | +| a003 | b004 | c004 | a004 | b004 | c004 | 仅第1个key不一致 | +| a004 | b004 | c003 | a004 | b004 | c004 | 仅第3个key不一致 | + +n为实际解析出的key项目数(例中n=3仅为示意,追加条数必须等于实际key项目数,严禁按示例机械生成3条)。键项目数n=1时不追加多key测试数据。追加的每条记录必须恰好只改变一个key项目,且不得与R01既有记录完全重复。这n条追加记录与R02的任何key都不完全一致,作为R01-only追加在R01末尾(不影响前面全部匹配)。 + +### 组2:N:1匹配(MT-N003) + +R01: a001→a002→a002→a002→a003(a002同key 3条) +R02: a001→a002→a003 + +说明:R02(从件・1侧)1件に対しR01(主件・N侧)N件が対応するN:1匹配(MT-N003)。 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配(第1件) | +| 第3条 | R01(a002)+R02(a002) | 匹配(第2件) | +| 第4条 | R01(a002)+R02(a002) | 匹配(第3件) | +| 第5条 | R01(a003)+R02(a003) | 匹配 | + +### 组3:主件剩余-R01(MT-N004) + +R01: a001→a002→a003→a004 +R02: a001→a002 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配 | +| 第3条 | R01(a003) | R01-only(主件剩余→W03) | +| 第4条 | R01(a004) | R01-only(主件剩余→W03) | + +### 组4:从件剩余-R02(MT-N005) + +R01: a001→a002 +R02: a001→a002→a003→a004 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配 | +| 第3条 | R02(a003) | R02-only(从件剩余→W02) | +| 第4条 | R02(a004) | R02-only(从件剩余→W02) | + +### 组5:极端不平衡 N:1(MT-N010) + +R01: a001→a001→a001→a001→a001(5条同key) +R02: a001(1条) + +说明:N:1プログラムではR01(主件)の同key多条は本来N件匹配として正常だが、MT-N010(N:1 极端不平衡)として R01=N条・R02=1条 の極端な件数差を確認する。 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配(第1件) | +| 第2条 | R01(a001)+R02(a001) | 同key第2条(N:1极端不平衡) | +| 第3条 | R01(a001)+R02(a001) | 同key第3条 | +| 第4条 | R01(a001)+R02(a001) | 同key第4条 | +| 第5条 | R01(a001)+R02(a001) | 同key第5条 | + +### 组6:主键重复-R01(异常系,MT-N006) + +R01: a001→a002→a002(重复) →a003 +R02: a001→a002→a003 + +说明:N:1程序中R01(主件)同key多条属于正常(N件匹配),本组验证主件重复键时程序的处理(N件R01对应1件R02)。若设计书对R01也要求键值唯一(1:1相当处理),则本组用于验证重复键检出/错误处理,不得静默处理。 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配(第1件) | +| 第3条 | R01(a002)+R02(a002) | 同key第2条(N件处理/重复检出) | +| 第4条 | R01(a003)+R02(a003) | 匹配 | + +### 组7:键值乱序-R01(异常系,MT-N007) + +R01: a001→a003→a002→a004(a003先于a002,违反升序) +R02: a001→a002→a003→a004(正常升序) + +说明:R01违反升序前提,程序可能产生误匹配或未预期结果。本组用于确认程序对乱序输入的行为(按设计书确认期望结果),不要求生成"正确匹配"。 + +records数组(按文件读取顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a003)+R02(a002) | 乱序错位(键不一致) | +| 第3条 | R01(a002)+R02(a003) | 乱序错位(键不一致) | +| 第4条 | R01(a004)+R02(a004) | 匹配 | + +### 组8:键值乱序-R02(异常系,MT-N007) + +R01: a001→a002→a003→a004(正常升序) +R02: a001→a003→a002→a004(a003先于a002,违反升序) + +说明:R02违反升序前提,程序可能产生误匹配或未预期结果。本组用于确认程序对乱序输入的行为(按设计书确认期望结果),不要求生成"正确匹配"。 + +records数组(按文件读取顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a003) | 乱序错位(键不一致) | +| 第3条 | R01(a003)+R02(a002) | 乱序错位(键不一致) | +| 第4条 | R01(a004)+R02(a004) | 匹配 | + +### 组9:主键重复-R02(异常系,MT-N006) + +R01: a001→a002→a003 +R02: a001→a002→a002(重复) →a003 + +说明:R02中键a002出现2次,违反"键值唯一"前提(N:1程序中从件・1侧键值必须唯一)。期望程序检出重复键并执行设计书规定的错误处理(错误输出/异常结束),不得静默处理。 + +records数组(按处理顺序): +| 处理顺序 | 内容 | 说明 | +|---------|------|------| +| 第1条 | R01(a001)+R02(a001) | 匹配 | +| 第2条 | R01(a002)+R02(a002) | 匹配 | +| 第3条 | R02(a002) | 重复键(第2次出现) | +| 第4条 | R01(a003)+R02(a003) | 匹配 | + +## 数据生成步骤 + +1. 从前提条件解析匹配键字段(如 A001>A002>A003) +2. 从COPYBOOK获取各字段的PIC定义 +3. 生成键值:键值以 a001/b001/c001 格式递增。组1/组3/组4保证键值唯一且升序;组2/组5生成R01同key多条(N件匹配);组6/组9生成重复键;组7/组8故意乱序 +4. 非键字段的数据生成:根据字段名关键词生成符合实际的数据,同时尽量保证每条相邻的数据的相同项目不一致。 +5. 按各组规则组装records数组 +6. 组号从1开始连续编号 +7. 生成后自检(MT-R001):统计每组匹配件数m1(R01中被匹配的件数)、m2(R02中被匹配的件数)、R01-only件数r1、R02-only件数r2,校验 m1 + r1 = R01输入件数 且 m2 + r2 = R02输入件数(组8/组9乱序组因匹配结果不确定,仅确认records数组包含R01与R02的全部输入记录) + +## 输出 + +每个组输出一个json文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的9组一致(g1〜g9),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。每组生成的records必须与对应组的定义完全一致。 diff --git a/black-box-data-create/rules/pgm_pattern/ランキング生成.md b/black-box-data-create/rules/pgm_pattern/ランキング生成.md new file mode 100644 index 0000000..f9bf164 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/ランキング生成.md @@ -0,0 +1,57 @@ +# ランキング生成 データ生成規則 + +## 概要 + +集計結果をスコア降順に順位付けし、ランキングレポートを出力するプログラムのテストデータ。 + +本规则定义5组数据,覆盖基本ランキング、同点処理、境界、異常系、件数確認。 + +## 输入 + +- 詳細設計書の処理詳細(順位キー、スコア項目、降順/昇順、同点時の順位規則) +- 入力ファイルのCOPYBOOKフィールド定義 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 基本ランキング | スコア降順で順位が正しく付与されるデータ(5件以上) | +| 2 | 同点処理 | 同スコアの順位規則(同順位・次順位スキップ等)を確認するデータ | +| 3 | 境界 | 1件のみ・0件のデータ | +| 4 | 異常系 | スコアがPIC定義を超える・不正値のデータ(エラー処理確認) | +| 5 | 件数確認 | 出力件数が入力件数と一致することを確認するデータ | + +### 组1:基本ランキング + +スコア: 95→80→72→65→50 の5件。降順に順位1〜5が付与されることを確認。 + +### 组2:同点処理 + +スコア: 80,80,70,70,50 の5件。同点時の順位規則(同順位付与、次順位スキップ)が設計書どおりであることを確認。 + +### 组3:境界 + +- 1件のみ(自身が1位)のデータ +- 0件(空ファイル)のデータ + +### 组4:異常系 + +スコアがPIC定義(例: 9(3))を超える値、または不正な値(非数値)を含むデータ。エラー処理が設計書どおりであることを確認。 + +### 组5:件数確認 + +ランキング出力件数(正常+異常)の合計が入力件数と一致するデータ。 + +## データ生成手順 + +1. 順位キー・スコア項目・順位規則の特定 +2. 组1〜组3: 正常系データ +3. 组4: 異常系データ +4. 组5: 件数確認データ +5. 生成後自检:順位の連続性と出力件数の整合確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的5组一致(g1〜g5),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/レイアウト編集のみ(GETPUT).md b/black-box-data-create/rules/pgm_pattern/レイアウト編集のみ(GETPUT).md new file mode 100644 index 0000000..794c268 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/レイアウト編集のみ(GETPUT).md @@ -0,0 +1,32 @@ +# レイアウト編集のみ(GETPUT) データ生成規則 + +## 概要 + +入力ファイルまたはDBからレコードを読み取り、項目の編集・変換を行ってそのまま出力ファイルに書き出すプログラムのテストデータを生成する。 + +処理パターン:READ → 編集/計算/変換 → WRITE(シンプルなパイプライン) + +## 入力 + +- 詳細設計書の処理詳細(編集・変換内容) +- 入力ファイルのCOPYBOOKフィールド定義 +- 出力レコード定義(出力項目と設定元の対応) + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 基本データ | 通常パターン2〜5件。各フィールドに有効な値を設定。 | + +## データ生成手順 + +1. **入力フィールドの把握**: COPYBOOKから全入力フィールドを取得 +2. **データ生成**: + - 通常値: 各フィールドのPIC定義に従い有効な値を生成 + - 異なる値: レコード間で同じ項目に異なる値を設定しバリエーションを確保 +3. **値のルール**: + - PIC X(n): 左詰め・スペース埋め、実在しそうな文字列 + - PIC 9(n): 右詰め・先行ゼロ、実在しそうな数値 + - DATE/日付系: 実在しそうな日付 + - 金額/SALARY系: 現実的な金額 +4. **出力**: 1組 = 1 JSONファイル diff --git a/black-box-data-create/rules/pgm_pattern/内部テーブル検索.md b/black-box-data-create/rules/pgm_pattern/内部テーブル検索.md new file mode 100644 index 0000000..519ac10 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/内部テーブル検索.md @@ -0,0 +1,64 @@ +# 内部テーブル検索 データ生成規則 + +## 概要 + +WORKING-STORAGE上の内部テーブル(OCCURS)を SEARCH / SEARCH ALL で検索し、該当データを取得するプログラムのテストデータ。 + +本规则定义5组数据,覆盖等值查找、未找到、顺序查找、可变长表边界、异常系。 + +## 输入 + +- 詳細設計書の処理詳細(検索キー、SEARCH/SEARCH ALLの区分、INDEX操作) +- 内部テーブルのOCCURS定義(キー項目、OCCURS DEPENDING ON有無) + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | SEARCH ALL 等值查找(T-N001) | KEYが一致するデータが存在するケース | +| 2 | SEARCH ALL 未找到(T-N002) | KEYが一致するデータが存在しないケース | +| 3 | 多键/顺序SEARCH(T-N003/T-N004) | 複合キー検索、SEARCHによる順次検索 | +| 4 | OCCURS DEPENDING ON 边界(T-N006) | 可変長テーブルの境界(ODL=1、ODL=最大値) | +| 5 | 异常系(T-A001〜T-A003) | 未ソート表、INDEX越界、ODL=0(空表) | + +### 组1:SEARCH ALL 等值查找(T-N001) + +テーブルに検索対象のキーが存在するデータ(例: キー a001〜a005 の5件、検索対象 a003)。 + +### 组2:SEARCH ALL 未找到(T-N002) + +テーブルに検索対象のキーが存在しないデータ(例: a001〜a004、検索対象 z999)。 + +### 组3:多键/顺序SEARCH(T-N003/T-N004) + +- 複合キー(主キー+副キー)で検索するデータ +- SEARCH(非ALL)で順次探索するデータ(先頭・末尾・中間の位置バリエーション) + +### 组4:OCCURS DEPENDING ON 边界(T-N006) + +可変長テーブルで ODL=1(最小)と ODL=最大値(満杯)のデータ。境界での検索が正しく動作することを確認。 + +### 组5:异常系(T-A001〜T-A003) + +- SEARCH ALL 対象テーブルが昇順でないデータ(誤命中確認) +- INDEXがOCCURS範囲外になるデータ(T-A002) +- ODL=0(空テーブル)のデータ(T-A003) + +## 多keyテスト + +検索キーが複数項目の場合、各組末尾に多keyテストを追加(キー項目ごとに1件ずつ基準と異なる値のデータ)。 + +## データ生成手順 + +1. テーブル定義と検索キーの特定 +2. 组1/组2: 存在・非存在データ +3. 组3: 複合キー・順次検索データ +4. 组4: ODL境界データ +5. 组5: 異常系データ +6. 生成後自检(T-R001):検索後のINDEX位置が期待どおりであること + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的5组一致(g1〜g5),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/振り分け(EVALUATE).md b/black-box-data-create/rules/pgm_pattern/振り分け(EVALUATE).md new file mode 100644 index 0000000..1b57464 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/振り分け(EVALUATE).md @@ -0,0 +1,68 @@ +# 振り分け(EVALUATE) データ生成規則 + +## 概要 + +単一の入力ファイル(CSVまたは固定長)を読み取り、EVALUATE文による条件分岐に基づいて複数の出力ファイルに振り分けるプログラムのテストデータを生成する。 + +本规则定义5组数据,覆盖测试基准说明 §2.2「条件分支系测试(IF / EVALUATE)」中 EVALUATE文相关的 B-N006〜B-N009、B-A002。IF文による分岐は「振り分け(IF).md」を参照。 + +## 输入 + +- 詳細設計書の処理詳細(EVALUATE条件と出力先の対応) +- 入力ファイルのCOPYBOOKフィールド定義 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | EVALUATE WHEN 多值(B-N006) | 各WHEN分岐を1件ずつカバー | +| 2 | EVALUATE ALSO(B-N007) | ALSOによる複数条件組合せの全パターン | +| 3 | EVALUATE TRUE/FALSE(B-N008) | 条件式をTRUE/FALSEで判定するケース | +| 4 | EVALUATE WHEN OTHER(B-N009) | 全WHEN不成立→WHEN OTHER到達データ | +| 5 | EVALUATE 无WHEN OTHER(B-A002) | 全WHEN不成立→無操作になるデータ(○) | + +### 组1:EVALUATE WHEN 多值(B-N006) + +EVALUATEの各WHEN分岐を1件ずつカバーするデータ。WHENに複数値(VALUE THRU等)を指定する場合も各値に1件生成する。 + +例:WHEN 'T'→出力A、WHEN 'P'→出力B、WHEN 'M'→出力C + +### 组2:EVALUATE ALSO(B-N007) + +EVALUATE ... ALSO ... による複数条件の組合せ判定。各条件の組合せ(成立×成立、成立×不成立、不成立×成立、不成立×不成立)をカバーする。 + +### 组3:EVALUATE TRUE/FALSE(B-N008) + +EVALUATE TRUE / EVALUATE FALSE による条件式判定。各条件式がTRUEになるデータ・FALSEになるデータを生成する。 + +### 组4:EVALUATE WHEN OTHER(B-N009) + +全WHENの条件に該当しないデータを生成し、WHEN OTHER(デフォルト分岐)に到達することを確認する。 + +### 组5:EVALUATE 无WHEN OTHER(B-A002) + +WHEN OTHERを持たないEVALUATEで、全WHENに該当しないデータ。無操作で終了することを確認する(○)。 + +## データ生成手順 + +1. **EVALUATE条件の特定**: 処理詳細からすべてのEVALUATE条件(WHEN、ALSO、TRUE/FALSE、WHEN OTHER)、対応する出力先、関係演算子を抽出する +2. **分岐パターンの列挙**: 正常系(検証通過→正常出力)と異常系(エラー→ERROR-LOG)の全パターンを列挙する +3. **データ生成**: + - 各组の分岐パターンに最低1件のレコードを生成する + - フィールド値はPIC定義に従い生成する + - 正常系データ: 全チェックを通過する有効な値 + - 異常系データ: 該当するチェックに引っかかる値(不正な日付、不正な時刻、範囲外の値など) +4. **値のルール**: + - PIC X(n): 左詰め・スペース埋め + - PIC 9(n): 右詰め・先行ゼロ + - DATE/日付系: 実在しそうな日付(YYYYMMDD形式)、異常系では不正な日付(99999999等) + - TIME/時刻系: 実在しそうな時刻(HHMM形式)、異常系では範囲外の時刻 + - STATUS系: 0/1/9 などのコード値 + - CSV項番号フィールド: 分割後の項目数 +5. **生成後自检**: 各WHEN分岐・ALSO組合せが漏れなくカバーされていること、各出力先に最低1件振り分けられることを確認する + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的5组一致(g1〜g5),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。正常系→異常系→特殊分岐の順にレコードを並べる。 diff --git a/black-box-data-create/rules/pgm_pattern/振り分け(IF).md b/black-box-data-create/rules/pgm_pattern/振り分け(IF).md new file mode 100644 index 0000000..2a9871a --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/振り分け(IF).md @@ -0,0 +1,73 @@ +# 振り分け(IF) データ生成規則 + +## 概要 + +単一の入力ファイル(CSVまたは固定長)を読み取り、IF文による条件分岐に基づいて複数の出力ファイルに振り分けるプログラムのテストデータを生成する。 + +本规则定义5组数据,覆盖测试基准说明 §2.2「条件分支系测试(IF / EVALUATE)」中 IF文相关的 B-N001〜B-N005。B-A001(IF未闭合)は编译错误であり运行时测试対象外(代码审查范畴)のためデータ组としない。EVALUATE文による分岐は「振り分け(EVALUATE).md」を参照。 + +## 输入 + +- 詳細設計書の処理詳細(IF条件と出力先の対応) +- 入力ファイルのCOPYBOOKフィールド定義 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | IF 2路分支(B-N001) | IF条件成立/不成立(ELSE)を各1件以上カバー | +| 2 | IF 3路分支 ELSE IF(B-N002) | ELSE IF 链式分支の全分岐を各1件カバー | +| 3 | IF 复合条件 AND/OR(B-N003) | 複数条件のAND/OR組合せ全パターン | +| 4 | IF 88-level 条件名(B-N004) | 88条件名の成立/不成立 | +| 5 | IF 嵌套3层(B-N005) | 3層ネストIFの全組合せ(外層×中層×内層) | + +### 组1:IF 2路分支(B-N001) + +IF条件成立→出力A、不成立(ELSE)→出力B の2路分岐。成立・不成立それぞれ1件以上のデータを生成する。 + +例:STATUS='1'→正常出力、STATUS='9'→取消出力 + +### 组2:IF 3路分支 ELSE IF(B-N002) + +IF → ELSE IF → ELSE の链式分支。各分岐(3路)に1件ずつデータを生成する。 + +例:STATUS='0'→出力A、STATUS='1'→出力B、それ以外(ELSE)→出力C + +### 组3:IF 复合条件 AND/OR(B-N003) + +複数条件の組合せをカバーする。AND/ORそれぞれについて成立・不成立の全パターンを生成する。 + +例(AND):STATUS='1' AND TIME >= '0900' → 成立/STATUS='1' AND TIME < '0900' → 不成立 +例(OR):STATUS='1' OR STATUS='2' → 成立/STATUS='9' → 不成立 + +### 组4:IF 88-level 条件名(B-N004) + +88-level条件名(例:88 WS-VALID VALUE '0' '1')による判定。条件名成立・不成立のデータを生成する。 + +### 组5:IF 嵌套3层(B-N005) + +3層ネストIF(外層IFの中で中層IF、さらに内層IF)の全組合せデータ。内層IFが外層IFの変数を参照するケースを含む。 + +## データ生成手順 + +1. **IF条件の特定**: 処理詳細からすべてのIF条件、対応する出力先、関係演算子を抽出する +2. **分岐パターンの列挙**: 正常系(検証通過→正常出力)と異常系(エラー→ERROR-LOG)の全パターンを列挙する +3. **データ生成**: + - 各组の分岐パターンに最低1件のレコードを生成する + - フィールド値はPIC定義に従い生成する + - 正常系データ: 全チェックを通過する有効な値 + - 異常系データ: 該当するチェックに引っかかる値(不正な日付、不正な時刻、範囲外の値など) +4. **値のルール**: + - PIC X(n): 左詰め・スペース埋め + - PIC 9(n): 右詰め・先行ゼロ + - DATE/日付系: 実在しそうな日付(YYYYMMDD形式)、異常系では不正な日付(99999999等) + - TIME/時刻系: 実在しそうな時刻(HHMM形式)、異常系では範囲外の時刻 + - STATUS系: 0/1/9 などのコード値 + - CSV項番号フィールド: 分割後の項目数 +5. **生成後自检**: 各IF分岐が漏れなくカバーされていること、各出力先に最低1件振り分けられることを確認する + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的5组一致(g1〜g5),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。正常系→異常系→特殊分岐の順にレコードを並べる。 diff --git a/black-box-data-create/rules/pgm_pattern/項目チェック(半角20桁-4桁).md b/black-box-data-create/rules/pgm_pattern/項目チェック(半角20桁-4桁).md new file mode 100644 index 0000000..b4acb3e --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/項目チェック(半角20桁-4桁).md @@ -0,0 +1,55 @@ +# 項目チェック(半角20桁-4桁) データ生成規則 + +## 概要 + +入力ファイル(CSVまたは固定長)の半角フィールドに対し、半角20桁/4桁の桁数チェック(半角英数字・全角混入チェック含む)を実施し、正常データと異常データを別々の出力ファイルに振り分けるプログラムのテストデータを生成する。 + +本规则定义4组数据,覆盖测试基准说明 §2.2「校验测试(含重复 / 不含重复 / 半角)」中半角校验相关的 VF-N005、VF-N006、VF-A001、VF-A002。字段校验・重复检测は「項目チェック(重複含まず).md」を参照。 + +## 输入 + +- 詳細設計書の処理詳細(半角チェック対象フィールド、桁数) +- 入力ファイルのCOPYBOOKフィールド定義 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 半角长度20桁(VF-N005) | 半角20桁ちょうど・20桁以内→通過するデータ | +| 2 | 半角长度4桁(VF-N006) | 半角4桁ちょうど・4桁以内→通過するデータ | +| 3 | 半角超长21桁(VF-A001) | 半角21桁→截断/エラーになるデータ | +| 4 | 全角混入(VF-A002) | 半角フィールドに全角文字が入る→エラーになるデータ | + +### 组1:半角长度20桁(VF-N005) + +半角フィールドに20桁ちょうど・20桁以内の値を設定するデータ。通過することを確認。 + +### 组2:半角长度4桁(VF-N006) + +半角フィールドに4桁ちょうど・4桁以内の値を設定するデータ。通過することを確認。 + +### 组3:半角超长21桁(VF-A001) + +半角フィールドに21桁の値を設定するデータ。截断またはエラー処理が設計書どおりであることを確認。 + +### 组4:全角混入(VF-A002) + +半角フィールド(PIC X 半角域)に全角文字を混入させたデータ。エラーになることを確認。 + +## データ生成手順 + +1. **半角フィールドの特定**: 処理詳細から半角チェック対象フィールドと桁数(20桁/4桁)を特定する +2. **データ生成**: + - 组1/组2: 半角20桁・4桁以内の正常値 + - 组3: 21桁の超過値 + - 组4: 全角文字混入値 +3. **値のルール**: + - 半角チェック: 20桁以内→通過、21桁→エラー、4桁以内→通過、全角混入→エラー + - 半角英大文字チェック(設計書指定時): 英大文字のみ→通過、小文字・数字混入→エラー +4. **生成後自检(VF-R001)**: 正常件+エラー件=入力件数 を確認する + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的4组一致(g1〜g4),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/項目チェック(重複含まず).md b/black-box-data-create/rules/pgm_pattern/項目チェック(重複含まず).md new file mode 100644 index 0000000..2aa5608 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/項目チェック(重複含まず).md @@ -0,0 +1,82 @@ +# 項目チェック(重複含まず) データ生成規則 + +## 概要 + +入力ファイル(CSVまたは固定長)の各レコードに対し、複数の項目チェック(値域・桁数・コード値等)と重複チェック(重複含まず)を実施し、正常データと異常データを別々の出力ファイルに振り分けるプログラムのテストデータを生成する。 + +本规则定义5组数据,覆盖测试基准说明 §2.2「校验测试(含重复 / 不含重复 / 半角)」中字段校验・重复检测相关的 VF-N001〜VF-N004、VF-R001。半角20桁/4桁チェックは「項目チェック(半角20桁-4桁).md」を参照。 + +## 输入 + +- 詳細設計書の処理詳細(チェック項目一覧、重複キー) +- 入力ファイルのCOPYBOOKフィールド定義 + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 字段校验通过(VF-N001) | 全項目が許容範囲内→正常出力されるデータ | +| 2 | 字段校验拒绝(VF-N002) | 不正値(日付・時刻・数値・コード値など)→エラー出力ファイルのデータ | +| 3 | 重复检测 正常(VF-N003) | キーが初回出現→通過するデータ | +| 4 | 重复检测 重复(VF-N004) | 同一キー2回目→エラー出力されるデータ + 末尾に多keyテスト | +| 5 | 件数确认(VF-R001) | 正常出力件数+エラー出力件数=入力件数 を確認するデータ | + +### 组1:字段校验通过(VF-N001) + +全チェック項目を通過する有効な値のレコード(3〜5件)。各フィールドはPIC定義どおり、日付・時刻・コード値とも許容範囲内。 + +### 组2:字段校验拒绝(VF-N002) + +各チェック項目に特化した不正値を含むレコード: +- 日付チェック: 不正日付(99999999等) +- 時刻チェック: 範囲外(2500等) +- 数値チェック: 非数値・桁数不足 +- フィールド数チェック: 項目数不足/過剰(CSV) +- コード値チェック: 無効コード + +### 组3:重复检测 正常(VF-N003) + +全レコードのキーが異なるデータ(重複なし)。初回出現キーのみで全件通過することを確認。 + +### 组4:重复检测 重复(VF-N004) + +同一キーを2件含むデータ。2回目の出現でエラー出力されることを確認。キーが複数項目の場合、基準キーに対し各キー項目のみ異なるN件を追加し、完全一致時のみ重複と判定されることを検証(多keyテスト)。 + +### 组5:件数确认(VF-R001) + +正常出力件数とエラー出力件数の合計が入力件数と一致することを確認するデータ(正常件+错误件=输入件数)。 + +## 多keyテスト + +重複チェックのキーが複数項目の場合、组4の末尾に追加。基準レコードの各キー項目のみ異なるN件を生成し、完全一致時のみ重複判定されることを検証する。 + +需要注意当key为n个项目组合而成时,需要在末尾追加n条数据。以基准数据的所有key为准,只有当前第n个key与基准不一致。保证key的比较没有问题。 + +例(n=3,基准 (a001, b001, c001)): +- 追加1: (a002, b001, c001) → 仅第1个key不同 +- 追加2: (a001, b002, c001) → 仅第2个key不同 +- 追加3: (a001, b001, c002) → 仅第3个key不同 + +n为实际解析出的key项目数(例中n=3仅为示意,追加条数必须等于实际key项目数)。键项目数n=1时不追加多key测试数据。追加的每条记录必须恰好只改变一个key项目,且不得与既有记录完全重复。 + +## データ生成手順 + +1. **チェック項目の特定**: 処理詳細からすべてのチェック項目(フィールド数、値域、重複キー、時刻範囲、コード値など)を抽出する +2. **重複キーの特定**: 重複チェックのキー項目を特定する +3. **データ生成**: + - 组1: 全チェックを通過する有効値 + - 组2: 各チェック項目に特化した不正値 + - 组3/组4: 重複なし/重複ありデータ + - 组5: 件数整合確認データ +4. **値のルール**: + - フィールド数チェック: 正しい項目数/足りない/多い + - 日付チェック: 正常日付 vs 不正日付 (99999999等) + - 時刻チェック: 正常時刻 (0900-1800) vs 範囲外 (2500等) + - コード値チェック: 有効コード vs 無効コード + - 社員番号: 8桁数字 vs 非数字/桁数不足 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的5组一致(g1〜g5),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/2段階マッチング(1:1⇒1:1).md b/black-box-data-create/rules/pgm_pattern/2段階マッチング(1:1⇒1:1).md new file mode 100644 index 0000000..59511ca --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/2段階マッチング(1:1⇒1:1).md @@ -0,0 +1,62 @@ +# 2段階マッチング(1:1⇒1:1) データ生成規則 + +## 概要 + +第1段階のマッチングで中間ファイルを生成し、第2段階で再度1:1マッチングするプログラムのテストデータ(AM-N001)。 + +本规则定义4组数据:完全匹配、剩余键、中间文件/键异常、件数确认。 + +## 输入 + +- 詳細設計書のキー項目一覧(第1段階キー、第2段階キー) +- 各入力ファイル・中間ファイルのCOPYBOOKフィールド定義 +- キー条件:各段階ともキー昇順ソート済み(重複NG) + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 二级1:1→1:1完全匹配(AM-N001) | 第1段階 R01×R02 をキーAで1:1完全マッチ(4件)→中間M01;第2段階 M01×R03 をキーBで1:1完全マッチ(4件) | +| 2 | 剩余键(MT-N004/N005) | 第1段階・第2段階それぞれで主件/從件の剩余キーを含むデータ | +| 3 | 中间文件/键异常(AM-A001/A002) | 中間ファイルOPEN失敗(環境テスト)、第1段階キーAと第2段階キーBが異なる設計のデータ | +| 4 | 件数确认(AM-R001) | 第1段階・第2段階それぞれの出力件数(マッチ件数+剩余件数)が入力件数と一致するデータ | + +### 组1:完全匹配(AM-N001) + +第1段階: R01×R02 をキーAで1:1完全マッチ(a001〜a004、4件)→中間ファイルM01 +第2段階: M01×R03 をキーBで1:1完全マッチ(b001〜b004、4件) + +### 组2:剩余键(MT-N004/N005) + +第1段階: R01にR02に無いキーを末尾に1件(主件剩余)、R02にR01に無いキーを先頭に1件(從件剩余) +第2段階: M01×R03 でも同様に剩余キーを1件ずつ含める + +### 组3:中间文件/键异常(AM-A001/A002) + +- 中間ファイルがOPENできないケース(環境テスト項目として明記) +- 第1段階キーAと第2段階キーBが異なる設計のデータで、第2段階のマッチ結果が設計書どおりになること + +### 组4:件数确认(AM-R001) + +第1段階出力件数と第2段階出力件数(マッチ件数+剩余件数)の合計が入力件数と一致することを確認するデータ。 + +## 多keyテスト + +第1段階・第2段階のキーが複数項目の場合、各組末尾に多keyテストを追加(既存マッチング規則と同じ手順)。 + +需要注意当key为n个项目组合而成时,需要在末尾追加n条数据。以基准数据的所有key为准,只有当前第n个key与基准不一致。保证key的比较没有问题。键项目数n=1时不追加多key测试数据。 + +## 数据生成步骤 + +1. 第1段階キー・第2段階キーの特定 +2. 组1: 完全マッチデータ(各4件) +3. 组2: 剩余キーを各段階に1件ずつ +4. 组3: キー不一致データ +5. 各組末尾に多keyテスト +6. 生成後自检(AM-R001):各段の出力件数の整合確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的4组一致(g1〜g4),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/2段階マッチング(M:N⇒M:N).md b/black-box-data-create/rules/pgm_pattern/2段階マッチング(M:N⇒M:N).md new file mode 100644 index 0000000..1e20769 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/2段階マッチング(M:N⇒M:N).md @@ -0,0 +1,62 @@ +# 2段階マッチング(M:N⇒M:N) データ生成規則 + +## 概要 + +第1段階のM:Nマッチング(M×N件の直積)で中間ファイルを生成し、第2段階で再度M:Nマッチングするプログラムのテストデータ(AM-N008)。 + +本规则定义4组数据:M:N匹配(直積)、不平衡、中间文件/键异常、件数确认。 + +## 输入 + +- 詳細設計書のキー項目一覧(第1段階キー、第2段階キー) +- 各入力ファイル・中間ファイルのCOPYBOOKフィールド定義 +- キー条件:各段階ともキー昇順ソート済み + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 二级M:N→M:N(AM-N008) | 第1段階 R01×R02 を同キーでM:Nマッチ(M×N件の直積)→中間M01;第2段階 M01×R03 を同キーで再マッチ(M×N件) | +| 2 | 不平衡(MT-N009/N010) | M=1件/N=N件、M=N件/N=1件 の极端不平衡データ | +| 3 | 中间文件/键异常(AM-A001/A002) | 中間ファイルOPEN失敗(環境テスト)、第1段階キーAと第2段階キーBが異なる設計のデータ | +| 4 | 件数确认(AM-R001) | 第1段階・第2段階それぞれの出力件数(マッチ件数+剩余件数)が入力件数と一致するデータ | + +### 组1:M:N匹配(AM-N008) + +第1段階: R01(同キーM件)×R02(同キーN件)をキーAでM:Nマッチ→M×N件→中間ファイルM01 +第2段階: M01×R03 をキーBで再マッチ(M×N件) + +### 组2:不平衡(MT-N009/N010) + +- M=1件、N=N件(同キー)のデータ +- M=N件、N=1件(同キー)のデータ + +### 组3:中间文件/键异常(AM-A001/A002) + +- 中間ファイルがOPENできないケース(環境テスト項目として明記) +- 第1段階キーAと第2段階キーBが異なる設計のデータで、第2段階のマッチ結果が設計書どおりになること + +### 组4:件数确认(AM-R001) + +第1段階出力件数と第2段階出力件数(マッチ件数+剩余件数)の合計が入力件数と一致することを確認するデータ。 + +## 多keyテスト + +第1段階・第2段階のキーが複数項目の場合、各組末尾に多keyテストを追加(既存マッチング規則と同じ手順)。 + +需要注意当key为n个项目组合而成时,需要在末尾追加n条数据。以基准数据的所有key为准,只有当前第n个key与基准不一致。保证key的比较没有问题。键项目数n=1时不追加多key测试数据。 + +## 数据生成步骤 + +1. 第1段階キー・第2段階キーの特定 +2. 组1: M:N直積マッチデータ +3. 组2: 不平衡データ +4. 组3: キー不一致データ +5. 各組末尾に多keyテスト +6. 生成後自检(AM-R001):各段の出力件数の整合確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的4组一致(g1〜g4),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/pgm_pattern/2段階マッチング(N:1⇒N:1).md b/black-box-data-create/rules/pgm_pattern/2段階マッチング(N:1⇒N:1).md new file mode 100644 index 0000000..e390747 --- /dev/null +++ b/black-box-data-create/rules/pgm_pattern/2段階マッチング(N:1⇒N:1).md @@ -0,0 +1,63 @@ +# 2段階マッチング(N:1⇒N:1) データ生成規則 + +## 概要 + +第1段階のN:1マッチングで中間ファイルを生成し、第2段階で再度N:1マッチングするプログラムのテストデータ(AM-N002)。 + +本规则定义4组数据:N:1匹配、剩余/不平衡、中间文件/键异常、件数确认。 + +## 输入 + +- 詳細設計書のキー項目一覧(第1段階キー、第2段階キー) +- 各入力ファイル・中間ファイルのCOPYBOOKフィールド定義 +- キー条件:各段階ともキー昇順ソート済み(主件の重複NG) + +## 生成データ組 + +| 组 | 用途 | 内容 | +|----|------|------| +| 1 | 二级N:1→N:1(AM-N002) | 第1段階 R01(主N件)×R02(從1件)をキーAでN:1マッチ→N件→中間M01;第2段階 M01×R03(從1件)をキーBでN:1マッチ→N件 | +| 2 | 剩余/不平衡(MT-N004/N005/N009) | 各段階で主件/從件の剩余キーと极端不平衡(R01=1件、R02=N件)を含むデータ | +| 3 | 中间文件/键异常(AM-A001/A002) | 中間ファイルOPEN失敗(環境テスト)、第1段階キーAと第2段階キーBが異なる設計のデータ | +| 4 | 件数确认(AM-R001) | 第1段階・第2段階それぞれの出力件数(マッチ件数+剩余件数)が入力件数と一致するデータ | + +### 组1:N:1匹配(AM-N002) + +第1段階: R01(主N件、同キー2件)×R02(從1件)をキーAでN:1マッチ→N件→中間ファイルM01 +第2段階: M01×R03(從1件)をキーBでN:1マッチ→N件 + +### 组2:剩余/不平衡(MT-N004/N005/N009) + +- 主件剩余: R01にR02に無いキーを末尾に追加 +- 從件剩余: R02にR01に無いキーを追加 +- 极端不平衡: R01=1件、R02=N件(同キー)のデータ + +### 组3:中间文件/键异常(AM-A001/A002) + +- 中間ファイルがOPENできないケース(環境テスト項目として明記) +- 第1段階キーAと第2段階キーBが異なる設計のデータで、第2段階のマッチ結果が設計書どおりになること + +### 组4:件数确认(AM-R001) + +第1段階出力件数と第2段階出力件数(マッチ件数+剩余件数)の合計が入力件数と一致することを確認するデータ。 + +## 多keyテスト + +第1段階・第2段階のキーが複数項目の場合、各組末尾に多keyテストを追加(既存マッチング規則と同じ手順)。 + +需要注意当key为n个项目组合而成时,需要在末尾追加n条数据。以基准数据的所有key为准,只有当前第n个key与基准不一致。保证key的比较没有问题。键项目数n=1时不追加多key测试数据。 + +## 数据生成步骤 + +1. 第1段階キー・第2段階キーの特定 +2. 组1: N:1マッチデータ +3. 组2: 剩余・不平衡データ +4. 组3: キー不一致データ +5. 各組末尾に多keyテスト +6. 生成後自检(AM-R001):各段の出力件数の整合確認 + +## 出力 + +每组输出1个JSON文件:`{プログラムID}_g{groupId}.json` + +组数约束:输出组数必须与本规则定义的4组一致(g1〜g4),组号连续从1开始。禁止添加本规则未定义的组,禁止缺组。 diff --git a/black-box-data-create/rules/special_feature/条件分支.md b/black-box-data-create/rules/special_feature/条件分支.md new file mode 100644 index 0000000..82a21b0 --- /dev/null +++ b/black-box-data-create/rules/special_feature/条件分支.md @@ -0,0 +1,27 @@ +# 条件分支 数据生成规则 + +## 概要 + +检索详细设计中処理詳細的所有条件分支,增加覆盖所有分支的输入数据。 + +## 输入 + +- 输入文件:详细设计书的処理詳細部分 + +## 生成的数据组 + +由你决定,以尽可能少的数据组完成分支覆盖 + + +## 数据生成步骤 + +1. 遍历処理詳細的所有内容 +2. 记录所有条件分支 +3. 找到条件判断数据与输入的关系 +4. 数据生成:根据字段名关键词生成符合实际的数据,同时尽量保证每条相邻的数据的相同项目不一致。 +5. 按各组规则组装records数组 +6. 组号从之前的最后一组+1开始连续编号 + +## 输出 + +每个组输出一个json文件:`{プログラムID}_g{groupId}.json` diff --git a/black-box-data-create/tests/__init__.py b/black-box-data-create/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/black-box-data-create/tests/conftest.py b/black-box-data-create/tests/conftest.py new file mode 100644 index 0000000..d1634f4 --- /dev/null +++ b/black-box-data-create/tests/conftest.py @@ -0,0 +1,10 @@ +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +@pytest.fixture +def data_dir(): + return os.path.join(os.path.dirname(__file__), 'test_data') diff --git a/black-box-data-create/tests/test_api_client.py b/black-box-data-create/tests/test_api_client.py new file mode 100644 index 0000000..da08c64 --- /dev/null +++ b/black-box-data-create/tests/test_api_client.py @@ -0,0 +1,68 @@ +import json +from unittest.mock import patch, MagicMock +from agent.api_client import APIClient + + +def test_parse_json_valid(): + text = '{"groups": {"g1": {"records": []}}}' + result = APIClient._parse_json(text) + assert result is not None + assert 'groups' in result + + +def test_parse_json_with_markdown_wrapper(): + text = '```json\n{"key": "value"}\n```' + result = APIClient._parse_json(text) + assert result is not None + assert result['key'] == 'value' + + +def test_parse_json_with_text_before(): + text = '说明\n{"key": "value"}\n以上' + result = APIClient._parse_json(text) + assert result is not None + assert result['key'] == 'value' + + +def test_parse_json_invalid(): + text = '这不是有效的JSON。' + result = APIClient._parse_json(text) + assert result is None + + +@patch('agent.api_client.requests.post') +def test_generate_success(mock_post): + mock_response = MagicMock() + mock_response.json.return_value = { + 'choices': [{'message': {'content': '{"groups": {"g1": {"type": "json"}}}'}}] + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + client = APIClient(api_key='test-key') + result = client.generate("test prompt") + + assert result['groups']['g1']['type'] == 'json' + + +@patch('agent.api_client.requests.post') +def test_generate_retry_on_json_error(mock_post): + bad_response = MagicMock() + bad_response.json.return_value = { + 'choices': [{'message': {'content': 'invalid response'}}] + } + bad_response.raise_for_status = MagicMock() + + good_response = MagicMock() + good_response.json.return_value = { + 'choices': [{'message': {'content': '{"result": "ok"}'}}] + } + good_response.raise_for_status = MagicMock() + + mock_post.side_effect = [bad_response, good_response] + + client = APIClient(api_key='test-key', max_retries=3) + result = client.generate("test") + + assert result['result'] == 'ok' + assert mock_post.call_count == 2 diff --git a/black-box-data-create/tests/test_copy_parser.py b/black-box-data-create/tests/test_copy_parser.py new file mode 100644 index 0000000..ac8bbf8 --- /dev/null +++ b/black-box-data-create/tests/test_copy_parser.py @@ -0,0 +1,87 @@ +# tests/test_copy_parser.py +import os +import tempfile +from agent.input_parser import InputParser + +FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'test_data') + + +def test_parse_single_copybook(): + parser = InputParser( + design_md_path='dummy.md', source_cbl_path='dummy.cbl', + file_db_md_path='dummy.md', cpy_dir=FIXTURE_DIR, + db_md_path='dummy_db.md' + ) + fields = parser._parse_single_copybook( + os.path.join(FIXTURE_DIR, 'ZAN01REC.cpy'), + prefix='R01' + ) + + assert len(fields) == 8 + + f0 = fields[0] + assert f0.name == 'R01-APPL-ID' + assert f0.raw_name == '(A)APPL-ID' + assert 'X' in f0.pic_type + assert f0.pic_bytes == 8 + + f1 = fields[1] + assert f1.name == 'R01-EMP-ID' + assert '9' in f1.pic_type + assert f1.pic_bytes == 8 + + f3 = fields[3] + assert f3.name == 'R01-START-TIME' + assert f3.pic_bytes == 4 + + f7 = fields[7] + assert 'FILLER' in f7.name + assert f7.pic_bytes == 46 + + +def test_extract_copy_replacing(): + source_text = """ + FD R01INNFIL. + 01 R01INNREC. + COPY ZAN01REC REPLACING ==(A)== BY ==R01==. + FD R02INNFIL. + 01 R02INNREC. + COPY ZAN04REC REPLACING ==(A)== BY ==R02==. + """ + + parser = InputParser( + design_md_path='dummy.md', source_cbl_path='dummy.cbl', + file_db_md_path='dummy.md', cpy_dir=FIXTURE_DIR, + db_md_path='dummy_db.md' + ) + parser._source_text = source_text + + result = parser._extract_copy_replacing() + assert len(result) == 2 + assert result[0] == ('ZAN01REC', 'R01') + assert result[1] == ('ZAN04REC', 'R02') + + +def test_calculate_pic_bytes_x(): + assert InputParser._calculate_pic_bytes('X(008)') == 8 + assert InputParser._calculate_pic_bytes('X(10)') == 10 + + +def test_calculate_pic_bytes_9(): + assert InputParser._calculate_pic_bytes('9(008)') == 8 + assert InputParser._calculate_pic_bytes('9(004)') == 4 + + +def test_calculate_pic_bytes_comp3(): + assert InputParser._calculate_pic_bytes('S9(009) COMP-3') == 5 + assert InputParser._calculate_pic_bytes('S9(7) COMP-3') == 4 + assert InputParser._calculate_pic_bytes('9(7) COMP-3') == 4 + + +def test_calculate_pic_bytes_comp(): + assert InputParser._calculate_pic_bytes('S9(4) COMP') == 2 + assert InputParser._calculate_pic_bytes('S9(9) COMP') == 4 + + +def test_calculate_pic_bytes_decimal(): + assert InputParser._calculate_pic_bytes('S9(7)V9(2)') == 9 diff --git a/black-box-data-create/tests/test_data/DB定義書.md b/black-box-data-create/tests/test_data/DB定義書.md new file mode 100644 index 0000000..34a4ecd --- /dev/null +++ b/black-box-data-create/tests/test_data/DB定義書.md @@ -0,0 +1,347 @@ +# DB定義書 + +## 変更履歴 + +| No | 変更内容 | 担当者 | 変更日 | 承認者 | 備考 | +|----|---------|--------|--------|--------|------| +| 1 | 新規作成(全8テーブル) | AI | 2026/06/23 | | サブシステムA(6) + B(2) | +| 2 | SALARYDB 4テーブル追加 | AI | 2026/07/08 | | サブシステムC(給与計算) | + +--- + +# 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.50(50%控除) | + +### 備考 + +- 全社員共通の設定値 +- 現時点では参照プログラム未実装(設計上定義) + +--- + +# 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が月次集計後にUPSERT(MERGE) +- サブシステム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 + +--- + +# EMP-MASTER(SALARYDB) — 社員マスタ(給与計算用) + +## Layout(テーブル定義) + +### DB基本情報 + +| DB名 | DB ID | COPY ID | INDEX | +|------|-------|---------|-------| +| 給与計算DB | EMP-MASTER | KYU01REC | PK: EMP-ID | + +### カラム定義 + +| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 | +|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------| +| 1 | 社員番号 | EMP-ID | CHAR | 8 | | 8 | NOT NULL | | ✓ | | | | | +| 2 | 氏名 | EMP-NAME | VARCHAR | 40 | | 40 | NOT NULL | | | | | | | +| 3 | 部署コード | DEPT-CODE | CHAR | 2 | | 2 | NOT NULL | | | | | | | +| 4 | 地域コード | REGION-CODE | CHAR | 2 | | 2 | NOT NULL | | | | | | | +| 5 | 職種コード | CATEGORY-CODE | CHAR | 3 | | 3 | NOT NULL | | | | | | | +| 6 | 基本給 | BASE-SALARY | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | +| 7 | 時給 | HOURLY-RATE | DECIMAL | 7 | 0 | 4 | NOT NULL | | | | | | | +| 8 | 扶養人数 | DEPENDENT-COUNT | SMALLINT | — | | 2 | NOT NULL | | | | | | | +| 9 | ステータス | STATUS | CHAR | 1 | | 1 | NOT NULL | | | | | | '0'=在籍, '9'=退職 | +| 10 | 更新日時 | UPDATED-AT | TIMESTAMP | — | | 10 | NOT NULL | CURRENT TIMESTAMP | | | | | | + +### 備考 + +- KYU02REGがINSERT/UPSERTを実行 +- KYU04CALがSELECTで参照 + +--- + +# TAX-TABLE(SALARYDB) — 所得税率テーブル + +## Layout(テーブル定義) + +### DB基本情報 + +| DB名 | DB ID | COPY ID | INDEX | +|------|-------|---------|-------| +| 給与計算DB | TAX-TABLE | — | PK: (TAX-FROM, TAX-TO) | + +### カラム定義 + +| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 | +|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------| +| 1 | 課税下限 | TAX-FROM | DECIMAL | 9 | 0 | 5 | NOT NULL | | ✓ | | | | | +| 2 | 課税上限 | TAX-TO | DECIMAL | 9 | 0 | 5 | NOT NULL | | ✓ | | | | | +| 3 | 税率 | TAX-RATE | DECIMAL | 5 | 4 | 3 | NOT NULL | | | | | | | +| 4 | 控除額 | DEDUCTION | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | + +### 備考 + +- KYU05DEDがSELECT ... BETWEENで参照 +- 課税所得に対する超過累進税率 + +--- + +# INSURANCE-TABLE(SALARYDB) — 社会保険料率テーブル + +## Layout(テーブル定義) + +### DB基本情報 + +| DB名 | DB ID | COPY ID | INDEX | +|------|-------|---------|-------| +| 給与計算DB | INSURANCE-TABLE | — | PK: (INS-INCOME-FROM, INS-INCOME-TO) | + +### カラム定義 + +| No | 項目名 | 項目名(英字名) | TYPE | 最大長 | 小数桁 | バイト数 | NULL | DEFAULT | PK | INDEX1 | INDEX2 | INDEX3 | 備考 | +|----|--------|---------------|------|--------|--------|---------|------|---------|----|--------|--------|--------|------| +| 1 | 所得下限 | INS-INCOME-FROM | DECIMAL | 9 | 0 | 5 | NOT NULL | | ✓ | | | | | +| 2 | 所得上限 | INS-INCOME-TO | DECIMAL | 9 | 0 | 5 | NOT NULL | | ✓ | | | | | +| 3 | 健康保険料率 | INS-HEALTH-RATE | DECIMAL | 7 | 6 | 4 | NOT NULL | | | | | | | +| 4 | 厚生年金保険料率 | INS-PENSION-RATE | DECIMAL | 7 | 6 | 4 | NOT NULL | | | | | | | +| 5 | 更新日時 | UPDATED-AT | TIMESTAMP | — | | 10 | NOT NULL | CURRENT TIMESTAMP | | | | | | + +### 備考 + +- KYU05DEDが内部テーブル(SEARCH ALL)に取込んで参照 +- 被保険者負担分のみ + +--- + +# SALARY-RESULTS(SALARYDB) — 給与計算結果テーブル + +## Layout(テーブル定義) + +### DB基本情報 + +| DB名 | DB ID | COPY ID | INDEX | +|------|-------|---------|-------| +| 給与計算DB | SALARY-RESULTS | — | 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 | 総支給額 | GROSS-PAYMENT | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | +| 4 | 源泉所得税 | INCOME-TAX | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | +| 5 | 社会保険料 | INSURANCE | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | +| 6 | 住民税 | RESIDENT-TAX | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | +| 7 | 差引支給額 | NET-PAYMENT | DECIMAL | 9 | 0 | 5 | NOT NULL | | | | | | | +| 8 | 更新日時 | UPDATED-AT | TIMESTAMP | — | | 10 | NOT NULL | CURRENT TIMESTAMP | | | | | | + +### 備考 + +- KYU06UPDがINSERT/UPSERTを実行 diff --git a/black-box-data-create/tests/test_data/ZAN01REC.cpy b/black-box-data-create/tests/test_data/ZAN01REC.cpy new file mode 100644 index 0000000..18e3282 --- /dev/null +++ b/black-box-data-create/tests/test_data/ZAN01REC.cpy @@ -0,0 +1,11 @@ + * + * 加班申請レコード 80B + * + 03 (A)APPL-ID PIC X(008). + 03 (A)EMP-ID PIC 9(008). + 03 (A)APPL-DATE PIC 9(008). + 03 (A)START-TIME PIC 9(004). + 03 (A)END-TIME PIC 9(004). + 03 (A)STATUS PIC X(001). + 03 (A)OVT-TYPE PIC X(001). + 03 (A)FILLER PIC X(046). diff --git a/black-box-data-create/tests/test_data/ZAN02REC.cpy b/black-box-data-create/tests/test_data/ZAN02REC.cpy new file mode 100644 index 0000000..7cca953 --- /dev/null +++ b/black-box-data-create/tests/test_data/ZAN02REC.cpy @@ -0,0 +1,12 @@ + * + * マッチング結果レコード 80B + * + 03 (A)APPL-ID PIC X(008). + 03 (A)EMP-ID PIC 9(008). + 03 (A)APPL-DATE PIC 9(008). + 03 (A)START-TIME PIC 9(004). + 03 (A)END-TIME PIC 9(004). + 03 (A)STATUS PIC X(001). + 03 (A)OVT-TYPE PIC X(001). + 03 (A)PROC-SEQ PIC 9(002). + 03 (A)FILLER PIC X(044). diff --git a/black-box-data-create/tests/test_data/ZAN04REC.cpy b/black-box-data-create/tests/test_data/ZAN04REC.cpy new file mode 100644 index 0000000..f466156 --- /dev/null +++ b/black-box-data-create/tests/test_data/ZAN04REC.cpy @@ -0,0 +1,5 @@ + * + * DBCLEAN対象レコード 80B + * + 03 (A)APPL-ID PIC X(008). + 03 (A)FILLER PIC X(072). diff --git a/black-box-data-create/tests/test_data/cpy/ZAN01REC.cpy b/black-box-data-create/tests/test_data/cpy/ZAN01REC.cpy new file mode 100644 index 0000000..96519fe --- /dev/null +++ b/black-box-data-create/tests/test_data/cpy/ZAN01REC.cpy @@ -0,0 +1,11 @@ + * + * 加班申请记录 80B + * + 03 (A)APPL-ID PIC X(008). + 03 (A)EMP-ID PIC 9(008). + 03 (A)APPL-DATE PIC 9(008). + 03 (A)START-TIME PIC 9(004). + 03 (A)END-TIME PIC 9(004). + 03 (A)STATUS PIC X(001). + 03 (A)OVT-TYPE PIC X(001). + 03 (A)FILLER PIC X(046). diff --git a/black-box-data-create/tests/test_data/cpy/ZAN04REC.cpy b/black-box-data-create/tests/test_data/cpy/ZAN04REC.cpy new file mode 100644 index 0000000..96519fe --- /dev/null +++ b/black-box-data-create/tests/test_data/cpy/ZAN04REC.cpy @@ -0,0 +1,11 @@ + * + * 加班申请记录 80B + * + 03 (A)APPL-ID PIC X(008). + 03 (A)EMP-ID PIC 9(008). + 03 (A)APPL-DATE PIC 9(008). + 03 (A)START-TIME PIC 9(004). + 03 (A)END-TIME PIC 9(004). + 03 (A)STATUS PIC X(001). + 03 (A)OVT-TYPE PIC X(001). + 03 (A)FILLER PIC X(046). diff --git a/black-box-data-create/tests/test_data/詳細設計書_ZAN04MAT.md b/black-box-data-create/tests/test_data/詳細設計書_ZAN04MAT.md new file mode 100644 index 0000000..3d33b75 --- /dev/null +++ b/black-box-data-create/tests/test_data/詳細設計書_ZAN04MAT.md @@ -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 | ファイルR01(OVT-SORTED) | 申請番号(APPL-ID)で昇順ソート済、重複なし | +| 2 | ファイルR02(OVT-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(終了メッセージ) +``` + +--- + +## 出力レコード定義 + +### 出力ファイル1(W01/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 | 初期値 | | + +### 出力ファイル2(W02/OVT-DBCLEAN) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | APPL-ID | R02.APPL-ID | DB削除対象申請番号 | +| 2 | FILLER | 初期値 | | + +### 出力ファイル3(W03/ERROR-LOG) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | ERR-CATEGORY | 04で固定 | 取消マッチ監査証跡 | +| 2 | ERR-DETAIL | STRINGで編集 | CANCEL-MATCH: + 申請番号 + 社員番号 + 申請日 + 開始時刻 + 終了時刻 | diff --git a/black-box-data-create/tests/test_db_parser.py b/black-box-data-create/tests/test_db_parser.py new file mode 100644 index 0000000..ddb06f3 --- /dev/null +++ b/black-box-data-create/tests/test_db_parser.py @@ -0,0 +1,53 @@ +# tests/test_db_parser.py +import os +from agent.input_parser import InputParser +from agent.models import ProgramMeta + +FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'test_data') + + +def test_parse_db_definition(): + parser = InputParser( + design_md_path='dummy.md', source_cbl_path='dummy.cbl', + file_db_md_path='dummy.md', cpy_dir='dummy_cpy', + db_md_path=os.path.join(FIXTURE_DIR, 'DB定義書.md') + ) + + meta = ProgramMeta( + program_id='TEST', program_name='テスト', system_name='', + pgm_type='メイン', pgm_pattern='DB更新', + summary_lines=[], prerequisites=[], files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='mixed', + copy_fields={}, db_tables={} + ) + + parser._parse_db_definition(meta) + + assert len(meta.db_tables) > 0 + assert 'EMP_MASTER' in meta.db_tables + + emp = meta.db_tables['EMP_MASTER'] + assert emp.db_id == 'EMP_MASTER' + assert len(emp.columns) >= 3 + assert emp.pk_columns == ['EMP_ID'] + + +def test_parse_db_skipped_for_file_type(): + parser = InputParser( + design_md_path='dummy.md', source_cbl_path='dummy.cbl', + file_db_md_path='dummy.md', cpy_dir='dummy_cpy', + db_md_path=os.path.join(FIXTURE_DIR, 'DB定義書.md') + ) + + meta = ProgramMeta( + program_id='TEST', program_name='テスト', system_name='', + pgm_type='メイン', pgm_pattern='マッチング(1:1)', + summary_lines=[], prerequisites=[], files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', + copy_fields={}, db_tables={} + ) + + parser._parse_db_definition(meta) + assert len(meta.db_tables) == 0 diff --git a/black-box-data-create/tests/test_input_parser.py b/black-box-data-create/tests/test_input_parser.py new file mode 100644 index 0000000..518d267 --- /dev/null +++ b/black-box-data-create/tests/test_input_parser.py @@ -0,0 +1,149 @@ +# tests/test_input_parser.py +import os +from agent.input_parser import InputParser + +FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'test_data') + + +def test_parse_design_basic_info(): + parser = InputParser( + design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'), + source_cbl_path='dummy.cbl', + file_db_md_path='dummy.md', + cpy_dir='dummy_cpy', + db_md_path='dummy_db.md' + ) + parser._design_text = parser._read_file(parser.design_md_path) + + from agent.models import ProgramMeta + meta = ProgramMeta(program_id='', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={}) + + parser._parse_basic_info(meta) + assert meta.program_id == 'ZAN04MAT' + assert meta.pgm_pattern == 'マッチング(1:1)' + assert meta.pgm_type == 'メイン' + assert '残業統計管理システム' in meta.system_name + + +def test_parse_use_files(): + parser = InputParser( + design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'), + source_cbl_path='dummy.cbl', file_db_md_path='dummy.md', + cpy_dir='dummy_cpy', db_md_path='dummy_db.md' + ) + parser._design_text = parser._read_file(parser.design_md_path) + + from agent.models import ProgramMeta + meta = ProgramMeta(program_id='', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={}) + + parser._parse_use_files(meta) + assert len(meta.files) == 5 + r01 = [f for f in meta.files if f.identifier == 'R01'][0] + assert r01.file_db_name == 'OVT-SORTED' + assert r01.copy_group == 'ZAN01REC' + assert r01.medium == 'PS' + assert r01.io == 'I' + + +def test_determine_input_type_file(): + parser = InputParser( + design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'), + source_cbl_path='dummy.cbl', file_db_md_path='dummy.md', + cpy_dir='dummy_cpy', db_md_path='dummy_db.md' + ) + + from agent.models import ProgramMeta, FileInfo + meta = ProgramMeta(program_id='ZAN04MAT', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={}) + meta.files = [ + FileInfo(no=1, file_db_name='F1', identifier='R01', dd_name='DD1', + io='I', copy_group='C1', format='FB', record_len=80, + medium='PS', remarks=''), + FileInfo(no=2, file_db_name='F2', identifier='W01', dd_name='DD2', + io='O', copy_group='C2', format='FB', record_len=80, + medium='PS', remarks=''), + ] + parser._determine_input_type(meta) + assert meta.input_type == 'file' + + +def test_determine_input_type_mixed(): + parser = InputParser( + design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'), + source_cbl_path='dummy.cbl', file_db_md_path='dummy.md', + cpy_dir='dummy_cpy', db_md_path='dummy_db.md' + ) + + from agent.models import ProgramMeta, FileInfo + meta = ProgramMeta(program_id='TEST', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={}) + meta.files = [ + FileInfo(no=1, file_db_name='F1', identifier='R01', dd_name='DD1', + io='I', copy_group='C1', format='FB', record_len=80, + medium='PS', remarks=''), + FileInfo(no=2, file_db_name='DB1', identifier='DB', dd_name='', + io='I', copy_group='', format='', record_len=0, + medium='DB', remarks=''), + ] + parser._determine_input_type(meta) + assert meta.input_type == 'mixed' + + +def test_parse_process_detail(): + parser = InputParser( + design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'), + source_cbl_path='dummy.cbl', file_db_md_path='dummy.md', + cpy_dir='dummy_cpy', db_md_path='dummy_db.md' + ) + parser._design_text = parser._read_file(parser.design_md_path) + + from agent.models import ProgramMeta + meta = ProgramMeta(program_id='', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={}) + + parser._parse_process_detail(meta) + assert '1000ITTSOR' in meta.process_detail + assert 'マッチの場合' in meta.process_detail + + +def test_parse_output_records(): + parser = InputParser( + design_md_path=os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md'), + source_cbl_path='dummy.cbl', file_db_md_path='dummy.md', + cpy_dir='dummy_cpy', db_md_path='dummy_db.md' + ) + parser._design_text = parser._read_file(parser.design_md_path) + + from agent.models import ProgramMeta + meta = ProgramMeta(program_id='', program_name='', system_name='', + pgm_type='', pgm_pattern='', + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={}) + + parser._parse_output_records(meta) + assert 'OVT-MATCHED' in meta.output_records + assert 'OVT-DBCLEAN' in meta.output_records diff --git a/black-box-data-create/tests/test_integration.py b/black-box-data-create/tests/test_integration.py new file mode 100644 index 0000000..95533d6 --- /dev/null +++ b/black-box-data-create/tests/test_integration.py @@ -0,0 +1,77 @@ +# tests/test_integration.py +import os +import tempfile +from unittest.mock import patch, MagicMock +from agent import generate + +FIXTURE_DIR = os.path.join(os.path.dirname(__file__), 'test_data') + + +@patch('agent.api_client.requests.post') +def test_full_pipeline_mock_api(mock_post): + """完整流水线测试,API 调用使用 mock。""" + mock_response = MagicMock() + mock_response.json.return_value = { + 'choices': [{'message': {'content': '''{ + "groups": { + "g1": { + "program": "ZAN04MAT", + "records": [ + { + "input": { + "R01INNFIL": { + "R01-APPL-ID": "A0000001", + "R01-EMP-ID": "00000101", + "R01-APPL-DATE": "20260101", + "R01-START-TIME": "0900", + "R01-END-TIME": "1800", + "R01-STATUS": "0", + "R01-OVT-TYPE": "W", + "R01-FILLER": "D000000000000000000000000000000000000000000001" + } + } + } + ] + } + } + }'''}}] + } + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + cpy_dir = FIXTURE_DIR + output_dir = tempfile.mkdtemp() + rules_dir = os.path.join(os.path.dirname(__file__), '..', 'rules') + + design_md = os.path.join(FIXTURE_DIR, '詳細設計書_ZAN04MAT.md') + source_cbl = os.path.join(FIXTURE_DIR, '..', '..', '..', + 'cobol-tna-system', 'src', 'ZAN04MAT.cbl') + + # Fallback if real source not found + if not os.path.exists(source_cbl): + source_cbl = os.path.join(output_dir, 'dummy.cbl') + with open(source_cbl, 'w', encoding='utf-8') as f: + f.write("COPY ZAN01REC REPLACING ==(A)== BY ==R01==.\n") + + try: + result = generate( + design_md=design_md, + source_cbl=source_cbl, + file_db_md='dummy.md', + cpy_dir=cpy_dir, + db_md='dummy_db.md', + output_dir=output_dir, + api_key='test-key', + rules_dir=rules_dir, + ) + + assert result['program_id'] == 'ZAN04MAT' + assert result['groups'] > 0 + assert len(result['output_files']) > 0 + + for path in result['output_files'].values(): + assert os.path.exists(path), f"输出文件不存在: {path}" + + finally: + if source_cbl.endswith('dummy.cbl') and os.path.exists(source_cbl): + os.remove(source_cbl) diff --git a/black-box-data-create/tests/test_markdown_utils.py b/black-box-data-create/tests/test_markdown_utils.py new file mode 100644 index 0000000..c1a493a --- /dev/null +++ b/black-box-data-create/tests/test_markdown_utils.py @@ -0,0 +1,65 @@ +# tests/test_markdown_utils.py +from agent.markdown_utils import extract_section, parse_table_rows, parse_table_from_section, find_row_by_key + +SAMPLE_MD = """ +## 基本情報 + +| # | 項目 | 内容 | +|---|------|------| +| 1 | システム名 | 残業統計管理システム | +| 4 | PGMパターン | マッチング(1:1) | +| 5 | 機能概要 | 取消マッチング処理 | + +## 使用ファイル一覧 + +| NO | 使用ファイル/DB名 | 識別子 | DD名 | I/O | COPY群 | 媒体 | 備考 | +|----|------------------|--------|------|-----|--------|------|------| +| 1 | OVT-SORTED | R01 | ZAN04R01 | I | ZAN01REC | PS | | +| 2 | ERROR-LOG | W01 | ZAN04W01 | O | ZAN05REC | PS | | + +## 出力レコード定義 + +### 出力ファイル1(W01/OVT-MATCHED) + +| No | 項目名 | 設定元 | 備考 | +|----|--------|--------|------| +| 1 | APPL-ID | R01.APPL-ID | | +| 2 | EMP-ID | R01.EMP-ID | | +""" + + +def test_extract_section(): + result = extract_section(SAMPLE_MD, "基本情報") + assert "残業統計管理システム" in result + assert "使用ファイル一覧" not in result + + +def test_extract_section_not_found(): + result = extract_section(SAMPLE_MD, "存在しないセクション") + assert result == '' + + +def test_parse_table_rows(): + rows = parse_table_from_section(SAMPLE_MD, "基本情報") + assert len(rows) >= 3 + assert rows[0]['項目'] == 'システム名' + + +def test_find_row_by_key(): + rows = parse_table_from_section(SAMPLE_MD, "基本情報") + row = find_row_by_key(rows, '項目', 'PGMパターン') + assert row is not None + assert row['内容'] == 'マッチング(1:1)' + + +def test_parse_use_file_table(): + rows = parse_table_from_section(SAMPLE_MD, "使用ファイル一覧") + assert len(rows) == 2 + assert rows[0]['識別子'] == 'R01' + assert rows[0]['DD名'] == 'ZAN04R01' + + +def test_extract_output_records_section(): + result = extract_section(SAMPLE_MD, "出力レコード定義") + assert "出力ファイル1" in result + assert "W01/OVT-MATCHED" in result diff --git a/black-box-data-create/tests/test_models.py b/black-box-data-create/tests/test_models.py new file mode 100644 index 0000000..50292b7 --- /dev/null +++ b/black-box-data-create/tests/test_models.py @@ -0,0 +1,59 @@ +from agent.models import FileInfo, CopyField, ProgramMeta, KeyInfo, ModuleInfo, TableColumn, TableInfo + + +def test_file_info(): + f = FileInfo( + no=1, file_db_name="OVT-SORTED", identifier="R01", + dd_name="ZAN04R01", io="I", copy_group="ZAN01REC", + format="FB", record_len=80, medium="PS", remarks="有效申请" + ) + assert f.identifier == "R01" + assert f.medium == "PS" + + +def test_copy_field(): + cf = CopyField(level=3, name="R01-APPL-ID", raw_name="(A)APPL-ID", + pic_type="X(008)", pic_bytes=8) + assert cf.name == "R01-APPL-ID" + assert cf.pic_bytes == 8 + + +def test_program_meta_defaults(): + meta = ProgramMeta( + program_id="TEST", program_name="", system_name="", + pgm_type="", pgm_pattern="", + summary_lines=[], prerequisites=[], + files=[], keys=[], modules=[], + process_detail="", output_records="", + input_type="file", + copy_fields={}, db_tables={} + ) + assert meta.program_id == "TEST" + assert meta.input_type == "file" + + +def test_table_info(): + col = TableColumn(no=1, name_jp="社員番号", name_en="EMP_ID", + type="CHAR", max_len="8", decimal_digits="", + byte_count="8", nullable=False, is_pk=True) + table = TableInfo(table_name="EMP_MASTER", db_id="EMP_MASTER", + copy_id="", columns=[col], pk_columns=["EMP_ID"]) + assert table.pk_columns == ["EMP_ID"] + assert len(table.columns) == 1 + + +def test_key_info(): + k = KeyInfo(no=1, file_name="ファイルR01", + sort_condition="A001>A002>A003", + key_condition="A001>A002>A003") + assert k.no == 1 + assert k.file_name == "ファイルR01" + assert "A001" in k.sort_condition + + +def test_module_info(): + m = ModuleInfo(no=1, function="メッセージ編集出力SUB", + program_id="SUB02MSG", copy_name="ZANMSGAC") + assert m.function == "メッセージ編集出力SUB" + assert m.program_id == "SUB02MSG" + assert m.copy_name == "ZANMSGAC" diff --git a/black-box-data-create/tests/test_output_writer.py b/black-box-data-create/tests/test_output_writer.py new file mode 100644 index 0000000..eecd934 --- /dev/null +++ b/black-box-data-create/tests/test_output_writer.py @@ -0,0 +1,117 @@ +import json +import os +import tempfile +from agent.output_writer import OutputWriter + + +def test_write_json_files(): + with tempfile.TemporaryDirectory() as tmpdir: + writer = OutputWriter(tmpdir) + + ai_result = { + "groups": { + "g1": { + "program": "TEST", + "records": [ + {"input": {"R01": {"R01-FIELD": "A001"}}} + ] + } + } + } + + written = writer.write("TESTPGM", ai_result, input_type="file") + + assert len(written) == 1 + json_path = written["g1/json"] + assert os.path.exists(json_path) + + with open(json_path, 'r', encoding='utf-8') as f: + content = json.load(f) + assert content['program'] == 'TEST' + assert len(content['records']) == 1 + + +def test_write_mixed_type(): + with tempfile.TemporaryDirectory() as tmpdir: + writer = OutputWriter(tmpdir) + + ai_result = { + "groups": { + "g1": { + "program": "MIXED", + "records": [{"input": {"R01": {"F1": "X"}}}], + "sql": "INSERT INTO T VALUES ('x');" + } + } + } + + written = writer.write("MIXEDPGM", ai_result, input_type="mixed") + + assert len(written) == 2 + assert os.path.exists(written["g1/json"]) + assert os.path.exists(written["g1/sql"]) + + +def test_write_multiple_groups(): + with tempfile.TemporaryDirectory() as tmpdir: + writer = OutputWriter(tmpdir) + + ai_result = { + "groups": { + "g1": {"records": [{}]}, + "g2": {"records": [{}]}, + "g3": {"records": [{}]}, + } + } + + written = writer.write("MULTI", ai_result, input_type="file") + + assert len(written) == 3 + for g in ['g1', 'g2', 'g3']: + assert f'{g}/json' in written + assert os.path.exists(written[f'{g}/json']) + + +def test_directory_structure(): + with tempfile.TemporaryDirectory() as tmpdir: + writer = OutputWriter(tmpdir) + ai_result = {"groups": {"g1": {"records": [{}]}}} + writer.write("MYPROG", ai_result, input_type="file") + + expected_dir = os.path.join(tmpdir, "MYPROG", "black_box", "g1") + assert os.path.isdir(expected_dir) + assert os.path.isfile(os.path.join(expected_dir, "MYPROG_g1.json")) + + +def test_write_case_table_md(): + with tempfile.TemporaryDirectory() as tmpdir: + writer = OutputWriter(tmpdir) + + ai_result = { + "groups": { + "g1": {"program": "TEST", "records": [{}]}, + "g2": {"program": "TEST", "records": [{}]}, + }, + "summary_md": ( + "| 组 | 规则md中的定义/用途 | 本次是否生成 |\n" + "|----|--------------------|--------------|\n" + "| g1 | 完全匹配 | 已生成 |\n" + "| g2 | 键值乱序 | 已生成 |\n" + "| g3 | 主键重复 | ✗ |\n" + ), + } + + written = writer.write("TESTPGM", ai_result, input_type="file") + + md_path = written["case表"] + assert os.path.exists(md_path) + assert os.path.basename(md_path) == 'case表.md' + + assert os.path.dirname(md_path).endswith( + os.path.join('TESTPGM', 'black_box') + ) + + with open(md_path, 'r', encoding='utf-8') as f: + content = f.read() + assert 'g3' in content + assert '✗' in content diff --git a/black-box-data-create/tests/test_prompt_builder.py b/black-box-data-create/tests/test_prompt_builder.py new file mode 100644 index 0000000..45ef5a6 --- /dev/null +++ b/black-box-data-create/tests/test_prompt_builder.py @@ -0,0 +1,98 @@ +from agent.prompt_builder import PromptBuilder +from agent.models import ProgramMeta, FileInfo, CopyField + + +def test_build_basic_prompt(): + meta = ProgramMeta( + program_id='ZAN04MAT', program_name='取消マッチング処理', + system_name='残業統計管理システム', + pgm_type='メイン', pgm_pattern='マッチング(1:1)', + summary_lines=['取消申請のマッチング'], prerequisites=[], + files=[], keys=[], modules=[], + process_detail='1.初期処理...\n2.主処理...', + output_records='### 出力ファイル1...', + input_type='file', copy_fields={}, db_tables={} + ) + + builder = PromptBuilder() + prompt = builder.build( + meta=meta, + rules_text='# マッチング(1:1) データ生成規則\n...', + group_descriptions=['两端不一致', '逆方向两端不一致', '中间不一致'], + group_count=3 + ) + + assert 'ZAN04MAT' in prompt + assert 'マッチング(1:1)' in prompt + assert '1.初期処理' in prompt + assert '生成するグループ数: 3' in prompt + assert 'g1: 两端不一致' in prompt + assert 'g2: 逆方向两端不一致' in prompt + assert 'g3: 中间不一致' in prompt + assert 'JSON形式' in prompt + assert 'summary_md' in prompt + assert 'case表' in prompt + + +def test_build_with_copy_fields(): + fields = { + 'R01': [ + CopyField(level=3, name='R01-APPL-ID', raw_name='(A)APPL-ID', + pic_type='X(008)', pic_bytes=8), + CopyField(level=3, name='R01-EMP-ID', raw_name='(A)EMP-ID', + pic_type='9(008)', pic_bytes=8), + ] + } + + meta = ProgramMeta( + program_id='TEST', program_name='', system_name='', + pgm_type='メイン', pgm_pattern='マッチング(1:1)', + summary_lines=[], prerequisites=[], + files=[ + FileInfo(no=1, file_db_name='INPUT-FILE', identifier='R01', + dd_name='TESTR01', io='I', copy_group='TESTREC', + format='FB', record_len=80, medium='PS', remarks='') + ], + keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields=fields, db_tables={} + ) + + builder = PromptBuilder() + prompt = builder.build( + meta=meta, + rules_text='# 規則', + group_descriptions=['テスト'], + group_count=1 + ) + + assert 'R01-APPL-ID' in prompt + assert 'X(008)' in prompt + assert 'R01-EMP-ID' in prompt + + +def test_build_db_prompt(): + meta = ProgramMeta( + program_id='TESTDB', program_name='DB更新', system_name='', + pgm_type='メイン', pgm_pattern='DB更新', + summary_lines=[], prerequisites=[], + files=[ + FileInfo(no=1, file_db_name='DB-TABLE', identifier='DB', + dd_name='', io='I', copy_group='', + format='', record_len=0, medium='DB', remarks='') + ], + keys=[], modules=[], + process_detail='', output_records='', + input_type='db', copy_fields={}, db_tables={} + ) + + builder = PromptBuilder() + prompt = builder.build( + meta=meta, + rules_text='# DB更新規則', + group_descriptions=['テスト'], + group_count=1 + ) + + assert 'DB' in prompt or 'SQL' in prompt + assert 'INSERT' in prompt diff --git a/black-box-data-create/tests/test_rule_loader.py b/black-box-data-create/tests/test_rule_loader.py new file mode 100644 index 0000000..7b3e843 --- /dev/null +++ b/black-box-data-create/tests/test_rule_loader.py @@ -0,0 +1,278 @@ +import os +import pytest +from agent.rule_loader import RuleLoader, PGM_PATTERN_MAP +from agent.models import ProgramMeta + +RULES_DIR = os.path.join(os.path.dirname(__file__), '..', 'rules') + +ALL_35_TYPES = [ + 'マッチング(1:1)', + 'マッチング(1:N)', + 'マッチング(N:1)', + 'レイアウト編集のみ(GETPUT)', + '振り分け(IF文)', + '振り分け(EVALUATE文)', + 'キーブレイク(集計)', + 'キーブレイク(集約)', + 'DB更新', + '50分割', + '25分割', + '100分割', + '項目チェック(重複含まず)', + 'オンラインPGM', + 'CSV→FB変換(改行なし)', + '2段階マッチング(1:1⇒1:1)', + '2段階マッチング(N:1⇒N:1)', + 'マッチングM:N⇒出力M件', + 'マッチングM:N⇒出力N件', + 'マッチングM:N⇒出力M×N件', + 'CSV→FB変換(改行あり)', + '2段階マッチング(M:N⇒M:N)', + 'SELECT条件', + '内部テーブル検索', + 'サブプログラム使用', + 'DB検索', + '項目チェック(半角20桁/4桁)', + 'SYSIN読込', + 'ASCII→EBCDIC変換', + 'キーブレイク(集計集約以外)', + '項目チェック(重複含む)', + '1:N+キーブレイク(同キー)', + '1:N+キーブレイク(異キー)', + 'SORT(INPUT/OUTPUT PROCEDURE)', + 'MERGE(複数ファイル結合)', +] + +DETAILED_DESIGN_PATTERNS = [ + '29(ASCII→EBCDIC変換)', + '18(M:N→M件マッチング)', + '20(M:N→M×N件直積出力)', + 'マッチング(1:1→1:1 2段階)', + 'マッチング(N:1→N:1 2段階)', + 'マッチング(M:N→M:N 2段階)', + '33(1:N+異キーキーブレイク)', + '34(SORT INPUT/OUTPUT PROCEDURE)', + '11(25分割)', + '12(100分割)', + '27(半角20桁/4桁チェック)', + '19(M:N→N件マッチング+集計)', + 'DB更新 + SYSIN読込(P28)', + '編集出力(ランキング生成)', + '32(1:N+同キーキーブレイク)', + '編集出力(印刷制御)', +] + + +def test_pgm_pattern_map_has_known_patterns(): + assert 'マッチング(1:1)' in PGM_PATTERN_MAP + assert 'マッチング(1:N)' in PGM_PATTERN_MAP + assert 'DB更新' in PGM_PATTERN_MAP + + +@pytest.mark.parametrize('pattern', ALL_35_TYPES) +def test_all_classification_types_loadable(pattern): + """程序分类说明.md の35类型すべてが規則に解決できること。""" + loader = RuleLoader(RULES_DIR) + assert loader._load_pgm_pattern_rule(pattern) is not None, f'未実装: {pattern}' + + +@pytest.mark.parametrize('pattern', DETAILED_DESIGN_PATTERNS) +def test_detailed_design_pattern_names_loadable(pattern): + """詳細設計書の PGMパターン/プログラムタイプ 表記が規則に解決できること。""" + loader = RuleLoader(RULES_DIR) + assert loader._load_pgm_pattern_rule(pattern) is not None, f'マッピング不足: {pattern}' + + +def test_non_aggregate_keybreak_uses_dedicated_rule(): + loader = RuleLoader(RULES_DIR) + rule = loader._load_pgm_pattern_rule('キーブレイク(集計集約以外)') + assert rule is not None + assert '非集計' in rule + + +def test_two_stage_matching_split_by_form(): + loader = RuleLoader(RULES_DIR) + r1 = loader._load_pgm_pattern_rule('2段階マッチング(1:1⇒1:1)') + r2 = loader._load_pgm_pattern_rule('2段階マッチング(N:1⇒N:1)') + r3 = loader._load_pgm_pattern_rule('マッチング(M:N→M:N 2段階)') + assert r1 is not None and r2 is not None and r3 is not None + assert '1:1⇒1:1' in r1 + assert 'N:1⇒N:1' in r2 + assert 'M:N⇒M:N' in r3 + assert r1 != r2 and r2 != r3 and r1 != r3 + + +def test_load_matching_1_1_rule(): + loader = RuleLoader(RULES_DIR) + meta = ProgramMeta( + program_id='TEST', program_name='', system_name='', + pgm_type='メイン', pgm_pattern='マッチング(1:1)', + summary_lines=[], prerequisites=[], files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={} + ) + combined, descriptions, count = loader.load(meta) + + assert count > 0 + assert len(combined) > 0 + assert 'マッチング(1:1)' in combined + assert '数据生成' in combined + + +def test_load_matching_1_n_rule(): + loader = RuleLoader(RULES_DIR) + meta = ProgramMeta( + program_id='TEST', program_name='', system_name='', + pgm_type='メイン', pgm_pattern='マッチング(1:N)', + summary_lines=[], prerequisites=[], files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={} + ) + combined, descriptions, count = loader.load(meta) + + assert count > 0 + assert len(combined) > 0 + assert 'マッチング(1:N)' in combined + + +def test_matching_n_1_has_only_n_1_imbalance(): + loader = RuleLoader(RULES_DIR) + rule = loader._load_pgm_pattern_rule('マッチング(N:1)') + assert rule is not None + _, count = loader._parse_group_info(rule) + assert count == 9 + assert 'N:1匹配(MT-N003)' in rule or 'N:1匹配(MT-N003)' in rule + assert '极端不平衡 N:1' in rule + assert '极端不平衡 1:N' not in rule + + +def test_if_branching_rule_covers_if_benchmark(): + loader = RuleLoader(RULES_DIR) + rule = loader._load_pgm_pattern_rule('振り分け(IF文)') + assert rule is not None + _, count = loader._parse_group_info(rule) + assert count == 5 + for marker in ('B-N001', 'B-N005', 'IF 2路分支'): + assert marker in rule + assert 'B-N006' not in rule + assert 'B-N010' not in rule + assert 'B-R001' not in rule + + +def test_evaluate_branching_rule_covers_evaluate_benchmark(): + loader = RuleLoader(RULES_DIR) + rule = loader._load_pgm_pattern_rule('振り分け(EVALUATE文)') + assert rule is not None + _, count = loader._parse_group_info(rule) + assert count == 5 + for marker in ('B-N006', 'B-N009', 'B-A002', 'EVALUATE WHEN 多值'): + assert marker in rule + assert 'B-N001' not in rule + assert 'B-N010' not in rule + assert 'B-R001' not in rule + + +def test_keybreak_summary_rule_covers_benchmark(): + loader = RuleLoader(RULES_DIR) + rule = loader._load_pgm_pattern_rule('キーブレイク(集計)') + assert rule is not None + _, count = loader._parse_group_info(rule) + assert count == 7 + for marker in ('KB-N001', 'KB-N004', 'KB-N005', 'KB-N006', 'KB-A001', 'KB-A002', 'KB-R001'): + assert marker in rule + assert 'KB-N002' not in rule + + +def test_keybreak_aggregation_rule_covers_benchmark(): + loader = RuleLoader(RULES_DIR) + rule = loader._load_pgm_pattern_rule('キーブレイク(集約)') + assert rule is not None + _, count = loader._parse_group_info(rule) + assert count == 7 + for marker in ('KB-N002', 'KB-N004', 'KB-N005', 'KB-N006', 'KB-A001', 'KB-A002', 'KB-R001'): + assert marker in rule + assert 'KB-N001' not in rule + + +def test_combined_keybreak_rule_removed_and_remapped(): + combined = os.path.join(RULES_DIR, 'pgm_pattern', 'キーブレイク(集計、集約).md') + assert not os.path.exists(combined) + + loader = RuleLoader(RULES_DIR) + summary = loader._load_pgm_pattern_rule('キーブレイク(集計)') + for pattern in ('キーブレイク(集計、集約)', '1:N+キーブレイク(同キー)', + '1:Nキーブレイク(同キー集約)', '32(1:N+同キーキーブレイク)'): + rule = loader._load_pgm_pattern_rule(pattern) + assert rule is not None + assert rule == summary + + +def test_item_check_no_dup_rule_covers_benchmark(): + loader = RuleLoader(RULES_DIR) + rule = loader._load_pgm_pattern_rule('項目チェック(重複含まず)') + assert rule is not None + _, count = loader._parse_group_info(rule) + assert count == 5 + for marker in ('VF-N001', 'VF-N002', 'VF-N003', 'VF-N004', 'VF-R001'): + assert marker in rule + assert 'VF-N005' not in rule + assert not os.path.exists(os.path.join(RULES_DIR, 'pgm_pattern', '項目チェック.md')) + generic = loader._load_pgm_pattern_rule('項目チェック') + assert generic == rule + + +def test_item_check_halfwidth_rule_covers_benchmark(): + loader = RuleLoader(RULES_DIR) + rule = loader._load_pgm_pattern_rule('項目チェック(半角20桁/4桁)') + assert rule is not None + _, count = loader._parse_group_info(rule) + assert count == 4 + for marker in ('VF-N005', 'VF-N006', 'VF-A001', 'VF-A002'): + assert marker in rule + assert 'VF-N001' not in rule + + +def test_load_nonexistent_pattern_raises(): + loader = RuleLoader(RULES_DIR) + meta = ProgramMeta( + program_id='TEST', program_name='', system_name='', + pgm_type='メイン', pgm_pattern='存在しないパターン', + summary_lines=[], prerequisites=[], files=[], keys=[], modules=[], + process_detail='', output_records='', + input_type='file', copy_fields={}, db_tables={} + ) + try: + loader.load(meta) + assert False, 'Should have raised FileNotFoundError' + except FileNotFoundError: + pass + + +def test_detect_conditional_branch(): + process = """ + 2-1-2.ロジック分岐判定(EVALUATE) + 2-1-2-1.STATUS='1'の場合 + INSERT処理 + """ + assert RuleLoader._detect_feature(process, ['場合', 'EVALUATE', 'IF']) is True + + +def test_no_keyword_match(): + process = "1.初期処理\n2.主処理\n3.終了処理" + assert RuleLoader._detect_feature(process, ['EVALUATE']) is False + + +def test_rule_with_conditional_feature(): + loader = RuleLoader(RULES_DIR) + meta = ProgramMeta( + program_id='TEST', program_name='', system_name='', + pgm_type='メイン', pgm_pattern='マッチング(1:1)', + summary_lines=[], prerequisites=[], files=[], keys=[], modules=[], + process_detail='2-1-2.EVALUATEで条件分岐する。', + output_records='', + input_type='file', copy_fields={}, db_tables={} + ) + combined, descriptions, count = loader.load(meta) + + assert count > 0 + assert '条件分支' in combined diff --git a/requirements.txt b/requirements.txt index 7ca6594..e789d4c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ fastapi==0.111.0 uvicorn==0.30.0 python-multipart==0.0.9 jinja2==3.1.4 +requests>=2.28.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..101ed3f --- /dev/null +++ b/run.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +"""COBOL 迁移验证平台全流程入口:先白盒 cobol_testgen 再黑盒 LLM 数据生成。 + +用法: + python run.py --design <詳細設計書.md> --source <程序.cbl> \ + --file-db-md --cpy \ + --db-md --output <输出目录> + +步骤: + 1) cobol_testgen (白盒静态分析 + 测试数据生成) → python -m cobol_testgen --gcov + 2) black-box-data-create (DeepSeek LLM 数据生成) → 透传全部参数给 black-box-data-create/main.py + +任一步失败即停止,返回该步退出码。 +""" +import argparse +import os +import subprocess +import sys + +ROOT = os.path.dirname(os.path.abspath(__file__)) +BLACKBOX_MAIN = os.path.join(ROOT, "black-box-data-create", "main.py") + + +def build_parser(): + p = argparse.ArgumentParser( + description="COBOL 迁移验证平台:先跑白盒 cobol_testgen,再跑黑盒 LLM 数据生成") + p.add_argument("--design", required=True, help="詳細設計書 .md のパス") + p.add_argument("--source", required=True, help="COBOL ソース .cbl のパス") + p.add_argument("--file-db-md", required=True, help="ファイル/DB 構造 .md のパス") + p.add_argument("--cpy", required=True, help="COPYBOOK 格納ディレクトリ") + p.add_argument("--db-md", required=True, help="DB 定義書 .md のパス") + p.add_argument("--output", default="output", help="出力ディレクトリ") + p.add_argument("--api-key", help="DeepSeek API Key(透传给黑盒)") + p.add_argument("--model", help="API モデル名(透传给黑盒)") + p.add_argument("--rules", help="ルール格納ディレクトリ(透传给黑盒)") + p.add_argument("--max-tokens", type=int, help="API 生成トークン上限(透传给黑盒)") + p.add_argument("--dry-run", action="store_true", help="只打印要执行的命令,不真正执行") + return p + + +def _run(cmd, cwd, label, dry_run=False): + print(f"\n== {label} ==") + print(f" $ {' '.join(cmd)}") + if dry_run: + print(" [dry-run] 跳过执行") + return 0 + r = subprocess.run(cmd, cwd=cwd) + return r.returncode + + +def main(): + args = build_parser().parse_args() + + rc = _run( + [sys.executable, "-m", "cobol_testgen", "--gcov", args.source, args.output], + cwd=ROOT, + label="步骤1: cobol_testgen 白盒数据生成", + dry_run=args.dry_run, + ) + if rc != 0: + return rc + + bb_cmd = [ + sys.executable, BLACKBOX_MAIN, + "--design", args.design, + "--source", args.source, + "--file-db-md", args.file_db_md, + "--cpy", args.cpy, + "--db-md", args.db_md, + "--output", args.output, + ] + for opt in ("--api-key", "--model", "--rules", "--max-tokens"): + v = getattr(args, opt.lstrip("-").replace("-", "_")) + if v is not None: + bb_cmd.extend([opt, str(v)]) + + return _run( + bb_cmd, + cwd=ROOT, + label="步骤2: black-box-data-create LLM 数据生成", + dry_run=args.dry_run, + ) + + +if __name__ == "__main__": + sys.exit(main())