From c05916369ce670616293c92f9ec048923a3b11e3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:40:42 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat(est-test):=20=EB=8B=A8=EA=B0=80=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC(=EC=A4=91=EB=B3=B5=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=C2=B7=EB=8B=A8=EA=B0=80=EC=A0=95=EC=9D=98=20=EC=97=91=EC=85=80?= =?UTF-8?q?=C2=B7=ED=95=A0=EC=A6=9D/=EB=82=99=EC=B0=B0/=EB=B3=B5=EC=9B=90?= =?UTF-8?q?=C2=B7=EB=B0=94=EA=BE=B8=EA=B8=B0=C2=B7SUM)=EC=99=80=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=EC=84=A0=ED=83=9D=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=83=89=EC=9D=B8=20=EB=B2=88=ED=98=B8=20=EC=9D=BD=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_price_tools.py | 61 +++++++ tools/est-test/PLAN.md | 2 + tools/est-test/index.html | 8 + tools/est-test/native-actions-more.js | 6 +- tools/est-test/native-data-update.js | 2 +- tools/est-test/native-price-tools.js | 211 +++++++++++++++++++++++ tools/est-test/native-rows.js | 11 +- tools/est-test/server.cjs | 1 + 8 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 resources/tester/test_est_price_tools.py create mode 100644 tools/est-test/native-price-tools.js diff --git a/resources/tester/test_est_price_tools.py b/resources/tester/test_est_price_tools.py new file mode 100644 index 000000000..45cfbca5b --- /dev/null +++ b/resources/tester/test_est_price_tools.py @@ -0,0 +1,61 @@ +"""EST 테스트 앱 단가 도구(원본 중복 항목 정리 · 단가정의 엑셀 · 할증/낙찰/복원 · 항목선택계산 SUM 줄).""" + +import json +import subprocess +from pathlib import Path + +from test_est_rea_parity import run + +ROOT = Path(__file__).resolve().parents[2] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone }}); + vm.runInContext(fs.readFileSync('tools/est-test/native-price-tools.js', 'utf8'), ctx); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8")) + + +def test_price_tools_rules(): + out = run_js( + r"""(() => { + const P = EstPriceTools; + const same = P.samecheck([{ Name: '철 근', Size: 'D10', Unite: 'ton', TC: 5 }, { Name: '철근', Size: 'D10', Unite: 'ton', TC: 9 }, + { Name: '철근|', Size: 'D10', Unite: 'ton', TC: 9 }, { Name: '모래', TC: 1 }]); + const g = P.gungu([{ Code: 'C1', Index: 3, Name: '포장', Size: 't=5', Unite: 'm2', EXPLEAN: '포함: 재료\n제외: 운반' }, { Code: 'C2', Name: '빈' }]); + const rows = [{ Name: 'a', TC: 100 }, { Name: 'b', TC: 200, OTC: 210 }, { Name: '', TC: 0 }]; + const n = P.setRate(rows, [], 'NachalRatio', 88); + const one = [{ Name: 'x', TC: 1 }, { Name: 'y', TC: 2 }]; P.setRate(one, [1], 'Ratio', 5); + const r = { Name: '', Size: '', LinkIndex: 0 }; + P.sumRow('HeadMainDat', r, { name: '기구손료', size: '', rate: 2, base: 2, target: 3, picked: [1, 3] }); + const errs = []; + for (const [row, o] of [[{ Index: 1 }, {}], [{}, { base: 0, target: 2, picked: [1] }], [{}, { base: 1, target: 1, picked: [] }], + [{}, { base: 1, target: 1, picked: [1, 2, 3, 4, 5, 6] }]]) + try { P.sumRow('HeadMainDat', row, { name: '', size: '', rate: 1, ...o }); } catch (e) { errs.push(e.message.slice(0, 8)); } + return { same: [same.removed, same.rows.map((x) => [x.Name, x.TC])], g, n, rows: rows.map((x) => [x.NachalRatio ?? null, x.OTC ?? null]), + one: one.map((x) => x.Ratio ?? null), r: [r.Code, r.LinkIndex, r.GN, r.Biul, r.Unite], errs }; + })()""" + ) + assert out["same"] == [2, [["철근|", 9], ["모래", 1]]] + assert out["g"] == [["C1", "3. 포장 (t=5) [m2]"], ["", "포함: 재료"], ["", "제외: 운반"], ["", ""]] + assert out["n"] == 3 + assert out["rows"] == [[88, 100], [88, 210], [None, None]] + assert out["one"] == [5, 5] + assert out["r"] == ["A0103", 62, 2, 0.02, "%"] + assert out["errs"] == ["현재 라인에는 ", "비목별항목은 비", "더할항목의 색인", "더할항목의 색인"] + + +def test_sum_row_code_numbers_select_groups(): + result = run( + "math", + """(()=>{ + const rows=[{Index:1,Code:'H'},{Code:'a',GN:1,NC:100,NU:100,SKind:1},{Code:'b',GN:1,NC:50,NU:50,SKind:3}, + {Code:'c',GN:1,NC:7,NU:7,SKind:2},{Code:'A0103',LinkIndex:62,GN:10,OCheck:2,SCheck:2}]; + const out=EstRows.head(rows,{HeadDigits:0}); + return out.rows?out.rows[4].NC:out[4].NC; + })()""", + ) + assert result == 15 diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 673b9f8c7..7dd2cba17 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [x] 단가 도구(spec_pb2) — 중복 항목 정리(자재단가: 품명·규격·단위 빈칸·| 무시 같으면 TC 가장 큰 행만, 같으면 뒤 행, 묻지 않음), 단가정의 엑셀변환(표준시장단가 설명문 있는 항목만 「코드 / 번호. 품명 (규격) [단위]」+설명 줄, .xlsx), 할증·낙찰(고른 2행 이상 또는 전 행 Ratio·NachalRatio, 원래 단가 OTC 보관, 100 이면 자리 해제)·복원(두 율 0), 바꾸기(Main Data 로 현재 표 하나만 갱신, 못 찾음 표시), SUM(현재 줄을 항목선택계산 62 줄로: Code 'A'+두 자리 색인·적용율·비목, 원본 거부 문구, 줄 자신 SKind = 묶음 최대+1). 엔진: 62 줄 Code 를 두 자리 색인 번호로 읽음(설치 main.estx 의 「A01」 꼴, 예전 글자 해석은 A0103 의 C 를 놓쳤음). + - 검증완료: `test_est_price_tools.py` 2개(중복 2개 지움·단가정의 줄·율 쓰기 범위·SUM Code A0103·거부 4종, 엔진 A0103 → 색인 1·3 합 150×10% = 15) 및 REA 대조 39개 통과. ORCA 브라우저에서 보통인부 낙찰 90% 172,068→154,861, 복원 →172,068, 바꾸기(Main) → 2026 하반기 값, 샘플 복구. - [x] 워드형 수량산출서 표와 편집 도구(원본 TFRMMULWORD·TFRMMULLANG — spec_word · spec_pa) — 가져오기가 WPT 을 통째로 버리던 결함 고침: 표를 한 줄 { table: [[칸]] }(글·수량 표지 r/L/G·품명/규격 표지·수식·세로병합)로 보존(설치 main.estx 표 5개 9×13~5×5). 문서 읽기는 `native-quantity-wpt.cjs` 로 분리. 표 화면: 칸 고르기(Shift 범위)·글/수식 고치기·방향 셀 합(우·좌·위·아래, 원본 범위 규칙·1,234.5 꼴)·수량으로 지정/해제·셀수직병합/분리·표 모양(테두리)·아래 표 만들기. 줄 도구: 문단(정렬·들여쓰기)·그림 넣기·한페이지 삽입/삭제(웹 한 쪽 45줄)·품명지정/규격지정(한 줄 하나·40자·제목 위 금지·원본 문구)·테두리선·산출근거 양식(화면 표시)·공종명칭 입력(머리 제목 줄 굵게 큰 글자). 수량 불러오기(Main Data 산출서 75개에서 새 산출서로), 수량목록보기(모든 산출서·수량 항목·원문 미리보기·검색·선택수량복사), 내역서 수량붙여넣기(연결)(고른 줄부터 아래로 수량·연결, 품명,규격입력 체크 시 이름도). 표·서식을 고친 산출서는 ESTX 저장 거부(JSON), 고치지 않은 표 산출서는 ESTX 저장이 표 줄을 건너 문단만 맞춤. - 검증완료: `test_est_quantity_table.py` 2개(표 읽기·문단 G 표지 무시·아래 합 SUM(B2:B3)=1,002.5·수량 값·병합/분리·설치 표 5개) 및 수량·ESTX 내보내기 회귀 49개 통과. ORCA 브라우저에서 보차도경계석 보수 표 35칸, 수량 1.0→2.5 항목 수량 반영, 아래 합 SUM(D2:D6)=3.923, 수량 불러오기 75개·문단 가운데·산출근거 양식·쪽 삽입 45줄·삭제, 공종명칭 「보차도경계석 보수(L=1m)」, 품명지정 ⟦품명⟧, 수량목록 복사 → 내역 붙여넣기 1·0.19·연결 id:36, 샘플 복구. - 남음: 글자모양(글꼴·색), 수량산출서 원본 모양 인쇄(쪽·간지), 엑셀형 시트 수량붙여넣기(연결). diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 0d0212ecb..f7a997cad 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -17,6 +17,7 @@ + @@ -309,6 +310,13 @@ > { - const modules = () => [typeof EstQuantityList !== "undefined" && EstQuantityList].filter(Boolean); + const modules = () => + [ + typeof EstQuantityList !== "undefined" && EstQuantityList, + typeof EstPriceTools !== "undefined" && EstPriceTools, + ].filter(Boolean); function handle(name, state, node) { for (const module of modules()) if (module.handle(name, state, node)) return true; return false; diff --git a/tools/est-test/native-data-update.js b/tools/est-test/native-data-update.js index 569eefa32..736d047e9 100644 --- a/tools/est-test/native-data-update.js +++ b/tools/est-test/native-data-update.js @@ -247,5 +247,5 @@ const EstDataUpdate = (() => { else return false; return true; } - return { apply, isFuel, handle }; + return { apply, isFuel, table, handle }; })(); diff --git a/tools/est-test/native-price-tools.js b/tools/est-test/native-price-tools.js new file mode 100644 index 000000000..c045102c5 --- /dev/null +++ b/tools/est-test/native-price-tools.js @@ -0,0 +1,211 @@ +// 단가 목록 도구(spec_pb2): 중복 항목 정리(TfrmStuffc popupSamecheck 0xf61d94), 단가정의 엑셀변환(TfrmSiljuk popupGunguToExcel), +// 할증·낙찰·복원(TfrmSetDiget 모드 51·52, btnRestor 0x110919c), 바꾸기(barbtnChange → TfrmChangeValue: Main Data 로 이 표만 갱신), +// SUM(barbtnSum → 항목선택계산 LinkIndex 62 줄, TfrmSetGabBiIndex SUM 모드). +const EstPriceTools = (() => { + const num = (v) => Number(v) || 0; + const key = (s) => String(s ?? "").replace(/[ |]/g, ""); + const TABLE_KIND = { StuffcDat: 5, LaborcDat: 6, SilJukDat: 21, EstiMateDat: 101 }; + + // 같은 품명·규격·단위(빈칸·| 무시) 묶음에서 TC 가장 큰 행 하나만(같으면 뒤 행) 남김. 묻지 않음, 다시 셈 없음. + function samecheck(rows) { + const keep = new Map(); + rows.forEach((row, i) => { + const k = `${key(row.Name)}\u0000${key(row.Size)}\u0000${key(row.Unite)}`, + prev = keep.get(k); + if (prev == null || !(num(rows[prev].TC) > num(row.TC))) keep.set(k, i); + }); + const kept = new Set(keep.values()); + const removed = rows.length - kept.size; + return { rows: rows.filter((_, i) => kept.has(i)), removed }; + } + + // 표준시장단가 EXPLEAN 이 있는 행만 「코드 / 번호. 품명 (규격) [단위]」 + 설명 줄 + function gungu(rows) { + const out = []; + for (const r of rows) { + const text = String(r.EXPLEAN ?? "").trim(); + if (!text) continue; + out.push([ + String(r.Code ?? ""), + `${r.Index ?? ""}. ${r.Name ?? ""} (${r.Size ?? ""}) [${r.Unite ?? ""}]`, + ]); + for (const line of text.split(/\r?\n/)) out.push(["", line]); + out.push(["", ""]); + } + return out; + } + + // 할증(Ratio)·낙찰(NachalRatio) 율 쓰기. v = 100 이면 원래값(그 율 칸 100, 자리 해제), 복원 = 0. + function setRate(rows, indices, field, v) { + const list = indices.length >= 2 ? indices : rows.map((_, i) => i); + for (const i of list) { + const row = rows[i]; + if (!num(row.TC) && !String(row.Name ?? "").trim()) continue; + if (!num(row.OTC)) row.OTC = row.TC; + for (const f of [field].flat()) row[f] = v; + if (v === 100) Object.assign(row, { Diget: 0, DigetIndex: 0 }); + } + return list.length; + } + + // 항목선택계산(62) 줄 + function sumRow(view, row, o) { + const banned = + view === "EstMainDat" + ? [ + 5, 6, 21, 51, 52, 53, 54, 56, 57, 58, 59, 61, 101, 103, 104, 105, 211, 212, 213, 214, + 215, 217, 218, + ] + : [5, 6, 21, 51, 52, 53, 61, 101, 103, 104, 105, 211, 212, 213, 214, 215, 217, 218]; + if ( + (view === "EstMainDat" && num(row.Level) > 0) || + (view === "HeadMainDat" && num(row.Index) > 0) || + (banned.includes(num(row.LinkIndex)) && + (String(row.Name ?? "").trim() || String(row.Size ?? "").trim())) + ) + throw Error("현재 라인에는 합계(SUM)를 할수가 없습니다."); + if ((o.base === 0) !== (o.target === 0)) throw Error("비목별항목은 비목별적용만 가능합니다."); + if (!o.picked.length) throw Error("더할항목의 색인들을 선택하세요"); + if (o.picked.length > 5) throw Error("더할항목의 색인들은 6개이상 선택할수없습니다"); + Object.assign(row, { + Name: o.name, + Size: o.size, + LinkIndex: 62, + Code: `A${o.picked.map((n) => String(n).padStart(2, "0")).join("")}`, + GN: o.rate, + Biul: o.rate / 100, + Unite: "%", + OCheck: o.base, + SCheck: o.target, + }); + } + function nextSKind(rows, at, view) { + let start = at, + end = at; + const head = (r) => (view === "EstMainDat" ? num(r.Level) > 0 : num(r.Index) > 0); + while (start > 0 && !head(rows[start - 1])) start--; + while (end + 1 < rows.length && !head(rows[end + 1])) end++; + return Math.max(0, ...rows.slice(start, end + 1).map((r) => num(r.SKind))) + 1; + } + + async function run(name, state) { + const view = state.view, + rows = state.project.tables[view], + picked = [...state.selection].filter((i) => rows?.[i]).sort((a, b) => a - b); + if (name === "price-samecheck") { + if (view !== "StuffcDat") throw Error("자재단가 화면에서 쓰세요."); + let removed = 0; + EstApp.change((project) => { + const out = samecheck(project.tables.StuffcDat); + project.tables.StuffcDat = out.rows; + removed = out.removed; + }); + state.notice = `중복 항목 ${removed}개를 지웠습니다.`; + } else if (name === "price-gungu-excel") { + const list = gungu(state.project.tables.SilJukDat || []); + if (!list.length) throw Error("단가정의(설명)가 있는 표준시장단가가 없습니다."); + const response = await fetch("/api/export-report-xlsx", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: "단가정의", + columns: ["코드", "단가정의"], + rows: list, + errors: [], + }), + }); + if (!response.ok) throw Error((await response.json()).error); + EstApp.download( + `${state.project.name || "공사"}_단가정의.xlsx`, + await response.blob(), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + } else if (name === "price-ratio" || name === "price-nachal" || name === "price-restore") { + if (!TABLE_KIND[view]) throw Error("자재·노임·표준시장·견적 단가 화면에서 쓰세요."); + // 복원(원본은 할증·낙찰 창 안 단추): 두 율 모두 0 + const field = + name === "price-nachal" + ? "NachalRatio" + : name === "price-ratio" + ? "Ratio" + : ["Ratio", "NachalRatio"]; + let v = 0; + if (name !== "price-restore") { + const input = prompt( + name === "price-nachal" ? "낙찰율을 입력하세요(%)" : "할증율을 입력하세요(%)", + "", + ); + if (input == null) return; + v = Number(input); + if (!Number.isFinite(v)) throw Error("율은 수치로 입력하세요."); + } + if ( + !confirm( + v === 100 || v === 0 ? "원래값으로 복원하시겠습니까?" : "설정값을 적용하시겠습니까?", + ) + ) + return; + let n = 0; + EstApp.change((project) => (n = setRate(project.tables[view], picked, field, v))); + state.notice = `${n}개 행에 적용했습니다.`; + } else if (name === "price-change") { + const kind = TABLE_KIND[view]; + if (!kind) throw Error("자재·노임·표준시장·견적 단가 화면에서 쓰세요."); + if ( + !confirm( + "선택한 Main데이타의 단가로 바꾸시겠습니까?\n바꾸기후 전체재계산을 해야 금액이 바뀝니다.", + ) + ) + return; + const source = await EstApp.api("/api/main-data"); + let missing = 0; + EstApp.change((project) => { + missing = EstDataUpdate.table(project.tables[view], source.tables[view] || [], kind); + }); + state.notice = missing + ? `${missing} 개의 찾지못한 데이타가 있습니다. 확인란에 체크된 데이타를 검토하세요.` + : "Main 데이타의 단가로 바꿨습니다."; + } else if (name === "price-sum") { + if (!["EstMainDat", "HeadMainDat"].includes(view) || !picked.length) + throw Error("내역서·일위대가에서 SUM 줄로 바꿀 행을 고르세요."); + const row = rows[picked[0]]; + const letters = prompt("더할 색인(A~Z, 최대 5개, 예 AC)", "")?.toUpperCase(); + if (letters == null) return; + const rate = Number(prompt("적용율(%)", "100")); + const o = { + name: prompt("명칭", row.Name || "잡재료비") ?? "", + size: row.Size ?? "", + rate: Number.isFinite(rate) ? rate : 0, + base: num(row.OCheck), + target: num(row.SCheck), + picked: [...new Set(letters.replace(/[^A-Z]/g, ""))].map((c) => c.charCodeAt(0) - 64), + }; + sumRow(view, structuredClone(row), o); + EstApp.change((project) => { + const list = project.tables[view]; + sumRow(view, list[picked[0]], o); + list[picked[0]].SKind = nextSKind(list, picked[0], view); + }); + } else return false; + EstApp.render(); + return true; + } + const names = new Set([ + "price-samecheck", + "price-gungu-excel", + "price-ratio", + "price-nachal", + "price-restore", + "price-change", + "price-sum", + ]); + function handle(name, state) { + if (!names.has(name)) return false; + run(name, state).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } + return { samecheck, gungu, setRate, sumRow, handle }; +})(); diff --git a/tools/est-test/native-rows.js b/tools/est-test/native-rows.js index 7a4d9f387..8520c15f3 100644 --- a/tools/est-test/native-rows.js +++ b/tools/est-test/native-rows.js @@ -13,13 +13,18 @@ const EstRows = (() => { } function percent(row, preceding, digits = 0) { if ([1, 3].includes(num(row.CalcIndex))) return row; - // f1a8c4 matches Chr(64 + source.SKind) against the percentage row's Code. - const selectedGroups = String(row.Code || "").toUpperCase(); + // 항목선택계산(62) Code = 'A' + 두 자리 색인 번호들(원본 SUM effc54, 예 A0103 = A·C); 그 밖 꼴은 글자 Chr(64+SKind) 로. + const selectedGroups = String(row.Code || "").toUpperCase(), + numbered = /^A((?:\d\d)+)$/.exec(selectedGroups), + groups = numbered ? new Set(numbered[1].match(/\d\d/g).map(Number)) : null; const eligible = num(row.LinkIndex) === 62 ? preceding.filter( (r) => - num(r.SKind) > 0 && selectedGroups.includes(String.fromCharCode(64 + num(r.SKind))), + num(r.SKind) > 0 && + (groups + ? groups.has(num(r.SKind)) + : selectedGroups.includes(String.fromCharCode(64 + num(r.SKind)))), ) : preceding; const base = baseKind(row), diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index e5be16fe4..6a53aa440 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -97,6 +97,7 @@ const allowed = new Set([ "native-data-update.js", "native-quantity-table.js", "native-quantity-list.js", + "native-price-tools.js", "native-actions-more.js", "native-wonga-tools.js", "native-estimate-print.js", From 3722bfa23c099d0117d78439e81d0d3855dc946d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:43:04 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat(est-test):=20=EC=A0=84=ED=9A=8C?= =?UTF-8?q?=EA=B8=B0=EC=84=B1=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=C2=B7?= =?UTF-8?q?=EC=A1=B0=EB=8B=AC=EC=88=98=EC=88=98=EB=A3=8C=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=C2=B7=EC=9B=90=EA=B0=80=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20=EB=84=A3=EA=B8=B0=C2=B7=EA=B5=AC=EC=84=B1?= =?UTF-8?q?=EB=B9=84=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tools/est-test/PLAN.md | 2 + tools/est-test/index.html | 7 +- tools/est-test/native-actions-more.js | 1 + tools/est-test/native-misc-tools.js | 233 ++++++++++++++++++++++++++ tools/est-test/native-progress.js | 11 +- tools/est-test/native-views.js | 8 +- tools/est-test/server.cjs | 1 + 7 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 tools/est-test/native-misc-tools.js diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 7dd2cba17..3355420c9 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [x] 전회기성 불러오기·조달수수료·원가에 내역 금액·원가 구성비(spec_pb1 1·9·10 · spec_pb2 8) — 전회기성: 완료 회차 아무것이나 골라 그 누계를 전회로(현재 회차 거부, `round.junFrom`). 조달수수료 입력(Ctrl+J): 현재 줄을 Code AJJ01·LinkIndex 57·GN %·자리방법 Index·자리수 Gangub·계산 비목 OCheck·저장 칸 SCheck 로(57 줄이면 수정, 허용 검사·원본 문구). 원가계산서에 내역 금액 넣기: 원본은 끌어 놓기 — 웹은 줄 고르기 + 합계/노무/재료/경비 고르기 → 원가 줄 Cost 고정(LinkIndex 1, 덮어쓰기 확인). 구성비 표시: 원가 요율 칸을 「비율」로 바꿔 기준 행(CalcBase 또는 도급액) 대비 대상 비목 Cost 를 0.01% 절사·최대 나머지로 100% 맞춤(계산 불변). + - 검증완료: 기성·바인딩 시험 14개 통과. ORCA 브라우저에서 구성비 20줄 합 100.00%·머리 「비율」, 내역 금액 넣기 노무비 384,804, Ctrl+J 조달수수료 AJJ01·57·0.54%·절하, 샘플 복구. - [x] 단가 도구(spec_pb2) — 중복 항목 정리(자재단가: 품명·규격·단위 빈칸·| 무시 같으면 TC 가장 큰 행만, 같으면 뒤 행, 묻지 않음), 단가정의 엑셀변환(표준시장단가 설명문 있는 항목만 「코드 / 번호. 품명 (규격) [단위]」+설명 줄, .xlsx), 할증·낙찰(고른 2행 이상 또는 전 행 Ratio·NachalRatio, 원래 단가 OTC 보관, 100 이면 자리 해제)·복원(두 율 0), 바꾸기(Main Data 로 현재 표 하나만 갱신, 못 찾음 표시), SUM(현재 줄을 항목선택계산 62 줄로: Code 'A'+두 자리 색인·적용율·비목, 원본 거부 문구, 줄 자신 SKind = 묶음 최대+1). 엔진: 62 줄 Code 를 두 자리 색인 번호로 읽음(설치 main.estx 의 「A01」 꼴, 예전 글자 해석은 A0103 의 C 를 놓쳤음). - 검증완료: `test_est_price_tools.py` 2개(중복 2개 지움·단가정의 줄·율 쓰기 범위·SUM Code A0103·거부 4종, 엔진 A0103 → 색인 1·3 합 150×10% = 15) 및 REA 대조 39개 통과. ORCA 브라우저에서 보통인부 낙찰 90% 172,068→154,861, 복원 →172,068, 바꾸기(Main) → 2026 하반기 값, 샘플 복구. - [x] 워드형 수량산출서 표와 편집 도구(원본 TFRMMULWORD·TFRMMULLANG — spec_word · spec_pa) — 가져오기가 WPT
을 통째로 버리던 결함 고침: 표를 한 줄 { table: [[칸]] }(글·수량 표지 r/L/G·품명/규격 표지·수식·세로병합)로 보존(설치 main.estx 표 5개 9×13~5×5). 문서 읽기는 `native-quantity-wpt.cjs` 로 분리. 표 화면: 칸 고르기(Shift 범위)·글/수식 고치기·방향 셀 합(우·좌·위·아래, 원본 범위 규칙·1,234.5 꼴)·수량으로 지정/해제·셀수직병합/분리·표 모양(테두리)·아래 표 만들기. 줄 도구: 문단(정렬·들여쓰기)·그림 넣기·한페이지 삽입/삭제(웹 한 쪽 45줄)·품명지정/규격지정(한 줄 하나·40자·제목 위 금지·원본 문구)·테두리선·산출근거 양식(화면 표시)·공종명칭 입력(머리 제목 줄 굵게 큰 글자). 수량 불러오기(Main Data 산출서 75개에서 새 산출서로), 수량목록보기(모든 산출서·수량 항목·원문 미리보기·검색·선택수량복사), 내역서 수량붙여넣기(연결)(고른 줄부터 아래로 수량·연결, 품명,규격입력 체크 시 이름도). 표·서식을 고친 산출서는 ESTX 저장 거부(JSON), 고치지 않은 표 산출서는 ESTX 저장이 표 줄을 건너 문단만 맞춤. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index f7a997cad..91b146e97 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -18,6 +18,7 @@ + @@ -159,7 +160,11 @@ > + >
diff --git a/tools/est-test/native-actions-more.js b/tools/est-test/native-actions-more.js index 4450ddaae..2c1a4608d 100644 --- a/tools/est-test/native-actions-more.js +++ b/tools/est-test/native-actions-more.js @@ -4,6 +4,7 @@ const EstActionsMore = (() => { [ typeof EstQuantityList !== "undefined" && EstQuantityList, typeof EstPriceTools !== "undefined" && EstPriceTools, + typeof EstMiscTools !== "undefined" && EstMiscTools, ].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-misc-tools.js b/tools/est-test/native-misc-tools.js new file mode 100644 index 000000000..2b5b16c0d --- /dev/null +++ b/tools/est-test/native-misc-tools.js @@ -0,0 +1,233 @@ +// 잡다한 원본 대화(spec_pb1·pb2): 전회기성 불러오기(TfrmAccomJunLoad — 완료 회차 아무것이나 전회로), 조달수수료 입력(TfrmJodalBiul, Ctrl+J), +// 원가계산에 내역 금액 넣기(TfrmEstCostToWonga — 끌어 놓기 대신 줄 고르기), 원가 구성비 모드(popupBiulMode — 표시 전용 「비율」 열). +const EstMiscTools = (() => { + const num = (v) => Number(v) || 0; + const plain = (s) => String(s ?? "").replace(/\s/g, ""); + + // 구성비(ca19d8·ca06c4): 기준 행(CalcBase, 없으면 도급액 줄) 대비 대상 비목 Cost 비율, 0.01% 절사 뒤 최대 나머지로 100% 맞춤 + function biul(rows) { + let base = rows + .map((r, i) => (r.CalcBase ? i : -1)) + .filter((i) => i >= 0) + .at(-1); + if (base == null) + base = rows.findIndex((r) => num(r.LinkIndex) === 55 || /도급액/.test(plain(r.Name))); + if (base < 0 || !num(rows[base].Cost)) return null; + const total = num(rows[base].Cost), + target = new Set(); + let sum = 0; + rows.slice(0, base).forEach((r, i) => { + if (!num(r.Cost) || num(r.CalcIndex) >= 2) return; + const end = num(r.EndLineIndex); + if (i === 0 || end === 0 || end === 3 || (end === 1 && String(r.Youl ?? "").includes("%"))) { + target.add(i); + sum += num(r.Cost); + } + }); + for (let i = base - 1; i >= 0 && sum < total; i--) { + const r = rows[i]; + if ( + num(r.Cost) && + !target.has(i) && + num(r.EndLineIndex) === 1 && + !String(r.VSusic ?? "").trim() + ) { + target.add(i); + sum += num(r.Cost); + } + } + const exact = new Map([...target].map((i) => [i, num(rows[i].Cost) / total])), + cut = new Map([...exact].map(([i, v]) => [i, Math.trunc(v * 10000) / 10000])); + let t = [...cut.values()].reduce((a, b) => a + b, 0); + const bumped = new Set(); + for (let n = Math.round((1 - t) * 10000); n > 0 && t < 1; n--) { + const pick = [...exact.keys()] + .filter((i) => !bumped.has(i)) + .sort((a, b) => exact.get(b) - cut.get(b) - (exact.get(a) - cut.get(a)))[0]; + if (pick == null) break; + cut.set(pick, cut.get(pick) + 0.0001); + bumped.add(pick); + t += 0.0001; + } + return rows.map((_, i) => + i === base ? "100%" : cut.has(i) ? `${(cut.get(i) * 100).toFixed(2)}%` : "", + ); + } + + function jodal(state) { + if (state.view !== "EstMainDat") throw Error("내역서에서 쓰세요."); + const rows = state.project.tables.EstMainDat, + at = [...state.selection].sort((a, b) => a - b)[0], + row = rows[at]; + if (!row) throw Error("조달수수료를 넣을 줄을 고르세요."); + if ( + (num(row.Level) > 0 && plain(row.Name) && !plain(row.Name).includes("수수")) || + ![0, 57, 64].includes(num(row.LinkIndex)) + ) + throw Error("현재 라인에는 조달수수료를 입력할수 없습니다."); + const edit = num(row.LinkIndex) === 57; + EstActions.form( + "조달수수료", + [ + { name: "name", label: "명 칭", value: edit ? row.Name : "조달수수료" }, + { name: "biul", label: "비 율(%)", type: "number", value: edit ? num(row.GN) : 0.54 }, + { + name: "o", + label: "계산할 비목", + value: edit ? num(row.OCheck) || 1 : 1, + choices: [ + [1, "재료비의"], + [2, "노무비의"], + [3, "경비의"], + [4, "합계의"], + ], + }, + { + name: "s", + label: "계산결과 저장위치", + value: edit ? num(row.SCheck) || 1 : 1, + choices: [ + [1, "재료비에"], + [3, "경비에"], + ], + }, + { + name: "m", + label: "자리수 방법", + value: edit ? num(row.Index) || 2 : 2, + choices: [ + [1, "사사오입"], + [2, "절하"], + [3, "절상"], + ], + }, + { name: "d", label: "자리수", type: "number", value: edit ? num(row.Gangub) : 0 }, + ], + (v) => { + if (!Number.isFinite(v.biul)) throw Error("비율은 수치로 입력하세요."); + EstApp.change((project) => + Object.assign(project.tables.EstMainDat[at], { + Code: "AJJ01", + Name: String(v.name).trim(), + GN: v.biul, + Unite: "%", + LinkIndex: 57, + Index: Number(v.m), + Gangub: v.d, + OCheck: Number(v.o), + SCheck: Number(v.s), + }), + ); + }, + ); + } + + function costToWonga(state) { + if (state.view !== "Wonga") throw Error("원가계산서에서 넣을 줄을 고르세요."); + const at = [...state.selection].sort((a, b) => a - b)[0], + target = state.project.tables.Wonga[at]; + if (!target) throw Error("원가계산서에서 넣을 줄을 고르세요."); + const est = state.project.tables.EstMainDat; + EstActions.form( + "원가계산에 입력할 비목선택", + [ + { + name: "row", + label: "내역 줄", + value: "", + choices: est + .map((r, i) => [i, `${i + 1}. ${r.Name || r.Code || ""} ${num(r.TC).toLocaleString()}`]) + .filter(([i]) => est[i].Name || est[i].Code), + }, + { + name: "kind", + label: "입력할 비목", + value: -5, + choices: [ + [-5, "합계 금액"], + [-1, "노무비 금액"], + [-2, "재료비 금액"], + [-3, "경비 금액"], + ], + }, + ], + (v) => { + const src = est[Number(v.row)]; + if (!src) throw Error("내역 줄을 고르세요."); + if (plain(target.Name) && !confirm(`${target.Name}에 덮어쓰시겠습니까?`)) return; + const field = { "-5": "TC", "-1": "NC", "-2": "GC", "-3": "JC" }[v.kind]; + EstApp.change((project) => + Object.assign(project.tables.Wonga[at], { + CalcIndex: 0, + LinkIndex: 1, + Youl: "", + VSusic: "", + BaseField: "", + Name: src.Name, + TC: num(src.TC), + NC: num(src.NC), + GC: num(src.GC), + JC: num(src.JC), + SC: num(src.SC), + OCheck: Number(v.kind), + Cost: num(src[field]), + }), + ); + }, + ); + } + + function junLoad(state) { + const project = state.project, + round = project.progress?.find((r) => r.id === state.roundId); + if (!round) throw Error("기성 회차를 고르세요."); + const done = project.progress.filter((r) => r.locked && r.result); + if (!done.length) throw Error("전회없슴"); + EstActions.form( + "전회기성 불러오기", + [ + { + name: "id", + label: "불러올 차수선택", + value: round.junFrom || "", + choices: done.map((r) => [r.id, `${r.name || r.id}`]), + }, + ], + (v) => { + if (v.id === round.id) throw Error("현재작업중인 회차를 전회로 불러올수 없습니다."); + EstApp.change((p) => (p.progress.find((r) => r.id === round.id).junFrom = v.id), false); + }, + ); + } + + function handle(name, state) { + if (name === "jodal-fee") jodal(state); + else if (name === "cost-to-wonga") costToWonga(state); + else if (name === "jun-load") junLoad(state); + else if (name === "wonga-biul-mode") + EstApp.change( + (project) => (project.options.WongaBiulMode = !project.options.WongaBiulMode), + false, + ); + else return false; + return true; + } + if (typeof document !== "undefined" && document.addEventListener) + document.addEventListener("keydown", (event) => { + if ( + event.ctrlKey && + !event.altKey && + event.key.toLowerCase() === "j" && + EstApp.state.view === "EstMainDat" + ) { + event.preventDefault(); + try { + jodal(EstApp.state); + } catch (error) { + EstApp.state.notice = error.message; + EstApp.render(); + } + } + }); + return { biul, handle }; +})(); diff --git a/tools/est-test/native-progress.js b/tools/est-test/native-progress.js index 9ff15966f..490ec2a3c 100644 --- a/tools/est-test/native-progress.js +++ b/tools/est-test/native-progress.js @@ -42,12 +42,17 @@ const EstProgress = (() => { const ambiguous = ambiguousKeys(rows); const earlier = project.progress.slice(0, position); if (earlier.some((item) => !item.locked)) errors.push("전회 기성 회차를 먼저 확정하세요."); - const previous = earlier.at(-1)?.result, + // 전회기성 불러오기(TfrmAccomJunLoad): 고른 완료 회차가 있으면 그 누계를 전회로 + const chosen = + round.junFrom && + project.progress.find((r) => r.id === round.junFrom && r.locked && r !== round), + prior = chosen || earlier.at(-1); + const previous = prior?.result, oldRows = new Map((previous?.rows || []).map((row, index) => [key(row, index), row])); - if (earlier.at(-1)?.locked && !Array.isArray(previous?.rows)) + if (prior?.locked && !Array.isArray(previous?.rows)) errors.push("확정된 전회 기성 결과가 없습니다. 전회 결과를 확인하세요."); if ( - earlier.at(-1)?.locked && + prior?.locked && source.wonga?.length && !Array.isArray(previous?.costs) && !Array.isArray(previous?.wonga) diff --git a/tools/est-test/native-views.js b/tools/est-test/native-views.js index e661c793e..ec26bf8b0 100644 --- a/tools/est-test/native-views.js +++ b/tools/est-test/native-views.js @@ -80,13 +80,19 @@ const EstViews = (() => { } function grid(table, list, columns) { const { esc, number, state } = EstApp; + const biul = + table === "Wonga" && + state.project.options.WongaBiulMode && + typeof EstMiscTools !== "undefined" + ? EstMiscTools.biul(state.project.tables.Wonga || []) + : null; const abbr = table === "Wonga" && state.project.options.WongaSNameShow && typeof EstWongaTools !== "undefined" ? EstWongaTools.abbreviate(state.project.tables.Wonga || [], state.project.options) : null; - return `
${columns.map((key) => ``).join("")}${list.map(({ row, index }) => ` 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}">${columns.map((key) => ``).join("")}`).join("")}
선택${esc((table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || (typeof EstSources !== "undefined" && EstSources.label(state.project.options, table, key)) || EstModel.labels[key] || key)}
0 ? ` style="padding-left:${Number(row.LeftSpace) * 6}px"` : ""}>${abbr && key === "VSusic" ? `${esc(abbr[index])}` : cell(table, index, key, row[key])}
`; + return `${columns.map((key) => ``).join("")}${list.map(({ row, index }) => ` 0 ? "heading " : ""}${Number(row.Level) > 0 ? "level " : ""}${row.Error ? "error-row" : ""}">${columns.map((key) => ``).join("")}`).join("")}
선택${esc((biul && key === "Youl" && "비율") || (table === "Wonga" && typeof EstJungSang !== "undefined" && EstJungSang.label(state.project, key)) || (typeof EstSources !== "undefined" && EstSources.label(state.project.options, table, key)) || EstModel.labels[key] || key)}
0 ? ` style="padding-left:${Number(row.LeftSpace) * 6}px"` : ""}>${biul && key === "Youl" ? `${esc(biul[index])}` : abbr && key === "VSusic" ? `${esc(abbr[index])}` : cell(table, index, key, row[key])}
`; } function render(state) { const { project, view } = state, diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 6a53aa440..5573ec358 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -98,6 +98,7 @@ const allowed = new Set([ "native-quantity-table.js", "native-quantity-list.js", "native-price-tools.js", + "native-misc-tools.js", "native-actions-more.js", "native-wonga-tools.js", "native-estimate-print.js", From aa58f3ef7afb208c41d6a93aeac5c7bf59544712 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:45:37 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat(est-test):=20=EC=97=91=EC=85=80?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=83=80=EB=A5=BC=20EST=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=207=EA=B0=9C=20=ED=83=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/helper_est_native.cjs | 1 + resources/tester/test_est_excel_to_est.py | 36 +++ tools/est-test/PLAN.md | 2 + tools/est-test/index.html | 4 +- tools/est-test/native-actions-more.js | 1 + tools/est-test/native-excel-to-est.js | 370 ++++++++++++++++++++++ tools/est-test/server.cjs | 1 + 7 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_est_excel_to_est.py create mode 100644 tools/est-test/native-excel-to-est.js diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 4113dbad9..5297e0b8a 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -36,6 +36,7 @@ for (const file of [ "native-extra.js", "native-business-actions.js", "native-forms.js", + "native-excel-to-est.js", ]) { vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), context, { filename: file }); } diff --git a/resources/tester/test_est_excel_to_est.py b/resources/tester/test_est_excel_to_est.py new file mode 100644 index 000000000..bf4874a95 --- /dev/null +++ b/resources/tester/test_est_excel_to_est.py @@ -0,0 +1,36 @@ +"""EST 테스트 앱 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst — 셀 규칙·머리 줄 꼴·호표 만들기).""" + +from test_est_rea_parity import run + + +def test_convert_tabs_and_heads(): + result = run( + "math", + r"""(()=>{ + const X=EstExcelToEst; + const sheets={노임단가:[['L1',' 보통인부 ','','M2',172698,'']], + 견적단가:[['','.펌프','','1','EA','1,500','500','1,000','0','']], + 표준시장단가:[['C1','포장','t=5','m2',1000,300,600,100,0.3,'포함',''],], + 단가산출:[['','산근 1 호표 : 거푸집 합판 (1 ㎡ 당)',100,60,40,0],['','합판 1*2',40,0,40,0],['','#.산근2|철근가공|D10|ton',9,9,0,0]], + 일위대가:[['','No 5 호표 터파기','','','',0,10,0,10,0,0,0,0,''],['','인부','','0.1','인',100,10,100,10,0,0,0,0,''],['','[합계]','','','',0,10,0,0,0,0,0,0,'']]}; + const cols=(n)=>Array.from({length:n},(_,i)=>String.fromCharCode(65+i)); + const tabs={LaborcDat:{sheet:'노임단가',start:1,end:1,columns:cols(6)},EstiMateDat:{sheet:'견적단가',start:1,end:1,columns:cols(10)}, + SilJukDat:{sheet:'표준시장단가',start:1,end:1,columns:cols(11)},CostMainDat:{sheet:'단가산출',start:1,end:3,columns:cols(6)}, + HeadMainDat:{sheet:'일위대가',start:1,end:3,columns:cols(14)},StuffcDat:{sheet:'없는시트',start:1,end:1,columns:cols(18)}}; + const r=X.convert(sheets,tabs,{name:'시험',wonga:[]}), t=r.project.tables; + return {failed:r.failed, lab:[t.LaborcDat[0].Name,t.LaborcDat[0].Unite,t.LaborcDat[0].TC], + est:[t.EstiMateDat[0].Name,t.EstiMateDat[0].TC,t.EstiMateDat[0].OTC,t.EstiMateDat[0].Code.slice(0,1)], + sil:[t.SilJukDat[0].NCRatio,t.SilJukDat[0].Cost1,t.SilJukDat[0].ONC], + cost:t.CostTitleDat.map(x=>[x.Name,x.Size,x.Unite]), line:t.CostMainDat[1].LineData, + head:[t.HeadTitleDat.map(x=>x.Name),t.HeadMainDat.length], rows:t.EstMainDat.length, auto:r.project.options.AutoCalc}; + })()""", + ) + assert result["failed"] == ["없는시트 시트가 없습니다."] + assert result["lab"] == ["보통인부", "㎡", 172698] + assert result["est"] == ["펌프", 1500, 1500, "N"] + assert result["sil"] == [30, 1000, 300] + assert result["cost"] == [["거푸집", "합판", "㎡"], ["철근가공", "D10", "ton"]] + assert result["line"] == "합판 1×2" + assert result["head"] == [["터파기"], 2] + assert result["rows"] == 36 + assert result["auto"] is False diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 3355420c9..3520cc57c 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [x] 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst 0x109c3bc — spec_pc §1) — 노임·자재·견적·표준시장·단가산출·일위대가·내역서 탭마다 시트명·시작/끝행·필드별 열 글자(.xlsx·.xls), 노임 → 자재 → 견적 → 표준 → 단산 → 일위 → 내역 차례로 새 공사(현재 원가 서식, 자동계산 끔 — Excel 값 그대로). 셀 규칙(Trim·줄바꿈·앞 「.」·㎡/㎥·「*」→「×」·쉼표·노무비율 ×100·「B:D」 이어붙임), 노임 단가 → Cost1·TC, 견적·표준 → O 칸 함께, 빈 코드 N+10자리, 내역 36행 채움. 단가산출·일위대가 머리 줄 꼴로 호표 만들기(코드·TitleCode·Index·LineData 품명/규격/단위 표지, [합계] 줄 지움), 못 읽은 시트 안내. 하지 않음: 글자 굵기 판정·고정폭 머리말·단가산출 목록 시트형(굵기·폭 정보 없음). + - 검증완료: `test_est_excel_to_est.py`(없는 시트 안내, 노임 ㎡·TC, 견적 앞점·쉼표·O칸·N코드, 표준 노무비율 30, 단산 호표 2개·×, 일위 호표·[합계] 지움, 36행, 자동계산 끔) 통과. ORCA 브라우저에서 탭 7개·단가산출 열 6칸 표시(파일 고르기는 수동). - [x] 전회기성 불러오기·조달수수료·원가에 내역 금액·원가 구성비(spec_pb1 1·9·10 · spec_pb2 8) — 전회기성: 완료 회차 아무것이나 골라 그 누계를 전회로(현재 회차 거부, `round.junFrom`). 조달수수료 입력(Ctrl+J): 현재 줄을 Code AJJ01·LinkIndex 57·GN %·자리방법 Index·자리수 Gangub·계산 비목 OCheck·저장 칸 SCheck 로(57 줄이면 수정, 허용 검사·원본 문구). 원가계산서에 내역 금액 넣기: 원본은 끌어 놓기 — 웹은 줄 고르기 + 합계/노무/재료/경비 고르기 → 원가 줄 Cost 고정(LinkIndex 1, 덮어쓰기 확인). 구성비 표시: 원가 요율 칸을 「비율」로 바꿔 기준 행(CalcBase 또는 도급액) 대비 대상 비목 Cost 를 0.01% 절사·최대 나머지로 100% 맞춤(계산 불변). - 검증완료: 기성·바인딩 시험 14개 통과. ORCA 브라우저에서 구성비 20줄 합 100.00%·머리 「비율」, 내역 금액 넣기 노무비 384,804, Ctrl+J 조달수수료 AJJ01·57·0.54%·절하, 샘플 복구. - [x] 단가 도구(spec_pb2) — 중복 항목 정리(자재단가: 품명·규격·단위 빈칸·| 무시 같으면 TC 가장 큰 행만, 같으면 뒤 행, 묻지 않음), 단가정의 엑셀변환(표준시장단가 설명문 있는 항목만 「코드 / 번호. 품명 (규격) [단위]」+설명 줄, .xlsx), 할증·낙찰(고른 2행 이상 또는 전 행 Ratio·NachalRatio, 원래 단가 OTC 보관, 100 이면 자리 해제)·복원(두 율 0), 바꾸기(Main Data 로 현재 표 하나만 갱신, 못 찾음 표시), SUM(현재 줄을 항목선택계산 62 줄로: Code 'A'+두 자리 색인·적용율·비목, 원본 거부 문구, 줄 자신 SKind = 묶음 최대+1). 엔진: 62 줄 Code 를 두 자리 색인 번호로 읽음(설치 main.estx 의 「A01」 꼴, 예전 글자 해석은 A0103 의 C 를 놓쳤음). diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 91b146e97..e03a04309 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -19,6 +19,7 @@ + @@ -164,7 +165,8 @@ > + >
diff --git a/tools/est-test/native-actions-more.js b/tools/est-test/native-actions-more.js index 2c1a4608d..1f2c9e305 100644 --- a/tools/est-test/native-actions-more.js +++ b/tools/est-test/native-actions-more.js @@ -5,6 +5,7 @@ const EstActionsMore = (() => { typeof EstQuantityList !== "undefined" && EstQuantityList, typeof EstPriceTools !== "undefined" && EstPriceTools, typeof EstMiscTools !== "undefined" && EstMiscTools, + typeof EstExcelToEst !== "undefined" && EstExcelToEst, ].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-excel-to-est.js b/tools/est-test/native-excel-to-est.js new file mode 100644 index 000000000..17cecb3af --- /dev/null +++ b/tools/est-test/native-excel-to-est.js @@ -0,0 +1,370 @@ +// 원본 「엑셀데이타를 EST로 변환」(mnuExcelToEst → TfrmExcelToEst btnOkClick 0x109c3bc — spec_pc §1) 7개 탭: +// 노임 → 자재 → 견적 → 표준시장 → 단가산출 → 일위대가 → 내역서 차례로 한 Excel 파일의 시트들을 새 공사로 옮긴다(금액은 Excel 값 그대로, +// 다시 셈 끔). 셀 규칙: 문자 Trim·줄바꿈 제거·품명/규격 앞 「.」 제거·단위 M2→㎡ M3→㎥·산출근거 「*」→「×」, 수치는 쉼표 빼고, +// 노무비율 ≤ 1 이면 ×100, 열 「B:D」 는 이어 붙임. 단가산출·일위대가는 머리 줄 꼴(#.산근 · #| · 제n호표 · 산근 n 호표 : … (… 당) · +// (산근 · No n 호표 · No.n)을 찾아 호표를 만든다(원본의 글자 굵기 판정은 웹 Excel 읽기가 굵기를 몰라 하지 않음). +const EstExcelToEst = (() => { + const TABS = [ + ["LaborcDat", "노임단가", ["Code", "Name", "Size", "Unite", "Cost1", "Bigo"]], + [ + "StuffcDat", + "자재단가", + [ + "Code", + "Name", + "Size", + "Unite", + "Cost1", + "Page1", + "Cost2", + "Page2", + "Cost3", + "Page3", + "Cost4", + "Page4", + "Cost5", + "Page5", + "Cost6", + "Page6", + "TC", + "Bigo", + ], + ], + [ + "EstiMateDat", + "견적단가", + ["Code", "Name", "Size", "GN", "Unite", "TC", "NC", "GC", "JC", "Bigo"], + ], + [ + "SilJukDat", + "표준시장단가", + ["Code", "Name", "Size", "Unite", "TC", "NC", "GC", "JC", "NCRatio", "EXPLEAN", "Bigo"], + ], + ["CostMainDat", "단가산출", ["Code", "LineData", "TC", "NC", "GC", "JC"]], + [ + "HeadMainDat", + "일위대가", + [ + "Code", + "Name", + "Size", + "GN", + "Unite", + "TU", + "TC", + "NU", + "NC", + "GU", + "GC", + "JU", + "JC", + "Bigo", + ], + ], + [ + "EstMainDat", + "내역서", + [ + "Code", + "Pr", + "Name", + "Size", + "GN", + "Unite", + "TU", + "TC", + "NU", + "NC", + "GU", + "GC", + "JU", + "JC", + "Bigo", + ], + ], + ]; + const TEXT = new Set([ + "Code", + "Pr", + "Name", + "Size", + "Unite", + "Bigo", + "LineData", + "EXPLEAN", + "Page1", + "Page2", + "Page3", + "Page4", + "Page5", + "Page6", + ]); + const num = (v) => Number(v) || 0; + const letter = (n) => { + let s = ""; + for (n += 1; n > 0; n = Math.floor((n - 1) / 26)) + s = String.fromCharCode(65 + ((n - 1) % 26)) + s; + return s; + }; + const col = (s) => + [...String(s).toUpperCase()].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1; + const unit = (s) => + String(s) + .replace(/^[Mm]2$/, "㎡") + .replace(/^[Mm]3$/, "㎥"); + + function cell(table, r, spec) { + const [a, b] = String(spec).toUpperCase().split(":"); + if (!b) return table[r]?.[col(a)]; + let out = ""; + for (let c = col(a); c <= col(b); c++) { + const v = table[r]?.[c]; + if (v != null && String(v) !== "") out += String(v); + else if (out.length <= 1) out += " "; + } + return out; + } + function value(field, raw) { + if (TEXT.has(field)) { + let t = String(raw ?? "").replace(/\r?\n/g, ""); + t = field === "LineData" ? t.replaceAll("*", "×") : t.trim(); + if (field === "Unite") t = unit(t); + if (field === "Name" || field === "Size") t = t.replace(/^\.+/, ""); + return t; + } + let v = typeof raw === "number" ? raw : Number(String(raw ?? "").replaceAll(",", "")) || 0; + if (field === "NCRatio" && v <= 1) v *= 100; + return v; + } + function readRows(table, tab, fields, columns) { + const rows = []; + for (let r = tab.start - 1; r <= tab.end - 1; r++) { + const row = { Index: 0 }; + fields.forEach((f, i) => { + if (columns[i]) row[f] = value(f, cell(table, r, columns[i])); + }); + rows.push(row); + } + return rows; + } + + // 머리 줄 꼴 → { Name, Size, Unite, skipNext } + function costHead(text, next) { + const t = String(text ?? "").trim(), + split = (s) => { + const parts = s.trim().split(/\s+/); + return { + Name: parts[0] || "", + Size: parts.slice(1, -1).join(" "), + Unite: parts.length > 1 ? parts.at(-1) : "", + }; + }; + if (/^#\./.test(t) && t.includes("산근")) { + if (t.includes("|")) { + const [, n = "", s = "", u = ""] = t.split("|"); + return { Name: n.trim(), Size: s.trim(), Unite: u.trim() }; + } + return split(t.replace(/^.*?산근\s*\d*/, "").replace(/\//g, " ")); + } + if (/^#/.test(t) && t.includes("|")) { + const [, n = "", s = "", u = ""] = t.split("|"); + return { Name: n.trim(), Size: s.trim(), Unite: u.trim() }; + } + if (/산근.*호표/.test(t) && t.includes("당")) { + const body = t.split(/[::]/).slice(1).join(":"), + u = /\(\s*[\d.]*\s*([^()]*?)\s*당\s*\)/.exec(body)?.[1] || "", + rest = body + .replace(/\([^()]*당\s*\)/, "") + .trim() + .split(/\s+/); + return { Name: rest[0] || "", Size: rest.slice(1).join(" "), Unite: u }; + } + if (/제\s*\d+\s*호표/.test(t) && next != null) + return { ...split(String(next)), skipNext: true }; + if (t.replace(/\s/g, "").includes("(산근")) return split(t.replace(/\(산근[^)]*\)/, "")); + return null; + } + function headHead(name) { + const t = String(name ?? "").trim(); + if (/No/.test(t) && t.includes("호표")) + return { Name: t.split("호표").slice(1).join("호표").trim(), Size: "", Unite: "" }; + if (/^No\.\s*[1-9]/.test(t)) + return { Name: t.replace(/^No\.\s*\d+\s*/, ""), Size: "", Unite: "" }; + if (/\(호표/.test(t)) { + const parts = t + .replace(/\(호표[^)]*\)/, "") + .trim() + .split(/\s+/); + return { Name: parts[0] || "", Size: parts.slice(1).join(" "), Unite: "" }; + } + return null; + } + function groups(rows, titles, kind, headOf) { + let pk = 0; + const out = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (kind === 103 && String(row.Name ?? "").replace(/\s/g, "") === "[합계]") continue; + const h = headOf(row, rows[i + 1]); + if (!h) { + out.push(row); + continue; + } + const index = titles.length + 1, + code = `N${String(++pk).padStart(10, "0")}`; + titles.push({ + Index: index, + Code: code, + Name: h.Name, + Size: h.Size, + Unite: unit(h.Unite), + TC: num(row.TC), + NC: num(row.NC), + GC: num(row.GC), + JC: num(row.JC), + }); + Object.assign(row, { Code: code, TitleCode: code, Index: index }); + if (kind === 104) + row.LineData = `\x0e${h.Name}\x0e${h.Size ? `\x13${h.Size}\x13` : ""}${h.Unite ? `\x15${unit(h.Unite)}\x15` : ""}`; + out.push(row); + if (h.skipNext) i++; + } + return out; + } + + function convert(sheets, tabs, template) { + const project = EstNative.empty(); + project.name = template.name; + project.options = { ...project.options, AutoCalc: false, SourceRefresh: false }; + project.tables.Wonga = structuredClone(template.wonga || []); + const failed = []; + for (const [table, label, fields] of TABS) { + const tab = tabs[table]; + if (!tab?.sheet || !(tab.start > 0) || !(tab.end > 0)) continue; + const data = sheets[tab.sheet]; + if (!data) { + failed.push(`${tab.sheet || label} 시트가 없습니다.`); + continue; + } + let rows = readRows(data, tab, fields, tab.columns); + if (table === "LaborcDat") rows.forEach((r) => (r.TC = r.Cost1)); + if (table === "SilJukDat" || table === "EstiMateDat") + rows.forEach((r) => + Object.assign(r, { + OTC: r.TC, + ONC: r.NC, + OGC: r.GC, + OJC: r.JC, + ...(table === "SilJukDat" && { Cost1: r.TC }), + }), + ); + if (table === "CostMainDat") { + project.tables.CostTitleDat = []; + rows = groups(rows, project.tables.CostTitleDat, 104, (r, n) => + costHead(r.LineData, n?.LineData), + ); + } else if (table === "HeadMainDat") { + project.tables.HeadTitleDat = []; + rows = groups(rows, project.tables.HeadTitleDat, 103, (r) => headHead(r.Name)); + } + project.tables[table] = rows; + } + while (project.tables.EstMainDat.length < 36) project.tables.EstMainDat.push({ Index: 0 }); + let pk = 0; + for (const t of Object.keys(project.tables)) + for (const r of project.tables[t]) + if (r && typeof r === "object" && r.PkID == null) r.PkID = ++pk; + for (const t of ["LaborcDat", "StuffcDat", "SilJukDat", "EstiMateDat"]) + for (const r of project.tables[t] || []) + if (!String(r.Code ?? "").trim()) r.Code = `N${String(r.PkID).padStart(10, "0")}`; + return { project, failed }; + } + + // 화면 + function form(state) { + const x = (state.e2e ||= { + tab: "LaborcDat", + tabs: Object.fromEntries( + TABS.map(([t, label, fields]) => [ + t, + { sheet: label, start: 0, end: 0, columns: fields.map((_, i) => letter(i)) }, + ]), + ), + }); + const { esc } = EstApp, + [, , fields] = TABS.find(([t]) => t === x.tab), + tab = x.tabs[x.tab]; + EstApp.showDialog( + "엑셀데이타를 EST로 변환", + `
${TABS.map(([t, l]) => ``).join("")}

${esc(x.file || "")} ${x.sheets ? `(시트 ${Object.keys(x.sheets).length}개)` : ""}

해당 칼럼명칭 입력(엑셀 열 글자, 비우면 건너뜀, 「B:D」 는 이어 붙임)

${fields.map((f, i) => ``).join("")}
`, + ); + } + function read(state) { + const x = state.e2e, + tab = x.tabs[x.tab], + q = (n) => document.querySelector(`#dialog-content [name="${n}"]`); + if (!q("e2e-sheet")) return; + tab.sheet = q("e2e-sheet").value.trim(); + tab.start = Number(q("e2e-start").value) || 0; + tab.end = Number(q("e2e-end").value) || 0; + document + .querySelectorAll('#dialog-content [name="e2e-col"]') + .forEach((n) => (tab.columns[Number(n.dataset.i)] = n.value.trim().toUpperCase())); + } + async function run(name, state, node) { + if (name === "excel-to-est") return form(state); + read(state); + const x = state.e2e; + if (name === "e2e-tab") x.tab = node.dataset.tab; + else if (name === "e2e-open") { + const file = await new Promise((resolve) => { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = ".xlsx,.xls"; + picker.addEventListener("change", () => resolve(picker.files[0])); + picker.click(); + }); + if (!file) return; + const body = await file.arrayBuffer(); + x.sheets = /\.xls$/i.test(file.name) + ? EstJdlCompare.xlsTables(await EstApp.api("/api/read-xls", { method: "POST", body })) + : EstExcelMap.sheetTables(await EstApp.api("/api/import-xlsx", { method: "POST", body })); + x.file = file.name; + } else if (name === "e2e-run") { + const tabs = Object.values(x.tabs); + if ( + tabs.every((t) => !t.start) || + tabs.every((t) => !t.end) || + tabs.some((t) => t.end < t.start) + ) + throw Error("변환할 엑셀자료의 범위설정이 잘못되었습니다."); + if (!confirm("현재 작업을 엑셀 자료로 만든 새 공사로 바꾸시겠습니까?")) return; + const out = convert(x.sheets, x.tabs, { + name: x.file.replace(/\.xlsx?$/i, ""), + wonga: (state.project.tables.Wonga || []).map(({ Cost, ...rest }) => rest), + }); + if ( + out.failed.length && + !confirm(`${out.failed.join("\n")}\n변환을 못한 시트가 있습니다.\n\n종료하시겠습니까?`) + ) + return; + EstApp.byId("dialog").close(); + EstApp.setProject(out.project); + state.notice = + "엑셀 역변환 작업이 완료되었습니다. 금액은 엑셀 값 그대로이며 자동계산은 꺼 두었습니다."; + EstApp.render(); + return; + } + form(state); + } + const names = new Set(["excel-to-est", "e2e-tab", "e2e-open", "e2e-run"]); + function handle(name, state, node) { + if (!names.has(name)) return false; + run(name, state, node).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } + return { convert, costHead, headHead, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 5573ec358..e4620e34f 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -99,6 +99,7 @@ const allowed = new Set([ "native-quantity-list.js", "native-price-tools.js", "native-misc-tools.js", + "native-excel-to-est.js", "native-actions-more.js", "native-wonga-tools.js", "native-estimate-print.js", From 6dc9fb84c5535195eb7c56bb283283603b91e9a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:48:39 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat(est-test):=20=EC=9D=B8=EC=87=84?= =?UTF-8?q?=EC=96=91=EC=8B=9D(.pfot)=20=EB=B6=88=EB=9F=AC=EC=98=A4?= =?UTF-8?q?=EA=B8=B0=C2=B7=EC=A0=80=EC=9E=A5=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_print_format.py | 34 ++++++ tools/est-test/PLAN.md | 2 + tools/est-test/index.html | 4 +- tools/est-test/native-misc-tools.js | 55 +++++++++ tools/est-test/print-format.cjs | 139 ++++++++++++++++++++++ tools/est-test/server.cjs | 19 +++ 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_est_print_format.py create mode 100644 tools/est-test/print-format.cjs diff --git a/resources/tester/test_est_print_format.py b/resources/tester/test_est_print_format.py new file mode 100644 index 000000000..f0c9bedce --- /dev/null +++ b/resources/tester/test_est_print_format.py @@ -0,0 +1,34 @@ +"""EST 테스트 앱 인쇄양식 파일(.pfot — 원본 mnuPrintFormatSave·Load) 읽기·쓰기.""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +PFOT = Path("C:/Dasansoft/Est Plus 2/System/EstFormat") + + +def test_pfot_round_trip_is_byte_identical_and_edits_apply(): + if not (PFOT / "main.pfot").exists(): + pytest.skip("installed EST print formats unavailable") + code = r""" + const P = require(process.cwd() + '/tools/est-test/print-format.cjs'), fs = require('fs'); + const out = {}; + for (const f of ['main.pfot', 'main(가로양식).pfot']) { + const b = fs.readFileSync(process.argv[1] + '/' + f), rows = P.readFull(b); + out[f] = [rows.length, P.write(b, rows).equals(b)]; + } + const b = fs.readFileSync(process.argv[1] + '/main.pfot'), rows = P.readFull(b); + rows[1].Name2 = '원가계산서(수정)'; rows[1].Checked = 0; + const again = P.read(P.write(b, rows)); + out.edit = [again[1].Name2, again[1].Checked, again.length]; + process.stdout.write(JSON.stringify(out)); + """ + out = json.loads( + subprocess.check_output(["node", "-e", code, str(PFOT)], cwd=ROOT).decode("utf-8") + ) + assert out["main.pfot"] == [41, True] + assert out["main(가로양식).pfot"][1] is True + assert out["edit"] == ["원가계산서(수정)", 0, 41] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 3520cc57c..466d20862 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [x] 인쇄양식 불러오기·저장하기(원본 mnuPrintFormatLoad·Save 0x10ed318·0x10ed000 — spec_pc §3) — .pfot(인쇄정보표 MIDAS 이진) 읽기·쓰기 `print-format.cjs`(설명 BLOB·빈 칸 표지까지 보존, 원본 파일 그대로 다시 쓰면 바이트 같음). 불러오면 공사 인쇄표를 그 값으로, 저장은 지금 인쇄표를 원본 설치 main.pfot 필드 설명자 위에(`EST_PFOT_TEMPLATE`). .gfot(cxGrid 열 배치)는 웹 격자와 호환되지 않아 하지 않음. + - 검증완료: `test_est_print_format.py`(설치 .pfot 두 개 바이트 같음·고친 값 반영) 및 바인딩 시험 통과, 서버 경로로 읽고 다시 쓴 파일 cmp 같음. ORCA 브라우저에서 샘플 인쇄양식 저장(32,755 바이트). - [x] 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst 0x109c3bc — spec_pc §1) — 노임·자재·견적·표준시장·단가산출·일위대가·내역서 탭마다 시트명·시작/끝행·필드별 열 글자(.xlsx·.xls), 노임 → 자재 → 견적 → 표준 → 단산 → 일위 → 내역 차례로 새 공사(현재 원가 서식, 자동계산 끔 — Excel 값 그대로). 셀 규칙(Trim·줄바꿈·앞 「.」·㎡/㎥·「*」→「×」·쉼표·노무비율 ×100·「B:D」 이어붙임), 노임 단가 → Cost1·TC, 견적·표준 → O 칸 함께, 빈 코드 N+10자리, 내역 36행 채움. 단가산출·일위대가 머리 줄 꼴로 호표 만들기(코드·TitleCode·Index·LineData 품명/규격/단위 표지, [합계] 줄 지움), 못 읽은 시트 안내. 하지 않음: 글자 굵기 판정·고정폭 머리말·단가산출 목록 시트형(굵기·폭 정보 없음). - 검증완료: `test_est_excel_to_est.py`(없는 시트 안내, 노임 ㎡·TC, 견적 앞점·쉼표·O칸·N코드, 표준 노무비율 30, 단산 호표 2개·×, 일위 호표·[합계] 지움, 36행, 자동계산 끔) 통과. ORCA 브라우저에서 탭 7개·단가산출 열 6칸 표시(파일 고르기는 수동). - [x] 전회기성 불러오기·조달수수료·원가에 내역 금액·원가 구성비(spec_pb1 1·9·10 · spec_pb2 8) — 전회기성: 완료 회차 아무것이나 골라 그 누계를 전회로(현재 회차 거부, `round.junFrom`). 조달수수료 입력(Ctrl+J): 현재 줄을 Code AJJ01·LinkIndex 57·GN %·자리방법 Index·자리수 Gangub·계산 비목 OCheck·저장 칸 SCheck 로(57 줄이면 수정, 허용 검사·원본 문구). 원가계산서에 내역 금액 넣기: 원본은 끌어 놓기 — 웹은 줄 고르기 + 합계/노무/재료/경비 고르기 → 원가 줄 Cost 고정(LinkIndex 1, 덮어쓰기 확인). 구성비 표시: 원가 요율 칸을 「비율」로 바꿔 기준 행(CalcBase 또는 도급액) 대비 대상 비목 Cost 를 0.01% 절사·최대 나머지로 100% 맞춤(계산 불변). diff --git a/tools/est-test/index.html b/tools/est-test/index.html index e03a04309..13a7cf025 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -113,7 +113,9 @@
- + diff --git a/tools/est-test/native-misc-tools.js b/tools/est-test/native-misc-tools.js index 2b5b16c0d..11d9757ff 100644 --- a/tools/est-test/native-misc-tools.js +++ b/tools/est-test/native-misc-tools.js @@ -200,7 +200,62 @@ const EstMiscTools = (() => { ); } + // 인쇄양식 불러오기·저장하기(mnuPrintFormatLoad·Save 0x10ed318·0x10ed000): .pfot = 인쇄정보표 한 벌(원본 이진 그대로). + // 불러오면 공사 인쇄표(printCatalog)를 그 값으로, 저장은 지금 인쇄표(불러온 원본 행 우선)를 .pfot 로. .gfot(격자 배치)는 다루지 않음. + async function pfot(name, state) { + if (name === "pfot-load") { + const file = await new Promise((resolve) => { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = ".pfot"; + picker.addEventListener("change", () => resolve(picker.files[0])); + picker.click(); + }); + if (!file) return; + const rows = await EstApp.api("/api/pfot-read", { + method: "POST", + body: await file.arrayBuffer(), + }); + EstApp.change((project) => { + project.printFormat = rows; + project.printCatalog = rows.map((r) => + Object.fromEntries(Object.entries(r).filter(([k, v]) => k !== "__nulls" && !v?.binary)), + ); + }, false); + state.notice = `인쇄양식 「${file.name}」(서식 ${rows.length}개)을 불러왔습니다.`; + } else { + const defaults = await EstApp.api("/api/print-catalog"), + saved = new Map((state.project.printCatalog || []).map((r) => [r.Index, r])), + full = new Map((state.project.printFormat || []).map((r) => [r.Index, r])); + const rows = defaults.map((r) => ({ + ...r, + ...full.get(r.Index), + ...saved.get(r.Index), + __nulls: full.get(r.Index)?.__nulls, + })); + const response = await fetch("/api/pfot-write", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rows }), + }); + if (!response.ok) throw Error((await response.json()).error); + EstApp.download( + `${state.project.name || "인쇄양식"}.pfot`, + await response.blob(), + "application/octet-stream", + ); + } + EstApp.render(); + } + function handle(name, state) { + if (name === "pfot-load" || name === "pfot-save") { + pfot(name, state).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } if (name === "jodal-fee") jodal(state); else if (name === "cost-to-wonga") costToWonga(state); else if (name === "jun-load") junLoad(state); diff --git a/tools/est-test/print-format.cjs b/tools/est-test/print-format.cjs new file mode 100644 index 000000000..50fce143d --- /dev/null +++ b/tools/est-test/print-format.cjs @@ -0,0 +1,139 @@ +// 원본 인쇄양식 파일(.pfot — mnuPrintFormatSave 0x10ed000 · Load 0x10ed318): 인쇄정보표(DatPrintOption) MIDAS 이진 한 벌. +// 읽기는 import-print.cjs 와 같은 꼴. 쓰기는 같은 꼴의 바탕 파일(필드 설명자)을 받아 행 부분만 새로 만든다. +// .gfot(화면 격자 열 배치)는 웹 격자와 호환되지 않아 다루지 않는다. +const { readPrintOptions } = require("./import-print.cjs"); + +function layout(buffer) { + if (buffer.length < 24 || buffer.readUInt32LE(0) !== 0xbde01996) + throw Error("인쇄양식 파일 헤더 오류"); + const count = buffer.readUInt16LE(12), + start = buffer.readUInt16LE(22); + let at = 24; + const fields = []; + for (let i = 0; i < count; i++) { + const length = buffer[at]; + const name = buffer.subarray(at + 1, at + 1 + length).toString("ascii"); + at += 1 + length; + const width = buffer.readUInt16LE(at), + kind = buffer.readUInt16LE(at + 2), + attributes = buffer.readUInt16LE(at + 6); + at += 8; + for (let j = 0; j < attributes; j++) { + at += 1 + buffer[at]; + const size = buffer.readUInt16LE(at), + type = buffer.readUInt16LE(at + 2); + at += 4; + at += type === 73 ? 2 + buffer.readUInt16LE(at) : size; + } + fields.push({ name, width, kind }); + } + return { count, start, fields, flag: buffer[start] }; +} + +// rows: 인쇄정보표 행들(읽은 값). BLOB(75) 칸은 비워 씀. +function write(template, rows) { + const { count, start, fields, flag } = layout(template); + const encoder = (s) => { + const bytes = []; + for (const ch of String(s)) { + const code = ch.codePointAt(0); + if (code < 0x80) bytes.push(code); + else { + const found = cp949Table().get(ch); + if (found == null) throw Error(`인쇄양식 글자를 cp949 로 바꿀 수 없습니다: ${ch}`); + bytes.push(found >> 8, found & 0xff); + } + } + return Buffer.from(bytes); + }; + const parts = [Buffer.from(template.subarray(0, start))]; + parts[0].writeUInt16LE(rows.length, 14); + for (const row of rows) { + const status = Buffer.alloc(Math.ceil(count / 4)), + values = []; + fields.forEach(({ name, width, kind }, i) => { + const v = row[name]; + if (kind === 75 && v?.binary != null) { + const data = Buffer.from(v.binary, "base64"), + length = Buffer.alloc(4); + length.writeUInt32LE(data.length); + values.push(length, data); + return; + } + if (v == null || kind === 75) { + status[i >> 2] |= (row.__nulls?.[name] || 1) << ((i % 4) * 2); + return; + } + if (kind === 1 && width === 4) { + const b = Buffer.alloc(4); + b.writeInt32LE(Number(v) | 0); + values.push(b); + } else if (kind === 3 && width === 2) { + const b = Buffer.alloc(2); + b.writeInt16LE(Number(v) | 0); + values.push(b); + } else if (kind === 4 && width === 8) { + const b = Buffer.alloc(8); + b.writeDoubleLE(Number(v)); + values.push(b); + } else if (kind === 73 && width === 1) { + const text = encoder(v); + if (text.length > 255) throw Error(`인쇄양식 글이 너무 깁니다: ${name}`); + values.push(Buffer.from([text.length]), text); + } else throw Error(`인쇄양식 필드형 오류: ${name}/${kind}/${width}`); + }); + parts.push(Buffer.from([flag]), status, ...values); + } + return Buffer.concat(parts); +} + +let table = null; +function cp949Table() { + if (table) return table; + table = new Map(); + const decoder = new TextDecoder("euc-kr"); + for (let hi = 0x81; hi <= 0xfe; hi++) + for (let lo = 0x41; lo <= 0xfe; lo++) { + const ch = decoder.decode(Buffer.from([hi, lo])); + if (ch.length === 1 && ch !== "�" && !table.has(ch)) table.set(ch, (hi << 8) | lo); + } + return table; +} + +// BLOB(설명 등)까지 담아 읽기 — 그대로 다시 쓰면 원본과 같은 바이트 +function readFull(buffer) { + const { count, start, fields } = layout(buffer), + rows = buffer.readUInt16LE(14), + decoder = new TextDecoder("euc-kr"); + let at = start; + const out = []; + for (let r = 0; r < rows; r++) { + at += 1; + const status = buffer.subarray(at, at + Math.ceil(count / 4)); + at += status.length; + const row = {}; + fields.forEach(({ name, width, kind }, i) => { + const state = (status[i >> 2] >> ((i % 4) * 2)) & 3; + if (state) { + if (state !== 1) (row.__nulls ||= {})[name] = state; // 빈 칸 표지(1·2)도 그대로 되살리게 + return; + } + if (kind === 1 && width === 4) ((row[name] = buffer.readInt32LE(at)), (at += 4)); + else if (kind === 3 && width === 2) ((row[name] = buffer.readInt16LE(at)), (at += 2)); + else if (kind === 4 && width === 8) ((row[name] = buffer.readDoubleLE(at)), (at += 8)); + else if (kind === 73 && width === 1) { + const n = buffer[at]; + row[name] = decoder.decode(buffer.subarray(at + 1, at + 1 + n)); + at += 1 + n; + } else if (kind === 75) { + const n = buffer.readUInt32LE(at); + row[name] = { binary: buffer.subarray(at + 4, at + 4 + n).toString("base64") }; + at += 4 + n; + } else throw Error(`인쇄양식 필드형 오류: ${name}/${kind}/${width}`); + }); + out.push(row); + } + return out; +} + +module.exports = { read: (buffer) => readPrintOptions(buffer), readFull, write, layout }; diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index e4620e34f..c96fcca5d 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -7,6 +7,9 @@ const http = require("node:http"), const { importEstx, table: dbisamTable } = require("./import-estx.cjs"); const { readXls } = require("./xls-read.cjs"); const bookStore = require("./book-store.cjs"); +const printFormat = require("./print-format.cjs"); +const pfotTemplate = + process.env.EST_PFOT_TEMPLATE || "C:/Dasansoft/Est Plus 2/System/EstFormat/main.pfot"; const mainDataPath = process.env.EST_MAIN_DATA || "C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx"; let mainData = null; @@ -311,6 +314,22 @@ const server = http.createServer(async (req, res) => { ); return; } + if (req.method === "POST" && url.pathname === "/api/pfot-read") { + reply(res, 200, printFormat.readFull(await body(req))); + return; + } + if (req.method === "POST" && url.pathname === "/api/pfot-write") { + // 인쇄양식(.pfot) 쓰기: 원본 설치 인쇄양식의 필드 설명자를 바탕으로 행만 새로 + const input = JSON.parse((await body(req)).toString("utf8")); + if (!Array.isArray(input.rows)) throw Error("인쇄양식 행 형식 오류"); + reply( + res, + 200, + printFormat.write(fs.readFileSync(pfotTemplate), input.rows), + "application/octet-stream", + ); + return; + } if (req.method === "POST" && url.pathname === "/api/read-xls") { reply(res, 200, readXls(await body(req))); return; From 6369c31eef12e0fb937e05417495ca8901cffc9a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:49:51 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat(est-test):=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EA=B2=80=EC=83=89=20=EC=B0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tools/est-test/PLAN.md | 1 + tools/est-test/index.html | 1 + tools/est-test/native-misc-tools.js | 33 ++++++++++++++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 466d20862..5a8fb0216 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,7 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [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 열 배치)는 웹 격자와 호환되지 않아 하지 않음. - 검증완료: `test_est_print_format.py`(설치 .pfot 두 개 바이트 같음·고친 값 반영) 및 바인딩 시험 통과, 서버 경로로 읽고 다시 쓴 파일 cmp 같음. ORCA 브라우저에서 샘플 인쇄양식 저장(32,755 바이트). - [x] 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst 0x109c3bc — spec_pc §1) — 노임·자재·견적·표준시장·단가산출·일위대가·내역서 탭마다 시트명·시작/끝행·필드별 열 글자(.xlsx·.xls), 노임 → 자재 → 견적 → 표준 → 단산 → 일위 → 내역 차례로 새 공사(현재 원가 서식, 자동계산 끔 — Excel 값 그대로). 셀 규칙(Trim·줄바꿈·앞 「.」·㎡/㎥·「*」→「×」·쉼표·노무비율 ×100·「B:D」 이어붙임), 노임 단가 → Cost1·TC, 견적·표준 → O 칸 함께, 빈 코드 N+10자리, 내역 36행 채움. 단가산출·일위대가 머리 줄 꼴로 호표 만들기(코드·TitleCode·Index·LineData 품명/규격/단위 표지, [합계] 줄 지움), 못 읽은 시트 안내. 하지 않음: 글자 굵기 판정·고정폭 머리말·단가산출 목록 시트형(굵기·폭 정보 없음). diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 13a7cf025..05c27ada2 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -316,6 +316,7 @@ > { EstApp.render(); } - function handle(name, state) { + // 사용위치 검색 창(TfrmUsesDataSerch btnSerch·btnAllDisplay): 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기 + function usesList(state, word) { + const rows = state.project.tables[state.view]; + if (!rows) throw Error("자료 화면에서 쓰세요."); + const norm = (x) => String(x ?? "").replace(/[\s|()]/g, ""), + w = norm(word), + { esc } = EstApp; + const list = rows + .map((r, i) => [r, i]) + .filter( + ([r]) => (r.Name || r.Code) && (!w || norm(r.Name).includes(w) || norm(r.Size).includes(w)), + ) + .slice(0, 300); + EstApp.showDialog( + "데이터 사용위치 검색", + `
${list.map(([r, i]) => ``).join("")}
${esc(r.Code ?? "")}${esc(r.Name ?? "")}${esc(r.Size ?? "")}
`, + ); + } + + function handle(name, state, node) { + if (name === "uses-list" || name === "uses-list-all") { + usesList(state, ""); + return true; + } + if (name === "uses-list-search") { + usesList(state, document.querySelector("[data-uses-word]")?.value || ""); + return true; + } + if (name === "uses-list-pick") { + EstExtra.showUsesSearchDialog(state.project.tables[state.view][Number(node.dataset.i)]); + return true; + } if (name === "pfot-load" || name === "pfot-save") { pfot(name, state).catch((error) => { state.notice = error.message; From 3e7c477631902e1840739b401f0f65284ce0161e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:51:34 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat(est-test):=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EA=B3=B5=EC=82=AC=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=BD(=EA=B0=99=EC=9D=80=20=ED=91=9C=20=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=C2=B7=EC=B0=BE=EA=B8=B0=C2=B7=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tools/est-test/PLAN.md | 1 + tools/est-test/index.html | 2 + tools/est-test/native-actions-more.js | 1 + tools/est-test/native-other-load.js | 113 ++++++++++++++++++++++++++ tools/est-test/server.cjs | 1 + 5 files changed, 118 insertions(+) create mode 100644 tools/est-test/native-other-load.js diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 5a8fb0216..a3f724170 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,7 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [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 열 배치)는 웹 격자와 호환되지 않아 하지 않음. - 검증완료: `test_est_print_format.py`(설치 .pfot 두 개 바이트 같음·고친 값 반영) 및 바인딩 시험 통과, 서버 경로로 읽고 다시 쓴 파일 cmp 같음. ORCA 브라우저에서 샘플 인쇄양식 저장(32,755 바이트). diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 05c27ada2..aed780765 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -20,6 +20,7 @@ + @@ -317,6 +318,7 @@ > { typeof EstPriceTools !== "undefined" && EstPriceTools, typeof EstMiscTools !== "undefined" && EstMiscTools, typeof EstExcelToEst !== "undefined" && EstExcelToEst, + typeof EstOtherLoad !== "undefined" && EstOtherLoad, ].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-other-load.js b/tools/est-test/native-other-load.js new file mode 100644 index 000000000..c1267e76f --- /dev/null +++ b/tools/est-test/native-other-load.js @@ -0,0 +1,113 @@ +// 원본 「다른 공사 불러오기」 창(barbtnSelfLoad → f135b8 → TfrmOtherLoad — spec_pb2 §5.2·§11): 저장된 다른 작업의 같은 표를 띄워 +// 찾기(Ctrl+F: 품명·규격 빈칸·|·괄호 무시, 「A&B」 는 둘 다)·고른 줄을 하위 자료와 함께 이 공사로 가져오기(EstTransfer, 같은 코드는 이 공사 것). +// 내역서는 고른 줄 아래에 줄을 넣고, 자재·노임·표준·견적·일위·단산·기계는 자료(호표)를 합친다. +const EstOtherLoad = (() => { + const KIND = { + StuffcDat: 5, + LaborcDat: 6, + SilJukDat: 21, + EstiMateDat: 101, + HeadMainDat: 103, + CostMainDat: 104, + MachMainDat: 105, + }; + const norm = (s) => String(s ?? "").replace(/[\s|()]/g, ""); + const match = (row, word) => { + const k = norm(row.Name || EstText.clean(row.LineData || "")) + norm(row.Size); + return norm(word) + .split("&") + .filter(Boolean) + .every((p) => k.includes(p)); + }; + + async function open(state) { + if (!state.project.tables[state.view]) throw Error("자료 화면에서 쓰세요."); + const o = (state.otherLoad ||= { id: "", word: "", picked: new Set() }); + o.projects = await EstApp.api("/api/projects"); + draw(state); + } + function rowsOf(o, view) { + const rows = o.source?.tables?.[view] || []; + return rows + .map((r, i) => [r, i]) + .filter(([r]) => { + if (["HeadMainDat", "CostMainDat", "MachMainDat"].includes(view) && !(Number(r.Index) > 0)) + return false; + return (r.Name || r.Code || r.LineData) && (!o.word || match(r, o.word)); + }); + } + function draw(state) { + const o = state.otherLoad, + { esc, number } = EstApp, + list = rowsOf(o, state.view).slice(0, 400); + EstApp.showDialog( + "다른 공사 불러오기", + `
${list.map(([r, i]) => ``).join("")}
${esc(r.Code ?? "")}${esc(r.Name || EstText.clean(r.LineData || ""))}${esc(r.Size ?? "")}${esc(r.Unite ?? "")}${number(r.TC)}
`, + ); + } + function take(state) { + const o = state.otherLoad, + view = state.view, + picked = [...o.picked].sort((a, b) => a - b).map((i) => o.source.tables[view][i]); + if (!picked.length) throw Error("가져올 줄을 고르세요."); + let added = 0, + kept = 0; + EstApp.change((project) => { + if (view === "EstMainDat") { + const deps = EstTransfer.collect(o.source, picked); + const r = EstTransfer.merge(project, deps); + added += r.added.length; + kept += r.kept.length; + const at = Math.max(-1, ...state.selection) + 1 || project.tables.EstMainDat.length; + added += EstTransfer.paste(project, picked, at); + } else { + const deps = EstTransfer.collect( + o.source, + picked.map((r) => ({ LinkIndex: KIND[view], Code: r.Code })), + ); + const r = EstTransfer.merge(project, deps); + added += r.added.length; + kept += r.kept.length; + } + }); + EstApp.byId("dialog").close(); + state.notice = `${added}건을 가져왔습니다${kept ? `, 같은 코드 ${kept}건은 이 공사 자료를 그대로 씀` : ""}.`; + o.picked = new Set(); + } + + function install() { + document.addEventListener("change", async (event) => { + const t = event.target, + o = EstApp.state.otherLoad; + if (!o) return; + try { + if (t.dataset.olProject !== undefined) { + o.id = t.value; + o.source = t.value ? await EstApp.api(`/api/projects/${t.value}`) : null; + o.picked = new Set(); + draw(EstApp.state); + } else if (t.dataset.olPick !== undefined) { + const i = Number(t.dataset.olPick); + if (t.checked) o.picked.add(i); + else o.picked.delete(i); + } + } catch (error) { + EstApp.state.notice = error.message; + EstApp.render(); + } + }); + } + if (typeof document !== "undefined" && document.addEventListener) install(); + + function handle(name, state) { + if (name === "other-load") + open(state).catch((e) => ((state.notice = e.message), EstApp.render())); + else if (name === "ol-search") { + state.otherLoad.word = document.querySelector("[data-ol-word]")?.value || ""; + draw(state); + } else if (name === "ol-take") take(state); + else return false; + return true; + } + return { match, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index c96fcca5d..1b9c4f2de 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -103,6 +103,7 @@ const allowed = new Set([ "native-price-tools.js", "native-misc-tools.js", "native-excel-to-est.js", + "native-other-load.js", "native-actions-more.js", "native-wonga-tools.js", "native-estimate-print.js", From 67cb82d8d2ce54b7249810ade03c8cdf4343997e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:53:58 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat(est-test):=20=ED=91=9C=EC=A7=80?= =?UTF-8?q?=C2=B7=EA=B0=91=EC=A7=80=20=EC=83=81=EC=84=B8=ED=8E=B8=EC=A7=91?= =?UTF-8?q?,=20=EC=82=B0=EC=B6=9C=20=EC=A4=84=20=EA=B8=80=EC=9E=90?= =?UTF-8?q?=EB=AA=A8=EC=96=91,=20=EC=88=98=EB=9F=89=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=EC=84=9C=20=EC=9D=B8=EC=87=84,=20=EC=97=91=EC=85=80=ED=98=95?= =?UTF-8?q?=20=EC=85=80=20=ED=85=8C=EB=91=90=EB=A6=AC=C2=B7=EC=9D=8C?= =?UTF-8?q?=EC=98=81=C2=B7=EC=88=98=EB=9F=89=EB=B6=99=EC=97=AC=EB=84=A3?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tools/est-test/PLAN.md | 2 + tools/est-test/native-doc-forms.js | 27 ++++++++++++ tools/est-test/native-docs.js | 4 ++ tools/est-test/native-excel-view.js | 34 +++++++++++++++- tools/est-test/native-quantity-sheets.js | 2 +- tools/est-test/native-quantity-table.js | 52 +++++++++++++++++++++++- 6 files changed, 118 insertions(+), 3 deletions(-) diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index a3f724170..a0f816c0f 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. + - [x] 표지·갑지 상세편집·용지모양, 산출 줄 글자모양, 수량산출서 인쇄, 엑셀형 셀 테두리·음영·수량붙여넣기(연결)(spec_pc §5 · spec_pa §1·6·8·9) — 상세편집: 인쇄 모양을 자유 편집해 저장하면 인쇄가 그 모양을 씀(해제 가능), 용지모양은 인쇄 창 용지·여백. 글자모양: 산출 줄 크기·굵게·색. 수량산출서 인쇄: 현재 또는 모두(간지 고르기), 줄·표·그림 그대로. 엑셀형: 셀 테두리·음영, 수량 목록에서 담은 항목을 고른 셀부터 가로/세로·명칭/규격/단위/수량으로(자기 산출서 거부, 수량 칸은 원본 항목 연결 기록). + - 검증완료: 문서·바인딩 시험 4개 통과. ORCA 브라우저에서 갑지 상세편집 제목 고침 → 인쇄 html 반영, 글자모양 18px·굵게·#800000, 인쇄 창 표·간지, 엑셀형 B2·B3 에 2.5·0.19(연결 기록), 샘플 복구. - [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 열 배치)는 웹 격자와 호환되지 않아 하지 않음. diff --git a/tools/est-test/native-doc-forms.js b/tools/est-test/native-doc-forms.js index e9ca4ec72..4ab8ce78a 100644 --- a/tools/est-test/native-doc-forms.js +++ b/tools/est-test/native-doc-forms.js @@ -63,6 +63,7 @@ const EstDocForms = (() => { ["docf-default", "기본양식으로 저장"], ["docf-init", kind === "gabgi" ? "갑지초기화" : "표지초기화"], ["docf-print", "인쇄"], + ["docf-detail", "상세편집"], ] .map(([a, l]) => ``) .join("")}`; @@ -108,6 +109,29 @@ const EstDocForms = (() => { if (project.docs) delete project.docs[kind]; }, false); EstDocs.handle("doc-edit", state, { dataset: { kind } }); + } else if (name === "docf-detail") { + // 상세편집: 인쇄 모양을 자유 편집(원본은 미리보기 ↔ 편집 전환). 저장하면 인쇄가 이 모양을 씀, 「상세편집 해제」로 칸 값 모양으로 + const doc = state.project.docs?.[kind] || {}; + const html = doc.detailHtml || EstDocs.html(kind === "gabgi" ? 8 : 9, state.project); + EstApp.showDialog( + `${kind === "gabgi" ? "갑지" : "표지"} 상세편집`, + `
${EstDocEditor.clean(html)}
`, + ); + } else if (name === "docf-detail-save" || name === "docf-detail-clear") { + const html = + name === "docf-detail-save" + ? EstDocEditor.clean(document.getElementById("doc-detail").innerHTML) + : null; + EstApp.byId("dialog").close(); + EstApp.change((project) => { + project.docs ||= {}; + project.docs[kind] = { ...(project.docs[kind] || {}) }; + if (html) project.docs[kind].detailHtml = html; + else delete project.docs[kind].detailHtml; + }, false); + state.notice = html + ? "상세편집 모양을 저장했습니다. 인쇄에 그대로 쓰입니다." + : "상세편집을 풀었습니다."; } else if (name === "docf-print") { EstApp.byId("dialog").close(); await EstReport.open(); @@ -117,6 +141,9 @@ const EstDocForms = (() => { } } const names = new Set([ + "docf-detail", + "docf-detail-save", + "docf-detail-clear", "docf-list", "docf-load", "docf-delete", diff --git a/tools/est-test/native-docs.js b/tools/est-test/native-docs.js index 9c9d63d6d..716e1b07a 100644 --- a/tools/est-test/native-docs.js +++ b/tools/est-test/native-docs.js @@ -98,6 +98,10 @@ const EstDocs = (() => { (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c], ); const align = (pos) => ["left", "center", "right"][Number(pos)] || "center"; + // 상세편집(popupDetailEdit)으로 고친 모양이 있으면 그대로 + const detail = { 8: "gabgi", 9: "cover" }[form]; + if (detail && project.docs?.[detail]?.detailHtml && typeof EstDocEditor !== "undefined") + return `
${EstDocEditor.clean(project.docs[detail].detailHtml)}
`; if (form === 9) { const c = doc(project, "cover"); return `
${c.YearShow ? `

${esc(year(c.Date))}

` : ""}

${esc(c.Name)}

${c.Name2 ? `

${esc(c.Name2)}

` : ""}

${esc(c.CompanyName)}

`; diff --git a/tools/est-test/native-excel-view.js b/tools/est-test/native-excel-view.js index bd9b6e5cb..d93a91555 100644 --- a/tools/est-test/native-excel-view.js +++ b/tools/est-test/native-excel-view.js @@ -46,6 +46,11 @@ const EstExcelView = (() => { btn("excel-style", "오른쪽", 'data-name="align" data-value="right"'), btn("excel-merge", "병합"), btn("excel-split", "분리"), + btn("excel-style", "테두리", 'data-name="border" data-value="1"'), + btn("excel-style", "테두리 없음", 'data-name="border" data-value=""'), + btn("excel-style", "음영", 'data-name="shade" data-value="#eeeeee"'), + btn("excel-style", "음영 없음", 'data-name="shade" data-value=""'), + btn("excel-mul-paste", "수량붙여넣기(연결)"), ``, btn("excel-style", "왼쪽대각선", 'data-name="diag" data-value="left"'), btn("excel-style", "오른쪽대각선", 'data-name="diag" data-value="right"'), @@ -119,6 +124,8 @@ const EstExcelView = (() => { s.u ? "text-decoration:underline" : "", s.align ? `text-align:${s.align}` : "", s.diag ? `background-image:${DIAG[s.diag]}` : "", + s.border ? "border:2px solid #222" : "", + s.shade && !link && !chosen ? `background-color:${s.shade}` : "", link ? "background-color:#e6f4ea" : chosen ? "background-color:#e8f0fb" : "", here && sel.row === r && sel.col === c ? "outline:2px solid #316ea5" : "", ] @@ -236,6 +243,7 @@ const EstExcelView = (() => { "excel-style", "excel-merge", "excel-split", + "excel-mul-paste", "excel-print", ]); function handle(name, state, node) { @@ -286,7 +294,31 @@ const EstExcelView = (() => { edit(() => EstExcelEdit.style(page, box, node.dataset.name, node.dataset.value)); else if (name === "excel-merge") edit(() => EstExcelEdit.merge(page, box)); else if (name === "excel-split") edit(() => EstExcelEdit.split(page, box)); - else if (name === "excel-print") { + else if (name === "excel-mul-paste") { + // 수량붙여넣기(연결)(actMulLangPaste 0xd31000): 수량 목록에서 담은 항목을 고른 셀부터 가로/세로로, 명칭·규격·단위·수량 중 하나 + const clip = state.mulClip; + if (!clip?.items.length) throw Error("수량 목록에서 선택수량복사를 먼저 하세요."); + if (clip.code === sheet.Code) + throw Error("자기 자신의 데이타는 불러올수없습니다.셀수식을 사용하세요."); + const across = clip.items.length > 1 ? confirm("가로로 넣을까요? (취소 = 세로)") : true; + const kind = + clip.items.length > 1 + ? Number(prompt("입력항목(0 명칭 · 1 규격 · 2 단위 · 3 수량)", "3")) + : 3; + if (![0, 1, 2, 3].includes(kind)) throw Error("입력항목은 0~3 입니다."); + edit(() => { + clip.items.forEach((item, k) => { + const r = box.r1 + (across ? 0 : k), + c = box.c1 + (across ? k : 0), + v = [item.Name, item.Size, item.Unite, Number(item.GN) || 0][kind]; + page.cells[`${r},${c}`] = { + value: v ?? "", + ...(kind === 3 && { qlink: { sheet: clip.code, code: item.Code } }), + }; + }); + return EstExcelSheet.recalc(page.cells); + }); + } else if (name === "excel-print") { // 미리보기·인쇄(tbsPreView·tbsPrint): 고른 시트 격자만 새 창에 const html = render({ ...sheet, excel: [page], excelLinks: {} }, { excelCell: null }) .replace(/
[\s\S]*?<\/div>/, "") diff --git a/tools/est-test/native-quantity-sheets.js b/tools/est-test/native-quantity-sheets.js index ca8bc1b96..364b6847f 100644 --- a/tools/est-test/native-quantity-sheets.js +++ b/tools/est-test/native-quantity-sheets.js @@ -129,7 +129,7 @@ const EstQuantitySheets = (() => { ``, ) .join(""); - return `
`; + return `
`; } function handle(name, state, node) { diff --git a/tools/est-test/native-quantity-table.js b/tools/est-test/native-quantity-table.js index a04548625..95e69b69d 100644 --- a/tools/est-test/native-quantity-table.js +++ b/tools/est-test/native-quantity-table.js @@ -13,6 +13,9 @@ const EstQuantityTable = (() => { p.align && `text-align:${p.align}`, p.indent && `padding-left:${p.indent * 8}px`, row.title && "font-weight:bold;font-size:20px;color:#800000", + p.bold && "font-weight:bold", + p.size && `font-size:${p.size}px`, + p.color && `color:${p.color}`, ] .filter(Boolean) .join(";"); @@ -155,6 +158,7 @@ const EstQuantityTable = (() => { sheet.tableEdited = true; }, false); } else if (name === "qt-mul-load" || name === "qt-mul-take") mulLoad(name, state, node); + else if (name === "qt-print") print(state); else return false; return true; } @@ -175,7 +179,23 @@ const EstQuantityTable = (() => { throw Error("정렬은 left·center·right 입니다."); const indent = Number(prompt("들여쓰기(칸)", String(sheet.rows[index].para?.indent ?? 0))); if (!(indent >= 0 && indent <= 40)) throw Error("들여쓰기는 0~40 칸입니다."); - change(() => (sheet.rows[index].para = { align, indent })); + // 글자모양(mnuFormatFont): 크기·굵게·색 + const size = + Number(prompt("글자 크기(px, 0 = 기본)", String(sheet.rows[index].para?.size ?? 0))) || 0, + bold = confirm("굵게 할까요?"), + color = + prompt("글자색(#RRGGBB, 비우면 기본)", sheet.rows[index].para?.color || "")?.trim() || ""; + if (color && !/^#[0-9a-f]{6}$/i.test(color)) throw Error("글자색은 #RRGGBB 꼴입니다."); + change( + () => + (sheet.rows[index].para = { + align, + indent, + ...(size && { size }), + ...(bold && { bold }), + ...(color && { color }), + }), + ); } else if (name === "qt-image") { const picker = document.createElement("input"); picker.type = "file"; @@ -233,6 +253,36 @@ const EstQuantityTable = (() => { state.notice = `「${src.Name}」 산출서를 불러왔습니다.`; } + // 수량산출서 인쇄(btnPrint → TfrmMulPrintRange): 현재 산출서 또는 모두(산출서마다 간지 고르기), 산출 줄·표 그대로 + function print(state) { + const sheets = state.project.quantitySheets || []; + if (!sheets.length) throw Error("수량산출서가 없습니다."); + const all = confirm("모든 수량산출서를 인쇄할까요? (취소 = 현재 산출서만)"), + gangi = all && confirm("산출서마다 간지를 넣을까요?"), + list = all ? sheets : [sheetOf(state)].filter(Boolean), + { esc } = EstApp; + const body = list + .map((sheet) => { + const rows = sheet.rows + .map((r) => + r.image + ? `` + : r.table + ? `${r.table.map((tr) => `${tr.map((c) => (c.vmerge ? "" : `${esc(c.text)}`)).join("")}`).join("")}
` + : `
${esc(EstText.clean(r.LineData || ""))}
`, + ) + .join(""); + return `${gangi ? `
${esc(sheet.Name)}
` : ""}

${esc(sheet.Name)} ${esc(sheet.Size || "")}

${rows}
`; + }) + .join(""); + const win = open("", "_blank"); + if (!win) throw Error("새 창을 열 수 없습니다. 팝업 차단을 확인하세요."); + win.document.write( + `수량산출서${body} + diff --git a/tools/est-test/native-actions-more.js b/tools/est-test/native-actions-more.js index 0314ab929..9d783f46c 100644 --- a/tools/est-test/native-actions-more.js +++ b/tools/est-test/native-actions-more.js @@ -7,6 +7,7 @@ const EstActionsMore = (() => { typeof EstMiscTools !== "undefined" && EstMiscTools, typeof EstExcelToEst !== "undefined" && EstExcelToEst, typeof EstOtherLoad !== "undefined" && EstOtherLoad, + typeof EstAppliedCost !== "undefined" && EstAppliedCost, ].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-applied-cost.js b/tools/est-test/native-applied-cost.js new file mode 100644 index 000000000..095995623 --- /dev/null +++ b/tools/est-test/native-applied-cost.js @@ -0,0 +1,236 @@ +// 원본 「물가변동 적용대가 분류 내역서」(TfrmAppliedCost — spec_pb1 §5·§6) 화면: 품목조정 표(native-extra.js 에서 옮김), +// 「적용대가 보기 변경」(cmStyleToggle 0x10b5918 → e43b48: 단가 밴드를 숨기고 계약·구분별 수량 7종만 — 값 변화 없음), +// 「수량불러오기」(cmGNLoad 0x10b57a4 → 10b6630·10b5934·10b617c: 고른 내역서의 행 i 수량 GN 을 이 내역 행 i 의 구분 칸에, +// 그 파일 원가 행 i 의 Cost 를 원가 행 i 의 Es*Cost 에 — 위치 짝, 원본 행이 없으면 건너뜀), +// 「적용대가집계표」(mnuESWonga → TfrmESPWonga: 원가 행마다 계약금액·적용제외금액·적용대가금액 — 계산은 품목조정과 같은 값). +const EstAppliedCost = (() => { + const num = (v) => Number(v) || 0; + const QTY = ["EscConstruction", "EscPlanned", "EscActual", "EscProgress", "EscPS", "EscBefore"]; + const QTY_LABELS = [ + "시공중 수량", + "예정공정 수량", + "실행공정 수량", + "기성 수량", + "PS 수량", + "선시공 수량", + ]; + const KINDS = [ + "계약분 수량", + "시공중인공사계약분 수량", + "예정공정율분 수량", + "실행공정율분 수량", + "기성율분 수량", + "PS분 수량", + "신시공부분 수량", + ]; + const COST = [ + "EsContractCost", + "EsSiGongCost", + "EsSchduleCost", + "EsWorkCost", + "EsACCost", + "EsPSCost", + "EsBeforeCost", + ]; + + function view(state, project, { box, button, table }) { + const { esc, number } = EstApp; + const output = EstBusiness.escalation(project); + state.business = output; + const comp = state.escalationComponent || "NU"; + const choices = EstMath.units + .map( + (key, i) => + ``, + ) + .join(""); + const page = EstBlockActions.escalationPage(state, project.tables.EstMainDat); + const qty = state.escQtyView; + const rows = page.rows.map(({ row, index }) => { + const result = output.rows[index]; + if (qty) + return [ + esc(row.Code), + esc(row.Name), + number(num(result.EscApplied) + num(result.EscExcluded)), + ...QTY.map((k) => EstViews.cell("EstMainDat", index, k, row[k] ?? 0)), + number(result.EscExcluded), + number(result.EscApplied), + ]; + return [ + esc(row.Code), + esc(row.Name), + EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]), + number(result[`Contract${comp}`]), + EstViews.cell("EstMainDat", index, comp, row[comp]), + number(result[`D${comp}`]), + number(result[`E${comp}`]), + number(result[`F${comp}`]), + EstViews.cell("EstMainDat", index, "EscPlanned", row.EscPlanned || 0), + EstViews.cell("EstMainDat", index, "EscActual", row.EscActual || 0), + EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0), + number(result.EscExcluded), + number(result.EscApplied), + ``, + EstViews.cell("EstMainDat", index, "EscManualQuantity", row.EscManualQuantity ?? 0), + EstViews.cell("EstMainDat", index, "EscConstruction", row.EscConstruction ?? 0), + EstViews.cell("EstMainDat", index, "EscPS", row.EscPS ?? 0), + EstViews.cell("EstMainDat", index, "EscBefore", row.EscBefore ?? 0), + ]; + }); + return box( + "물가변동 품목조정", + `` + + button("capture-bid", "현재 단가를 입찰단가로 기록") + + button("load-bid", "입찰단가 불러오기") + + button("load-contract", "계약 내역 불러오기") + + button("load-progress-exclusion", "기성 누계수량 불러오기") + + button( + "applied-style-toggle", + qty ? "적용대가 보기 변경(풀기)" : "적용대가 보기 변경", + qty ? 'aria-pressed="true"' : "", + ) + + button("applied-gn-load", "수량불러오기") + + button("es-wonga", "적용대가집계표") + + button("reset-escalation", "물가변동 입력 초기화") + + button("capture-baseline", "계약 내역 확정") + + button("escalation-prev", "이전", state.escalationPage === 0 ? "disabled" : "") + + `${state.escalationPage + 1} / ${page.pages}쪽 · ${page.total}행` + + button( + "escalation-next", + "다음", + state.escalationPage + 1 >= page.pages ? "disabled" : "", + ) + + ``, + table( + qty + ? ["코드", "품명", "계약 수량", ...QTY_LABELS, "적용제외", "적용수량"] + : [ + "코드", + "품명", + "입찰 당시", + "계약 단가", + "변동 당시", + "등락률 %", + "등락폭", + "조정단가", + "예정공정수량", + "실행공정수량", + "기성수량", + "적용제외", + "적용수량", + "제외 자동", + "수동 제외수량", + "시공중 수량(참고)", + "PS 수량(참고)", + "선시공 수량(참고)", + ], + rows, + ), + `조사 도급액 ${number(output.designTotal)} · 계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. 계약·입찰 파일은 동일 행 순서로 불러옵니다. ${esc(output.errors.join(" / "))}`, + ); + } + + // 수량불러오기 한 벌 적용: kind 0 = 계약 수량(계약 내역 확정본, 없으면 당초 칸 GN_), 1~6 = 구분 칸 + function load(project, kind, source) { + const rows = project.tables.EstMainDat, + from = source.tables?.EstMainDat || [], + base = project.baseline?.tables?.EstMainDat; + let count = 0; + rows.forEach((row, i) => { + const src = from[i]; + if (!src) return; + if (kind === 0) { + if (base?.[i]) base[i].GN = num(src.GN); + else row.GN_ = num(src.GN); + } else row[QTY[kind - 1]] = num(src.GN); + count++; + }); + const wonga = project.tables.Wonga || [], + fromWonga = source.tables?.Wonga || []; + wonga.forEach((row, i) => fromWonga[i] && (row[COST[kind]] = num(fromWonga[i].Cost))); + return count; + } + + function summary(project) { + const out = EstBusiness.escalation(project), + wonga = project.tables.Wonga || []; + return wonga.map((row, i) => { + const contract = + row.EsContractCost != null ? num(row.EsContractCost) : num(out.contractWonga?.[i]?.Cost), + except = num(out.excludedWonga?.[i]?.Cost); + return { row, contract, except, apply: contract - except }; + }); + } + function summaryDialog(state) { + const { esc, number } = EstApp, + list = summary(state.project); + if (!list.length) throw Error("원가계산서가 없습니다."); + EstApp.showDialog( + "적용대가집계표", + `
${list + .map( + ({ row, contract, except, apply }) => + ``, + ) + .join("")}
비목약어계약금액적용제외금액적용대가금액산출근거
${esc([row.Bimog1, row.Bimog2, row.Name].filter(Boolean).join(" "))}${esc(row.SName ?? "")}${number(contract)}${number(except)}${number(apply)}${esc(row.VSusic ?? "")}
`, + ); + } + + async function gnLoad(state) { + const kind = await new Promise((resolve) => + EstActions.form( + "수량불러오기", + [ + { + name: "kind", + label: "불러올 수량분", + value: "", + choices: [["", "선택"], ...KINDS.map((k, i) => [i, k])], + }, + ], + (values) => resolve(values.kind), + ), + ); + if (kind === "" || kind == null) throw Error("불러올 수량분을 선택하세요.."); + const file = await new Promise((resolve) => { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = ".estx,.json"; + picker.addEventListener("change", () => resolve(picker.files[0])); + picker.click(); + }); + if (!file) return; + const name = KINDS[Number(kind)]; + let source; + try { + source = /\.estx$/i.test(file.name) + ? await EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, { + method: "POST", + body: await file.arrayBuffer(), + }) + : EstModel.legacy(JSON.parse(await file.text())); + } catch { + throw Error(`${name} 를 불러오지 못했습니다...`); + } + let count = 0; + EstApp.change((project) => (count = load(project, Number(kind), source))); + state.notice = `${name} ${count}행을 불러왔습니다.`; + EstApp.render(); + } + + function handle(name, state) { + if (name === "applied-style-toggle") { + state.escQtyView = !state.escQtyView; + EstApp.render(); + } else if (name === "applied-gn-load") + gnLoad(state).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + else if (name === "es-wonga") summaryDialog(state); + else return false; + return true; + } + return { view, load, summary, handle, KINDS }; +})(); diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index 30ede036e..37ef721c6 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -229,81 +229,7 @@ const EstExtra = (() => { "전회는 직전 확정 회차 누계를 읽습니다. 금회·사정은 수량·금액·비율 중 하나를 입력해 계산합니다.", ); } else if (view === "escalation") { - const output = EstBusiness.escalation(project); - state.business = output; - const comp = state.escalationComponent || "NU"; - const choices = EstMath.units - .map( - (key, i) => - ``, - ) - .join(""); - const page = EstBlockActions.escalationPage(state, project.tables.EstMainDat); - const rows = page.rows.map(({ row, index }) => { - const result = output.rows[index]; - return [ - esc(row.Code), - esc(row.Name), - EstViews.cell("EstMainDat", index, `A${comp}`, row[`A${comp}`]), - number(result[`Contract${comp}`]), - EstViews.cell("EstMainDat", index, comp, row[comp]), - number(result[`D${comp}`]), - number(result[`E${comp}`]), - number(result[`F${comp}`]), - EstViews.cell("EstMainDat", index, "EscPlanned", row.EscPlanned || 0), - EstViews.cell("EstMainDat", index, "EscActual", row.EscActual || 0), - EstViews.cell("EstMainDat", index, "EscProgress", row.EscProgress || 0), - number(result.EscExcluded), - number(result.EscApplied), - ``, - EstViews.cell("EstMainDat", index, "EscManualQuantity", row.EscManualQuantity ?? 0), - EstViews.cell("EstMainDat", index, "EscConstruction", row.EscConstruction ?? 0), - EstViews.cell("EstMainDat", index, "EscPS", row.EscPS ?? 0), - EstViews.cell("EstMainDat", index, "EscBefore", row.EscBefore ?? 0), - ]; - }); - html = box( - "물가변동 품목조정", - `` + - button("capture-bid", "현재 단가를 입찰단가로 기록") + - button("load-bid", "입찰단가 불러오기") + - button("load-contract", "계약 내역 불러오기") + - button("load-progress-exclusion", "기성 누계수량 불러오기") + - button("reset-escalation", "물가변동 입력 초기화") + - button("capture-baseline", "계약 내역 확정") + - button("escalation-prev", "이전", state.escalationPage === 0 ? "disabled" : "") + - `${state.escalationPage + 1} / ${page.pages}쪽 · ${page.total}행` + - button( - "escalation-next", - "다음", - state.escalationPage + 1 >= page.pages ? "disabled" : "", - ) + - ``, - table( - [ - "코드", - "품명", - "입찰 당시", - "계약 단가", - "변동 당시", - "등락률 %", - "등락폭", - "조정단가", - "예정공정수량", - "실행공정수량", - "기성수량", - "적용제외", - "적용수량", - "제외 자동", - "수동 제외수량", - "시공중 수량(참고)", - "PS 수량(참고)", - "선시공 수량(참고)", - ], - rows, - ), - `조사 도급액 ${number(output.designTotal)} · 계약 도급액 ${number(output.contractTotal)} · 조정 도급액 ${number(output.adjustedTotal)} · 적용제외(간접비 포함) ${number(output.excludedTotal)} · 적용대가 ${number(output.applied)} · 조정률 ${number(output.rate)}% · 조정금액 ${number(output.adjustment)}. 계약·입찰 파일은 동일 행 순서로 불러옵니다. ${esc(output.errors.join(" / "))}`, - ); + html = EstAppliedCost.view(state, project, { box, button, table }); } else if (view === "annual") { const output = EstBusiness.annual(project, false); state.business = output; diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 1b9c4f2de..dd3b01591 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -104,6 +104,7 @@ const allowed = new Set([ "native-misc-tools.js", "native-excel-to-est.js", "native-other-load.js", + "native-applied-cost.js", "native-actions-more.js", "native-wonga-tools.js", "native-estimate-print.js", From 812928a51fe0d0ae5d5e2012e267c7807b70be56 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 19:03:36 +0900 Subject: [PATCH 11/13] =?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", From 47062291000c23a4afb5a20eb1569a5d19953346 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 19:06:09 +0900 Subject: [PATCH 12/13] =?UTF-8?q?feat(est-test):=20=EA=B8=B0=EC=84=B1=20?= =?UTF-8?q?=ED=9A=8C=EC=B0=A8=20=EB=8F=84=EA=B8=89=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D(=EB=8B=B9=EC=B4=88/=EB=B3=80=EA=B2=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설계변경 내역서에서 회차 추가 때 당초/변경 고르기 - 당초면 행마다 당초 칸으로 Index·품명·규격·단위·수량·단가·금액(GC 포함) - 회차 추가 처리를 native-progress-view.js 로 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_accom_option.py | 25 +++++++++++++++ tools/est-test/native-actions.js | 15 ++------- tools/est-test/native-progress-view.js | 37 ++++++++++++++++++++++- tools/est-test/native-progress.js | 23 ++++++++++++-- 4 files changed, 83 insertions(+), 17 deletions(-) diff --git a/resources/tester/test_est_accom_option.py b/resources/tester/test_est_accom_option.py index e0e590576..10c919faa 100644 --- a/resources/tester/test_est_accom_option.py +++ b/resources/tester/test_est_accom_option.py @@ -59,3 +59,28 @@ def test_view_headers_follow_options_and_zero_hiding(): "비고", ] assert result["jun"] == "" + + +def test_origin_load_method_uses_original_fields(): + result = run( + "math", + """(()=>{ + const rows=[{Index:2,Index_:1,Name:'b',Name_:'a',Size:'s',Size_:' ',GN:5,GN_:3,GU:10,GU_:8,GC:50,GC_:24}, + {Name:'c',GN:1,GU:4}]; + const o=EstProgress.originRows(rows); + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{Code:'A',Name:'a',GN:10,GN_:4,GU:100,GU_:100},{LinkIndex:54}]; + const q=EstNative.calculate(p); + q.progress=[{id:'r1',name:'1회',inputs:{},locked:false,loadMethod:'origin'}, + {id:'r2',name:'2회',inputs:{},locked:false}]; + const a=EstProgress.calculate(q,'r1').rows[0].GN; + q.progress=[q.progress[1]]; + const b=EstProgress.calculate(q,'r2').rows[0].GN; + return {o:o.map(r=>[r.Index,r.Name,r.Size,r.GN,r.GU,r.GC]),a,b}; + })()""", + ) + assert result == { + "o": [[1, "a", "s", 3, 8, 24], [None, "c", None, 1, 4, None]], + "a": 4, + "b": 10, + } diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index a5945e75d..3dae41fab 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -428,19 +428,8 @@ const EstActions = (() => { const totals = EstQuantity.totals(project, state.quantitySheet); if (confirm("연결된 내역 수량을 산출 결과로 바꾸시겠습니까?")) EstApp.change(() => EstQuantity.applyTotals(project, totals)); - } else if (name === "add-round") { - const next = { - id: EstApp.uid(), - name: EstBusiness.newProgressName(project.progress), - inputs: {}, - approved: false, - locked: false, - }; - EstApp.change(() => { - project.progress.push(next); - state.roundId = next.id; - }, false); - } else if (name === "lock-round") { + } else if (name === "add-round") EstProgressView.addRound(state); + else if (name === "lock-round") { if ( confirm("이 회차를 확정하고 저장하시겠습니까? 전회 누계로 보존되고 직접 편집이 잠깁니다.") ) { diff --git a/tools/est-test/native-progress-view.js b/tools/est-test/native-progress-view.js index 3bcad8148..77973299b 100644 --- a/tools/est-test/native-progress-view.js +++ b/tools/est-test/native-progress-view.js @@ -287,6 +287,41 @@ const EstProgressView = (() => { } if (typeof document !== "undefined" && document.addEventListener) install(); + // 기성 회차 추가: 설계변경 내역서면 「도급내역을 선택하세요.」(당초/변경 — 닫기 없음, 고른 값으로 회차를 만듦) + function addRound(state) { + const project = state.project, + push = (loadMethod) => { + const next = { + id: EstApp.uid(), + name: EstBusiness.newProgressName(project.progress), + inputs: {}, + approved: false, + locked: false, + ...(loadMethod && { loadMethod }), + }; + EstApp.change(() => { + project.progress.push(next); + state.roundId = next.id; + }, false); + }; + if (project.options.Mode !== "설계변경") return push(); + EstActions.form( + "도급내역을 선택하세요.", + [ + { + name: "method", + label: "설계변경한 내역서입니다. 도급내역으로 불러올 데이타를 선택하세요.", + value: "origin", + choices: [ + ["origin", "당초 내역서"], + ["change", "변경 내역서"], + ], + }, + ], + (values) => push(values.method), + ); + } + function handle(name, state) { if (name === "accom-option") dialog(state); else if (name === "accom-option-ok") close(true); @@ -294,5 +329,5 @@ const EstProgressView = (() => { else return false; return true; } - return { view, get, parts, move, handle, dialog }; + return { view, get, parts, move, handle, dialog, addRound }; })(); diff --git a/tools/est-test/native-progress.js b/tools/est-test/native-progress.js index 490ec2a3c..4a763a262 100644 --- a/tools/est-test/native-progress.js +++ b/tools/est-test/native-progress.js @@ -26,11 +26,28 @@ const EstProgress = (() => { } return output; } + // 도급내역 선택(TfrmAccomLoadMethodEst 0xc8cd50 · 덮어쓰기 c8e5e8): 설계변경 내역서로 회차를 처음 만들 때 당초를 고르면 + // 행마다 당초 칸(_)으로 — Index, 품명·규격·단위(당초가 비면 지금 값), 수량·단가·금액. 원본은 GC 를 빠뜨리지만 웹은 함께 옮김. + function originRows(rows) { + return rows.map((row) => { + const out = { ...row }; + if (row.Index_ != null) out.Index = row.Index_; + for (const k of ["Name", "Size", "Unite"]) + if (String(row[k + "_"] ?? "").trim()) out[k] = row[k + "_"]; + for (const k of ["GN", "TU", "TC", ...units, ...components]) + if (row[k + "_"] != null) out[k] = row[k + "_"]; + return out; + }); + } + const contractRows = (project, round) => + round.loadMethod === "origin" + ? originRows(project.tables.EstMainDat) + : project.tables.EstMainDat; function calculate(project, roundId) { const round = project.progress.find((item) => item.id === roundId); if (!round) throw Error("기성 회차 없음"); const source = round.contract || { - rows: project.tables.EstMainDat, + rows: contractRows(project, round), wonga: project.tables.Wonga, options: project.options, }; @@ -316,7 +333,7 @@ const EstProgress = (() => { if (!round) throw Error("기성 회차 없음"); if (round.locked) throw Error("이미 확정한 회차입니다."); round.contract = { - rows: structuredClone(project.tables.EstMainDat), + rows: structuredClone(contractRows(project, round)), wonga: structuredClone(project.tables.Wonga), options: { ...project.options }, }; @@ -346,5 +363,5 @@ const EstProgress = (() => { } return project; } - return { calculate, lock, unlock, rebuild, ambiguousKeys }; + return { calculate, lock, unlock, rebuild, ambiguousKeys, originRows }; })(); From 863a958398b9e0b1fae151e8f45a6691d576cdce Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 19:06:11 +0900 Subject: [PATCH 13/13] auto: 2026-11-12 19:06 (EOMSANGDON-HOME) --- tools/est-test/PLAN.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index a0f816c0f..5438e7a19 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -491,6 +491,12 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. - [x] 표지·갑지 상세편집·용지모양, 산출 줄 글자모양, 수량산출서 인쇄, 엑셀형 셀 테두리·음영·수량붙여넣기(연결)(spec_pc §5 · spec_pa §1·6·8·9) — 상세편집: 인쇄 모양을 자유 편집해 저장하면 인쇄가 그 모양을 씀(해제 가능), 용지모양은 인쇄 창 용지·여백. 글자모양: 산출 줄 크기·굵게·색. 수량산출서 인쇄: 현재 또는 모두(간지 고르기), 줄·표·그림 그대로. 엑셀형: 셀 테두리·음영, 수량 목록에서 담은 항목을 고른 셀부터 가로/세로·명칭/규격/단위/수량으로(자기 산출서 거부, 수량 칸은 원본 항목 연결 기록). - 검증완료: 문서·바인딩 시험 4개 통과. ORCA 브라우저에서 갑지 상세편집 제목 고침 → 인쇄 html 반영, 글자모양 18px·굵게·#800000, 인쇄 창 표·간지, 엑셀형 B2·B3 에 2.5·0.19(연결 기록), 샘플 복구. + - [x] 물가변동 적용대가 보기 변경·수량불러오기·적용대가집계표(spec_pb1 §5·§6) — 보기 변경: 단가 칸 숨기고 계약·구분별 수량 7종만. 수량불러오기: 수량분 7종 중 고른 뒤 내역서 파일의 행 i 수량을 이 내역 행 i 구분 칸에(계약분은 계약 확정본, 없으면 당초 칸), 그 파일 원가 행 i 금액을 원가 행 Es*Cost 에. 집계표: 원가 행마다 계약·적용제외·적용대가 금액(계약금액은 불러온 값, 없으면 계약 내역 계산). + - 검증완료: 적용대가 시험 3개 통과. ORCA 브라우저에서 보기 변경 머리 11칸·집계표 31행 확인. + - [x] 기성 환경설정(spec_pb1 §4) — 공통적용(코드·수량 0·금액 0·비목순서·품명정렬·두줄·Enter 뒤 셀 이동·바탕색)은 계산 환경 Display, 기성고(레벨·비고란 발췌근거=비고·집계공종 진하게·사정금액·노재경 구분·실적 비목)는 AccomOption. 칸을 바꾸면 바로 화면에, 취소면 되돌림. 주 메뉴 환경설정(기성 화면)·기성 원가 「환경 설정」·Ctrl+U. 원본도 처리기 없는 자동저장 분·계산 확인·가로 계산방법·재료비 처리·이윤 사사오입은 값만. + - 검증완료: 시험 2개 통과. ORCA 브라우저에서 코드 끔·레벨·노재경 구분·사정 끔·수량 0 숨김 반영과 취소 원복 확인, 임시 회차·옵션 원복. + - [x] 도급내역 선택(spec_pb1 §3) — 설계변경 내역서에서 기성 회차를 추가하면 당초/변경 고르기. 당초면 행마다 당초 칸(_)으로 Index·품명·규격·단위(당초가 비면 지금 값)·수량·단가·금액. 웹은 `_` = 당초(설계변경 시작 때 복사본) 해석을 따름(spec_pb1 은 원본 분기상 `_` = 변경으로 읽음 — 실무 샘플 3건의 `_` 값이 서로 같아 당초 쪽으로 판단). 원본의 GC 누락 결함은 재현하지 않음. + - 검증완료: 시험 1개 통과(당초 수량 4·변경 10). ORCA 브라우저에서 고르기 창·회차 loadMethod 확인, 원복. - [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 열 배치)는 웹 격자와 호환되지 않아 하지 않음.