- 변경 차수 상한 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>
199 lines
6.0 KiB
Python
199 lines
6.0 KiB
Python
# -*- 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
|