From 812928a51fe0d0ae5d5e2012e267c7807b70be56 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 19:03:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EA=B8=B0=EC=84=B1=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=84=A4=EC=A0=95(=EA=B3=B5=ED=86=B5=C2=B7?= =?UTF-8?q?=EA=B8=B0=EC=84=B1=EA=B3=A0=C2=B7=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=B9=B8,=20=EC=A0=81=EC=9A=A9=C2=B7=EC=B7=A8?= =?UTF-8?q?=EC=86=8C,=20Ctrl+U)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기성고 화면을 native-progress-view.js 로 옮김 - 코드·레벨·사정금액·노재경 구분(비목순서·실적)·비고란·집계공종 진하게·수량/금액 0 숨김·품명정렬·두줄·바탕색 반영 - Enter 뒤 셀 이동 방향, 취소면 연 때 값으로 되돌림 - 주 메뉴 환경설정은 기성 화면에서 이 창을 엶 Co-Authored-By: Claude Opus 5.5 --- resources/tester/helper_est_native.cjs | 1 + resources/tester/test_est_accom_option.py | 61 +++++ tools/est-test/index.html | 1 + tools/est-test/native-actions-more.js | 1 + tools/est-test/native-actions.js | 3 +- tools/est-test/native-extra.js | 103 +------- tools/est-test/native-progress-view.js | 298 ++++++++++++++++++++++ tools/est-test/native-progress-wonga.js | 1 + tools/est-test/server.cjs | 1 + 9 files changed, 367 insertions(+), 103 deletions(-) create mode 100644 resources/tester/test_est_accom_option.py create mode 100644 tools/est-test/native-progress-view.js diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index bd585437c..6208af505 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -35,6 +35,7 @@ for (const file of [ "native-block-actions.js", "native-extra.js", "native-applied-cost.js", + "native-progress-view.js", "native-business-actions.js", "native-forms.js", "native-excel-to-est.js", diff --git a/resources/tester/test_est_accom_option.py b/resources/tester/test_est_accom_option.py new file mode 100644 index 000000000..e0e590576 --- /dev/null +++ b/resources/tester/test_est_accom_option.py @@ -0,0 +1,61 @@ +"""기성 환경설정: 옵션 기본값·비목 열 순서·표 머리 반영.""" + +from test_est_rea_parity import run + + +def test_defaults_and_part_order(): + result = run( + "math", + """(()=>{ + const d=EstProgressView.get({}); + const o=(x)=>EstProgressView.parts(EstProgressView.get({Display:{order:x.order},AccomOption:x})); + return {code:d.code,sajung:d.sajung,gubun:d.gubun, + a:o({order:0}),b:o({order:2,siljuk:true}),c:o({order:3}),d:o({order:5})}; + })()""", + ) + assert result == { + "code": True, + "sajung": True, + "gubun": False, + "a": ["T", "N", "G", "J"], + "b": ["N", "G", "J", "S", "T"], + "c": ["G", "N", "J", "T"], + "d": ["T", "N", "G", "J"], + } + + +def test_view_headers_follow_options_and_zero_hiding(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{Code:'A',Name:'a',GN:10,GU:100},{LinkIndex:54}]; + const q=EstNative.calculate(p);q.progress=[{id:'r1',name:'1회',inputs:{},locked:false}]; + const g=globalThis;g.EstApp={...(g.EstApp||{}),esc:String,number:String}; + g.EstProgressWonga={render:()=>''}; + const box=(t,tools,body)=>body,button=()=>'',table=(h,rows)=>JSON.stringify({h,rows}); + const st={progressPage:0,selection:new Set()}; + const head=(o)=>{q.options.Display=o.d;q.options.AccomOption=o.a; + return JSON.parse(EstProgressView.view({...st},q,{box,button,table}).split(' + diff --git a/tools/est-test/native-actions-more.js b/tools/est-test/native-actions-more.js index 9d783f46c..1abf375ef 100644 --- a/tools/est-test/native-actions-more.js +++ b/tools/est-test/native-actions-more.js @@ -8,6 +8,7 @@ const EstActionsMore = (() => { typeof EstExcelToEst !== "undefined" && EstExcelToEst, typeof EstOtherLoad !== "undefined" && EstOtherLoad, typeof EstAppliedCost !== "undefined" && EstAppliedCost, + typeof EstProgressView !== "undefined" && EstProgressView, ].filter(Boolean); function handle(name, state, node) { for (const module of modules()) if (module.handle(name, state, node)) return true; diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index d01a765b0..a5945e75d 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -333,7 +333,8 @@ const EstActions = (() => { "REA 샘플 대조", `

${result.checked}개 중 ${result.passed}개 일치 · 불일치 ${result.failed}개

보관된 EST 샘플의 저장값과 계산 결과를 비교했습니다. 원본 실행 비교는 포함하지 않습니다.

${result.errors.length ? `

${esc(result.errors.join(" / "))}

` : ""}${[...new Set(result.results.map((r) => r.group))].map((group) => ``).join("")}
영역검사 수일치
${esc(group)}${result.results.filter((r) => r.group === group).length}${result.results.filter((r) => r.group === group && r.pass).length}
`, ); - } else if (name === "settings") EstSettings.show(); + } else if (name === "settings") + EstApp.state.view === "progress" ? EstProgressView.dialog(EstApp.state) : EstSettings.show(); else if (name === "raw-line") { const row = activeRows()[Number(node.dataset.row)]; showDialog( diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index 37ef721c6..d78e2ac23 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -126,108 +126,7 @@ const EstExtra = (() => { : "현재 작업을 저장한 뒤 당초 내역을 확정하세요.", ); } else if (view === "progress") { - state.roundId ||= project.progress[0]?.id; - const round = project.progress.find((r) => r.id === state.roundId); - let body = '

회차를 추가하세요.

', - tools = - button("add-round", "기성 회차 추가") + - '' + - (project.progress.length ? button("clear-progress", "기성 전체 초기화") : ""); - if (round) { - tools += - `` + - button( - round.locked ? "unlock-round" : "lock-round", - round.locked ? "확정 해제(이후 포함)" : "회차 확정·저장", - ) + - button("fill-progress", "전량·비율 일괄 입력") + - button("rename-round", "회차 이름") + - button("delete-round", "회차 삭제") + - button("reset-round-inputs", "회차 입력 초기화") + - `` + - ``; - let output; - try { - output = - round.locked && round.result ? round.result : EstBusiness.progress(project, round.id); - } catch (e) { - output = { rows: [], errors: [e.message] }; - } - state.business = output; - const page = EstBlockActions.progressPage(state, output.rows); - tools += - button("progress-prev", "이전", state.progressPage === 0 ? "disabled" : "") + - `${state.progressPage + 1} / ${page.pages}쪽 · ${page.total}행` + - button("progress-next", "다음", state.progressPage + 1 >= page.pages ? "disabled" : ""); - body = - table( - [ - "코드", - "공종", - "도급수량", - "전회수량", - "금회 입력", - "입력방법", - "사정 입력", - "입력방법", - "금회금액", - "사정금액", - "누계수량", - "누계금액", - ], - page.rows.map(({ row, index }) => { - const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex)); - const key = EstBusiness.rowKey(row, index), - input = round.inputs?.[key] || {}; - const edit = (prefix) => - `` + - (input[prefix + "Mode"] === "components" - ? EstMath.components - .map( - (field, i) => - ``, - ) - .join("") - : ""); - const method = (prefix) => - !editable - ? "자동계산" - : ``; - return [ - esc(row.Code), - esc(row.Name), - number(row.GN), - number(row.JunGN), - edit("Gum"), - method("Gum"), - edit("Sa"), - method("Sa"), - number(row.GumTC), - number(row.SaTC), - number(row.TotalGN), - number(row.TotalTC), - ]; - }), - ) + - `

${esc((output.errors || []).join(" / "))} 금회 도급액 ${number(output.total)} · 기성비율 ${number(output.ratio)}%

`; - body += EstProgressWonga.render(state, round, output); - } - html = box( - "기성고", - tools, - body, - "전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.", - ); + html = EstProgressView.view(state, project, { box, button, table }); } else if (view === "escalation") { html = EstAppliedCost.view(state, project, { box, button, table }); } else if (view === "annual") { diff --git a/tools/est-test/native-progress-view.js b/tools/est-test/native-progress-view.js new file mode 100644 index 000000000..3bcad8148 --- /dev/null +++ b/tools/est-test/native-progress-view.js @@ -0,0 +1,298 @@ +// 기성고 화면(native-extra.js 에서 옮김)과 원본 기성 「환경설정」(TfrmAccomOption — spec_pb1 §4: 주 메뉴 환경 설정 0x10ccddc 가 +// 기성 문서일 때 · 기성 원가계산서 팝업 popupOptision 0xd479f4 Ctrl+U). 칸을 누르면 바로 옵션에 쓰고 「취소」면 연 때 값으로 되돌림(d45e1c). +// 공통적용(코드·수량 0·금액 0·비목순서·품명정렬·품명 두줄·Enter 이동·바탕색)은 다른 표와 같은 계산 환경 Display, +// 기성고(레벨·비고란 발췌근거·집계공종 진하게·사정금액·노재경 구분·실적 비목)는 AccomOption. 원본도 처리기가 없는 +// 자동저장 분·가로세로 계산 확인·가로 계산방법·재료비 처리·이윤 사사오입은 값만 담는다(화면·계산 불변). +const EstProgressView = (() => { + const num = (v) => Number(v) || 0; + const COMMON = { + code: true, + gnZero: true, + costZero: true, + order: 0, + align: 0, + twoLine: false, + enter: 0, + color: "", + autoSave: false, + saveTime: 10, + }; + const ACCOM = { + level: false, + gungu: false, + sumBold: false, + calcCheck: false, + horCalc: 0, + sajung: true, + gubun: false, + siljuk: false, + gcTo: 0, + eyunRound: false, + }; + const get = (options) => ({ + ...COMMON, + ...(options?.Display || {}), + ...ACCOM, + ...(options?.AccomOption || {}), + }); + const ORDERS = ["TNGJ", "TGNJ", "NGJT", "GNJT"]; + const PART = { T: "합계", N: "노무", G: "재료", J: "경비", S: "실적" }; + + // 비목 금액 열(금회): 비목순서대로, 실적은 실적 비목 표시 때만 경비 뒤에 + function parts(o) { + const seq = [...(ORDERS[o.order] || ORDERS[0])]; + if (o.siljuk) seq.splice(seq.indexOf("J") + 1, 0, "S"); + return seq; + } + + function view(state, project, { box, button, table }) { + const { esc } = EstApp, + o = get(project.options); + const shown = (v, zero) => (!zero && !num(v) ? "" : EstApp.number(v)); + const qty = (v) => shown(v, o.gnZero), + cost = (v) => shown(v, o.costZero); + state.roundId ||= project.progress[0]?.id; + const round = project.progress.find((r) => r.id === state.roundId); + let body = '

회차를 추가하세요.

', + tools = + button("add-round", "기성 회차 추가") + + '' + + (project.progress.length ? button("clear-progress", "기성 전체 초기화") : "") + + button("accom-option", "환경 설정"); + if (round) { + tools += + `` + + button( + round.locked ? "unlock-round" : "lock-round", + round.locked ? "확정 해제(이후 포함)" : "회차 확정·저장", + ) + + button("fill-progress", "전량·비율 일괄 입력") + + button("rename-round", "회차 이름") + + button("delete-round", "회차 삭제") + + button("reset-round-inputs", "회차 입력 초기화") + + `` + + ``; + let output; + try { + output = + round.locked && round.result ? round.result : EstBusiness.progress(project, round.id); + } catch (e) { + output = { rows: [], errors: [e.message] }; + } + state.business = output; + const page = EstBlockActions.progressPage(state, output.rows); + tools += + button("progress-prev", "이전", state.progressPage === 0 ? "disabled" : "") + + `${state.progressPage + 1} / ${page.pages}쪽 · ${page.total}행` + + button("progress-next", "다음", state.progressPage + 1 >= page.pages ? "disabled" : ""); + const seq = o.gubun ? parts(o) : ["T"]; + const nameStyle = [ + ["", "text-align:right", "text-align:center"][o.align] || "", + o.twoLine ? "white-space:normal;max-width:16em" : "white-space:nowrap", + ] + .filter(Boolean) + .join(";"); + const headers = [ + ...(o.code ? ["코드"] : []), + ...(o.level ? ["레벨"] : []), + "공종", + "도급수량", + "전회수량", + "금회 입력", + "입력방법", + ...(o.sajung ? ["사정 입력", "입력방법"] : []), + ...seq.map((p) => (p === "T" ? "금회금액" : `금회 ${PART[p]}`)), + ...(o.sajung ? ["사정금액"] : []), + "누계수량", + "누계금액", + ...(o.gungu ? ["비고"] : []), + ]; + body = + table( + headers, + page.rows.map(({ row, index }) => { + const editable = ![54, 55, 58, 59, 62, 67].includes(Number(row.LinkIndex)); + const key = EstBusiness.rowKey(row, index), + input = round.inputs?.[key] || {}; + const edit = (prefix) => + `` + + (input[prefix + "Mode"] === "components" + ? EstMath.components + .map( + (field, i) => + ``, + ) + .join("") + : ""); + const method = (prefix) => + !editable + ? "자동계산" + : ``; + const bold = o.sumBold && num(row.Level) > 0 ? ";font-weight:bold" : ""; + return [ + ...(o.code ? [esc(row.Code)] : []), + ...(o.level ? [num(row.Level) || ""] : []), + `${esc(row.Name)}`, + qty(row.GN), + qty(row.JunGN), + edit("Gum"), + method("Gum"), + ...(o.sajung ? [edit("Sa"), method("Sa")] : []), + ...seq.map((p) => cost(row[`Gum${p}C`])), + ...(o.sajung ? [cost(row.SaTC)] : []), + qty(row.TotalGN), + cost(row.TotalTC), + ...(o.gungu ? [esc(row.Bigo ?? "")] : []), + ]; + }), + ) + + `

${esc((output.errors || []).join(" / "))} 금회 도급액 ${EstApp.number(output.total)} · 기성비율 ${EstApp.number(output.ratio)}%

`; + body += EstProgressWonga.render(state, round, output); + } + const html = box( + "기성고", + tools, + body, + "전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.", + ); + return o.color ? `
${html}
` : html; + } + + // 환경설정 창: 칸을 바꾸면 바로 옵션에 쓰고(화면에 즉시), 「취소」 는 연 때 값으로 되돌림 + let opened = null; + function dialog(state) { + const { esc } = EstApp, + o = get(state.project.options); + opened = { + Display: structuredClone(state.project.options.Display), + AccomOption: structuredClone(state.project.options.AccomOption), + }; + const check = (name, label, extra = "") => + ``; + const pick = (name, label, choices, extra = "") => + ``; + EstApp.showDialog( + "환경설정", + `

공통적용

${check("code", "코드표시")}${check("gnZero", '수량 "0" 표시')}${check("costZero", '금액 "0" 표시')}${check("autoSave", "데이타 자동저장")}${pick("order", "비목순서", ["합/노/재/경", "합/재/노/경", "노/재/경/합", "재/노/경/합"], o.gubun ? "" : "disabled")}${pick("align", "품명정렬", ["좌정렬", "우정렬", "중앙정렬"])}${pick("twoLine", "품명표기", ["한줄로 표시", "두줄로 표시"])}${pick("enter", "Enter 뒤 셀 이동", ["이동안함", "좌", "우", "위", "아래"])}
` + + `

기성고

${check("level", "레벨 표시")}${check("gungu", "비고란에 발췌근거 표시")}${check("sumBold", "집계공종 진하게 표시")}${check("calcCheck", "가로,세로 계산 확인")}${pick("horCalc", "데이타(가로) 계산방법", ["절하", "절상"])}${check("sajung", "사정금액 표시")}${check("gubun", "노,재,경 구분 표시")}${check("siljuk", "실적 비목 표시", o.gubun ? "" : "disabled")}
` + + `

원가계산

${pick("gcTo", "순직접공사비의 재료비 처리", ["간접재료비", "직접재료비"])}${check("eyunRound", "이윤 비율 사사오입")}
` + + ` `, + ); + } + function set(name, value) { + const options = EstApp.state.project.options, + bucket = name in COMMON ? "Display" : "AccomOption"; + const flag = typeof COMMON[name] === "boolean" || typeof ACCOM[name] === "boolean"; + const typed = flag + ? typeof value === "string" + ? value === "1" + : Boolean(value) + : name === "color" + ? value + : num(value); + options[bucket] = { ...(options[bucket] || {}), [name]: typed }; + const box = document.getElementById("dialog-content"); + if (name === "autoSave") box.querySelector('[data-ao="saveTime"]').disabled = !typed; + if (name === "gubun") { + box.querySelector('[data-ao="order"]').disabled = !typed; + box.querySelector('[data-ao="siljuk"]').disabled = !typed; + } + EstApp.render(); + } + function close(apply) { + const options = EstApp.state.project.options; + if (opened) { + const now = { Display: options.Display, AccomOption: options.AccomOption }; + for (const k of ["Display", "AccomOption"]) + if (opened[k] === undefined) delete options[k]; + else options[k] = opened[k]; + // 적용: 연 때 값으로 돌린 뒤 바뀐 값을 한 번에 기록(되돌리기 한 단계) + if (apply) EstApp.change((project) => Object.assign(project.options, now), false); + } + opened = null; + EstApp.byId("dialog").close(); + EstApp.render(); + } + + // Enter 뒤 셀 이동(ComboBoxEnter 62c+0x60): 표 안 입력칸에서 Enter 면 좌·우·위·아래 칸으로 + function move(input, direction) { + const cell = input.closest("td"), + tr = cell?.parentElement; + if (!tr) return null; + const col = [...tr.children].indexOf(cell); + let target = null; + if (direction === 1 || direction === 2) { + const cells = [...tr.children]; + for ( + let c = col + (direction === 1 ? -1 : 1); + c >= 0 && c < cells.length; + c += direction === 1 ? -1 : 1 + ) + if ((target = cells[c].querySelector("input:not([disabled]),select:not([disabled])"))) + break; + } else if (direction === 3 || direction === 4) { + for ( + let r = direction === 3 ? tr.previousElementSibling : tr.nextElementSibling; + r; + r = direction === 3 ? r.previousElementSibling : r.nextElementSibling + ) + if ( + (target = r.children[col]?.querySelector("input:not([disabled]),select:not([disabled])")) + ) + break; + } + return target; + } + function install() { + document.addEventListener("change", (event) => { + const t = event.target; + if (t.dataset?.ao === undefined) return; + set(t.dataset.ao, t.type === "checkbox" ? t.checked : t.value); + }); + document.addEventListener("keydown", (event) => { + const state = EstApp.state; + if (state?.view !== "progress") return; + if (event.ctrlKey && event.key.toLowerCase() === "u") { + event.preventDefault(); + dialog(state); + return; + } + const t = event.target; + if (event.key !== "Enter" || !t.matches?.("table.sheet input, table.sheet select")) return; + const direction = num(get(state.project.options).enter); + if (!direction) return; + const target = move(t, direction); + if (target) { + event.preventDefault(); + t.dispatchEvent(new Event("change", { bubbles: true })); + setTimeout(() => + ( + document.querySelector( + `[data-key="${CSS.escape(target.dataset.key || "")}"][data-progress="${target.dataset.progress}"]`, + ) || target + ).focus(), + ); + } + }); + } + if (typeof document !== "undefined" && document.addEventListener) install(); + + function handle(name, state) { + if (name === "accom-option") dialog(state); + else if (name === "accom-option-ok") close(true); + else if (name === "accom-option-cancel") close(false); + else return false; + return true; + } + return { view, get, parts, move, handle, dialog }; +})(); diff --git a/tools/est-test/native-progress-wonga.js b/tools/est-test/native-progress-wonga.js index a1e3b2aa6..dc242ba53 100644 --- a/tools/est-test/native-progress-wonga.js +++ b/tools/est-test/native-progress-wonga.js @@ -119,6 +119,7 @@ const EstProgressWonga = (() => { b("pw-total", "공사비역산 자리수 맞추기"), b("pw-init", "원가계산서 초기화"), b("pw-undo", "되살리기"), + '', ``, round.wonga ? "회차 서식 사용 중" : "", round.wongaTotalDigits ? `역산 ${round.wongaTotalDigits}자리` : "", diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index dd3b01591..256ce2a4c 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -105,6 +105,7 @@ const allowed = new Set([ "native-excel-to-est.js", "native-other-load.js", "native-applied-cost.js", + "native-progress-view.js", "native-actions-more.js", "native-wonga-tools.js", "native-estimate-print.js",