diff --git a/resources/tester/test_est_sources.py b/resources/tester/test_est_sources.py new file mode 100644 index 000000000..0d8627572 --- /dev/null +++ b/resources/tester/test_est_sources.py @@ -0,0 +1,60 @@ +"""EST 테스트 앱 발췌근거(원본 barbtnColumn — 가격 출처 열 이름·보이기, 숨긴 출처는 단가 고르기에서 제외).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FILES = [ + "native-math.js", + "native-formula.js", + "native-machine.js", + "native-rows.js", + "native-text.js", + "native-quantity.cjs", + "native-wonga.js", + "native-sources.js", + "native-engine.js", + "native-model.js", +] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone, console }}); + for (const f of {json.dumps(FILES)}) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }}); + 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_hidden_source_leaves_price_choice_and_labels(): + out = run_js( + r"""(() => { + const p = EstNative.empty(); + p.options.SourceRefresh = false; + p.tables.StuffcDat = [{ Code: 'M1', Name: '철근', Cost1: 100, Cost2: 80, Cost3: 120, ValueIndex: 0 }, + { Code: 'M2', Name: '모래', Cost1: 100, Cost2: 80, Cost3: 120, ValueIndex: 2 }]; + const tc = (q) => EstNative.calculate(q).tables.StuffcDat.map((r) => r.GC); + const before = tc(p); + const list = EstSources.get(p.options, 'StuffcDat'); + list[1] = { caption: '물가자료', page: '120', visible: false }; + list[2] = { caption: '유통물가', page: '', visible: false }; + EstSources.set(p.options, 'StuffcDat', list); + const after = tc(p); + let dup = ''; + try { EstSources.set(p.options, 'StuffcDat', list.map(() => ({ caption: '가격정보' }))); } catch (e) { dup = e.message; } + EstSources.set(p.options, 'LaborcDat', list.map(() => ({ caption: '같음' }))); + return { before, after, dup, label: EstSources.label(p.options, 'StuffcDat', 'Cost2'), + other: EstSources.label(p.options, 'EstMainDat', 'Cost2'), + cols: EstSources.columns(p.options, 'StuffcDat', ['Code', 'Cost1', 'Cost2', 'Cost3', 'TC']) }; + })()""" + ) + assert out["before"] == [80, 120] + assert out["after"] == [100, 100] + assert out["dup"] == "같은 발췌근거가 있습니다." + assert out["label"] == "물가자료 (120)" + assert out["other"] == "" + assert out["cols"] == ["Code", "Cost1", "TC"] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index b2eed5f56..08bf91248 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -487,6 +487,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] 발췌근거(원본 barbtnColumn — TfrmHeaderChange 0x110b7ec · 자재 TfrmStuffcHeadChange 0xf61b84) — 자재·노임 화면 「발췌근거」 단추로 출처 열(Cost1~6) 이름·쪽수(자재)·보기 편집, 자재는 8가지 출처 이름에서 고르고 중복 거부, 숨긴 출처는 최저·평균·최고 단가 고르기에서 빠짐(서버 재계산도 같은 코드). 값은 계산 환경 `PriceSources`(ESTX 저장에는 남지 않음 — 원본도 출처 이름은 격자 배치에만 둠). + - 검증완료: `test_est_sources.py`(최저 80→100·최고 120→100, 중복 거부, 머리글 「물가자료 (120)」, 숨긴 열 빠짐) 및 REA 대조 39개 통과. ORCA 브라우저에서 자재단가 발췌근거 2번 「물가자료」 쪽 77, 3번 숨김 → 머리글 반영·도급액 불변, 설정 지워 복구. - [x] 조달청 표준공사코드 적용(원본 mnuCodeChange → TfrmCodeChange — 디스어셈블 근거) — 작업데이타 7종 탭(일위·단산·기계·표준·견적·자재·노임), 코드변경(목록 코드·직접 입력, 기계 16자리면 끝 0), 검사 색(EST 코드 노랑·다른 표 중복 연분홍+비고·자리수 진황), 변경취소, 적용(자기 표·본문 머리 Code/TitleCode·참조 행 LinkIndex=종류·Index≤0 만, 재계산 없음), 코드일괄변경(고른 .estx 의 같은 표에서 자동연결 맞춤 규칙, EST→비EST 만·중복 금지, 표준은 옛 코드 부분일치), 자동변경(표준 행 번호 구간 C·A·M), 다산코드정리, 사용자정의코드, 목록·작업데이타 검색(& 두 조각). 조달청 목록은 원본 설치의 PPS_STUFFC.dat·PPS_HEAD.dat 를 사용자가 골라 불러옴(내장 자료 없음, `/api/pps-table`). 원본의 분류 트리(PPS_*TYPE)·다산정리 PkID 쓰기는 하지 않음. - 검증완료: `test_est_code_change.py` 2개(검사·중복·자리수 거부, 취소, 적용 범위·Index>0 제외, 기계 머리 TitleCode, 자동변경·다산·사용자정의코드 꼴, 설치 JodalCodeDB 로 노임 일괄변경 1건·PPS_STUFFC 58,653행) 통과. ORCA 브라우저에서 샘플 노임 「보통인부」 L0000000000→L001010101000001 적용 → 일위·단산 참조 11행 함께 바뀜, 도급액 79,603,000 불변, 샘플 복구. - [x] 기성 원가계산서 편집(원본 TfrmWongaAccomH/V 팝업 A3~A8 — 디스어셈블 근거) — 회차마다 원가 서식 사본(`round.wonga`)에 한줄 삽입·추가(끝줄 표지 이어받기)·삭제(확인), 내용 삭제(연결·금액·요율·수식·자리 비움), 선택항목 절하(도급액 행 허용), 선택데이타 절하(직전 자리, 구성비 기준 행 제외), 공사비역산 자리수(금회 도급액 10^n 미만 절하 → 이윤 역산), 초기화(사본 지움 = 도급 원가 서식), 되살리기(직전 조작 전), 산출근거 약어로 표시(수식칸 잠금만). 기성 행도 Diget>0 이면 자리맞춤. 도급금액 열은 순서가 아닌 기호·이름으로 짝지음. 원본의 번호·참조 재작성은 하지 않음(새 행 기호는 직접 입력). 확정 회차는 거부. 서버 허용 목록에서 빠져 있던 할증·부가세 스크립트(404)도 넣음. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index fdd27f49f..334a59e19 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -76,6 +76,8 @@ 편집(E) › `조달청 표준공사코드 적용`은 노임·자재·기계·일위 등의 코드를 조달청 코드로 바꿉니다. 왼쪽 목록은 원본 설치 폴더 `System\MainSys` 의 `PPS_STUFFC.dat`(노임·자재·장비) 또는 `PPS_HEAD.dat`(일위·단산·표준)를 골라 불러옵니다. 코드를 바꾼 뒤 `적용`을 누르면 그 코드를 쓰는 내역·일위·단산 줄도 함께 바뀝니다. `코드일괄변경`은 고른 .estx 파일(예: 원본 JodalCodeDB.estx)에서 품명·규격이 같은 코드를 찾아 EST 코드만 바꿉니다. +자재단가·노임단가 화면의 `발췌근거` 단추로 가격 출처 열(출처1~6)의 이름과 쪽수를 바꾸거나 숨길 수 있습니다. 숨긴 출처는 최저·평균·최고 단가를 고를 때 빠집니다. + 원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값과 수식을 함께 읽어 수량산출 화면 아래 격자로 보입니다. 셀을 누르면 수식줄에서 값이나 `=수식`을 고치고 바로 다시 계산합니다(참조·범위·사칙·SUM·ROUND 등). Shift 로 범위를 골라 행·열 넣기·지우기, 잘라내기·복사·붙여넣기, 굵게·정렬·병합·대각선을 쓸 수 있고, 수식의 참조는 원본처럼 따라 옮겨집니다. `우측·위·좌측·아래 합`은 원본처럼 그 방향의 숫자 셀을 더하는 SUM 을 넣고, `수량으로 지정`은 셀을 수량 항목에 연결해 항목 수량이 그 셀 값(산출서 자리수)을 따르게 합니다. 원본 파일에는 셀과 항목의 대응이 보이지 않아, 가져올 때 저장 수량과 값이 같은 합계 셀을 「추정 연결」로 둡니다. 셀을 고친 엑셀형 산출서는 ESTX 저장을 거부합니다. 산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 6e509e17a..06b215741 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -16,6 +16,7 @@ + @@ -268,6 +269,7 @@ > { (typeof EstDefaults !== "undefined" && EstDefaults.handle(name, state, node)) || (typeof EstExcelView !== "undefined" && EstExcelView.handle(name, state, node)) || (typeof EstProgressWonga !== "undefined" && EstProgressWonga.handle(name, state)) || - (typeof EstCodeChangeView !== "undefined" && EstCodeChangeView.handle(name, state, node)) + (typeof EstCodeChangeView !== "undefined" && EstCodeChangeView.handle(name, state, node)) || + (typeof EstSources !== "undefined" && EstSources.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-engine.js b/tools/est-test/native-engine.js index 79ebb5dfe..16550d278 100644 --- a/tools/est-test/native-engine.js +++ b/tools/est-test/native-engine.js @@ -33,10 +33,11 @@ const EstNative = (() => { "MulTitleDat", ]; const clone = (value) => structuredClone(value); - function price(row, kind) { - const candidates = Array.from({ length: 6 }, (_, i) => num(row[`Cost${i + 1}`])).filter( - (value) => value !== 0, - ); + function price(row, kind, hidden) { + // 발췌근거에서 숨긴 출처(EstSources)는 최저·평균·최고에서 뺀다 + const candidates = Array.from({ length: 6 }, (_, i) => + hidden?.has(i + 1) ? 0 : num(row[`Cost${i + 1}`]), + ).filter((value) => value !== 0); let total; switch (num(row.ValueIndex)) { case 0: @@ -144,7 +145,12 @@ const EstNative = (() => { .concat(calculated.filter((row) => !existing.has(row.Code))); } for (const kind of [5, 6, 21, 101]) - for (const row of tables[sourceTables[kind]]) price(row, kind); + for (const row of tables[sourceTables[kind]]) + price( + row, + kind, + typeof EstSources !== "undefined" ? EstSources.hidden(options, sourceTables[kind]) : null, + ); const machine = EstMachine.calculate(tables.MachMainDat, tables.MachGijunDat, options); tables.MachMainDat = machine.rows; saveTitles("MachTitleDat", machine.titles); diff --git a/tools/est-test/native-sources.js b/tools/est-test/native-sources.js new file mode 100644 index 000000000..083b42f9a --- /dev/null +++ b/tools/est-test/native-sources.js @@ -0,0 +1,93 @@ +// 원본 「발췌근거」(barbtnColumn — 일반 TfrmHeaderChange 0x110b7ec · 자재 TfrmStuffcHeadChange 0xf61b84): +// 단가 표의 가격 출처 열(Cost1~6) 머리글 이름·쪽수·보이기를 고친다. 숨긴 출처는 최저·평균·최고 단가 고르기(113e918)에서 빠진다. +// 자재 표는 출처 이름을 정해진 8가지에서 고르고 같은 이름이 둘이면 거부한다. 값은 계산 환경 PriceSources[표] 에 둔다. +const EstSources = (() => { + const NAMES = [ + "가격정보", + "물가자료", + "유통물가", + "물가정보", + "거래가격", + "견적가1", + "견적가2", + "견적가3", + ]; + const TABLES = ["StuffcDat", "LaborcDat"]; + + function get(options, table) { + const saved = options?.PriceSources?.[table] || []; + return Array.from({ length: 6 }, (_, i) => ({ + caption: saved[i]?.caption || `출처${i + 1}`, + page: saved[i]?.page ?? "", + visible: saved[i]?.visible !== false, + })); + } + function hidden(options, table) { + return new Set( + get(options, table) + .map((s, i) => (s.visible ? -1 : i + 1)) + .filter((i) => i > 0), + ); + } + function columns(options, table, cols) { + const off = hidden(options, table); + return off.size ? cols.filter((k) => !off.has(Number(/^Cost(\d)$/.exec(k)?.[1]))) : cols; + } + function label(options, table, key) { + const m = /^Cost(\d)$/.exec(key); + if (!m || !TABLES.includes(table)) return ""; + const s = get(options, table)[Number(m[1]) - 1]; + return s.page ? `${s.caption} (${s.page})` : s.caption; + } + function set(options, table, list) { + if (table === "StuffcDat") { + const seen = new Set(); + for (const s of list) { + if (seen.has(s.caption)) throw Error("같은 발췌근거가 있습니다."); + seen.add(s.caption); + } + } + options.PriceSources = { ...(options.PriceSources || {}), [table]: list }; + } + + function dialog(state) { + const table = state.view; + if (!TABLES.includes(table)) throw Error("자재단가·노임단가 화면에서 쓰세요."); + const { esc } = EstApp, + list = get(state.project.options, table), + stuffc = table === "StuffcDat"; + EstApp.showDialog( + "발췌근거 수정 및 보기", + `${stuffc ? "" : ""}${list + .map( + (s, i) => + `${stuffc ? `` : ""}`, + ) + .join("")}
No발췌근거페이지보기
${i + 1}${ + stuffc + ? `` + : `` + }
`, + ); + } + function apply(state) { + const table = state.view, + q = (name, i) => document.querySelector(`#dialog-content [name="${name}"][data-i="${i}"]`); + const list = Array.from({ length: 6 }, (_, i) => ({ + caption: q("src-caption", i).value.trim() || `출처${i + 1}`, + page: q("src-page", i)?.value.trim() ?? "", + visible: q("src-visible", i).checked, + })); + set(structuredClone(state.project.options), table, list); + EstApp.change((project) => set(project.options, table, list)); + EstApp.byId("dialog").close(); + } + function handle(name, state) { + if (name === "sources") dialog(state); + else if (name === "sources-apply") apply(state); + else return false; + return true; + } + + return { NAMES, get, hidden, columns, label, set, handle }; +})(); diff --git a/tools/est-test/native-views.js b/tools/est-test/native-views.js index 41d3c55e0..65fe81ced 100644 --- a/tools/est-test/native-views.js +++ b/tools/est-test/native-views.js @@ -76,7 +76,7 @@ const EstViews = (() => { } function grid(table, list, columns) { const { esc, number, state } = EstApp; - 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)) || EstModel.labels[key] || key)}
0 ? ` style="padding-left:${Number(row.LeftSpace) * 6}px"` : ""}>${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((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"` : ""}>${cell(table, index, key, row[key])}
`; } function render(state) { const { project, view } = state, @@ -103,6 +103,8 @@ const EstViews = (() => { if (haljungBtn) haljungBtn.hidden = view !== "HeadMainDat"; const vatBtn = byId("vat-calc-btn"); if (vatBtn) vatBtn.hidden = view !== "StuffcDat"; + const sourcesBtn = byId("sources-btn"); + if (sourcesBtn) sourcesBtn.hidden = !["StuffcDat", "LaborcDat"].includes(view); const unitCalcBtn = byId("unit-calc-btn"); if (unitCalcBtn) unitCalcBtn.hidden = view !== "HeadMainDat"; byId("move-estimate-up").hidden = ![ @@ -124,7 +126,9 @@ const EstViews = (() => { columns = view === "Wonga" && typeof EstJungSang !== "undefined" ? EstJungSang.columns(project, EstModel.columns(view)) - : EstModel.columns(view); + : typeof EstSources !== "undefined" + ? EstSources.columns(project.options, view, EstModel.columns(view)) + : EstModel.columns(view); byId("work-content").innerHTML = grid(view, list, columns); byId("paging").hidden = false; byId("page-info").textContent = diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 60b6307f3..52ff7ef96 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -26,6 +26,7 @@ const scripts = [ "native-quantity.cjs", "native-wonga.js", "native-jungsang.js", + "native-sources.js", "native-engine.js", "native-row-match.js", "native-escalation.js",