feat(est-test): 환경설정 전체 창(탭 5·92칸, 적용 알림·순공사비 명칭)

- 웹 계산·표시 옵션에 이어진 칸은 그 옵션에, 나머지는 값만, PC 설정은 브라우저에
- 바꾼 칸만 씀, 원본 적용 알림
- 코드표시 켜면 표에 코드 열, 레벨 표시 끄면 레벨 열 뺌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 19:41:55 +09:00
co-authored by Claude Opus 5.5
parent 3879de8b34
commit 04a432c08a
9 changed files with 322 additions and 1 deletions
+1
View File
@@ -41,6 +41,7 @@ for (const file of [
"native-work-mode.js",
"native-dongwon-check.js",
"native-excel-files.js",
"native-option-all.js",
"native-business-actions.js",
"native-forms.js",
"native-excel-to-est.js",
+26
View File
@@ -0,0 +1,26 @@
"""환경설정 전체: 읽기 기본값·바뀐 칸만 쓰기·옵션 자리(계산·표시·기성·Env)."""
from test_est_rea_parity import run
def test_write_only_changed_fields_to_their_places():
result = run(
"math",
"""(()=>{
const A=EstOptionAll, item=(k)=>A.TABS.flatMap(t=>t[1]).find(i=>i.k===k);
const o={HeadDigits:2,VerFormat:true,CostValueTrunc:false,AutoCalc:true};
const r0={head:A.read(o,item('headDigits')),fmt:A.read(o,item('format')),code:A.read(o,item('code')),
total:A.read(o,item('totalName'))};
A.write(o,{headDigits:1,format:1,code:true,level:true,jabbi:true,costMethod:1,recalc:2,twoLine:1});
return {r0,o};
})()""",
)
assert result["r0"] == {"head": 1, "fmt": 0, "code": False, "total": "순공사비"}
o = result["o"]
assert o["HeadDigits"] == 2 # 1 → 1 그대로(바뀌지 않음)
assert o["VerFormat"] is False
assert o["CostValueTrunc"] is True
assert o["OldRecalcMethod"] == 2
assert o["Display"] == {"code": True, "twoLine": True}
assert o["AccomOption"] == {"level": True}
assert o["Env"] == {"jabbi": True}
+2
View File
@@ -510,6 +510,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 검증완료: 확인 시험 1개·동원 관련 시험 35개 통과. ORCA 브라우저에서 일위 8건 모두 일치·당초값=계산값, 항목 3개 표시.
- [x] 엑셀물량 파일목록(TfrmExcelMullang btnNewProject·btnDelete · spec_pa §7) — 공사에 딸린 엑셀 물량 파일을 작업에 담음(Index·파일명). 추가 = 파일 골라 목록에(같은 이름 거부 — 원본은 엑셀에 띄우기만, 웹은 엑셀이 없어 여기서 등록) · 삭제 = 확인 뒤 목록·담은 파일 지움(셀 연결은 남음) · 열기 = 내려받기. 셀 수량 연결 창에서 목록 파일을 골라 쓰고, 새 파일로 연결하면 목록에 들어감. ESTX 저장은 JSON 안내.
- 검증완료: 파일목록 시험 1개·수량산출 시험 6개 통과. ORCA 브라우저에서 목록 등록 → 목록 파일로 B2 셀 연결(수량 12.5)·연결 수 1 → 삭제 확인, 원복.
- [x] 환경설정 전체 창(TfrmOption btnLoad · spec_pb2 §9) — 탭 다섯(공통적용·내역서·일위단산기계·표준자재원가·설계변경수량심사) 92칸. 웹 계산·표시에 이어진 칸은 그 옵션에(일위 자리수·단산 금액란·중간 자리수·외화 절하·재료비 처리·이윤 사사오입·관급 조달수수료·원가 방향·자동계산·재계산 범위·정산금액·비목순서·정렬·두줄·Enter·코드 열·0 표시·바탕색·레벨·발췌근거·집계 진하게), 나머지는 값만(Env), PC 설정은 이 브라우저. 바꾼 칸만 씀. 적용 때 원본 알림(자동계산 끔/켬·표준단가비목·조달청 계산방식·산식 표시·공종+품명 합치기)과 순공사비 명칭 바꾸면 순공사비 행 이름도 바꿈. 기성 화면에서는 기성 환경설정. 순공사비 상단/하단 이동·제잡비 행 넣기는 값만(웹 계산이 순공사비 위치를 아래로 가정).
- 검증완료: 환경설정 시험 1개·표시·기성 시험 4개 통과. ORCA 브라우저에서 탭 5·칸 92, 코드표시 켬 → 자재 표에 코드 열, 순공사비 명칭 → 행 이름 바뀜, 원복.
- [x] 다른 공사 불러오기 창(원본 barbtnSelfLoad → TfrmOtherLoad, 찾기 popSerch) — 저장된 다른 작업을 골라 같은 표를 띄우고 찾기(품명·규격, A&B 둘 다)·고른 줄을 하위 자료와 함께 가져오기(내역서는 고른 줄 아래에 줄, 나머지는 자료·호표 합침, 같은 코드는 이 공사 것). 검증완료: 바인딩 통과, ORCA 브라우저에서 산불진화임도 작업의 일위대가 3개 → 「취수장」 1개 → 가져오기 호표 8→9·하위 41건, 샘플 복구.
- [x] 사용위치 검색 창(원본 TfrmUsesDataSerch btnSerch·btnAllDisplay) — 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기. 검증완료: 바인딩 통과, ORCA 브라우저에서 노임 9개 → 「보 통」 1개 → 보통인부 사용위치 11곳.
- [x] 인쇄양식 불러오기·저장하기(원본 mnuPrintFormatLoad·Save 0x10ed318·0x10ed000 — spec_pc §3) — .pfot(인쇄정보표 MIDAS 이진) 읽기·쓰기 `print-format.cjs`(설명 BLOB·빈 칸 표지까지 보존, 원본 파일 그대로 다시 쓰면 바이트 같음). 불러오면 공사 인쇄표를 그 값으로, 저장은 지금 인쇄표를 원본 설치 main.pfot 필드 설명자 위에(`EST_PFOT_TEMPLATE`). .gfot(cxGrid 열 배치)는 웹 격자와 호환되지 않아 하지 않음.
+1
View File
@@ -29,6 +29,7 @@
<script defer src="native-work-mode.js"></script>
<script defer src="native-dongwon-check.js"></script>
<script defer src="native-excel-files.js"></script>
<script defer src="native-option-all.js"></script>
<script defer src="native-actions-more.js"></script>
<script defer src="native-wonga.js"></script>
<script defer src="native-jungsang.js"></script>
+1
View File
@@ -14,6 +14,7 @@ const EstActionsMore = (() => {
typeof EstWorkMode !== "undefined" && EstWorkMode,
typeof EstDongwonCheck !== "undefined" && EstDongwonCheck,
typeof EstExcelFiles !== "undefined" && EstExcelFiles,
typeof EstOptionAll !== "undefined" && EstOptionAll,
].filter(Boolean);
function handle(name, state, node) {
for (const module of modules()) if (module.handle(name, state, node)) return true;
+3 -1
View File
@@ -334,7 +334,9 @@ const EstActions = (() => {
`<p><b>${result.checked}개 중 ${result.passed}개 일치 · 불일치 ${result.failed}개</b></p><p>보관된 EST 샘플의 저장값과 계산 결과를 비교했습니다. 원본 실행 비교는 포함하지 않습니다.</p>${result.errors.length ? `<p>${esc(result.errors.join(" / "))}</p>` : ""}<table><thead><tr><th>영역</th><th>검사 수</th><th>일치</th></tr></thead><tbody>${[...new Set(result.results.map((r) => r.group))].map((group) => `<tr><td>${esc(group)}</td><td>${result.results.filter((r) => r.group === group).length}</td><td>${result.results.filter((r) => r.group === group && r.pass).length}</td></tr>`).join("")}</tbody></table>`,
);
} else if (name === "settings")
EstApp.state.view === "progress" ? EstProgressView.dialog(EstApp.state) : EstSettings.show();
EstApp.state.view === "progress"
? EstProgressView.dialog(EstApp.state)
: EstOptionAll.dialog(EstApp.state);
else if (name === "raw-line") {
const row = activeRows()[Number(node.dataset.row)];
showDialog(
+5
View File
@@ -22,6 +22,11 @@ const EstDisplay = (() => {
function columns(options, cols) {
const d = get(options);
let out = d.gubun ? cols : cols.filter((k) => k !== "ValueIndex");
// 코드표시(CommonCheckCode): 켜면 품명 앞에 코드 열, 끄면 뺌(웹 표는 처음에 코드 열이 없음)
if (d.code === false) out = out.filter((k) => k !== "Code");
else if (d.code === true && !out.includes("Code") && out.includes("Name"))
out = out.flatMap((k) => (k === "Name" ? ["Code", k] : [k]));
if (options?.AccomOption?.level === false) out = out.filter((k) => k !== "Level"); // 레벨 표시 끔
const order = ORDERS[d.order];
if (!order || d.order === 0) return out;
for (const suffix of ["C", "U"]) {
+282
View File
@@ -0,0 +1,282 @@
// 원본 환경설정 TfrmOption 「적 용」(btnLoad 0xd034f8 → FormClose 후처리 d03d20 — spec_pb2 §9): 탭별 칸 전부.
// 웹 계산·표시에 이어진 칸은 그 옵션(HeadDigits·CostValueTrunc·MidCostDiget·DollarCostCut·GCToWonga·EYunUp·
// GangubExceptJodalRatio·VerFormat·AutoCalc·OldRecalcMethod·JungSangShow·Display·AccomOption)에 쓰고, 나머지는 계산 환경 Env 에
// 값만 담는다. PC 설정(INI: 자동저장 분·백업·새이름 공사명·불러오기창·찾을문자크기·금액 변경확인·자재 틀고정)은 이 브라우저에.
// 적용 때 원본 알림: 자동계산 끔/켬 · 표준단가비목 · 조달청 계산방식 · 산식 표시 · 공종+품명 합치기 · 순공사비 명칭(행 이름 바꿈).
const EstOptionAll = (() => {
const num = (v) => Number(v) || 0;
const PC = "est-pc-options";
const yes = (k, label, at, def = false) => ({ k, label, kind: "check", at, def });
const pick = (k, label, choices, at, def = 0) => ({ k, label, kind: "pick", choices, at, def });
const int = (k, label, min, max, at, def = 0) => ({ k, label, kind: "int", min, max, at, def });
const text = (k, label, at, def = "") => ({ k, label, kind: "text", at, def });
const ORDER = [
"합/노/재/경",
"합/재/노/경",
"노/재/경/합",
"재/노/경/합",
"합/경/재/노",
"경/재/노/합",
];
const GUNGU = [
"일위대가",
"단가산출",
"기계경비",
"표준단가",
"견적단가",
"자재단가",
"노임단가",
];
// at: "opt:키"(계산 옵션) · "disp:키"(Display) · "accom:키"(AccomOption) · "pc"(이 브라우저) · 없음 = Env
const TABS = [
[
"공통적용",
[
pick("order", "비목순서", ORDER, "disp:order"),
pick("align", "품명정렬", ["좌정렬", "우정렬", "중앙정렬"], "disp:align"),
pick("twoLine", "품명표기", ["한줄로 표시", "두줄로 표시"], "disp:twoLine"),
pick("enter", "Enter 후 셀 이동", ["이동안함", "좌", "우", "위", "아래"], "disp:enter"),
yes("code", "코드표시", "disp:code"),
yes("gnZero", '수량 "0" 표시', "disp:gnZero", true),
yes("costZero", '금액 "0" 표시', "disp:costZero", true),
int("gnDigits", "수량 표시 자리수", 0, 6, null, 3),
int("nachalDigits", "낙찰율 표시 자리수", 0, 6, null, 2),
yes("nachalShow", "낙찰율 표시"),
yes("haljungShow", "할증율 표시"),
yes("siljukBimog", "표준단가비목표시"),
yes("autoSave", "데이타 자동저장", "disp:autoSave"),
int("saveTime", "자동저장 분", 5, 30, "pc", 10),
yes("siljukSearch", "표준단가 동시검색"),
yes("numSort", "공종일련번호 자동정렬"),
yes("jodalMethod", "조달청 계산방식적용"),
yes("nameMatch", "명칭 불일치 검색"),
yes("valueCompare", "신,구 단가비교 보기"),
int("searchLength", "찾을문자크기", 1, 20, "pc", 2),
text("color", "바탕색(#rrggbb)", "disp:color"),
yes("backup", "백업 저장", "pc"),
yes("projectAutoChange", "새이름 저장 시 공사명 변경", "pc"),
yes("searchFormAuto", "불러오기창 위치 기억", "pc"),
],
],
[
"내역서",
[
pick("nameShow", "공종 표시", ["공종=(공종+품명)", "별도"], null, 1),
yes("gungu", "비고란에 발췌근거 표시", "accom:gungu"),
yes("level", "레벨 표시", "accom:level"),
yes("projectTop", "공사명 설계서 첫줄에 표시"),
yes("titleTopPrint", "공사명 총괄표 첫줄에 인쇄"),
yes("jabbi", "내역서에 제잡비 표시"),
yes("sumBold", "집계공종 진하게", "accom:sumBold"),
yes("nachalGubun", "낙찰율 노,재,경 구분 표시"),
pick("totalPos", "순공사비 계산 위치", ["하단", "상단"]),
text("totalName", "순공사비 명칭", null, "순공사비"),
yes("firstTotal", "새공사 시작시 순공사비 자동생성", null, true),
pick("horCalc", "데이타(가로) 계산방법", ["절하", "절상"], "accom:horCalc"),
yes("calcCheck", "가로,세로 계산 확인", "accom:calcCheck"),
yes("calcAuto", "내역서 자동계산", "opt:AutoCalc", true),
yes("costCompareMsg", "재계산후 공사금액 변경확인", "pc"),
...GUNGU.map((g, i) => yes(`gungu${i}`, `발췌근거 표시 — ${g}`)),
],
],
[
"일위·단산·기계",
[
pick("headDigits", "일위대가 금액 계산 자리수", ["원단위", "소수1위"], "opt:HeadDigits"),
pick("headHorCalc", "일위대가 데이타(가로) 계산방법", ["절하", "절상"]),
yes("headBigo", "일위대가 비고란 발췌근거"),
yes("headInfo", "일위대가 공종 정보창"),
pick("costMethod", "단가산출 금액란", ["소수1위", "절하"], "opt:CostValueTrunc"),
int("midCost", "단가산출 중간계산 결과값 자리수", 0, 6, "opt:MidCostDiget", 2),
yes("sansic", "수식을 산식으로 변환 표시"),
yes("susicCheck", "수식오류 검색"),
yes("linkValueEdit", "연결 항목 편집허용"),
yes("costShowToHead", "공종 라인 총단가"),
yes("costInfo", "단가산출 정보창"),
yes("dollarCut", "외화장비 손료 1,000단위 미만 절하", "opt:DollarCostCut"),
yes("machInfo", "기계경비 정보창"),
],
],
[
"표준·자재·원가",
[
pick("remainBimog", "노무비율계산후 잔여단가 적용비목", ["재료비", "경비"]),
yes("siljukCode", "표준단가 비고란 코드"),
yes("stuffcGungu", "자재 비고란 발췌근거"),
yes("stuffcFixed", "자재 품명,규격 틀고정", "pc"),
pick("gcTo", "순직접공사비의 재료비", ["간접재료비", "직접재료비"], "opt:GCToWonga"),
yes("eyun", "이윤 비율 사사오입", "opt:EYunUp"),
yes("gangub", "안전관리비 관급자재대 조달수수료 제외", "opt:GangubExceptJodalRatio"),
yes("digetOldCost", "자리수 조정후 수식에 원금액 표시"),
yes("jungsan", "정산금액 표시", "opt:JungSangShow"),
pick("format", "원가계산서 양식 방향", ["세로", "가로"], "opt:VerFormat"),
yes("selectPrint", "원가 선택항목만 인쇄"),
],
],
[
"설계변경·수량·심사",
[
pick("changeOrder", "상단·하단", ["상단=당초 · 하단=변경", "상단=변경 · 하단=당초"]),
text("upText", "상단 표시문자", null, "당초"),
text("downText", "하단 표시문자", null, "변경"),
yes("upBold", "상단 굵게 인쇄"),
yes("downBold", "하단 굵게 인쇄"),
pick("nameShowMethod", "공종,품명,규격", ["변경만", "당초,변경 모두", "다르면 둘다"]),
pick("gunguShowMethod", "근거자료", ["신규만", "전부"]),
yes("oldCostShow", "신규공종 당초단가 표시"),
yes("nachalAuto", "신규공종 낙찰율 자동계산"),
yes("nachalToBigo", "비고란에 낙찰율 표시"),
pick("recalc", "재계산", ["신규만", "변경단가만", "전체"], "opt:OldRecalcMethod"),
yes("changeZero", "변경금액만 0 표시"),
yes("cellMerge", "증감 셀 병합"),
yes("mnsShow", "감 부호(-) 표시"),
yes("changeBigo", "변경 비고란 표시"),
yes("changeCalcAuto", "변경 내역서 자동계산", null, true),
pick("mulCalc", "수량산출 계산결과값", ["사사오입", "절하", "절상"]),
int("mulDigits", "수량산출 소수이하 자리수", 0, 6, null, 3),
yes("mulSansic", "수량산출 산식 변환"),
yes("mulColor", "도면칼라 인쇄"),
pick("investPrint", "계약심사 인쇄방법", ["내역서 양식", "설계변경 양식"]),
pick("investItems", "계약심사 인쇄항목", ["전부", "변경만"]),
],
],
];
const ALL = TABS.flatMap(([, items]) => items);
const pc = () => JSON.parse(localStorage.getItem(PC) || "{}");
// 지금 값 읽기
function read(options, item) {
const [where, key] = String(item.at || "").split(":");
let v;
if (where === "opt") v = options[key];
else if (where === "disp") v = options.Display?.[key];
else if (where === "accom") v = options.AccomOption?.[key];
else if (where === "pc") v = typeof localStorage !== "undefined" ? pc()[item.k] : undefined;
else v = options.Env?.[item.k];
if (item.k === "format") return v === false ? 1 : 0; // VerFormat true = 세로
if (item.k === "headDigits") return num(v) > 0 ? 1 : 0;
if (item.k === "twoLine") return v ? 1 : 0;
return v ?? item.def;
}
// 고친 값 → 옵션 쓰기(project.options 를 직접 고침). pcOut 은 이 브라우저 값
function write(options, values, pcOut = {}) {
for (const item of ALL) {
if (!(item.k in values)) continue;
let v = values[item.k];
// 바꾼 칸만 씀(웹 고유 값 — 예: 일위 자리수 2 — 을 그대로 두게)
const now = read(options, item);
if (
item.kind === "check"
? Boolean(now) === Boolean(v)
: item.kind === "text"
? String(now ?? "") === String(v)
: num(now) === num(v)
)
continue;
if (item.kind === "check") v = Boolean(v);
else if (item.kind !== "text") v = num(v);
if (item.k === "format") v = v === 0;
if (item.k === "twoLine") v = v === 1;
if (["costMethod", "gcTo"].includes(item.k)) v = v === 1;
const [where, key] = String(item.at || "").split(":");
if (where === "opt") options[key] = v;
else if (where === "disp") options.Display = { ...(options.Display || {}), [key]: v };
else if (where === "accom")
options.AccomOption = { ...(options.AccomOption || {}), [key]: v };
else if (where === "pc") pcOut[item.k] = v;
else options.Env = { ...(options.Env || {}), [item.k]: v };
}
return pcOut;
}
let tab = 0;
function dialog(state) {
const { esc } = EstApp,
o = state.project.options;
const field = (item) => {
const v = read(o, item);
if (item.kind === "check")
return `<label><input type="checkbox" data-oa="${item.k}" ${v ? "checked" : ""}> ${esc(item.label)}</label>`;
if (item.kind === "pick")
return `<label>${esc(item.label)} <select data-oa="${item.k}">${item.choices.map((c, i) => `<option value="${i}" ${num(v) === i ? "selected" : ""}>${esc(c)}</option>`).join("")}</select></label>`;
if (item.kind === "int")
return `<label>${esc(item.label)} <input type="number" data-oa="${item.k}" min="${item.min}" max="${item.max}" value="${num(v)}" style="width:60px"></label>`;
return `<label>${esc(item.label)} <input data-oa="${item.k}" value="${esc(v ?? "")}"></label>`;
};
EstApp.showDialog(
"환경설정",
`<div class="business-tools">${TABS.map(([name], i) => `<button data-action="option-all-tab" data-tab="${i}" ${i === tab ? 'aria-pressed="true"' : ""}>${esc(name)}</button>`).join("")}</div>${TABS.map(([, items], i) => `<div class="form-fields" data-oa-tab="${i}" ${i === tab ? "" : "hidden"}>${items.map(field).join("")}</div>`).join("")}<p><button data-action="option-all-apply">적 용</button> <button data-action="option-all-cancel">취 소</button> <button data-action="option-all-web">웹 계산 환경(도급액 고정 등)</button> <span id="option-all-error" role="alert"></span></p>`,
);
}
function collect() {
const values = {};
for (const node of document.querySelectorAll("#dialog-content [data-oa]"))
values[node.dataset.oa] = node.type === "checkbox" ? node.checked : node.value;
for (const item of ALL)
if (item.kind === "int") {
const v = Number(values[item.k]);
if (!Number.isInteger(v) || v < item.min || v > item.max)
throw Error(`${item.label}은(는) ${item.min}~${item.max} 정수입니다.`);
}
if (values.color && !/^#[0-9a-f]{6}$/i.test(values.color))
throw Error("바탕색은 #rrggbb 꼴입니다.");
return values;
}
// 적용(d03d20 후처리): 알림과 자료 반영
function apply(state) {
const values = collect(),
before = state.project.options,
notes = [];
const was = (k) =>
read(
before,
ALL.find((i) => i.k === k),
);
if (was("calcAuto") && !values.calcAuto && !confirm("내역서의 모든 계산기능이 해제됩니다."))
values.calcAuto = true;
if (!was("calcAuto") && values.calcAuto) notes.push("내역서를 재계산 시켜주세요.");
if (Boolean(was("siljukBimog")) !== values.siljukBimog)
notes.push("반드시 재계산을 시켜야 금액이 정상적으로 표시됩니다.");
if (Boolean(was("jodalMethod")) !== values.jodalMethod) notes.push("전체 재계산을 해주세요.");
if (Boolean(was("sansic")) !== values.sansic) notes.push("일건서류 전체를 재계산 합니다.");
if (
num(was("nameShow")) === 1 &&
num(values.nameShow) === 0 &&
!confirm("공종과 품명을 합치면 되돌릴수 없습니다.")
)
values.nameShow = 1;
const rename = String(values.totalName ?? "").trim() && values.totalName !== was("totalName");
let pcOut;
EstApp.change((project) => {
pcOut = write(project.options, values);
if (rename)
for (const row of project.tables.EstMainDat)
if (num(row.LinkIndex) === 54) row.Name = values.totalName.trim();
});
localStorage.setItem(PC, JSON.stringify({ ...pc(), ...pcOut }));
EstApp.byId("dialog").close();
state.notice = notes.join(" ") || "환경설정을 적용했습니다.";
EstApp.render();
}
function handle(name, state, node) {
if (name === "option-all") dialog(state);
else if (name === "option-all-tab") {
tab = Number(node.dataset.tab);
for (const box of document.querySelectorAll("#dialog-content [data-oa-tab]"))
box.hidden = Number(box.dataset.oaTab) !== tab;
for (const b of document.querySelectorAll('[data-action="option-all-tab"]'))
b.setAttribute("aria-pressed", String(Number(b.dataset.tab) === tab));
} else if (name === "option-all-apply")
try {
apply(state);
} catch (error) {
EstApp.byId("option-all-error").textContent = error.message;
}
else if (name === "option-all-cancel") EstApp.byId("dialog").close();
else if (name === "option-all-web") EstSettings.show();
else return false;
return true;
}
return { TABS, read, write, handle, dialog };
})();
+1
View File
@@ -113,6 +113,7 @@ const allowed = new Set([
"native-work-mode.js",
"native-dongwon-check.js",
"native-excel-files.js",
"native-option-all.js",
"native-actions-more.js",
"native-wonga-tools.js",
"native-estimate-print.js",