feat(stmate-test): REA MCP(Ghidra) 변경 차수·잔여 재검증 — 변경 4차 = 계열 5 · 할증 계수 4바이트 실수 · 손료 계수 칸 걷음 · 강제금액 = JDL 변환 · JDL 고정폭 형식

- 변경 차수 상한 4차(K_ORD>4 거부) · 4차 = 5번째 칸(MCOST5·XX5NM·QTY5)
- 할증 계수 float32 — 70%·115% 1원 적음
- 손료 E0~E3 = 조달청 XML 구분값뿐 · 계수 곱 없음
- jdl_format.py — 13종 자르는 자리 · 단가 목록 8종 열 이름 · 줄 만들기·읽기·검사
- 지식DB 34번 §17 · REA_MCP_보강 근거

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 13:24:24 +09:00
co-authored by Claude Opus 5.5
parent e655e29cdc
commit c5fc1fd7aa
36 changed files with 14374 additions and 19 deletions
+198
View File
@@ -0,0 +1,198 @@
# -*- coding: utf-8 -*-
"""조달청 JDL TEXT 묶음 — 고정폭 줄 형식(자르기 자리) · 열 개수 검사 · 줄 만들기/읽기
근거: 지식DB STmate/REA 분석/34_변경차수_잔여계산_작업자료_20261114 「REA MCP 보강」
JDLCHK.Tab_33.dll(sha256 c5052736…) 파일마다 검사 함수
— 줄을 바이트 자리 표(0x405dd8 SetLength 뒤 dword 표)로 잘라(0x5051e0) 열 배열 → 열 개수 비교(0x405bfc 뒤 cmp)
— 열 → 필드 대입(FieldByName · TFieldDefs.Add 0x491dec 순서 · 크기)
M.Txt 0x4ea818(Ghidra 확인) · L 0x4ecfab · E 0x4ee03f · W 0x4ef503 · J 0x4f09c8 · Unit 0x4f3885 · JUnit 0x4f4e79
Danga 0x4f649c · GongJong 0x4f2788 · MainPart 0x4f7db4 · UnitPart 0x4fad23 · JPart 0x4fe0f4 · Main 0x4ea305
자리는 cp949 바이트 기준 · 마지막 열 = 남은 전부 · F_ORICODE·F_MEMO 는 줄에 없음(검사기가 따로 채움)
열 이름이 확인된 표 = 단가 목록 8종(M·L·E·W·J·Unit·JUnit·Danga) · 나머지(MainPart·UnitPart·JPart·GongJong·Main)는
자르기 자리·열 개수만(열 이름 짝 조건부)
"""
from __future__ import annotations
CUTS: dict[str, list[int]] = {
"Main.Txt": [11, 91],
"M.Txt": [12, 42, 72, 78, 90, 102, 114, 120, 132, 138, 150, 156, 168, 174, 186, 192, 204, 210],
"L.Txt": [12, 42, 72, 78, 90, 102],
"E.Txt": [12, 42, 72, 78, 90, 102, 114, 126],
"W.Txt": [12, 42, 72, 78, 90, 102, 114, 126],
"J.Txt": [12, 42, 72, 78, 90, 102, 114, 126],
"GongJong.Txt": [13, 43, 47],
"Unit.Txt": [12, 42, 72, 78, 90, 102, 114, 126, 130],
"JUnit.Txt": [12, 42, 72, 78, 90, 102, 114, 126, 130],
"Danga.Txt": [12, 42, 72, 78, 90, 102, 114, 126, 130],
"MainPart.Txt": [
13,
24,
54,
84,
90,
100,
112,
124,
136,
148,
150,
151,
152,
162,
163,
175,
176,
188,
198,
210,
220,
232,
242,
254,
264,
276,
286,
298,
308,
318,
],
"UnitPart.Txt": [
12,
23,
53,
83,
89,
99,
111,
123,
135,
147,
149,
150,
151,
161,
162,
172,
184,
194,
206,
216,
228,
238,
250,
260,
272,
282,
292,
],
"JPart.Txt": [
12,
23,
34,
64,
94,
100,
110,
122,
134,
146,
158,
160,
161,
162,
168,
169,
173,
177,
],
}
_PRICE = ["F_CODE", "F_DES", "F_SIZE", "F_UNIT", "F_MPRICE", "F_LPRICE", "F_EPRICE", "F_SCODE"]
COLUMNS: dict[str, list[str]] = {
"M.Txt": ["F_CODE", "F_DES", "F_SIZE", "F_UNIT", "F_MPRICE", "F_SCODE"]
+ [f"F_{k}{i}" for i in range(1, 7) for k in ("PRICE", "PAGE")]
+ ["F_BIGO"],
"L.Txt": ["F_CODE", "F_DES", "F_SIZE", "F_UNIT", "F_LPRICE", "F_SCODE", "F_BIGO"],
"E.Txt": _PRICE + ["F_BIGO"],
"W.Txt": _PRICE + ["F_BIGO"],
"J.Txt": _PRICE + ["F_BIGO"],
"Unit.Txt": _PRICE + ["F_TFNO", "F_BIGO"],
"JUnit.Txt": _PRICE + ["F_TFNO", "F_BIGO"],
"Danga.Txt": _PRICE + ["F_TFNO", "F_BIGO"],
}
# 묶음 파일 차례(검사기 파일 목록 0x4e6a20~0x4e6b58) · 설정 = Global.Ini
FILES = [
"M.Txt",
"L.Txt",
"E.Txt",
"W.Txt",
"J.Txt",
"GongJong.Txt",
"Unit.Txt",
"Danga.Txt",
"MainPart.Txt",
"UnitPart.Txt",
"JPart.Txt",
"Main.Txt",
"JUnit.Txt",
"CostAccount.Txt",
]
CODE_CHARS = set("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_/^")
def widths(name: str) -> list[int | None]:
"""열 너비(바이트) — 마지막 열 = None(남은 전부)."""
c = [0] + CUTS[name]
return [c[i + 1] - c[i] for i in range(len(c) - 1)] + [None]
def split_line(name: str, line: bytes | str) -> list[str]:
"""한 줄을 자르기 자리로 나눔(cp949 바이트) · 각 열 양끝 빈칸 걷음."""
b = line.encode("cp949") if isinstance(line, str) else line
b = b.rstrip(b"\r\n")
# 줄이 자르기 자리보다 짧으면 그 뒤 열은 생기지 않음(열 개수 오류로 걸림 — 조건부)
c = [0] + [x for x in CUTS[name] if x < len(b)] + [len(b)]
return [b[c[i] : c[i + 1]].decode("cp949", "replace").strip() for i in range(len(c) - 1)]
return out
def build_line(name: str, values: list | dict) -> bytes:
"""열 값 → 고정폭 줄(cp949) · 넘치면 자름(한글 반쪽 바이트는 빈칸으로) · dict 면 COLUMNS 이름 차례."""
if isinstance(values, dict):
values = [values.get(k, "") for k in COLUMNS[name]]
ws = widths(name)
if len(values) != len(ws):
raise ValueError(f"{name} 열 {len(ws)}개 필요 — {len(values)}개")
out = b""
for v, w in zip(values, ws):
s = "" if v is None else (f"{v}" if not isinstance(v, float) else f"{v:g}")
b = s.encode("cp949")
if w is not None:
if len(b) > w:
b = b[:w]
while True:
try:
b.decode("cp949")
break
except UnicodeDecodeError:
b = b[:-1]
b = b.ljust(w, b" ")
out += b
return out
def check_lines(name: str, text: bytes) -> list[str]:
"""검사기 꼴 — 줄마다 열 개수(자르기 자리 + 1) · F_CODE 글자(A~Z 0~9 - _ / ^)."""
errs = []
for no, raw in enumerate(text.splitlines(), 1):
if not raw.strip():
continue
cols = split_line(name, raw)
if len(cols) != len(CUTS[name]) + 1:
errs.append(f"{name} - Line : {no} 열 개수 {len(cols)}")
if name in COLUMNS and COLUMNS[name][0] == "F_CODE":
bad = set(cols[0]) - CODE_CHARS
if bad:
errs.append(f"{name} - Line : {no} 코드 글자 {''.join(sorted(bad))}")
return errs