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
+4 -1
View File
@@ -379,7 +379,10 @@ const StmateCalc = (() => {
const hr = row.halRate;
if (!su && hr != null) {
const pct = (c) => num(typeof hr === "object" ? hr[c] : hr);
["M", "L", "S"].forEach((c) => pct(c) && (u[c] = roundDown(u[c] * (1 + pct(c) / 100), 0)));
// 원본 = 원 미만 버림(기준단가 × 4바이트 실수 계수) — ST_Boq 0x4e9b20 fstp dword · 0x4e9bde fmul dword(REA 34)
["M", "L", "S"].forEach(
(c) => pct(c) && (u[c] = Math.trunc(u[c] * Math.fround((100 + pct(c)) / 100))),
);
}
u.T = u.M + u.L + u.S;
const qty =
+9 -8
View File
@@ -438,7 +438,7 @@ const StmateChange = (() => {
const findItem = (state, id) => (state.boqItems || []).find((b) => b.id === id);
const ensureQtys = (b) => {
if (!Array.isArray(b.qtys)) b.qtys = [n0(b.qty), n0(b.changedQty ?? b.qty), 0, 0];
while (b.qtys.length < 4) b.qtys.push(0);
while (b.qtys.length < 5) b.qtys.push(0); // 계열 1~5(BOQ QTY5·xAMT5 · REA 34)
if (!b.units) b.units = {};
return b;
};
@@ -446,7 +446,7 @@ const StmateChange = (() => {
function setQty(state, id, stage, qty) {
const b = ensureQtys(findItem(state, id));
if (String(b.unit || "").endsWith("%")) {
if (!Array.isArray(b.pctQtys)) b.pctQtys = [null, null, null, null];
if (!Array.isArray(b.pctQtys)) b.pctQtys = [null, null, null, null, null];
b.pctQtys[stage - 1] = n0(qty);
return b;
}
@@ -456,7 +456,7 @@ const StmateChange = (() => {
return b;
}
// 변경 단가 — 차수 2~4 칸에 [재,노,경]
// 변경 단가 — 차수 2~5 칸에 [재,노,경]
function setUnit(state, id, stage, mls) {
if (stage < 2) throw new Error("당초(1차수) 단가는 단가 화면에서 바꿈");
const b = ensureQtys(findItem(state, id));
@@ -473,11 +473,11 @@ const StmateChange = (() => {
let k = 1;
while (used.has(`NEW_${k}`)) k++;
const stage = spec.stage || 2;
const qtys = [0, 0, 0, 0];
const qtys = [0, 0, 0, 0, 0];
qtys[stage - 1] = n0(spec.qty);
const units = {};
if (spec.mls) for (let n = Math.max(2, stage); n <= 4; n++) units[n] = spec.mls.map(n0);
for (let n = stage; n < 4; n++) qtys[n] = qtys[n] || 0;
if (spec.mls) for (let n = Math.max(2, stage); n <= 5; n++) units[n] = spec.mls.map(n0);
for (let n = stage; n < 5; n++) qtys[n] = qtys[n] || 0;
const item = {
id: `NEW_${k}`,
code: spec.code || "",
@@ -509,14 +509,15 @@ const StmateChange = (() => {
// kjPrev = 직전 계열(n−1) 원가계산서 — 원본 직전 계열 = 현재 − 1(Main 0x473ade DEC · 0x4645f4 되부름)
// 기본값 = 원본 제비율 다시 잡기(ST_KanJub 0x47a6ec 안전 2 · 0x47b633 보험료 1) — 이미 고른 값은 그대로
function startStage(state, n, kjPrev, carry = CARRY_DEFAULT, boqRows = []) {
if (n < 2 || n > 4) throw new Error("변경 차수는 2~4");
// 상한 = 계열 5(K_ORD 4 · 4차 변경) — ST_Prt 0x4b3183 K_ORD ≥ 5 이면 「1차 ~ 4차까지」 오류(REA 34)
if (n < 2 || n > 5) throw new Error("변경 차수는 2~5");
const dbfBy = new Map(boqRows.map((r) => [r._recno, r]));
for (const b of state.boqItems || []) {
ensureQtys(b);
if (String(b.unit || "").endsWith("%")) {
const r = dbfBy.get(b._recno) || {};
if (!Array.isArray(b.pctQtys))
b.pctQtys = [1, 2, 3, 4].map((k) => (r[`QTY${k}`] ? n0(r[`QTY${k}`]) : null));
b.pctQtys = [1, 2, 3, 4, 5].map((k) => (r[`QTY${k}`] ? n0(r[`QTY${k}`]) : null));
b.pctQtys[n - 1] = b.pctQtys[n - 2] != null ? b.pctQtys[n - 2] : n0(r[`QTY${n - 1}`]);
}
b.qtys[n - 1] = b.qtys[n - 2];
+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
+2 -2
View File
@@ -81,8 +81,8 @@ def add_amounts(kv: dict, slot: int = 1) -> list[dict]:
def stage_slots(kv: dict) -> list[int]:
"""원가계산서가 저장된 차수 — [n_KANJUB] 묶음이 있는 차수."""
return [n for n in range(1, 5) if any(k.startswith(f"{n}_C_") for k in kv)] or [1]
"""원가계산서가 저장된 차수 — [n_KANJUB] 묶음이 있는 차수 · 1~5(변경 4차 = 계열 5 · PRJLST MCOST5·XX5NM · REA 34)."""
return [n for n in range(1, 6) if any(k.startswith(f"{n}_C_") for k in kv)] or [1]
def scheme_of(keys: dict) -> str:
+2
View File
@@ -28,6 +28,8 @@ const STMATE_KANJUB_NATIVE = (() => {
? pairs([" 계약내역", 2, " 실행내역", 1])
: pairs([" 계역내역", 1]);
if (pc === 7) {
// K_ORD > 4 = 「설계변경 내역서 차수입력 오류 · 1차 ~ 4차까지」 로 화면을 열지 않음(0x46c2c8 · Ghidra 확인)
if (k > 4) return [];
const out = [{ label: " 계약(당초) 내역", series: 1 }];
for (let i = 1; i <= k; i++) out.push({ label: ` ${i}차 변경내역`, series: i + 1 });
return out;
+1 -1
View File
@@ -124,7 +124,7 @@
)}</select></label>`
: ""
}
${isX ? `<label>계수 <input id="comp-shift-f" type="number" step="0.0001" style="width:70px" value="${esc((sys.shiftFactors || {})[row.SHIFT || sys.shiftMode || "E0"] || "")}" title="E1~E3 손료 계수 — 품셈 값 입력(빈칸 = 1)"></label>` : ""}
<!-- 손료 계수 칸 없음 — 원본 계산에 E1~E3 계수 곱이 없음(REA 34 §17.7) -->
<label>결과적용 비목 <select id="comp-sbmok">${opt(SB, String(row.SBMOK || "").trim())}</select></label>
</div>
${errs.length ? `<div class="err-box" style="color:#b91c1c;padding:4px 8px;">${errs.map(esc).join("<br>")}</div>` : ""}
+2 -2
View File
@@ -206,7 +206,7 @@
<span>변경 차수</span><select id="stg-b" class="win-btn">${stages
.map((n) => `<option value="${n}" ${n === b ? "selected" : ""}>${n}차수</option>`)
.join("")}</select>
<button class="win-btn primary" id="stg-start" ${stages.length >= 4 ? "disabled" : ""}>변경 차수 시작(${Math.min(4, stages[stages.length - 1] + 1)}차수 — 앞 차수 복사)</button>
<button class="win-btn primary" id="stg-start" ${stages.length >= 5 ? "disabled" : ""}>변경 차수 시작(${Math.min(5, stages[stages.length - 1] + 1)}차수 — 앞 차수 복사)</button>
<button class="win-btn" id="stg-add">신규 공종 추가</button>
<button class="win-btn" id="stg-save">차수 칸 저장(BOQ·SMT)</button>
<span style="margin-left:auto;">수량·단가 칸을 고치면 즉시 다시 계산 · 삭제 = 그 차수 수량 0(행 유지)</span>
@@ -404,7 +404,7 @@
api.onClick("stg-start", () => {
const st = S.stages(ctx);
const n = st[st.length - 1] + 1;
if (n > 4) return;
if (n > 5) return; // 계열 5 = 4차 변경(REA 34)
if (!st.includes(1)) ctx.state.kanjubStages = { 1: ctx.state.kanjubConfig };
// 당초액 유지 = 직전 계열(n−1) 금액(Main 0x473ade)
C.startStage(ctx.state, n, S.kj(ctx, n - 1), C.CARRY_DEFAULT, S.boqRows(ctx));
+4 -3
View File
@@ -149,7 +149,7 @@
}
// 일위대가·중기 구성 계산 설정 — Start8x.Ini(CHBNM · OPTNM · CON*D · RX*NM · 환율 · 자리) · COSTN RXX
// 손료 갈래(E0 주간 · E1 야간 · E2 2교대 · E3 3교대, 30 §4) — 계수는 사용자 입력(sysConfig.shiftFactors)
// 손료 갈래(E0 주간 · E1 야간 · E2 2교대 · E3 3교대, 30 §4) — XML 내보내기 구분값 · 금액 계수 없음(REA 34 §17.7)
function compositionOptions(ctx, code, row) {
const ini = (ctx.dbfData && ctx.dbfData.start_ini) || {};
const sys = (ctx.state && ctx.state.sysConfig) || {};
@@ -168,8 +168,9 @@
// JCD 빈칸 기본 = KCD 2 이면 2 · 아니면 0(Main 0x42ab08)
wageDigits: digitOf(ini.JCDNM, digitOf(ini.KCDNM, 0) === 2 ? 2 : 0),
fxDigits: digitOf(ini.ECDNM, 0),
shiftFactor:
code[0] === "X" && shift !== "E0" ? num((sys.shiftFactors || {})[shift]) || 1 : 1,
// 원본 계산에는 손료 구분 계수 곱이 없음 — E0~E3 = 조달청 XML 열거값뿐(REA 34 §17.7) · 구분은 행에 남기기만
shiftFactor: 1,
shift,
};
}
+2 -2
View File
@@ -302,7 +302,7 @@
* CBoxNotUC 「보호된 견적단가 수정안함」(기본 켬) — 보호(UC=T) 행은 비목 가리지 않고 건너뜀(ST_Boq 0x507cc8)
* 여러 행 적용 = 코드 첫 글자 B·D·L·M·S·W·X 행만 입력 율 · 그 밖 행은 율 0(=100%)(0x507cc8)
* 원본 계산(0x4e9ae6) = 칸 1 단가 = 원 미만 버림(칸 2 기준단가 × 율/100) · 율은 4바이트 실수(float32)로 보관 —
* 70%·115% 등에서 1원 적게 나올 수 있음(조건부 · 실물 표본 없음 · 여기서는 십진 계산 유지) — 지식DB REA 34
* 70%·115% 등에서 1원 적게 나옴(0x4e9b20 fstp dword · 0x4e9bde fmul dword 확인 · 여기서도 Math.fround 계수) — 지식DB REA 34
* 계산 원천 halRate = 할증율 − 100(calc.js 가산식 × (1 + p/100) = × 할증율/100)
* o.base(코드) = 기준단가 {M,L,S}(COSTN 1칸) — 주면 행 1칸 단가도 비목별 원 미만 버림으로 씀(저장 반영)
*/
@@ -335,7 +335,7 @@
["M", "L", "S"].forEach(
(c) =>
(r[`${c}AMT1`] = String(
Math.trunc((num(b[c]) * (u[c] > 0 ? u[c] : 100)) / 100 + 1e-9),
Math.trunc(num(b[c]) * Math.fround((u[c] > 0 ? u[c] : 100) / 100)),
)),
);
r.TAMT1 = String(num(r.MAMT1) + num(r.LAMT1) + num(r.SAMT1));