feat(stmate-test): 보고서·제잡비·식언어·수량산출·화면 묶음 합침 · 재계산 후 저장 무변경

- 보고서: 원본 STMATE 엑셀과 셀 대조(목록표·자재단가대비·중기·일위대가·산근 목록·설계내역서·원가계산서) · 조달청 XML 공식 XSD 검증 · PM_Prt 화면
- 제잡비: 원본 계산 순서 4형식(T/S/TA/SA) · 실무 7건 저장 결과키 전수 1원 · 요율표 조회 · 선택지 계산 · 제잡비·제비율 화면
- 식언어·수량: 함수·조건식·금액변수·문법 검사·수량변수·QCODE · 산근·구성·중기공식 편집 화면 · 순환 알림
- 화면: 원본 메뉴 체계·단축키 · 설계내역서 편집(행 넣기·지우기·이동·레벨·보관함) · 초기값 · 단가목록표
- 저장: 초기값(Start8x.Ini) 바뀐 키 저장 · 노임 환산 직종 = 초기값 CONLD · 산근·구성 결과에 적용단가 칸 채우지 않음(원본 0)
- DBF 글 칸 앞 빈칸 보존 · 시험 도우미 표준입력 utf8(한글 조각 깨짐 원인)
- 실무 5건 재계산 후 저장 = 바뀐 칸 0 · 시험 332건 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 19:12:04 +09:00
co-authored by Claude Opus 5.5
parent 5bb46004f3
commit 3d8fddfc34
59 changed files with 5593 additions and 341 deletions
+80 -14
View File
@@ -28,22 +28,79 @@
const FIELDS = {
rate: [
{ key: "EXLNM{n}", cap: "A 미국 USD (₩/$)", type: "num", hint: "외산장비중 $장비에 적용될 미국 USD 환율(경비중 단위가『$』인 장비)" },
{ key: "EXHNM{n}", cap: " └ 10만$ 이상 장비", type: "num", hint: "외산장비『$』(10만$이상)에 적용될 환율" },
{
key: "EXLNM{n}",
cap: "A 미국 USD (₩/$)",
type: "num",
hint: "외산장비중 $장비에 적용될 미국 USD 환율(경비중 단위가『$』인 장비)",
},
{
key: "EXHNM{n}",
cap: " └ 10만$ 이상 장비",
type: "num",
hint: "외산장비『$』(10만$이상)에 적용될 환율",
},
{ key: "EXEUR{n}", cap: "유로 EUR (₩/€)", type: "num", hint: "경비중 단위가『€』인 장비" },
{ key: "EXYEN{n}", cap: "일본 JPY100(₩/100¥)", type: "num", hint: "경비중 단위가『¥』인 장비" },
{ key: "EXRNM", cap: "B 환율 자동적용 범위", type: "sel", opts: [["0", "시간당중기사용료 단독적용"], ["1", "중기, 대가, 산근, 내역 전체"]] },
{ key: "RXXNM", cap: "C 시간당 노임산출계수", type: "text", hint: "시간당 노임산출계수(ex. 일반구간)" },
{
key: "EXYEN{n}",
cap: "일본 JPY100(₩/100¥)",
type: "num",
hint: "경비중 단위가『¥』인 장비",
},
{
key: "EXRNM",
cap: "B 환율 자동적용 범위",
type: "sel",
opts: [
["0", "시간당중기사용료 단독적용"],
["1", "중기, 대가, 산근, 내역 전체"],
],
},
{
key: "RXXNM",
cap: "C 시간당 노임산출계수",
type: "text",
hint: "시간당 노임산출계수(ex. 일반구간)",
},
{ key: "CONLD", cap: " └ 적용코드", type: "codes" },
],
digits: [
{ key: "KCDNM", cap: "D 중기사용료 금액소수점선택", type: "sel", opts: D012 },
{ key: "JCDNM", cap: "중기 노임 단가소수점선택", type: "sel", opts: D012, hint: '"X" 코드에서 "L(중기노임)"코드 사용시 적용' },
{ key: "ECDNM", cap: "중기$장비 단가소수점선택", type: "sel", opts: D012, hint: '"X" 코드에서 "S($장비)"코드 사용시 적용' },
{
key: "JCDNM",
cap: "중기 노임 단가소수점선택",
type: "sel",
opts: D012,
hint: '"X" 코드에서 "L(중기노임)"코드 사용시 적용',
},
{
key: "ECDNM",
cap: "중기$장비 단가소수점선택",
type: "sel",
opts: D012,
hint: '"X" 코드에서 "S($장비)"코드 사용시 적용',
},
{ key: "ACDNM", cap: "E 단가산출서 금액소수점선택", type: "sel", opts: D012 },
{ key: "BPNNM", cap: "일위대가표 금액소수점선택", type: "sel", opts: D012 },
{ key: "ZCDNM", cap: "F 단가산출서 수량소수점선택", type: "sel", opts: [["2", "2 자리"], ["3", "3 자리"]] },
{ key: "CUTNM", cap: "계금액 3원미만 결과소수점", type: "sel", opts: [["1", "1 자리"], ["0", " 버림"]], hint: "대가,산근 계산결과 3원미만일 때 결과금액 자리수" },
{
key: "ZCDNM",
cap: "F 단가산출서 수량소수점선택",
type: "sel",
opts: [
["2", "2 자리"],
["3", "3 자리"],
],
},
{
key: "CUTNM",
cap: "계금액 3원미만 결과소수점",
type: "sel",
opts: [
["1", "1 자리"],
["0", " 버림"],
],
hint: "대가,산근 계산결과 3원미만일 때 결과금액 자리수",
},
],
calc: [
{ key: "CHGNM", cap: "G 단가산출근거", type: "sel", opts: CALC },
@@ -70,7 +127,9 @@
[...FIELDS.digits, ...FIELDS.calc].forEach((f) => take(f.key));
RX_SETS.forEach((s) => (take(s.rx), take(s.con)));
["ARENM", "OPTNM"].forEach(take);
["CHGNM", "CHBNM", "CHNNM", "CUTNM", "EXRNM"].forEach((k) => d[k] !== "" && (d[k] = bool01(d[k])));
["CHGNM", "CHBNM", "CHNNM", "CUTNM", "EXRNM"].forEach(
(k) => d[k] !== "" && (d[k] = bool01(d[k])),
);
if (!/^[SML]{3}$/.test(d.OPTNM) || new Set(d.OPTNM).size !== 3) d.OPTNM = "SML";
if (d.ARENM !== "2") d.ARENM = "1";
return d;
@@ -78,13 +137,18 @@
// 적용코드 — 6자리 코드를 이어 붙인 글(CONLD=L00008L00009L00010)
const splitCodes = (s) => String(s || "").match(/[A-Z]\d{5}/g) || [];
const joinCodes = (list) => (list || []).map((c) => String(c).trim().toUpperCase()).filter((c) => /^[A-Z]\d{5}$/.test(c)).join("");
const joinCodes = (list) =>
(list || [])
.map((c) => String(c).trim().toUpperCase())
.filter((c) => /^[A-Z]\d{5}$/.test(c))
.join("");
// 노임 환산식 검사 — 숫자·사칙·괄호·공백만
function checkFormula(s) {
const t = String(s || "").trim();
if (!t) return { ok: true, value: null };
if (!/^[\d.\s+\-*/()]+$/.test(t)) return { ok: false, error: "숫자와 + - * / ( ) 만 쓸 수 있음" };
if (!/^[\d.\s+\-*/()]+$/.test(t))
return { ok: false, error: "숫자와 + - * / ( ) 만 쓸 수 있음" };
try {
const v = Function(`"use strict";return (${t});`)();
return Number.isFinite(v) ? { ok: true, value: v } : { ok: false, error: "계산 불가" };
@@ -109,7 +173,8 @@
});
["CONLD", ...RX_SETS.map((s) => s.con)].forEach((k) => {
const raw = String(d[k] || "");
if (raw && joinCodes(splitCodes(raw)) !== raw.replace(/\s/g, "")) errs.push(`${k}: 코드는 영문 1자 + 숫자 5자리`);
if (raw && joinCodes(splitCodes(raw)) !== raw.replace(/\s/g, ""))
errs.push(`${k}: 코드는 영문 1자 + 숫자 5자리`);
});
return errs;
}
@@ -142,7 +207,8 @@
if (ini.EXLNM2 !== undefined && ini.EXLNM2 !== "") s.usdRate2 = Number(ini.EXLNM2);
s.useChangedUsd = String(ini.ARENM) === "2";
if (ini.ECDNM !== undefined && ini.ECDNM !== "") s.ecdDigits = Number(ini.ECDNM) || 0;
if (ini.CHNNM !== undefined && ini.CHNNM !== "") s.chnMode = bool01(ini.CHNNM) === "0" ? "total" : "bimok";
if (ini.CHNNM !== undefined && ini.CHNNM !== "")
s.chnMode = bool01(ini.CHNNM) === "0" ? "total" : "bimok";
if (/^[SML]{3}$/.test(String(ini.OPTNM || ""))) s.optOrder = ini.OPTNM;
return s;
}