From 24d8704caeea4da59dd3552d86b9b78a58005406 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 17:59:11 +0900 Subject: [PATCH 01/32] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC801):=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EC=97=90=EC=84=9C=200.7=20=E2=86=92=201.0=20?= =?UTF-8?q?=EB=B0=94=EA=BF=94=EB=8F=84=20=EC=9A=B0=EC=B8=A1=20=EA=B0=92=20?= =?UTF-8?q?=EC=95=88=20=EB=B0=94=EB=80=9C=20=EC=9E=AC=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=C2=B7=20=EC=A7=81=EC=A2=85=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/사용자요구_1104.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index 74f675c29..e504ff885 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -1041,3 +1041,8 @@ STmate 의 「굴삭기(1.0m3)은 무한궤도 1. 추가 확인사항 sk-000048 항목은 내가 다른 경쟁사의 프로그램에서 가져오기 테스트를 하고 있음. 정상적으로 가져온것같고 여기서 기계를 변경했는데 산출근거의 데이터가 업데이트 안됨. 조사 시작 2. 단가산출에서 기계의 우측에 복사해 내 기계로 항목이 있음. 복사해서 내기계로 가져갈 필요가 없어보임. 기계의 시스템 관리자를 제외한 회사관리자와 일반사용자는 에서 항목추가로 추가하는게 만들어서 하는게 어떤지. 검토 . + +## 요구 801 (11-12 · 사용자 · 1 그 말이 아니라 단가산출 상세 화면에서 0.7㎥ 를 1.0㎥ 로 바꿨는데 우측 산출 로직 값이 안 바뀌어 확인하라는 것 · 다시 확인 · 2 새 기계 추가의 직종 = 내용이 많아 드롭다운에 검색 필요 · 검색되면 적당히만 보여 줌 · 지정된 경우 다시 열면 선택된 항목 주변을 보여 주는 기능은 유지) + +1. 그말이 아니라 내가 단가산출 상세화면에 접근해서 0.7m2 을 1.0m2으로 변경해봤는데 우측 산출로직에서 값 변경이 없어서 확인하라고 한거야. 다시 확인 +2. 새 기계 추가에서 직종의 경우 내용이 많아 드롭다운 항목들 중 검색도 있어야함. 검색이 되면 많은 항목을 보여줄필요없으니 적당히만 보여주고 추가로 지금은 지정된 경우 다시 열면 선택된 항목이 주변을 보여줌. 이 기능은 유지. From 5270eb335f38f740b5934dd553dcbfda5ce63850 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:03:42 +0900 Subject: [PATCH 02/32] =?UTF-8?q?feat(est-test):=20=EC=97=B0=EC=B0=A8=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=EC=84=9C=C2=B7=EC=83=81=EB=8B=A8=C2=B7?= =?UTF-8?q?=ED=95=98=EB=8B=A8=EB=8D=B0=EC=9D=B4=ED=83=80=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=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/helper_est_native.cjs | 1 + .../tester/test_est_progress_workflow.py | 18 +++++ tools/est-test/PLAN.md | 2 + tools/est-test/index.html | 1 + tools/est-test/native-annual-load.js | 74 +++++++++++++++++++ tools/est-test/native-business-actions.js | 3 +- tools/est-test/native-business.js | 1 + tools/est-test/native-extra.js | 9 +++ tools/est-test/server.cjs | 1 + 9 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 tools/est-test/native-annual-load.js diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index c1b0fb606..9b5f65fc9 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -15,6 +15,7 @@ for (const file of [ "native-row-match.js", "native-escalation.js", "native-progress.js", + "native-annual-load.js", "native-business.js", "native-review.js", "native-resources.js", diff --git a/resources/tester/test_est_progress_workflow.py b/resources/tester/test_est_progress_workflow.py index ae94c9051..431afafe7 100644 --- a/resources/tester/test_est_progress_workflow.py +++ b/resources/tester/test_est_progress_workflow.py @@ -202,3 +202,21 @@ def test_progress_preserves_prior_costs_for_multiple_blank_codes(): assert result["errors"] == [] assert [row["JunCost"] for row in result["costs"]] == [200, 100] assert [row["TotalCost"] for row in result["costs"]] == [500, 250] + + +def test_annual_loaded_rows_replace_formula(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{PkID:2,Code:'S',GN:4,NU:50},{LinkIndex:54}]; + const loaded=[{PkID:1,Code:'R',GN:3,GU:100,GC:300,TC:300},{PkID:2,Code:'S',GN:2,NU:50,NC:100,TC:100}]; + p.annual=[{name:'1차',formula:'다른공사',rows:loaded}]; + const out=EstBusiness.annual(EstNative.calculate(p)); + const rows=out.results[0].project.tables.EstMainDat; + return {gn:rows.slice(0,2).map(r=>r.GN), tc:out.results[0].project.calculation.direct.TC, errors:out.errors}; + })()""", + ) + assert result["gn"] == [3, 2] + assert result["tc"] == 400 + assert result["errors"] == [] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 970b7c90a..37ed2916b 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] 연차 내역서·상단·하단데이타 불러오기(원본 TFRMESTMULTIEDIT popLoadEst·popUpDataLoad·popDownLoad 0xedb784 → 0xf11a60 — spec_part §7) — 차수 줄의 세 단추: 고른 .estx 내역서 줄을 차수에 짝지어 옮기고(행 수는 현재 내역), 수식칸에 파일 이름. 상단·하단은 설계변경 내역만(당초 「_」 칸 있는 것), 상단 = 당초값·하단 = 변경값. 그 차수의 상세 줄은 수식 대신 불러온 값(금액 고정, 다시 셈 없음). 서버 계산도 같은 코드(`native-annual-load.js`). + - 검증완료: 기성·연차 시험 13개(불러온 줄 수량 3·2·직접공사비 400, 수식 무시) 통과. 당초/변경 고르기·설계변경 아닌 파일 거부 확인. ORCA 브라우저에서 차수 줄에 불러오기 단추 3개 표시(파일 선택은 수동 조작 필요). - [x] 현재데이타 갱신(원본 TFRMDATAUPDATE btnApply 0x10bc3d4 → 엔진 0x10e12c8 — spec_update) — 원천: Main Data(원본 설치 main.estx = BufDat\Main\Main_1, 서버 `/api/main-data`, `EST_MAIN_DATA` 로 경로 변경) 또는 고른 .estx. 항목: 노임·자재(유류 제외)·유류(이름 8종)·표준(코드 일치)·기계(손료계수·환율 표 교체, 기계 호표 이름·규격 일치, 산출근거 줄 교체 뒤 자재·노임 다시 잇기)·환율(36·89·69·77·70·76). 맞춤 규칙·복사 칸·단가미갱신 표시·안내 문구 원본대로. 행 추가·삭제 없음. 갱신 뒤 재계산. 하지 않음: 최신(웹접속 — 원본 회사 서버·유지보수 인증), 자재 단가열 출처 재배치(OtherTables — 같은 열 순서 가정), 원본 결함(환율 끔 때 파운드를 76 값으로 덮음). - 검증완료: `test_est_data_update.py`(노임 갱신·당초 칸 유지·못 찾음 표시와 문구, 자재 규격·단위 정리 맞춤·쪽수, 자재만 갱신 때 유류·비율 제외, 표준 코드 맞춤·이름 교체, 손료 계수 표 교체·당초 칸 유지, 환율 끔 유지) 통과. 샘플을 원본 main.estx 로 갱신하면 보통인부 172,068→172,698(2026 하반기)·못 찾음 0(145ms). ORCA 브라우저에서 같은 결과, 샘플 복구. - [x] 견적서 엑셀변환(원본 TFRMEXCELCONVERTESTIPRN btnConvert) — 견적서 창 「엑셀변환」: 머리(제목·공사명·귀하·견적일자·합계금액 한글·공급자)와 레벨에 맞춘 내역 줄(품명·규격·단위·수량·재료/노무/경비/합계 단가·금액·비고)을 .xlsx 로 내려받기. 원본의 인쇄비율·셀 수식은 하지 않음(값만). diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 43f5f8115..29f6f57cd 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -22,6 +22,7 @@ + diff --git a/tools/est-test/native-annual-load.js b/tools/est-test/native-annual-load.js new file mode 100644 index 000000000..17d4c1460 --- /dev/null +++ b/tools/est-test/native-annual-load.js @@ -0,0 +1,74 @@ +// 원본 연차(다차수) 「내역서데이타 불러오기」·「상단데이타 불러오기」·「하단데이타 불러오기」(TFRMESTMULTIEDIT popLoadEst 0xedb784 → +// 0xf11a60 — spec_part §7): 고른 .estx 의 내역서 줄을 차수 열에 짝지어 옮기고(행 수는 현재 내역 그대로), 차수 수식칸에 파일 이름을 적는다. +// 상단·하단은 설계변경 내역만 받으며 상단 = 당초값(「_」 짝 칸)·하단 = 변경값(원본 옵션 +0x37 = 0 기준). 금액은 파일 값 그대로(다시 셈 없음). +const EstAnnualLoad = (() => { + const num = (v) => Number(v) || 0; + const ORIGINAL = [ + "Name", + "Size", + "Unite", + "GN", + "TU", + "TC", + "NU", + "NC", + "GU", + "GC", + "JU", + "JC", + "SU", + "SC", + "Bigo", + ]; + // 차수 계산에서 불러온 줄을 쓰면 true + function row(year, index, target) { + const loaded = year.rows?.[index]; + if (!loaded) return false; + Object.assign(target, structuredClone(loaded)); + target.CalcIndex = num(loaded.CalcIndex) >= 2 ? 3 : 1; + return true; + } + function rows(source, tag) { + const list = structuredClone(source.tables?.EstMainDat || []); + if (!list.length) throw Error("내역서 데이타를 찾을수 없습니다."); + if (tag) { + const changed = list.some((r) => + Object.entries(r).some(([k, v]) => k.endsWith("_") && v != null && v !== ""), + ); + if (!changed) throw Error("설계변경을 한 내역서가 아닙니다."); + if (tag === 1) + for (const r of list) for (const k of ORIGINAL) if (`${k}_` in r) r[k] = r[`${k}_`]; + } + return list; + } + async function load(state, index, tag) { + const file = await new Promise((resolve) => { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = ".estx"; + picker.addEventListener("change", () => resolve(picker.files[0])); + picker.click(); + }); + if (!file) return; + const source = await EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, { + method: "POST", + body: await file.arrayBuffer(), + }); + const list = rows(source, tag); + EstApp.change((project) => { + const year = project.annual[index]; + year.rows = list.slice(0, project.tables.EstMainDat.length); + year.formula = file.name.replace(/\.estx$/i, ""); + }, false); + state.notice = `「${file.name}」 내역서 ${list.length}줄을 차수에 불러왔습니다.`; + } + function handle(name, state, node) { + if (name !== "annual-load") return false; + load(state, Number(node.dataset.row), Number(node.dataset.tag)).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } + return { row, rows, handle }; +})(); diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index bdcce47e8..a2c660209 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -638,7 +638,8 @@ const EstBusinessActions = (() => { (typeof EstGlobalVars !== "undefined" && EstGlobalVars.handle(name, state, node)) || (typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state)) || (typeof EstBook !== "undefined" && EstBook.handle(name, state, node)) || - (typeof EstDataUpdate !== "undefined" && EstDataUpdate.handle(name, state)) + (typeof EstDataUpdate !== "undefined" && EstDataUpdate.handle(name, state)) || + (typeof EstAnnualLoad !== "undefined" && EstAnnualLoad.handle(name, state, node)) ) { EstApp.render(); } else if (name === "insert-annual") { diff --git a/tools/est-test/native-business.js b/tools/est-test/native-business.js index 86a3f0bda..31eb0fb42 100644 --- a/tools/est-test/native-business.js +++ b/tools/est-test/native-business.js @@ -632,6 +632,7 @@ const EstBusiness = (() => { row.LinkIndex = 99; } if (num(row.Level) > 0 || [54, 55, 58, 59, 62, 67].includes(num(row.LinkIndex))) continue; + if (typeof EstAnnualLoad !== "undefined" && EstAnnualLoad.row(year, index, row)) continue; try { // f0f9b8/f1098c resolve the same column formula against each current field. const value = (field) => diff --git a/tools/est-test/native-extra.js b/tools/est-test/native-extra.js index 7f1ef4ca4..30ede036e 100644 --- a/tools/est-test/native-extra.js +++ b/tools/est-test/native-extra.js @@ -329,6 +329,15 @@ const EstExtra = (() => { `data-row="${index}" data-direction="1" ${index === project.annual.length - 1 ? "disabled" : ""}`, ) + button("insert-annual", "위에 삽입", `data-row="${index}"`) + + ["내역서", "상단", "하단"] + .map((l, t) => + button( + "annual-load", + `${l}데이타 불러오기`, + `data-row="${index}" data-tag="${t}"`, + ), + ) + .join("") + button("delete-extra", "삭제", `data-section="annual" data-row="${index}"`), ]), ), diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 9a171af68..248e3fb1a 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -36,6 +36,7 @@ const scripts = [ "native-row-match.js", "native-escalation.js", "native-progress.js", + "native-annual-load.js", "native-business.js", "native-review.js", "native-resources.js", From 4332e343b3592472bd691deda6fd1de266d31fd2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:06:17 +0900 Subject: [PATCH 03/32] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC802):=20M02?= =?UTF-8?q?=20=EA=B0=9C=EC=9D=B8=EA=B4=80=EB=A6=AC=20=EC=BB=A8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=84=88(=EA=B8=B0=EA=B3=84=20=C2=B7=20=EC=9E=90?= =?UTF-8?q?=EC=9E=AC)=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=9E=90=EB=A6=AC=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/사용자요구_1104.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index e504ff885..c75af85e2 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -1046,3 +1046,7 @@ STmate 의 「굴삭기(1.0m3)은 무한궤도 1. 그말이 아니라 내가 단가산출 상세화면에 접근해서 0.7m2 을 1.0m2으로 변경해봤는데 우측 산출로직에서 값 변경이 없어서 확인하라고 한거야. 다시 확인 2. 새 기계 추가에서 직종의 경우 내용이 많아 드롭다운 항목들 중 검색도 있어야함. 검색이 되면 많은 항목을 보여줄필요없으니 적당히만 보여주고 추가로 지금은 지정된 경우 다시 열면 선택된 항목이 주변을 보여줌. 이 기능은 유지. + +## 요구 802 (11-12 · 사용자 · M02 — 내 기계 · 내 품목을 「개인관리」 컨테이너로 병합 · 안에 단추 둘 · 이름 「기계」 · 「자재」 · 「구조물 조합」 컨테이너를 「일위대가」 바로 아래로) + +M02에서 내 기계와 내 품목은 개인관리 컨테이너로 병합함. 내부에는 2개의 버튼으로 해당페이지를 열면됨. 내 기계의 이름은 기계, 내 품목은 자재로 변경, 그리고 일위대가 바로 아래에 구조물 조합 컨테이너가 들어보게 변경 From 0fee9fb69a005105ca3cfc08cfb5a161aa149003 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:07:41 +0900 Subject: [PATCH 04/32] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC803):=20?= =?UTF-8?q?=EB=82=B4=EC=9E=A5=20=EC=BA=90=EB=93=9C=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=EA=B0=80=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=EB=95=8C=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=E2=80=94=20=EC=9D=98=EA=B2=AC=20=EB=AC=BC?= =?UTF-8?q?=EC=9D=8C=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/사용자요구_1104.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index c75af85e2..4c388a751 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -1050,3 +1050,7 @@ STmate 의 「굴삭기(1.0m3)은 무한궤도 ## 요구 802 (11-12 · 사용자 · M02 — 내 기계 · 내 품목을 「개인관리」 컨테이너로 병합 · 안에 단추 둘 · 이름 「기계」 · 「자재」 · 「구조물 조합」 컨테이너를 「일위대가」 바로 아래로) M02에서 내 기계와 내 품목은 개인관리 컨테이너로 병합함. 내부에는 2개의 버튼으로 해당페이지를 열면됨. 내 기계의 이름은 기계, 내 품목은 자재로 변경, 그리고 일위대가 바로 아래에 구조물 조합 컨테이너가 들어보게 변경 + +## 요구 803 (11-12 · 사용자 · 내장 캐드 — 드래그하면 그리드 표현이 고정돼 있음 · 따라가는 게 맞는 것 같은데 어떻게 생각하는지) + +내장 캐드에서 드래그를 하면 그리드 표현이 고정되어있음. 따라가는게 맞는것같은데? 어떻게 생각함. From 200bbb57afb73917d61b45787b560ff48a5d445f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:10:53 +0900 Subject: [PATCH 05/32] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC804):=20?= =?UTF-8?q?=EC=98=81=EA=B5=AC=EC=A0=80=EC=9E=A5=EC=86=8C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=98=95=ED=83=9C=20=C2=B7=20=EC=86=8D?= =?UTF-8?q?=EB=8F=84=20=EC=A1=B0=EC=82=AC=20=EC=9A=94=EC=B2=AD=20=EC=9B=90?= =?UTF-8?q?=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/사용자요구_1104.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index 4c388a751..49014f0cb 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -1054,3 +1054,7 @@ M02에서 내 기계와 내 품목은 개인관리 컨테이너로 병합함. ## 요구 803 (11-12 · 사용자 · 내장 캐드 — 드래그하면 그리드 표현이 고정돼 있음 · 따라가는 게 맞는 것 같은데 어떻게 생각하는지) 내장 캐드에서 드래그를 하면 그리드 표현이 고정되어있음. 따라가는게 맞는것같은데? 어떻게 생각함. + +## 요구 804 (11-12 · 사용자 · 영구저장소 데이터 형태 조사 — M01 기계 · 단가산출 기초 · M02 단가산출 · 일위대가 · 구조물 조합 · 내 기계가 마스터 · 회사 · 개인마다 각각 하나의 파일로 저장 → 불러오기 · 업데이트가 느린 것으로 판단 · 조사부터 하고 판단) + +다음 조사는 영구저장소의 데이터 형태에 대한 고민, 내가 보기에는 현재 영구저장소에 저장되는 M01 기계, 단가산출 기초, M02의 단가산출, 일위대가, 구조물조합, 내 기계 등이 각각 마스터, 회사, 개인 항목에 따라 각각 하나의 파일로 저장되어있어. 불러오기나 업데이트에 속도가 느린걸로 판단됨. 너의 생각은 조사부터 해보고 판단하자. From 83d941e515ad950454bce382697363b2af82e19c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:11:58 +0900 Subject: [PATCH 06/32] =?UTF-8?q?feat(824-1):=20M02=20=EC=99=BC=EC=AA=BD?= =?UTF-8?q?=20=E3=80=8C=EB=82=B4=20=EA=B8=B0=EA=B3=84=E3=80=8D=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EB=82=B4=20=ED=92=88=EB=AA=A9=E3=80=8D=20=E2=86=92=20?= =?UTF-8?q?=E3=80=8C=EA=B0=9C=EC=9D=B8=EA=B4=80=EB=A6=AC=E3=80=8D=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88(=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=E3=80=8C=EA=B8=B0=EA=B3=84=E3=80=8D=20=C2=B7=20=E3=80=8C?= =?UTF-8?q?=EC=9E=90=EC=9E=AC=E3=80=8D)=20=C2=B7=20=E3=80=8C=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EC=A1=B0=ED=95=A9=E3=80=8D=20=EC=9D=84=20?= =?UTF-8?q?=E3=80=8C=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=E3=80=8D=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=20=EC=95=84=EB=9E=98=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 페이지 제목 「개인관리 › 기계」 · 「개인관리 › 자재」 · 동작 · 저장 그대로 Co-Authored-By: Claude Sonnet 5.5 --- .../M02_MasterTemplete_UI_Side.ts | 25 +++++++++++-------- .../M02_MasterTemplete_UI_Style.css | 5 ++++ M02_MasterTemplete/M02_MyItems_View.ts | 2 +- M02_MasterTemplete/M02_MyMachine_Text.ts | 9 ++++--- M02_MasterTemplete/M02_MyMachine_View.ts | 4 +-- 5 files changed, 27 insertions(+), 18 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index df39ccbcc..7acb55966 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts * 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` · - * 50-3 · 134-3) › 「내 기계」 · 「내 품목」(821-3 · 개인 층 · 펼치면 가운데 화면) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 · + * 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 「개인관리」(824-1 · 단추 「기계」 · 「자재」 · 가운데 화면) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 · * `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기). * 구조물 옵션 정의는 M01 로 옮김(716-1). * 도면 양식 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4). @@ -157,17 +157,16 @@ export function buildSide(opt: SideOptions): SideHandle { getOpen: opt.getOpen, }); const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body); - // 내 기계 · 내 품목(821-3) — 자기 개인 층 · 펼치면 가운데 화면(목록 · 양식) - const hint = (key: "Side_Hint" | "Items_Side_Hint"): HTMLElement => - createEmpty({ text: mt(key), small: true }); - const myMachSec = openOnExpand(section(mt("Title"), hint("Side_Hint")), async () => { + // 개인관리(824-1) — 단추 「기계」 · 「자재」(821-3 · 자기 개인 층) · 누르면 가운데 화면(목록 · 양식) + const myMachBtn = createButton({ label: mt("Title"), variant: "ghost", size: "sm" }); + myMachBtn.addEventListener("click", async () => { if (!(await opt.confirmLeave())) return; let mark = (_d: boolean): void => {}; const node = buildMyMachineView({ owner: null, setDirty: (d) => mark(d) }); - mark = opt.showView(mt("Title"), node); + mark = opt.showView(mt("Crumb_Machine"), node); }); - myMachSec.dataset.myMachines = ""; - const myItemsSec = openOnExpand(section(mt("Items_Title"), hint("Items_Side_Hint")), async () => { + const myItemsBtn = createButton({ label: mt("Items_Title"), variant: "ghost", size: "sm" }); + myItemsBtn.addEventListener("click", async () => { if (!(await opt.confirmLeave())) return; const node = buildMyItemsView({ owner: null, @@ -184,8 +183,13 @@ export function buildSide(opt: SideOptions): SideHandle { }); }, }); - opt.showView(mt("Items_Title"), node); + opt.showView(mt("Crumb_Items"), node); }); + const personalSec = section( + mt("Personal_Title"), + el("div", { className: "m02-side__row", children: [myMachBtn, myItemsBtn] }), + ); + personalSec.dataset.myMachines = ""; // 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝 const onBuilt = (): void => precombo.body.isConnected @@ -205,9 +209,8 @@ export function buildSide(opt: SideOptions): SideHandle { children: [ sangeunSec, precomboSec, - myMachSec, - myItemsSec, summary, + personalSec, createDivider(), section(hopyo.title, hopyo.body), drawing, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 674704b72..4716f793b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -16,6 +16,11 @@ gap: var(--spacing-4); } +/* 개인관리(824-1) — 제목 아래 단추 줄 띄움 */ +[data-my-machines] > .m02-side__row { + margin-top: var(--spacing-12); +} + /* 일위대가 조합 서고 목록표(51-4) — 표 꼴은 M01 목록(`m01-master__grid`) · 첫 칸 = 고름 */ .m01-master__grid .m02-lib__pick { width: 2.5rem; diff --git a/M02_MasterTemplete/M02_MyItems_View.ts b/M02_MasterTemplete/M02_MyItems_View.ts index 823a63507..3cf894d95 100644 --- a/M02_MasterTemplete/M02_MyItems_View.ts +++ b/M02_MasterTemplete/M02_MyItems_View.ts @@ -28,7 +28,7 @@ export function buildMyItemsView(opt: MyItemsViewOptions): HTMLElement { const uses = createFindList({ placeholder: mt("Open_Sk") }); uses.root.hidden = true; const table = createDataTable({ - caption: mt("Items_Title"), + caption: mt("Crumb_Items"), columns: [ { key: "이름", label: mt("Name"), width: 200 }, { key: "규격", label: mt("Spec"), width: 140 }, diff --git a/M02_MasterTemplete/M02_MyMachine_Text.ts b/M02_MasterTemplete/M02_MyMachine_Text.ts index 19b1e1732..d06f4c1cc 100644 --- a/M02_MasterTemplete/M02_MyMachine_Text.ts +++ b/M02_MasterTemplete/M02_MyMachine_Text.ts @@ -4,8 +4,11 @@ * ========================================================================== */ const TEXT = { - Title: "내 기계", - Items_Title: "내 품목", + Personal_Title: "개인관리", + Title: "기계", + Items_Title: "자재", + Crumb_Machine: "개인관리 › 기계", + Crumb_Items: "개인관리 › 자재", New: "새 기계", Copy_Title: "복사해 내 기계로", Find: "이름 · 규격 · 키로 찾기", @@ -38,8 +41,6 @@ const TEXT = { No_Uses: "쓰는 단가산출 없음", Leave_Ask: "저장 안 한 고침을 버리고 닫을까?", Mine: "내 기계", - Side_Hint: "펼치면 가운데에 목록 · 양식", - Items_Side_Hint: "펼치면 가운데에 모아 보기(읽기)", Won: "원", Labor: "노", Material: "재", diff --git a/M02_MasterTemplete/M02_MyMachine_View.ts b/M02_MasterTemplete/M02_MyMachine_View.ts index ffebeaa77..88b6aea9a 100644 --- a/M02_MasterTemplete/M02_MyMachine_View.ts +++ b/M02_MasterTemplete/M02_MyMachine_View.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M02_MyMachine_View.ts - * M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「내 기계」 칸을 펼치면 가운데에 뜸(개인 층). + * M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「개인관리」 › [기계] 단추로 가운데에 뜸(개인 층). * 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 · [새 기계] * 줄 누름 = 양식 한 장(`M02_MyMachine_Form`) · [목록] = 저장 안 한 고침이면 물음 · 저장 · 지우기 뒤 목록 다시 * ========================================================================== */ @@ -33,7 +33,7 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement { const hint = createText(""); const fail = el("div"); const table = createDataTable({ - caption: mt("Title"), + caption: mt("Crumb_Machine"), columns: [ { key: "이름", label: mt("Name"), width: 180 }, { key: "규격", label: mt("Spec"), width: 120 }, From 58bebaf0c31182e2e066db9b8d1dc579b3175e2b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:12:21 +0900 Subject: [PATCH 07/32] =?UTF-8?q?feat(822-2):=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EB=8B=B9=20=EC=8B=9D=20=EC=9E=90=EB=A3=8C=20?= =?UTF-8?q?=E2=80=94=20=EC=83=88=20=EC=8B=9D=20=EB=84=B7=20=C2=B7=2033=20?= =?UTF-8?q?=EB=8C=80=20=EC=98=AE=EA=B9=80=20=C2=B7=20=EC=A7=81=EC=A2=85=20?= =?UTF-8?q?13=20=EB=8C=80=20=C2=B7=20=EC=A0=84=EB=A0=A5=20=EC=B9=B8=20?= =?UTF-8?q?=C2=B7=20POWER=20TROWEL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 08장 로직: GC001196 전력 기종 · GC001197 전력 + 조종원 · GC001198 손료계수 한 칸 연료 · GC001199 직접 값 - 손료만 식(GC000273) 목록에서 연료 · 조종원 · 전력 있는 기계 뺌 → 276 · 268 · 새 식 목록(보정 식 274 그대로) - 기계표: 주연료 칸 ㎾ 글 10 대 → 전력(㎾) 숫자 칸(값원문 보존) · 원문 잡재료 「-」 = 0 · 조종원직종 13 대(요구 799) - EQ000534 이름 POWER TLOWEL → POWER TROWEL(옛이름 보존) - 가격 없는 기계(스프레어 둘 · TBM 4.5m) · 중유 믹싱플랜트 · 73.7+ 표층재생기는 목록 밖(꼴로 고름 → 까닭 글) - 수식 문서 802-2 다시(1,518 저장 · 기본 다름 0) · 기존 M01 문서 1,514 금액 전후 같음 - M01 기계 저장 손질: 보낸 단위 · 출처 칸이 있으면 그 글 Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Machine.py | 83 ++++- docs/raw/요구_1104/_목록_802-2_변환대조.xlsx | Bin 13006 -> 13302 bytes resources/master_data/_키대장.json | 6 +- .../scripts/build_기계시간당_822_2.py | 310 ++++++++++++++++++ resources/master_data/기계.json | 273 ++++++++------- .../로직_건설품셈_08장_건설기계.json | 187 +++++++++-- .../수식문서/수식_건설품셈_08장_건설기계.json | 145 +++++++- 7 files changed, 849 insertions(+), 155 deletions(-) create mode 100644 resources/master_data/scripts/build_기계시간당_822_2.py diff --git a/M01_MasterData/M01_MasterData_Machine.py b/M01_MasterData/M01_MasterData_Machine.py index 0972627af..fa6061d72 100644 --- a/M01_MasterData/M01_MasterData_Machine.py +++ b/M01_MasterData/M01_MasterData_Machine.py @@ -3,11 +3,14 @@ 계수 = `master_machine_coef`(TS 짝 `common_util/common_util_machine_coef.ts`) · 시간당 = 서버 기계경비식(`M02_Machine_Hourly.hourly` · 전국평균 · 기본 옵션) · +쓰는 계산식 = `M02_Machine_Hourly.pick`(목록 › 꼴 · 822-2) · [새 기계] 저장 = 꼴에 맞는 식 목록에 원문번호 등록 · +M01 에서 만든 기계(출처 모두 「자체」)를 지우면 그 번호 기계가 더 없을 때 목록에서 뺌(`register`) · 마스터 파일은 읽기만 — 쓰기는 `Store_Make.save`. """ from __future__ import annotations +import copy import json import re from decimal import Decimal @@ -102,6 +105,78 @@ def hourly(keys: list[str]) -> dict[str, dict]: return out +def formula(row: dict) -> dict: + """기계 줄(저장 전 고친 값도) → 쓰는 계산식 {키, 이름, 방법 목록|꼴|그 기계 식, 까닭}(822-2 상세 페이지).""" + from M02_MasterTemplete import M02_Machine_Hourly as mh # M02 가 M01 을 부름 — 늦게 + + code = str(row.get("원문번호") or "") + got = mh.pick({**row, "값": store._dec(row.get("값") or {})}) + how = "목록" if mh.listed(code) else "그 기계 식" if got and mh._own(code) else "꼴" + base = {"방법": how, "까닭": mh.why({**row, "값": store._dec(row.get("값") or {})}, got)} + return base | ({"키": got["키"], "이름": got.get("이름")} if got else {}) + + +def _family_files() -> dict[str, str]: + """기계경비식 키 → 로직 파일 이름.""" + return { + str(r.get("키")): name + for name, data in store.loaded()[0].items() + if data.get("그룹") == "로직" and "건설기계" in name + for r in data.get("줄") or [] + } + + +def register(batch: list[dict]) -> None: + """[새 기계] 저장 = 꼴 식 「기계」 목록에 그 원문번호 더함 · M01 에서 만든 기계(출처 모두 「자체」)를 + 지우면 같은 번호 기계가 더 없을 때 목록에서 뺌 — 로직 파일 고침을 같은 저장 묶음에 붙임(정본 저장 길).""" + from M02_MasterTemplete import M02_Machine_Hourly as mh # M02 가 M01 을 부름 — 늦게 + from M02_MasterTemplete import M02_MyMachine as mym + + part = next((p for p in batch if p.get("file") == FILE), None) + if part is None: + return + rows = _rows() + gone = {str(c.get("key")) for c in part.get("changes") or [] if c.get("op") == "delete"} + add: dict[str, list[str]] = {} + drop: set[str] = set() + for ch in part.get("changes") or []: + row = ch.get("row") if ch.get("op") == "add" else None + code = str((row or {}).get("원문번호") or "").strip() + if row and code and not mh.listed(code) and not mh._own(code): + key = mym.logic_key({**row, "값": store._dec(row.get("값") or {})}) + if key and code not in add.get(key, []): + add.setdefault(key, []).append(code) + old = next((r for r in rows if r.get("키") == ch.get("key")), None) + if ( + ch.get("op") == "delete" + and old + and set((old.get("출처") or {}).values()) == {OWN_SOURCE} + ): + code = str(old.get("원문번호") or "") + if code and not any( + str(r.get("원문번호")) == code and r.get("키") not in gone for r in rows + ): + drop.add(code) + if not add and not drop: + return + files = _family_files() + for name in dict.fromkeys(files.values()): + changes = [] + for logic in store.read(name)[0].get("줄") or []: + ins = logic.get("입력") or [] + pick = next((i for i in ins if i.get("이름") == "기계" and "고르기" in i), None) + if pick is None: + continue + codes = [c for c in pick["고르기"] if str(c) not in drop] + codes += [c for c in add.get(str(logic["키"]), []) if c not in codes] + if codes != pick["고르기"]: + new = copy.deepcopy(logic) + next(i for i in new["입력"] if i.get("이름") == "기계")["고르기"] = codes + changes.append({"op": "edit", "key": logic["키"], "row": new}) + if changes: + batch.append({"file": name, "version": store.read(name)[1], "changes": changes}) + + # ── 쓰는 곳 ───────────────────────────────────────────────────────── def _logic_index() -> tuple[dict[str, list[str]], dict[str, list[str]]]: """(원문번호 → 로직 「키 이름」 · 기계 키 → 로직) — 기계 고르기 · 식 안 EQ 키.""" @@ -218,5 +293,9 @@ def fix(part: dict) -> None: values.update(got) was_unit = (old or {}).get("단위") or {} was_src = (old or {}).get("출처") or {} - row["단위"] = {k: was_unit.get(k, units.get(k, "")) for k in values} - row["출처"] = {k: was_src.get(k, OWN_SOURCE) for k in values} + unit, src = ( + row.get("단위") or {}, + row.get("출처") or {}, + ) # 보낸 칸이 있으면 그 글(822-2 빌더) + row["단위"] = {k: unit.get(k) or was_unit.get(k, units.get(k, "")) for k in values} + row["출처"] = {k: src.get(k) or was_src.get(k, OWN_SOURCE) for k in values} diff --git a/docs/raw/요구_1104/_목록_802-2_변환대조.xlsx b/docs/raw/요구_1104/_목록_802-2_변환대조.xlsx index d3abd9b33f25d6ee46b63f6ce3cfa96731470cf7..ada56c1562df2ae9a570f1ce1043a2cdd8c15b9f 100644 GIT binary patch delta 8069 zcmZWubzIYLyB;7R-Q69dQM$VuMoCCYcQd*sF(zFqNDC8Cx*KF5C>>G`1Vma&&h&TQ zbI$vo{ju-9&vW0`b=~)Ke?HrH7sBbng}^`?;{iDU0KfqlWq3dd-jbj$HZRWSxb{(t z6KDVc35-OD9Q+2_r#B#kpJ?m!y(@4wRan(VfLV{3MfhQ})pm$gb440yS&+BB)dw#e zFZ!+fQx~i1X>AXTuEaTGJ8j=j9qlQkcT$o<;SY(A74$|tm&jpg#@9{-$O2*(6gmdKW@(+kda!WH+*k0Ajxm^BqUUttL@b;bb4bhhBOWt4S-rw>5ttB@3)M&~1p>D2YdHviRunZoN}`K#!dl^0h4Ntg>rAOXF^2|Xe)rYO4L62amgcm3o$}3 zs#SOdb1l*+OnK)IJI*r335d_nHF3exVi6?xZR?LbFbMkCwla@C9S8#ppS{(J(2($D zP!k<=Y^fK`OjJotPy`0Mm5P!OJxJ-e8fyM^G1q7PnVBsFZgLiYTkEuV-!Kwekx7u? z;YzwY}V4l5=&WW ztk}^1JwC@%T>8;&j#A6_PuE>@4>{Ux;=K3*IvC%sdlXOC^aYWBC>!`KW7%1_!&F(7 z2NrbhsTq~>WLPwFvzb#^xGIZMU?RVeOMj$KW(_$r5SwdJ-+CGu=@OBgjw-8op zN>1Ip@TbkL5d87b&Mzk9$Z$NH4f(b`Yz6%9({?rAqxgUp08rqA!3mhbtNyc~g5i4# z3UOugM=2a&efyf6^2@S{pxTy>pi4hK0xYBLIL(!T3kKq(hwS+FQeHE%no9v((*}!{ zum?iYKB8bP{V#Jlfw%GpP{*4hHUyjYh&t4I*_@rTVQmC?rJ2Lj^#QSNZQb4-@z(M! z&AqUE3*1`L|E*ca@%|WiY)5;(=2Uco3?g2GcLuHp3QSIY-fhq}=gRd4nz{&dsGj=6 z%s7qnic0fsB>XozZx$-GsStuu7KMd2RZ;DZK(M5|eAC&Nq3M?9kS08vS~I+fF*y1zC$X(FDO8HageZ`M3_w>-jzSF=vJL())Md zhcN&An?;`z1Qi4_Uu^q(!*54t*wD1N_DPK-UH+^4+j=R};zIuqs_$<Vnces>o7Ch=H@1Rc&gCKXF+S7Y)35}EkuWvNvk)WNW<&KCLJ$l=Df%te?_i_@yA^#Lm z-BOHHW@28KJ$hUa^puUB+0B^aYbM>-Oygd(FRnvq;75BKc0C}DG=3VwDxdg-%Yw9c z73tuVx3x>C0^B8rvhodVsoTXYpGJ@wu!$4f){mxd+^7++jfF&-aJ z!!z`EQ^0lj4^684_qu*;7-(cLqQ%SrVzSWDtJL_=;VDT9!NIdpVM?~VPUJ5wi)-bG zi3#IPG)Y0+L?CYLcXkw7B9sOMk6Qe#k+Q)|yYyWx>vL0C9fcPebcAi?i$3g2mn}s- z%<^?n>~Sou+HZ=4MUf&E+0ee|XII%hVog753&6tnJJy~T!z<>)e&;Q=W>f;EX@cD% zl$2D6NrBJLhxpVEYw1k4%yVmVosllzI4ioqU=Dw29sS13_ydL+r$Z92xC+86n^?m^oUT^%r{{j@>SKUywK`4D7x6y z_it@q-V^D!rxhdiBd^Pqt_xqHe{!Hixd-$%K>YFO(LOV12zN)@W;>_7!-gvy`%ISpi`EzKtP%c3zmAT^Eg-n- z?p*ts>b*^se_xs4C^ZhQrg8yCYAZZs5`TQ2TL6_Izuds1H* zc7-F;CXzbtyvp{5WWwFSU1?DsnU$CL`n8aLqWyIGn(?*e->t#Jic^n^j59OI?;HJ~ z=6dvShj&#J528=d;4f6;%?ODvXip!&|`|_lkK6Kd*-GYpG zvh|Yu68DlfiBni7zotL!FHQjWe}au zE3513_GW4(2wJ&FpG%)aDBk>-|6zD?&foaqseC#1(wy>$>y+K)*R|l~{5OF%vYhKh znJxi%L?yGOyOQN#Wc#v*|8iP$+~t<5VHlu#tBH{1a>LfldGz;$t~9poJ>W)A=PK|= zPt{n`O@qz-CkBs~kk1i2MLze_dk!B571bAud#{>WE*>`P zhaqK_-7os2!pmE5usGcY+8EoQu>+YYCVUbc+UN}ik2(>aX{eM)a?ylj z@M&L4EcC6~c?J{tu;I?oniIwUph^Wzun$T{u8w-ppX;|_f)zJa3yp@(7z zU5(>*M_`74qp94Qj{(v@Wdh6#`h-+E^5V@)z00+fc$<@{;o8<_KRE=V(K@Bd2rw2D zC4grQRhl%(K@WNCi0G|bj2|$Evb?@INX@2*7{YBO66|sHXA1hd8obu>`&WA1(aij? zHzSlwa4$oxSD0nSvpN6kD@1G2WInwa_dCLdk-SJs zJg=in>T;E|UAc}{CxW(Cj$kI+m6;tmdZx>jU}!Ev(av*;a>bo-=DvB9M85c=K&xYt zwGR}G4uCYH0i@3WLaS3|qxG5kPkoniI_G7#v0=t#>W{b4_}ec}%>9yb3xWvU!coW6-NY-g6NPe?A059FihMP$GAjOS4?N9! zjp*;H(`vd%~{{U`k^ znVkmL3~F#3;?2l)5W7;qB=05xhe&N6tt2baO7Jpa3h9oF%5zeRkZBQcZE}m>byMoz zG^;~z|IkXNk72svppPM1+$R@`V@|zLGhs|6l}n4kJUUY0@zo4!Vcu4zkvi5j*UhHX z>KB|M1Dj(jnIT39oxu>J7@F0l)R4Fo1aF02&gesksK4^2jwBb!mJX(#i3`QMW_D+a z+1F|Tr5puqEGZQ$cgZQ>irAa+rc{s>D+&(Ew#FBePl@kCK?0?mQi1cHsFj4xP7LMbuREbianFH0I|xd z&7t>a4$TWq2mAHFX2J}XD(e=RdFBsEQ3JUC#hKzAe74k7CyDY?FeTPE(|!d|`Bd{~SEnrmLzLFM+ppgPfNTwVRGVyC;b12G{zSzD z#0oOde1OtoqE>7=ND4z~seBA2&Y_}=J?)RZtr)LLzFiP&Zy~B?Y-}G|=W=z*U2$8y zBs%zp22d>k==JG!HGR}!ad&nMrYT6w2A_m;lXr!4=XDiwcdqSqN|kS}UV6~{2tc?~ zXT`H~Bqui2!HJ6~&<6~mQ3FX%iTSZ^V+0xGa|us88|*a(U@w?y5q|wjK6*I#NtbQ& zUJ?g`Z?WIoTsxCesz32rie4Y8i>M$zk#%gORhhTY0^#UAjM@LGcCloWRQzC`AM9p^ z4=HPC%{C8|H6(bw4`c&AaEb2?3=!l%tRK*e-JkI`C`}ytWG>Ut*}zq!7*Na+pYS!F zxRua5@6Br9f~HRw_xgO`1EBLJFAPxtVjl zkJ^4P4*}8xv6ABa=IxP0`2=ucqtw#NNdwQ(gHJpU|tvBDgS*PyBj(KEe zSUa9TNG4G@WiAN($uDPwBJQ;H_1VQDd~|Jzbu#i&Z?WU(x!2a<@9Ne2$>xmZ`eX8# zEptYkl4I~&HRQJ0QNuPn$d5D=-`lFV?D8yA5Xo$*WhM|N-gpr~?NQ9=*;QyBJ~dka zf2LL$DaekisYUE&Cut)rQHdee`*SbAO+kd3z)3;5`3&E}kTD?kt~J;ti-@2J}{PgH~=TlyjXx(BXPdfL2rBYJ(QG$ z-%r_V8&mX}Kp7oc{vala6z@)}?^S6LHYlK7MuA3_(T|K~6e9|kE$@>zb$XHtN3slD z0!~hkjm~H{9M~?yOtY5e&ewG_KSQzSCC?fx-k#=p@(Ug;?|7+8bGNQGD;I&Rx4id4 zfRDz4`FWI}SzXqgY`-plJ_&xK_g%2KA!N_1#RktNau`QMIaWNXU)9xZ@yYnFrzp1qY&C##f{dkHHA}NM2|B#FsLN? zy~$A)`hN`^z!#=;;o*xn&F;lL?$m%xoF?fWxe_dhX)h z9Wp&`w+cj_kSzA7LfOtM?EU65RyJp+wUs3i>#`}6dW z;`O;a+_8M9MXirT75pb4#2T0IPr&>C4v@md*H*Kvaol+sCn^*>wh74){l!-f3xYcITtlp;KxqMNmyu}RTc=!K1I#Ux5?->10nU0k zC)men+d)aQELAvY2~~R`8tB)u*cJfKxeOh&OCe<0NL!|DZ;L*n4#%N@L{BKQ+DV%H zs+HP`##O77F`<+)%%JQXMnWkrK;TDmv8q*|Olw9`_K8>h>ln1yCRNK6-VckVvjZ*N z%e#^FivF|VTL=7=FPdCayo9;l@mINU;|-ua#?n%41aaChW@%20^@|gy#sNR;YR%c{ z6EO2xO317T+Uln{ro|Y=07VB*Jdwy_jr?r%PDb35>RLH$svO^kO@*PcOXr%t&M;yglBO-!ppZ0E zP0d$gOgxh5iMsC3+CU~!3x&ZUR391h&{zUzmVW;-0k-y=r1B^^yp+^i|cM5+;8LXA{A zP4I5O)q93{y};vlQ1#$rD=%k|K?j=j{!RHJ=hXMi;^b;i75CPQ8t_56*~^S#(vftn z^yjq>4%nPyKSu4-7)IXs zG91eC+xu!(N-+souetN?bisK!t?kzKBbzzNd|ufKP4${_+6JC8v)~uFc61F~Y=N;-sr90RiOeDVoyR&m~&Y4hvHdl0o*@gcC zXrll={{_&A?3%il5}d^JAtm!JxQ<~%sR;c?#k4yw=07T?_cu2q_EMXC9Vf)r6m&k| zr1rmtIj6}*w>Hn4XocIG>HuB^%~MoLA5*e@49}1e+bg{z3^k4yrZ{?<35I0qzQLg} z8yaMO#wEgC$?u>CA%Nu8zL+3?*w|{fXoQ<%Zz}VkFK9kN(vU|HZZ?H9?nEpp(2~8E zD{0+cG}0qStpd!=8KQuYrtl)A@WIviaIgo2MYrRQNc4={yuS?=JRz7(IHE z>9|Z$CP%voa4(Ra+_gHDm-zO=WISt(-rlx+c*It;LU^i6Ln5ib^v*gC`z+oXQ+5t&GXN#yXPcBLwh+!MJRSw+~Gat zN)LNCYszzMi|l+_x;|DD3jzBnUXBAeFJqNm$G)oE_Gt5Ny|bSRXK0od!o|)g!jjh=Bvrby{F|1 zBNVx9F4-c%APO>bA%7;hJ3RNTa8;V@+GE3mtyIWLlNJ<}n8HWhMpR0Y`2Jn{w$28x z|0B87NNeAea5Em?BvWBuOtQ;YutB((0PK389XxLGPr0Kdrnrl!b{X_#%5HZGuP9jS zr3`eAN2ld`FgaV#lrUs9=Wge{;yt;Ma`P~3MP%KnZ=X|Itly1(74kp2pbp6`tK;D! zz?#l#+m7~i>}06+=a}$E#FKTQiRyK6EP%Je$S+_dJZ8HC$rE!;eT!ihLHjijaBI!1 zS77=+y~G*@>ZjMm^0mdu!DBZ$k=-bko7Ac5-pQpAPf05K8P~>^@6k8&R&*sLJ!@eG zV+J9G7kv*S2jN!hqx=L-zU^P8?jzwg$VEvar=rqBs;NV+4koX))2Dt{mhUYKOR6WW z|8YZ@Typ9W0zf+dFMQ2z&dRSfYzcBvehJ;JkyWSSq4d*;cc5B+M2Gy7T7-4IfFxMq z{(gMZ!Sm+?yQh!8?_T@zcK?_+JAr) z5}*<&V4}h#%zqTMP(i9KP$soHit*pszHt2w3>7B%TXLE(9l9eAtVWnl=5L<*erhjD z6(uSU@BgTbM%&;60Brr7Jp2U({=EN(G8mc&9r`#B#wfz@7gi2+8z}(u<){CDHO#_# Zwz*N#{4oEsS;2y^G7%1}X`w&F{{<2MA~gU2 delta 7750 zcmZu$byQUCyB!*2Xz3nGU}$Ni8IFqCwcG)PN#mvjh-+|lo@ z-}-%b-TCLt*?T|le$TV^S?j#(xw0Lx#!**6L46DYfiOWfc@9Z9WdP!0OyiEfyN3XW zkU$^;#MR2#Qr*qj#huI2+0B~M$MJPxQs=9$+~7#l!k;-$UgTkH+*Va-VTTb5c=6TE zNA`UA!k^rB|GrdJux_D)^x)w_sdoa^JfQpYE{gmso-+BFOC?yPxotijC|S5*vUpfF z_iI7I3$3U}`4a0xc?rJ|qa(jPEL=`6?y#iBMk6v|_8YPTNYrCi3)#f2p*noL zP=a#XioS1(bBY_hH8yOt9QE}e=vs`+v?dU;L}{gL!0=pFRlHEDcRY3Ea=vC1*6mU4uVV&^hHm;epzCx5Y8xheVuUNbRvE35LIhj0i%0d@VN)Z#%nUk=T#h;(van!?|Q0&tM$&Ry%UVM2M>LLkZecaX>_??MERI*|w9h|aB&zY2c+Xi2i z7Hc-zuY3a3oq~&;C3$BH$E(LSLM$%;@MefwFTu*^lsA>HN?JKj#n-xP$+PL0n>~Mg zc*>{=1rs%@V7!m^d7`-ZO=2|0xx#T+4u?1N4ZED_=gN7VgNIAKvBy;(5^+>R58!e^ zz7gf5J3YT@VoHgYrJUH@s&n4A-zA$;=_YwfSEDaz&Zfy5KL6R10G1lR z*7^8_M@!`j2iFEH?{;l)nhM6U{c3`$J*DC33)-{gxOeA1H#Rx#xgRV}TVW3+kb@e} z%^b+Jhr(XzA<34+@4WR*M`Ase+Dy+}U&9aeo~nQdn|M$eWdE`Ed!`{HHC(NkEtkOi?lJxqE#t<8ufr>3AP_S%QpyW#GJx;Ti}oYi*nP4;r?)*O zhP}vwP$ftrqS&D8P$hf5Y)-hgc_yP%GA6w;+IFrL#lj6aL}oUch5SVqEF-*raGC;h zbq+;vr(n3Jm;-)KUB@(cjtEvz5`~a0$PV*da|X9~RurRUx%Wui*H;GkPPZ%Q-62 zXk>pi_^a!?`6rOD52S3#!+6M~#ZmyvtkZ<%gyk$+4H{Scp{A9c#fbX3P#X0JBIV;jkPROP=b z?x@VRZPs&T=;m*z=}MyeOXKWnbZtR*y1RaK;*u68l#c=xW9s3X5vT2fwt@h-=+pIn z;?wI}JORI@c>j}5H&Ib5>zVtt_{*WUs_Z{2fbah2b?vv)RTAt$I*D(|41U^u;eV*- zuku@2N{^>A2)#Q~BWY~ij@^@3{Ib2vwjJYj*-){CIyZmPon(kXqVJsI&2cb#T(-P` za`w?OY39fB9p2`K*TZ(m-9;Wyf6%J@FshTLYn`-IE_apr4m0YK}43e;L!F0WZGjyeE7x;O`zUe^1@Q~vuR%Rm2 zG1t8ai<L_P8WhR#uXEuNRF8JeRF+P4-` z#p;7Hyuc7Kzj5cWmp89)jJd&fZ+$p|8Fz}RaD zRn;fujt@dzABsXyN_b*WUawd9eWRqZ{p7Ycc-X^4{#J<7VgH|M;izI7cg^_h40;nWkJBDCA+!nC2#sNLEzuLqO5cqWX1x*5;}BvzU(t z#kV+B-|5)}@7P6`$C_8#@^!!oVXgh1Px>jaViaZ4m|_|#@yB!ugCD0SKPDx!fA(y5 z<-z6sVT}@8ZrWSTWfoPFN1zI*ju4(Lz7G0)eJjV2o6<$d?{=QuPwL)A^uncMasH#F zgD?6R?J`pJ%D~{xuOw6ZbkYH9bknAyVsemdw1;a@Q zs&omHv{FjOM8&>;t-Wu%AUAjP6AX3BJ#4t%Q*NG2TsST3lMMdE0dM##hGuv%dW*~f1llz)um&MpBhs_FFwlUEPp{R6MrO8 z?(bH_c71v^&`E&tZ2kmS3ZnVzpudMk*wxbVw4`iMcdZxwt8@w_K>6xy!A$nmS68}v z+})@I`LMRJWy~O_d(tKCx9GcbsuMv^sD9!OgN?AEwY{(D3^8(+J4|f!A3CXz?|(a; zG8n52-5-}X2dz52_McAHow_-KWlaQ1PEC1Ax}hbBSz8jgiavOHKwb|)Y3je<2+gLM zKYHzED6cBNaAw!y2V_$o)i-x_ye^CJ*Y^$_T{<>+-A;X2_AIM4`)a@bx5?QOPF3B8 zc%JC|2+HVghZ(RV?y->dZKwG*GQe9gXPjvfkOB(&56;JZv z64bAmD#*9qiyYp*DQNpmzY=7gH1LB|;Kt+5GfUzoU1yK37Fh9nBYI!PUp0M&n`+{* z3#%BPL_a&fzMqbtslEPf3%t|u@?ZMe$uoMD+58Z46m`8w`es>7!%x$^ae;Dq72Vc5 zOosnqth9DcR7^}sW3MTjpJtaxe2C4bxfV z;STnvy|55!RGS?BC5#me)r183gYb9hdZei@KlS_XS0(f7Ls$;|5F1%W6AB@9igOJW z0hfmY>AT7*wo>NyUnKf4&M_qBxbK$1F##sj@vhp-wOn7nCC7kRhD_qknG6ih_$ zkRA90R;A9Pe%s2$V}hDj*Taoml2C%V8eqsEPco3%U3IgyZvUu-Yj0%0*fR)n#yA6S zN2nhQV^KO^aG0MKI1g6_o$4V>ZBViWrt1INTx5;`%G(Eek0Y^&r}#rK^{`l=F>1tCD#lI(o9tW! z3BlTmyr-4mHrDYZIp7tX_#XA?FY9dx&K4pd$>5`2CT8GX%18>Dw>0ksA~U_>?fjW* zqS~tIcq)h(yCCjuZ=U;d4fji|scyOYl-ln^wb@F??uufgoQs&#L3wvX_qoBoHXnC> z>cJY-t(n)cf&~7QyKq$y9d-UG5BK-NCCd%S+^&t^4G`##ny$9usz^CRaMK02x&DHW z?oPel#*^(SA8P|4Jp&jm9XLLfF6?>@4`_Th#LadipSff`vU_}evxV?TGTf;>$(mlw zggZ(rO_05}? zYf7;#4oO!dN)N}WS-DNz0y8WW5dBn^Wwr#SP_%4Wd=h|M2F;5spHN^AQ{|~+I5*Lg z`M`7DVpYcQt%!)k7Im+{D=-u}9MxMaMlPzjF~&!#cvyQljw%U*go#8tUsf28r$82m zCT>`}TSfS`F=lQIV$spnBdyr*q>s*~p(vzt)1CMf67N%PfgT=pA#_i{U>xy|AJ$QVntjbh%7cO8_7WrcM)otKN%v76_G-~ zJI((K1OMRH>YUl!_#_$U#?Ivs60Zw);_44_7uga1a@j732%HYx{P|_q6o@t z2TU|r=ECH*OJ{Q7Q9abBMm=Cm0%KcO1U!O(jRg612~<=)%PqF8q5~?6DvF3M5!nkM zgTbS;Z7j+yY__5k4y~filvoIWaJgRf5bAtHQwb+?;V@N42tAYwT72G>{Oe2kNz$c6 zEyr==mIPJeLe?+$P#v#*6~|)~^^aJqREJN628UD6yk1i;o$mq)8*+R;~jW1jTgpNPTO< zA_;KR{z|Y^_TT>_!4-AgkaA4VdET`?i}HfIq&Qjps= zev^$lLv61Ej@?LT(3!a^)sHPpZD}s%ovhRZ5fY@#`uIOpG z=&JiO9g)uzt_lzC|Cmrgi@!>(im>q>iySgG;9i}4Ar#t*IyheY>i}s_7CB=dpos}U zCuvl>0yoEt$caQI5SMHX^%V7uZV^7y0^)c5N+?ighM=wN@JZ3egUbGfgUXauKkZi{ zI^^CR@TQ#4Y*IDvdtA2?g?--0Hxzd?0# z8zN4*1{>lQNGzXKEuO)A$!LzN>bC5a@i}ahjD#7JQ@*_uL5a2VzV5tUp27P@0noC- zQfLP{4Q`5b&wdppXsQ7nXm~LM3F{LFTm~a&4OJou>?*XmII)Z0R410tbKkYzb?(>W zF6}I>nbzHXI-pb|@HrO%(Q~&=?o%(5wWz!ZWne)Xz)bt@f1h-8wnf#gGElO$Dm5K~ zS5P?F>UeUpZ}u#k{ImIGlQ$$k5%`3fgq}8;`dyeh4=GYjK?Nnb)2;}aV!hC|KW3lt z=|@(nk0D#$)}BEacEKN}#wUWUxG8qyU&hO{=;8RTahT-3dC4CoS!q{M8h-`C=dKVj zpi7L)bN!aXdTYtR1Too+TzvA#223fB3Q@&^;6X3g;W82(D9@|n(=ZqpdmS7VR_(mGVJ%#T2=zUvx75~?#^OyitYIYg~r7fr6Or@ zJbCqrXq8^e{;{Z-iozW!%YKIIAVgMH??_)`CkI48wo@2~InjeZFoYLqt(J zRUtFv~WN%%#YI+H-PmmXmD71;IsUCsRP zIyQBLMA>Uq<75fQ`28wN%&@w@4COZL_nPl!ZPhP6=lV%{xSC&uh)33WymbCiC+|na z?vS27V$T4uOK_v3rchp_IPS#4Lq~En`bq}s8!#)OtJ#{X95oFJmD#JT$GAlw62d&# zxS<(#C$TB3iS%+1T&QSyQMF|-3fM8F3yJ^2HF1RwfBHG_^!7^j&$&YpRV9Ia|AGu3 z$#UUiRPYUpAZ{&TLtPY{g?D`t??}@_po&q+tY9#IzC@&?Oi}HvnZ|M8Okm3ouVdD^ z;L*l`q0;QIrGHKZo8#C`-3A&#*W^Y}esgYWXfF2A&$IHqBWJ^-p&gGhF;OvHryD_k z#OV$Ab9{SCTjXIx9p&t3a1A2CY-u6CWJawZg6z`UIA6CY!odFWvn|A-s=6^jYZ(;!`&szJExj z9^vejfvU~@Vyn2SlUl&_k|4`Up^ab%c&S9vX<_fKoh^V;d9w601%z!=fULJm#BEZQ znWv&5f&5va5|cPK7*0=6NyQcbA7RW39cRT$4a1$U?XAFl?k;H ze)&^8Pf0SODH|_x%wJQ#qFO#P;hx9N8I@(E(?wZ0Hhnh@n{eUfGX{M!oJv-8fGxR) zckh)VGY1Q{gya*fDs@3t{Q?ecDV-J30IW*5&6;QJPDtb=X~AA3-zt1(m-Afk0w?8Y+d2DfOU%o|w+y2^t*qzdoqH)5`5Xw5k!Nx2 z)yAR^o5x9VJ$6cL9=+pek}K;kjwWC0J&$m%znMzb-j;CBXiW)|9Hwnh}sJ$z56n=_6F(dS4R#N4q zMOCpAIGy)R>d>4Skd4hD+z{@IOAvPjju->az>{7Zb+tR>BuVdCv8PMLj z(5|d9x;_#9i_9)sSw-Oc+cnKCmvN;a#8w(Q4rlt9j&Y=sR+H`WZ;Rg5S;sF|5o z_BYp-27T~Ke`@Qo&eRLrcy=l~aC);$KpW0oxZX#$39&L{myXoN;)$A%1`pcDW=vXq zP2OVL;O)a)54ARA*F$gw;q%6XHqnDvF&Rs}zg{Sh%>h)3lOMd|SU7#_YLjgd+fXZ_ z?jy>Cqd2@pWT)>b^((m?D$Y!=ntXe`RIpm*QX)&wBA1>+FFkj?a{6ah4bfhrbYoG2 zIsQ`RD3&Rw8bEVIGGZ%Iz)xsEE32fKKn$IP)K%1gK(JI96N=yqG)J@$d|i+b@k`3m zV&IP;JzUg4N$4K|rq1qbod#5y6>8mYB;R`-GM^?8^fK+l>$u^<2_fKGN&*sr9b(ST zM8gu{!ufF3lx?1_hRUV!M+=Dlgd_SRZQI*5@zE)O=Y2rQx9?M!G+{B=tJGy68XYE7 zV({wM?a{8mZs?=!qE`(39DYDw)cU%I0{e?Mi0+_mT#V`0RX$~njU>{q&B&zav{ZM+ zYA#5v{8|#87mDS-Yhf2SGq6XN~c+4EaS-lhX%%hhRs(SdC~O7s}`MUTzGQGqq^qD;uWOR%-%o~R>I_G#Zxj}qwSm-U~mypZPYpZEZ~Z( zRP~6+TC@CjYtYv z8ng9;OI5jmwibgAn2pnF)FI*!!4{eI)3~A9 zXm4HvsuS}cU8q_7@dlbztJ?g#*16AfPl^JnG?vp+&{6CLTC93JtXimw-HBOTMk4wA zh~3|#%T!*ET|e0uWZ*63?y&Q^v1nZu5?z5w1n$rOinv`F9@A7F)B^zY#A?~pTvfk)tP zoe>WuQh$mM59IG%HV-8-7G{bDHgU=*4*}hO=6uK?P$W7CgqYI&=T{fPhm8L?aoFG dict: + sign = {"손료": "C", "시간환산": "Q"} + return { + n: {"기호": sign.get(n, "S"), "아래": n.replace("전력 계약종별", "전력계약종별")} + for n in names + } + + +def _machine_in(codes: list[str]) -> dict: + return {"이름": "기계", "고르기": codes} + + +def new_rows(regions: list) -> dict[str, dict]: + """새 식 넷(키 없음 — 저장이 대장 다음 번호).""" + region = {**_REGION, "고르기": regions} + return { + P1: { + **_HEAD, + "이름": "기계 시간당 기계경비(전력 기종)", + "출처": f"{_SRC} · 8-1-6 3 가", + "부품": {"갈래": "기계경비식"}, + "소유": "공용", + "비고": "원문 운전경비 표 주연료 칸에 ㎾ 가 찍힌 전동 기종 — 전력비 = 전력(㎾) × 전력량요금 · 잡재료 = 전력비의 %(8-4 주②)", + "입력": [_machine_in(MOVE[P1]), _ELEC_IN], + "중간": [_LOSS_ONE], + "호표": [_ELEC], + "덧줄": [_MISC, _LOSS_ADD], + "끝수": _CUT, + "변수표": _vars("기계", "전력 계약종별", "손료"), + }, + P2: { + **_HEAD, + "이름": "기계 시간당 기계경비(전력 · 조종원 기종)", + "출처": f"{_SRC} · 8-1-6 3 가", + "부품": {"갈래": "기계경비식"}, + "소유": "공용", + "비고": "원문 운전경비 표 주연료 칸에 ㎾ 가 찍히고 조종원이 있는 전동 기종(점보드릴) — 전력비 + 잡재료(전력비의 %) + 조종원", + "입력": [_machine_in(MOVE[P2]), _ELEC_IN], + "중간": [_LOSS_ONE, _JOB, _HOUR], + "호표": [_ELEC, _CREW], + "덧줄": [_MISC, _LOSS_ADD], + "끝수": _CUT, + "변수표": _vars("기계", "전력 계약종별", "손료", "직종", "시간환산"), + }, + N3: { + **_HEAD, + "이름": "기계 시간당 기계경비(조종원 없는 기종 · 손료계수 한 칸)", + "출처": _SRC, + "부품": {"갈래": "기계경비식"}, + "소유": "공용", + "비고": "원문 손료 표가 시간당 손료계수 하나만 준 기종(8-3-8 기타기계 등) 가운데 주연료가 있고 조종원이 없는 것", + "입력": [_machine_in(MOVE[N3]), region], + "중간": [_LOSS_ONE, _FUEL], + "호표": [_FUEL_LINE], + "덧줄": [_MISC, _LOSS_ADD], + "끝수": _CUT, + "변수표": _vars("기계", "지역", "손료", "연료"), + }, + DIRECT: { + **_HEAD, + "이름": "기계 시간당 기계경비(직접 값)", + "출처": "자체", + "부품": {"갈래": "기계경비식"}, + "소유": "공용", + "비고": "자체 — 구성 없는 기계 · 구성(손료 · 연료 · 조종원) 없이 시간당 노무비 · 재료비 · 경비를 바로 둔 기계(내 기계 「시간당 값 직접」) — 기계 줄 값 그대로", + "입력": [{"이름": "기계", "고르기": []}], + "중간": [], + "호표": [], + "덧줄": [ + { + "이름": "시간당 노무비", + "식": "EQ:{기계}.노무비", + "비목": "노무비", + "출처": "자체", + }, + { + "이름": "시간당 재료비", + "식": "EQ:{기계}.재료비", + "비목": "재료비", + "출처": "자체", + }, + {"이름": "시간당 경비", "식": "EQ:{기계}.경비", "비목": "경비", "출처": "자체"}, + ], # fmt: skip + "끝수": None, + "변수표": _vars("기계"), + }, + } + + +def _note(row: dict, text: str) -> None: + was = str(row.get("비고") or "") + if text not in was: + row["비고"] = f"{was} · {text}" if was else text + + +def machine_changes() -> list[dict]: + rows = {r["키"]: r for r in store.read(MACHINE)[0]["줄"]} + out = [] + for key, row in rows.items(): + new = copy.deepcopy(row) + vals, unit, src = new["값"], new.setdefault("단위", {}), new.setdefault("출처", {}) + if key in POWER and vals.get("전력") is None: + text = str(vals.get("주연료")) + text += "㎾" if unit.get("주연료") == "㎾" else "" # 모르타르 믹서 = 단위 칸에 ㎾ + got = re.search(r"([\d.]+)\s*㎾", text) + assert got and float(got.group(1)) == POWER[key], (key, text) + vals["주연료"], vals["전력"] = None, POWER[key] + unit["주연료"], unit["전력"] = "ℓ/hr", "kWh/hr" + src["전력"] = src.get("주연료") + _note(new, f"전력 = 원문 주연료 칸 「{text}」(㎾ · 전동기 부하율 규정 없음 → 그대로)") + if key == "EQ000532": + _note(new, "원문 「휘발유1.3」 갈래는 주연료2 칸 · 시간당 식은 전력") + if key in MISC_DASH and vals.get("잡재료율") is None: + vals["잡재료율"] = 0 + _note(new, "잡재료율 0 = 원문 잡재료 「-」") + if key in JOBS and not vals.get("조종원직종"): + job, why = JOBS[key] + vals["조종원직종"] = job + src["조종원직종"] = JOB_SRC.format(job) + _note(new, f"직종 = 사용자 확정(요구 799) — {why}") + if key == TYPO[0] and new.get("이름") == TYPO[1]: + items = list(new.items()) + at = [k for k, _ in items].index("이름") + 1 + items.insert(at, ("옛이름", TYPO[1])) + new = dict(items) + new["이름"] = TYPO[2] + if new.get("상세구분") == TYPO[1]: + new["상세구분"] = TYPO[2] + if new != row: + out.append({"op": "edit", "key": key, "row": new}) + return out + + +def logic_changes() -> list[dict]: + data = store.read(LOGIC)[0] + rows = {r["키"]: r for r in data["줄"]} + if any(r.get("이름") == "기계 시간당 기계경비(직접 값)" for r in rows.values()): + return [] + moved = {c for codes in MOVE.values() for c in codes} | set(DROP_ONLY) + out = [] + for key in (LOSS_ONLY, *[k for k in MOVE if k.startswith("GC")]): + new = copy.deepcopy(rows[key]) + pick = next(i for i in new["입력"] if i.get("이름") == "기계") + if key == LOSS_ONLY: + pick["고르기"] = [c for c in pick["고르기"] if str(c) not in moved] + else: + pick["고르기"] += [c for c in MOVE[key] if c not in pick["고르기"]] + out.append({"op": "edit", "key": key, "row": new}) + regions = next(i["고르기"] for i in rows["GC000268"]["입력"] if i["이름"] == "지역") + out += [{"op": "add", "row": row} for row in new_rows(regions).values()] + return out + + +def main(check: bool) -> None: + parts = [] + for file, changes in ((MACHINE, machine_changes()), (LOGIC, logic_changes())): + print(file, len(changes), [c.get("key") or c["row"]["이름"] for c in changes]) + if changes: + parts.append({"file": file, "version": store.read(file)[1], "changes": changes}) + if check or not parts: + return + # 대장 열쇠 = 원문번호 — 넷 다 「8」 이면 한 키로 겹침 → 옛 기계경비식처럼 이름 붙은 열쇠(줄은 「8」) + labels = {r["이름"]: f"공통 8 기계 시간당 경비({LABEL[t]})" for t, r in new_rows([]).items()} + plain = store.ledger_label + store.ledger_label = lambda book, tid, no, row: ( + labels.get(row.get("이름")) or plain(book, tid, no, row) + ) + got = make.save(parts, confirmed=True) + print("저장", got["added"]) + + +if __name__ == "__main__": + main("--check" in sys.argv) diff --git a/resources/master_data/기계.json b/resources/master_data/기계.json index 03b622f14..b1f595b6d 100644 --- a/resources/master_data/기계.json +++ b/resources/master_data/기계.json @@ -10907,7 +10907,7 @@ "주연료종류": null, "잡재료율": null, "조종원": 1, - "조종원직종": null + "조종원직종": "1048" }, "출처": { "가격": "건설품셈 공통 8-5-3", @@ -10924,9 +10924,9 @@ "주연료종류": "건설품셈 공통 8-4-3", "잡재료율": "건설품셈 공통 8-4-3", "조종원": "건설품셈 공통 8-4-3", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "규격 단위: (m×ton)" + "비고": "규격 단위: (m×ton) · 직종 = 사용자 확정(요구 799) — 건설기계관리법상 건설기계" }, { "키": "EQ000166", @@ -10968,7 +10968,7 @@ "주연료종류": null, "잡재료율": null, "조종원": 1, - "조종원직종": null + "조종원직종": "1048" }, "출처": { "가격": "건설품셈 공통 8-5-3", @@ -10985,9 +10985,9 @@ "주연료종류": "건설품셈 공통 8-4-3", "잡재료율": "건설품셈 공통 8-4-3", "조종원": "건설품셈 공통 8-4-3", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "규격 단위: (m×ton)" + "비고": "규격 단위: (m×ton) · 직종 = 사용자 확정(요구 799) — 건설기계관리법상 건설기계" }, { "키": "EQ000167", @@ -11029,7 +11029,7 @@ "주연료종류": null, "잡재료율": null, "조종원": 1, - "조종원직종": null + "조종원직종": "1048" }, "출처": { "가격": "건설품셈 공통 8-5-3", @@ -11046,9 +11046,9 @@ "주연료종류": "건설품셈 공통 8-4-3", "잡재료율": "건설품셈 공통 8-4-3", "조종원": "건설품셈 공통 8-4-3", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "규격 단위: (m×ton)" + "비고": "규격 단위: (m×ton) · 직종 = 사용자 확정(요구 799) — 건설기계관리법상 건설기계" }, { "키": "EQ000168", @@ -11090,7 +11090,7 @@ "주연료종류": null, "잡재료율": null, "조종원": 1, - "조종원직종": null + "조종원직종": "1048" }, "출처": { "가격": "건설품셈 공통 8-5-3", @@ -11107,9 +11107,9 @@ "주연료종류": "건설품셈 공통 8-4-3", "잡재료율": "건설품셈 공통 8-4-3", "조종원": "건설품셈 공통 8-4-3", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "규격 단위: (m×ton)" + "비고": "규격 단위: (m×ton) · 직종 = 사용자 확정(요구 799) — 건설기계관리법상 건설기계" }, { "키": "EQ000169", @@ -11151,7 +11151,7 @@ "주연료종류": null, "잡재료율": null, "조종원": 1, - "조종원직종": null + "조종원직종": "1048" }, "출처": { "가격": "건설품셈 공통 8-5-3", @@ -11168,9 +11168,9 @@ "주연료종류": "건설품셈 공통 8-4-3", "잡재료율": "건설품셈 공통 8-4-3", "조종원": "건설품셈 공통 8-4-3", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "규격 단위: (m×ton)" + "비고": "규격 단위: (m×ton) · 직종 = 사용자 확정(요구 799) — 건설기계관리법상 건설기계" }, { "키": "EQ000170", @@ -12251,7 +12251,7 @@ "손료계수": 2501, "주연료": 487.2, "주연료종류": "중유", - "잡재료율": null, + "잡재료율": 0, "조종원": 2, "조종원직종": "1048" }, @@ -12272,10 +12272,8 @@ "조종원": "건설품셈 공통 8-4-4", "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름", - "값원문": { - "8-4-4 주연료": "중유487.2" - } + "비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름 · 잡재료율 0 = 원문 잡재료 「-」", + "값원문": { "8-4-4 주연료": "중유487.2" } }, { "키": "EQ000188", @@ -12315,7 +12313,7 @@ "손료계수": 2159, "주연료": 614.7, "주연료종류": "경유", - "잡재료율": null, + "잡재료율": 0, "조종원": 2, "조종원직종": "1048" }, @@ -12336,7 +12334,7 @@ "조종원": "건설품셈 공통 8-4-4", "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름" + "비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름 · 잡재료율 0 = 원문 잡재료 「-」" }, { "키": "EQ000189", @@ -12376,7 +12374,7 @@ "손료계수": 2159, "주연료": 678.4, "주연료종류": "경유", - "잡재료율": null, + "잡재료율": 0, "조종원": 2, "조종원직종": "1048" }, @@ -12397,7 +12395,7 @@ "조종원": "건설품셈 공통 8-4-4", "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름" + "비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름 · 잡재료율 0 = 원문 잡재료 「-」" }, { "키": "EQ000190", @@ -12437,7 +12435,7 @@ "손료계수": 2159, "주연료": 746.7, "주연료종류": "경유", - "잡재료율": null, + "잡재료율": 0, "조종원": 2, "조종원직종": "1048" }, @@ -12458,7 +12456,7 @@ "조종원": "건설품셈 공통 8-4-4", "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름" + "비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름 · 잡재료율 0 = 원문 잡재료 「-」" }, { "키": "EQ000191", @@ -12498,7 +12496,7 @@ "손료계수": 2159, "주연료": 819.6, "주연료종류": "경유", - "잡재료율": null, + "잡재료율": 0, "조종원": 2, "조종원직종": "1048" }, @@ -12519,7 +12517,7 @@ "조종원": "건설품셈 공통 8-4-4", "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름" + "비고": "조종원직종 원문 이름: 건설기계운전사 · 규격 단위: (ton/hr) · 주연료종류: 원문 글을 가름 · 잡재료율 0 = 원문 잡재료 「-」" }, { "키": "EQ000192", @@ -16081,7 +16079,7 @@ "주연료종류": null, "잡재료율": null, "조종원": 1, - "조종원직종": null + "조종원직종": "1048" }, "출처": { "가격": "건설품셈 공통 8-5-6", @@ -16098,9 +16096,9 @@ "주연료종류": "건설품셈 공통 8-4-6", "잡재료율": "건설품셈 공통 8-4-6", "조종원": "건설품셈 공통 8-4-6", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "규격 단위: (ton/hr)(㎾)" + "비고": "규격 단위: (ton/hr)(㎾) · 직종 = 사용자 확정(요구 799) — 원문 8-1-3 5 표의 「쇄석기」" }, { "키": "EQ000250", @@ -16142,7 +16140,7 @@ "주연료종류": null, "잡재료율": null, "조종원": 1, - "조종원직종": null + "조종원직종": "1048" }, "출처": { "가격": "건설품셈 공통 8-5-6", @@ -16159,9 +16157,9 @@ "주연료종류": "건설품셈 공통 8-4-6", "잡재료율": "건설품셈 공통 8-4-6", "조종원": "건설품셈 공통 8-4-6", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "규격 단위: (ton/hr)(㎾)" + "비고": "규격 단위: (ton/hr)(㎾) · 직종 = 사용자 확정(요구 799) — 원문 8-1-3 5 표의 「쇄석기」" }, { "키": "EQ000251", @@ -16203,7 +16201,7 @@ "주연료종류": null, "잡재료율": null, "조종원": 1, - "조종원직종": null + "조종원직종": "1048" }, "출처": { "가격": "건설품셈 공통 8-5-6", @@ -16220,9 +16218,9 @@ "주연료종류": "건설품셈 공통 8-4-6", "잡재료율": "건설품셈 공통 8-4-6", "조종원": "건설품셈 공통 8-4-6", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "규격 단위: (ton/hr)(㎾)" + "비고": "규격 단위: (ton/hr)(㎾) · 직종 = 사용자 확정(요구 799) — 원문 8-1-3 5 표의 「쇄석기」" }, { "키": "EQ000252", @@ -16264,7 +16262,7 @@ "주연료종류": null, "잡재료율": null, "조종원": 1, - "조종원직종": null + "조종원직종": "1048" }, "출처": { "가격": "건설품셈 공통 8-5-6", @@ -16281,9 +16279,9 @@ "주연료종류": "건설품셈 공통 8-4-6", "잡재료율": "건설품셈 공통 8-4-6", "조종원": "건설품셈 공통 8-4-6", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048" }, - "비고": "규격 단위: (ton/hr)(㎾)" + "비고": "규격 단위: (ton/hr)(㎾) · 직종 = 사용자 확정(요구 799) — 원문 8-1-3 5 표의 「쇄석기」" }, { "키": "EQ000253", @@ -19689,7 +19687,7 @@ "주연료종류": null, "잡재료율": null, "조종원": 1, - "조종원직종": null + "조종원직종": "1050" }, "출처": { "가격": "건설품셈 공통 8-5-6", @@ -19706,8 +19704,9 @@ "주연료종류": "건설품셈 공통 8-4-6", "잡재료율": "건설품셈 공통 8-4-6", "조종원": "건설품셈 공통 8-4-6", - "조종원직종": "건설품셈 공통 8-1-3 5" - } + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1050" + }, + "비고": "직종 = 사용자 확정(요구 799) — 두 법에 없는 기계" }, { "키": "EQ000310", @@ -21858,7 +21857,8 @@ "주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", - "조종원직종": "" + "조종원직종": "", + "전력": "kWh/hr" }, "값": { "가격": null, @@ -21871,11 +21871,12 @@ "정비비계수": 167, "관리비계수": 328, "손료계수": 870, - "주연료": "동력330㎾", + "주연료": null, "주연료종류": null, "잡재료율": 10, "조종원": null, - "조종원직종": null + "조종원직종": null, + "전력": 330 }, "출처": { "가격": "건설품셈 공통 8-5", @@ -21892,11 +21893,11 @@ "주연료종류": "건설품셈 공통 8-4-6", "잡재료율": "건설품셈 공통 8-4-6", "조종원": "건설품셈 공통 8-4-6", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5", + "전력": "건설품셈 공통 8-4-6" }, - "값원문": { - "8-4-6 주연료": "동력330㎾" - } + "값원문": { "8-4-6 주연료": "동력330㎾" }, + "비고": "전력 = 원문 주연료 칸 「동력330㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로)" }, { "키": "EQ000346", @@ -21981,7 +21982,8 @@ "주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", - "조종원직종": "" + "조종원직종": "", + "전력": "kWh/hr" }, "값": { "가격": 605472, @@ -21994,11 +21996,12 @@ "정비비계수": 777, "관리비계수": 738, "손료계수": 2515, - "주연료": "135㎾", + "주연료": null, "주연료종류": null, "잡재료율": 6, "조종원": 1, - "조종원직종": null + "조종원직종": "1048", + "전력": 135 }, "출처": { "가격": "건설품셈 공통 8-5-6", @@ -22015,11 +22018,11 @@ "주연료종류": "건설품셈 공통 8-4-6", "잡재료율": "건설품셈 공통 8-4-6", "조종원": "건설품셈 공통 8-4-6", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048", + "전력": "건설품셈 공통 8-4-6" }, - "값원문": { - "8-4-6 주연료": "135㎾" - } + "값원문": { "8-4-6 주연료": "135㎾" }, + "비고": "전력 = 원문 주연료 칸 「135㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 직종 = 사용자 확정(요구 799) — 원문 8-1-3 5 표의 「천공기」" }, { "키": "EQ000348", @@ -22044,7 +22047,8 @@ "주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", - "조종원직종": "" + "조종원직종": "", + "전력": "kWh/hr" }, "값": { "가격": 1153045, @@ -22057,11 +22061,12 @@ "정비비계수": 777, "관리비계수": 738, "손료계수": 2515, - "주연료": "239㎾", + "주연료": null, "주연료종류": null, "잡재료율": 10, "조종원": 1, - "조종원직종": null + "조종원직종": "1048", + "전력": 239 }, "출처": { "가격": "건설품셈 공통 8-5-6", @@ -22078,11 +22083,11 @@ "주연료종류": "건설품셈 공통 8-4-6", "잡재료율": "건설품셈 공통 8-4-6", "조종원": "건설품셈 공통 8-4-6", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1048", + "전력": "건설품셈 공통 8-4-6" }, - "값원문": { - "8-4-6 주연료": "239㎾" - } + "값원문": { "8-4-6 주연료": "239㎾" }, + "비고": "전력 = 원문 주연료 칸 「239㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 직종 = 사용자 확정(요구 799) — 원문 8-1-3 5 표의 「천공기」" }, { "키": "EQ000349", @@ -26768,7 +26773,8 @@ "주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", - "조종원직종": "" + "조종원직종": "", + "전력": "kWh/hr" }, "값": { "가격": 16828, @@ -26781,11 +26787,12 @@ "정비비계수": 2333, "관리비계수": 1169, "손료계수": 6502, - "주연료": "동력 15.1㎾", + "주연료": null, "주연료종류": null, - "잡재료율": null, + "잡재료율": 0, "조종원": null, - "조종원직종": null + "조종원직종": null, + "전력": 15.1 }, "출처": { "가격": "건설품셈 공통 8-5-8", @@ -26802,12 +26809,11 @@ "주연료종류": "건설품셈 공통 8-4-8", "잡재료율": "건설품셈 공통 8-4-8", "조종원": "건설품셈 공통 8-4-8", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5", + "전력": "건설품셈 공통 8-4-8" }, - "비고": "규격 단위: (W×L×H)", - "값원문": { - "8-4-8 주연료": "동력 15.1㎾" - } + "비고": "규격 단위: (W×L×H) · 전력 = 원문 주연료 칸 「동력 15.1㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 잡재료율 0 = 원문 잡재료 「-」", + "값원문": { "8-4-8 주연료": "동력 15.1㎾" } }, { "키": "EQ000427", @@ -26832,7 +26838,8 @@ "주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", - "조종원직종": "" + "조종원직종": "", + "전력": "kWh/hr" }, "값": { "가격": 21722, @@ -26845,11 +26852,12 @@ "정비비계수": 2333, "관리비계수": 1169, "손료계수": 6502, - "주연료": "동력 15.1㎾", + "주연료": null, "주연료종류": null, - "잡재료율": null, + "잡재료율": 0, "조종원": null, - "조종원직종": null + "조종원직종": null, + "전력": 15.1 }, "출처": { "가격": "건설품셈 공통 8-5-8", @@ -26866,12 +26874,11 @@ "주연료종류": "건설품셈 공통 8-4-8", "잡재료율": "건설품셈 공통 8-4-8", "조종원": "건설품셈 공통 8-4-8", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5", + "전력": "건설품셈 공통 8-4-8" }, - "비고": "규격 단위: (W×L×H)", - "값원문": { - "8-4-8 주연료": "동력 15.1㎾" - } + "비고": "규격 단위: (W×L×H) · 전력 = 원문 주연료 칸 「동력 15.1㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 잡재료율 0 = 원문 잡재료 「-」", + "값원문": { "8-4-8 주연료": "동력 15.1㎾" } }, { "키": "EQ000428", @@ -27218,7 +27225,7 @@ "주연료종류": null, "잡재료율": null, "조종원": 1, - "조종원직종": null + "조종원직종": "1050" }, "출처": { "가격": "건설품셈 공통 8-5-8", @@ -27235,8 +27242,9 @@ "주연료종류": "건설품셈 공통 8-4-8", "잡재료율": "건설품셈 공통 8-4-8", "조종원": "건설품셈 공통 8-4-8", - "조종원직종": "건설품셈 공통 8-1-3 5" - } + "조종원직종": "건설품셈 공통 8-1-3 5 · 건설노임 1050" + }, + "비고": "직종 = 사용자 확정(요구 799) — 두 법에 없는 기계" }, { "키": "EQ000434", @@ -33047,7 +33055,8 @@ "주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", - "조종원직종": "" + "조종원직종": "", + "전력": "kWh/hr" }, "값": { "가격": 16913, @@ -33060,11 +33069,12 @@ "정비비계수": null, "관리비계수": null, "손료계수": 4677, - "주연료": "3.73㎾", + "주연료": null, "주연료종류": null, - "잡재료율": null, + "잡재료율": 0, "조종원": null, - "조종원직종": null + "조종원직종": null, + "전력": 3.73 }, "출처": { "가격": "건설품셈 공통 8-5-8", @@ -33081,11 +33091,11 @@ "주연료종류": "건설품셈 공통 8-4-8", "잡재료율": "건설품셈 공통 8-4-8", "조종원": "건설품셈 공통 8-4-8", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5", + "전력": "건설품셈 공통 8-4-8" }, - "값원문": { - "8-4-8 주연료": "3.73㎾" - } + "값원문": { "8-4-8 주연료": "3.73㎾" }, + "비고": "전력 = 원문 주연료 칸 「3.73㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 잡재료율 0 = 원문 잡재료 「-」" }, { "키": "EQ000530", @@ -33110,7 +33120,8 @@ "주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", - "조종원직종": "" + "조종원직종": "", + "전력": "kWh/hr" }, "값": { "가격": 21887, @@ -33123,11 +33134,12 @@ "정비비계수": null, "관리비계수": null, "손료계수": 4677, - "주연료": "7.46㎾", + "주연료": null, "주연료종류": null, - "잡재료율": null, + "잡재료율": 0, "조종원": null, - "조종원직종": null + "조종원직종": null, + "전력": 7.46 }, "출처": { "가격": "건설품셈 공통 8-5-8", @@ -33144,11 +33156,11 @@ "주연료종류": "건설품셈 공통 8-4-8", "잡재료율": "건설품셈 공통 8-4-8", "조종원": "건설품셈 공통 8-4-8", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5", + "전력": "건설품셈 공통 8-4-8" }, - "값원문": { - "8-4-8 주연료": "7.46㎾" - } + "값원문": { "8-4-8 주연료": "7.46㎾" }, + "비고": "전력 = 원문 주연료 칸 「7.46㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 잡재료율 0 = 원문 잡재료 「-」" }, { "키": "EQ000531", @@ -33173,7 +33185,8 @@ "주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", - "조종원직종": "" + "조종원직종": "", + "전력": "kWh/hr" }, "값": { "가격": 40769, @@ -33186,11 +33199,12 @@ "정비비계수": null, "관리비계수": null, "손료계수": 4677, - "주연료": "37㎾", + "주연료": null, "주연료종류": null, - "잡재료율": null, + "잡재료율": 0, "조종원": null, - "조종원직종": null + "조종원직종": null, + "전력": 37 }, "출처": { "가격": "건설품셈 공통 8-5-8", @@ -33207,11 +33221,11 @@ "주연료종류": "건설품셈 공통 8-4-8", "잡재료율": "건설품셈 공통 8-4-8", "조종원": "건설품셈 공통 8-4-8", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5", + "전력": "건설품셈 공통 8-4-8" }, - "값원문": { - "8-4-8 주연료": "37㎾" - } + "값원문": { "8-4-8 주연료": "37㎾" }, + "비고": "전력 = 원문 주연료 칸 「37㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 잡재료율 0 = 원문 잡재료 「-」" }, { "키": "EQ000532", @@ -33232,13 +33246,14 @@ "정비비계수": "10^-7/시간", "관리비계수": "10^-7/시간", "손료계수": "10^-7/시간", - "주연료": "㎾", + "주연료": "ℓ/hr", "주연료종류": "", "주연료2": "ℓ/hr", "주연료2종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", - "조종원직종": "" + "조종원직종": "", + "전력": "kWh/hr" }, "값": { "가격": 5696, @@ -33251,13 +33266,14 @@ "정비비계수": null, "관리비계수": null, "손료계수": 3708, - "주연료": 1.87, + "주연료": null, "주연료종류": null, "주연료2": 1.3, "주연료2종류": "휘발유", "잡재료율": 2, "조종원": null, - "조종원직종": null + "조종원직종": null, + "전력": 1.87 }, "출처": { "가격": "건설품셈 공통 8-5-8", @@ -33276,11 +33292,11 @@ "주연료2종류": "건설품셈 공통 8-4-8", "잡재료율": "건설품셈 공통 8-4-8", "조종원": "건설품셈 공통 8-4-8", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5", + "전력": "건설품셈 공통 8-4-8" }, - "값원문": { - "8-4-8 주연료": "1.87㎾ 휘발유1.3" - } + "값원문": { "8-4-8 주연료": "1.87㎾ 휘발유1.3" }, + "비고": "전력 = 원문 주연료 칸 「1.87㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 원문 「휘발유1.3」 갈래는 주연료2 칸 · 시간당 식은 전력" }, { "키": "EQ000533", @@ -33305,7 +33321,8 @@ "주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", - "조종원직종": "" + "조종원직종": "", + "전력": "kWh/hr" }, "값": { "가격": 38, @@ -33318,11 +33335,12 @@ "정비비계수": null, "관리비계수": null, "손료계수": 3375, - "주연료": "1.49㎾", + "주연료": null, "주연료종류": null, - "잡재료율": null, + "잡재료율": 0, "조종원": null, - "조종원직종": null + "조종원직종": null, + "전력": 1.49 }, "출처": { "가격": "건설품셈 공통 8-5-8", @@ -33339,19 +33357,20 @@ "주연료종류": "건설품셈 공통 8-4-8", "잡재료율": "건설품셈 공통 8-4-8", "조종원": "건설품셈 공통 8-4-8", - "조종원직종": "건설품셈 공통 8-1-3 5" + "조종원직종": "건설품셈 공통 8-1-3 5", + "전력": "건설품셈 공통 8-4-8" }, - "값원문": { - "8-4-8 주연료": "1.49㎾" - } + "값원문": { "8-4-8 주연료": "1.49㎾" }, + "비고": "전력 = 원문 주연료 칸 「1.49㎾」(㎾ · 전동기 부하율 규정 없음 → 그대로) · 잡재료율 0 = 원문 잡재료 「-」" }, { "키": "EQ000534", "원문번호": "7994-0050", "원문": "건설품셈", "구분": "[70]기타기계", - "상세구분": "POWER TLOWEL", - "이름": "POWER TLOWEL", + "상세구분": "POWER TROWEL", + "이름": "POWER TROWEL", + "옛이름": "POWER TLOWEL", "규격": "3.73㎾", "단위": { "가격": "천원", diff --git a/resources/master_data/로직_건설품셈_08장_건설기계.json b/resources/master_data/로직_건설품셈_08장_건설기계.json index 929d896f8..abb927060 100644 --- a/resources/master_data/로직_건설품셈_08장_건설기계.json +++ b/resources/master_data/로직_건설품셈_08장_건설기계.json @@ -284,7 +284,13 @@ "9090-1300", "9090-1400", "9090-1500", - "9090-1600" + "9090-1600", + "3108-0060", + "3108-0080", + "3108-0100", + "3108-0120", + "9010-0120", + "9010-0200" ] }, { @@ -853,11 +859,6 @@ "2104-0220", "2104-0250", "2104-0300", - "2208-5008", - "2208-5010", - "2208-5012", - "2208-5016", - "2208-5020", "2105-0002", "2105-0003", "2105-0005", @@ -921,7 +922,6 @@ "2118-0010", "2210-0145", "3430-0300", - "3450-0642", "4115-0100", "4115-0150", "4115-0200", @@ -929,10 +929,6 @@ "4505-0015", "4505-0026", "4611-0075", - "5105-0050", - "5105-0100", - "5105-0150", - "5105-0200", "5111-0040", "5111-0050", "5111-0060", @@ -989,15 +985,12 @@ "5118-0005", "5118-0006", "5118-0007", - "5119-0625", "5203-1800", "5203-2200", "5203-2700", "5203-3500", "5203-4500", "5330-0074", - "5805-0002", - "5805-0003", "5901-0006", "5901-0010", "5901-0016", @@ -1018,9 +1011,6 @@ "6550-0130", "7103-0010", "7104-0010", - "7202-0008", - "7202-0010", - "7205-0125", "7206-0020", "7206-0070", "7206-0100", @@ -1058,10 +1048,6 @@ "7930-0075", "7930-0100", "7935-0180", - "7991-0050", - "7991-0100", - "7991-0500", - "7993-0020", "8201-0100", "8202-0100", "8203-0019", @@ -1370,7 +1356,30 @@ "소유": "공용", "비고": "원문 운전경비 표에 주연료가 빈 기종(공기식 크롤러드릴 등 — 동력은 공기압축기) — 손료 + 조종원", "입력": [ - { "이름": "기계", "고르기": ["5401-0017", "4108-0060", "4108-0090", "4108-0120", "4108-0150", "4108-0180", "4108-0210", "5401-0015"] }, + { + "이름": "기계", + "고르기": [ + "5401-0017", + "4108-0060", + "4108-0090", + "4108-0120", + "4108-0150", + "4108-0180", + "4108-0210", + "5401-0015", + "2208-5008", + "2208-5010", + "2208-5012", + "2208-5016", + "2208-5020", + "5105-0050", + "5105-0100", + "5105-0150", + "5105-0200", + "5119-0625", + "7205-0125" + ] + }, { "이름": "지역", "고르기": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], @@ -3456,6 +3465,140 @@ "조건": { "기호": "S", "아래": "조건" }, "유속": { "기호": "v", "아래": "유속" } } + }, + { + "키": "GC001196", + "원문번호": "8", + "구분": "건설품셈 공통", + "상세구분": "08장 건설기계", + "결과단위": "원/시간", + "이름": "기계 시간당 기계경비(전력 기종)", + "출처": "건설품셈 공통 8-1-5 · 8-1-6 · 8-1-6 3 가", + "부품": { "갈래": "기계경비식" }, + "소유": "공용", + "비고": "원문 운전경비 표 주연료 칸에 ㎾ 가 찍힌 전동 기종 — 전력비 = 전력(㎾) × 전력량요금 · 잡재료 = 전력비의 %(8-4 주②)", + "입력": [ + { "이름": "기계", "고르기": ["7991-0050", "7202-0008", "7202-0010", "7991-0100", "7991-0500", "7992-0001", "7993-0020"] }, + { "이름": "전력 계약종별", "고르기": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "출처": "유가전력 한전 전기요금표 2025-01-01", "비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) — 기본 = 불러오기 기본값" } + ], + "중간": [{ "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * EQ:{기계}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" }], + "호표": [ + { + "종류": "재료", + "요소": { "이름": "전력량요금", "계약종별": "입력" }, + "이름": "전력", + "단위": "㎾h", + "수량": "EQ:{기계}.전력", + "비목": "재료비", + "출처": "건설품셈 공통 8-1-6 3 가", + "비고": "기계 줄 전력(㎾) 그대로 — 전동기 부하율 규정 없음" + } + ], + "덧줄": [{ "이름": "잡재료비", "식": "재료비 * EQ:{기계}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②" }, { "이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2" }], + "끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음", + "변수표": { "기계": { "기호": "S", "아래": "기계" }, "전력 계약종별": { "기호": "S", "아래": "전력계약종별" }, "손료": { "기호": "C", "아래": "손료" } } + }, + { + "키": "GC001197", + "원문번호": "8", + "구분": "건설품셈 공통", + "상세구분": "08장 건설기계", + "결과단위": "원/시간", + "이름": "기계 시간당 기계경비(전력 · 조종원 기종)", + "출처": "건설품셈 공통 8-1-5 · 8-1-6 · 8-1-6 3 가", + "부품": { "갈래": "기계경비식" }, + "소유": "공용", + "비고": "원문 운전경비 표 주연료 칸에 ㎾ 가 찍히고 조종원이 있는 전동 기종(점보드릴) — 전력비 + 잡재료(전력비의 %) + 조종원", + "입력": [ + { "이름": "기계", "고르기": ["5805-0002", "5805-0003"] }, + { "이름": "전력 계약종별", "고르기": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "출처": "유가전력 한전 전기요금표 2025-01-01", "비고": "품셈·계약예규에 계약종별 규정 없음(전력비는 실비 계상) — 기본 = 불러오기 기본값" } + ], + "중간": [ + { "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * EQ:{기계}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" }, + { "이름": "직종", "식": "EQ:{기계}.조종원직종", "출처": "건설품셈 공통 8-1-3 5" }, + { "이름": "시간환산", "식": "찾기(CC000336, 구분='시간환산').분자 / 찾기(CC000336, 구분='시간환산').분모", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)" } + ], + "호표": [ + { + "종류": "재료", + "요소": { "이름": "전력량요금", "계약종별": "입력" }, + "이름": "전력", + "단위": "㎾h", + "수량": "EQ:{기계}.전력", + "비목": "재료비", + "출처": "건설품셈 공통 8-1-6 3 가", + "비고": "기계 줄 전력(㎾) 그대로 — 전동기 부하율 규정 없음" + }, + { + "종류": "인력", + "요소": "LB:{직종}", + "이름": "조종원", + "단위": "인", + "수량": "EQ:{기계}.조종원 * 시간환산", + "비목": "노무비", + "출처": "건설품셈 공통 8-1-3 6", + "비고": "하루 노임 × 1/8(1일 8시간 · 건설품셈 공통 1-1-3 2)" + } + ], + "덧줄": [{ "이름": "잡재료비", "식": "재료비 * EQ:{기계}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②" }, { "이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2" }], + "끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음", + "변수표": { + "기계": { "기호": "S", "아래": "기계" }, + "전력 계약종별": { "기호": "S", "아래": "전력계약종별" }, + "손료": { "기호": "C", "아래": "손료" }, + "직종": { "기호": "S", "아래": "직종" }, + "시간환산": { "기호": "Q", "아래": "시간환산" } + } + }, + { + "키": "GC001198", + "원문번호": "8", + "구분": "건설품셈 공통", + "상세구분": "08장 건설기계", + "결과단위": "원/시간", + "이름": "기계 시간당 기계경비(조종원 없는 기종 · 손료계수 한 칸)", + "출처": "건설품셈 공통 8-1-5 · 8-1-6", + "부품": { "갈래": "기계경비식" }, + "소유": "공용", + "비고": "원문 손료 표가 시간당 손료계수 하나만 준 기종(8-3-8 기타기계 등) 가운데 주연료가 있고 조종원이 없는 것", + "입력": [ + { "이름": "기계", "고르기": ["7994-0050"] }, + { + "이름": "지역", + "고르기": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], + "출처": "건설품셈 공통 8-1-7 5" + } + ], + "중간": [ + { "이름": "손료", "식": "버림(EQ:{기계}.가격 * 1000 * EQ:{기계}.손료계수 / 10000000, 0)", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4" }, + { "이름": "연료", "식": "EQ:{기계}.주연료종류", "출처": "건설품셈 공통 8-4 주①㉰" } + ], + "호표": [{ "종류": "재료", "요소": "MO:{연료}:{지역}", "이름": "주연료", "단위": "ℓ", "수량": "EQ:{기계}.주연료", "비목": "재료비", "출처": "건설품셈 공통 8-4-8" }], + "덧줄": [{ "이름": "잡재료비", "식": "재료비 * EQ:{기계}.잡재료율 / 100", "비목": "재료비", "출처": "건설품셈 공통 8-4 주②" }, { "이름": "기계손료", "식": "손료", "비목": "경비", "출처": "건설품셈 공통 8-1-6 2" }], + "끝수": "손료 원 미만 버림(건설품셈 공통 8-1-5 12) · 합계 원문 규정 없음", + "변수표": { "기계": { "기호": "S", "아래": "기계" }, "지역": { "기호": "S", "아래": "지역" }, "손료": { "기호": "C", "아래": "손료" }, "연료": { "기호": "S", "아래": "연료" } } + }, + { + "키": "GC001199", + "원문번호": "8", + "구분": "건설품셈 공통", + "상세구분": "08장 건설기계", + "결과단위": "원/시간", + "이름": "기계 시간당 기계경비(직접 값)", + "출처": "자체", + "부품": { "갈래": "기계경비식" }, + "소유": "공용", + "비고": "자체 — 구성 없는 기계 · 구성(손료 · 연료 · 조종원) 없이 시간당 노무비 · 재료비 · 경비를 바로 둔 기계(내 기계 「시간당 값 직접」) — 기계 줄 값 그대로", + "입력": [{ "이름": "기계", "고르기": [] }], + "중간": [], + "호표": [], + "덧줄": [ + { "이름": "시간당 노무비", "식": "EQ:{기계}.노무비", "비목": "노무비", "출처": "자체" }, + { "이름": "시간당 재료비", "식": "EQ:{기계}.재료비", "비목": "재료비", "출처": "자체" }, + { "이름": "시간당 경비", "식": "EQ:{기계}.경비", "비목": "경비", "출처": "자체" } + ], + "끝수": null, + "변수표": { "기계": { "기호": "S", "아래": "기계" } } } ] } diff --git a/resources/master_data/수식문서/수식_건설품셈_08장_건설기계.json b/resources/master_data/수식문서/수식_건설품셈_08장_건설기계.json index d637e805c..59a932359 100644 --- a/resources/master_data/수식문서/수식_건설품셈_08장_건설기계.json +++ b/resources/master_data/수식문서/수식_건설품셈_08장_건설기계.json @@ -62,7 +62,7 @@ "갈래": "기계경비식" }, "변수": [ - {"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["1730-0015", "7204-0055", "0602-0150", "2702-0020", "0101-0007", "0101-0010", "0101-0012", "0101-0019", "0101-0032", "2105-0005", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "4504-0032", "4504-0036", "4504-0041", "4504-0043", "4504-0047", "4504-0052", "4205-0010", "4205-0017", "4205-0020", "4205-0030", "4205-0040", "4205-0045", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "2502-0025", "0602-0025", "2502-0050", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "7204-0018", "7204-0160", "5205-0210", "5405-0110", "7505-0450", "5205-0255", "7505-0150", "5205-0103", "6701-0147", "6701-0184", "5205-0170", "7505-0350", "0302-0134", "7505-0100", "7505-0050", "5205-0071", "7505-0025", "0502-0036", "1306-0100", "1506-0032", "0121-0004", "1305-0007", "7204-0038", "7204-0065", "0302-0057", "5701-0020", "0302-0095", "3201-0003", "1106-0012", "1406-0015", "1206-0008", "3601-0102", "1306-0025", "0211-0018", "0602-0045", "0602-0060", "0602-0105", "1630-0080", "3901-0300", "7505-0250", "2502-0035", "1306-0120", "1106-0010", "3201-0001", "1406-0008", "3601-0202", "3601-0402", "3611-0142", "2502-0020", "2502-0075", "5205-0035", "2702-0030", "2702-0060", "0211-0060", "0211-0080", "0211-0100", "0221-0040", "0221-0070", "0302-0025", "0302-0172", "0302-0229", "0302-0287", "0302-0350", "0302-0500", "0406-0054", "0406-0115", "0406-0161", "0406-0206", "0503-0036", "0602-0200", "0602-0240", "0602-0320", "1106-0015", "1206-0010", "1206-0014", "1209-0001", "1209-0002", "1209-0004", "1209-0006", "1209-0007", "1209-0008", "1209-0013", "1306-0044", "1306-0060", "1406-0025", "1506-0011", "1506-0012", "1506-0015", "1506-0019", "1506-0025", "1506-0030", "1506-0037", "2602-0015", "2602-0025", "2602-0035", "2602-0045", "2702-0040", "3530-0015", "3530-0036", "3601-0204", "3601-0302", "3701-0200", "3805-0120", "4504-0021", "4504-0028", "5405-0150", "5701-0010", "7106-0035", "7505-0125", "7505-0200", "7505-0500", "7505-0700", "9010-0003", "9010-0006", "9010-0010", "9010-0012", "9010-0020", "9010-0022", "9010-0033", "9010-0040", "9010-0044", "9010-0060", "9010-0080", "9020-0010", "9020-0015", "9020-0016", "9020-0022", "9020-0035", "9020-0050", "9020-0072", "9020-0160", "9020-0180", "9020-0200", "9050-0075", "9050-0150", "9050-0450", "9050-0750", "9050-0850", "9090-0800", "9090-1000", "9090-1200", "9090-1300", "9090-1400", "9090-1500", "9090-1600"], "값": "1730-0015"}}, + {"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["1730-0015", "7204-0055", "0602-0150", "2702-0020", "0101-0007", "0101-0010", "0101-0012", "0101-0019", "0101-0032", "2105-0005", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "4504-0032", "4504-0036", "4504-0041", "4504-0043", "4504-0047", "4504-0052", "4205-0010", "4205-0017", "4205-0020", "4205-0030", "4205-0040", "4205-0045", "2105-0002", "2105-0003", "2105-0010", "2105-0015", "2105-0018", "2502-0025", "0602-0025", "2502-0050", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "7204-0018", "7204-0160", "5205-0210", "5405-0110", "7505-0450", "5205-0255", "7505-0150", "5205-0103", "6701-0147", "6701-0184", "5205-0170", "7505-0350", "0302-0134", "7505-0100", "7505-0050", "5205-0071", "7505-0025", "0502-0036", "1306-0100", "1506-0032", "0121-0004", "1305-0007", "7204-0038", "7204-0065", "0302-0057", "5701-0020", "0302-0095", "3201-0003", "1106-0012", "1406-0015", "1206-0008", "3601-0102", "1306-0025", "0211-0018", "0602-0045", "0602-0060", "0602-0105", "1630-0080", "3901-0300", "7505-0250", "2502-0035", "1306-0120", "1106-0010", "3201-0001", "1406-0008", "3601-0202", "3601-0402", "3611-0142", "2502-0020", "2502-0075", "5205-0035", "2702-0030", "2702-0060", "0211-0060", "0211-0080", "0211-0100", "0221-0040", "0221-0070", "0302-0025", "0302-0172", "0302-0229", "0302-0287", "0302-0350", "0302-0500", "0406-0054", "0406-0115", "0406-0161", "0406-0206", "0503-0036", "0602-0200", "0602-0240", "0602-0320", "1106-0015", "1206-0010", "1206-0014", "1209-0001", "1209-0002", "1209-0004", "1209-0006", "1209-0007", "1209-0008", "1209-0013", "1306-0044", "1306-0060", "1406-0025", "1506-0011", "1506-0012", "1506-0015", "1506-0019", "1506-0025", "1506-0030", "1506-0037", "2602-0015", "2602-0025", "2602-0035", "2602-0045", "2702-0040", "3530-0015", "3530-0036", "3601-0204", "3601-0302", "3701-0200", "3805-0120", "4504-0021", "4504-0028", "5405-0150", "5701-0010", "7106-0035", "7505-0125", "7505-0200", "7505-0500", "7505-0700", "9010-0003", "9010-0006", "9010-0010", "9010-0012", "9010-0020", "9010-0022", "9010-0033", "9010-0040", "9010-0044", "9010-0060", "9010-0080", "9020-0010", "9020-0015", "9020-0016", "9020-0022", "9020-0035", "9020-0050", "9020-0072", "9020-0160", "9020-0180", "9020-0200", "9050-0075", "9050-0150", "9050-0450", "9050-0750", "9050-0850", "9090-0800", "9090-1000", "9090-1200", "9090-1300", "9090-1400", "9090-1500", "9090-1600", "3108-0060", "3108-0080", "3108-0100", "3108-0120", "9010-0120", "9010-0200"], "값": "1730-0015"}}, {"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}}, {"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}}, {"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}}, @@ -306,7 +306,7 @@ "결과단위": "원/시간", "출처": "건설품셈 공통 8-1-5 · 8-1-6", "변수": [ - {"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7620-0003", "7621-0100", "7995-0050", "5210-0013", "5210-0027", "5220-0015", "7730-0150", "6516-0060", "6516-0120", "6516-0250", "6516-0300", "6105-0190", "6202-0060", "0230-0002", "6410-0080", "6410-0100", "6410-0120", "6410-0150", "6410-0200", "6901-0010", "6530-0090", "6530-0120", "6517-0100", "6517-0150", "6517-0200", "6517-0250", "6517-0300", "6510-0100", "6510-0150", "6510-0200", "6510-0250", "6510-0300", "6518-0100", "6518-0150", "6518-0200", "6518-0250", "6518-0300", "5501-0080", "7750-0016", "7750-0025", "7730-0050", "5401-0017", "5202-0127", "5202-0240", "5202-0300", "7611-0200", "7612-0200", "5204-0200", "5204-0300", "5204-0400", "5204-0500", "5204-0600", "5702-0095", "3430-0400", "7431-1300", "7740-0080", "7740-0150", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "2208-5008", "2208-5010", "2208-5012", "2208-5016", "2208-5020", "2105-0002", "2105-0003", "2105-0005", "2105-0010", "2105-0015", "2105-0018", "0201-0012", "0201-0020", "0201-0040", "0201-0060", "0201-0070", "0201-0080", "0201-0100", "0201-0120", "0201-0200", "6105-0390", "6202-0125", "7830-0081", "7950-0149", "7951-0066", "7952-0246", "7953-3400", "7954-1119", "7611-0300", "7431-1100", "7431-2300", "7431-2500", "7611-0400", "7611-0500", "7614-0300", "0250-0100", "5210-0010", "5210-0019", "0103-0016", "0103-0019", "0103-0023", "0103-0027", "0103-0032", "0230-0004", "0230-0006", "0230-0007", "0230-0008", "0230-0010", "0240-0007", "0250-0080", "0407-0054", "0407-0092", "0407-0107", "0407-0161", "0407-0206", "0610-0150", "0610-0200", "0610-0240", "2115-0024", "2115-0031", "2115-0036", "2116-0031", "2116-0036", "2117-0022", "2117-0027", "2118-0010", "2210-0145", "3430-0300", "3450-0642", "4115-0100", "4115-0150", "4115-0200", "4115-0300", "4505-0015", "4505-0026", "4611-0075", "5105-0050", "5105-0100", "5105-0150", "5105-0200", "5111-0040", "5111-0050", "5111-0060", "5111-0076", "5111-0091", "5112-0001", "5112-0002", "5112-0003", "5112-0004", "5112-0005", "5113-0001", "5113-0002", "5113-0003", "5113-0004", "5113-0005", "5113-0006", "5113-0007", "5113-0008", "5113-0009", "5113-0010", "5113-0011", "5114-0001", "5114-0002", "5114-0003", "5114-0004", "5114-0005", "5114-0006", "5114-0007", "5114-0008", "5115-0030", "5115-0055", "5115-0075", "5115-0095", "5116-0001", "5116-0002", "5116-0003", "5116-0004", "5116-0005", "5116-0006", "5116-0007", "5116-0008", "5117-0001", "5117-0002", "5117-0003", "5117-0004", "5117-0005", "5117-0006", "5117-0007", "5117-0008", "5118-0001", "5118-0002", "5118-0003", "5118-0004", "5118-0005", "5118-0006", "5118-0007", "5119-0625", "5203-1800", "5203-2200", "5203-2700", "5203-3500", "5203-4500", "5330-0074", "5805-0002", "5805-0003", "5901-0006", "5901-0010", "5901-0016", "6202-0200", "6408-0015", "6408-0020", "6408-0030", "6408-0040", "6408-0050", "6408-0085", "6408-0100", "6515-0090", "6530-0030", "6530-0040", "6530-0045", "6530-0060", "6532-0220", "6550-0130", "7103-0010", "7104-0010", "7202-0008", "7202-0010", "7205-0125", "7206-0020", "7206-0070", "7206-0100", "7430-1100", "7430-1300", "7430-1500", "7430-2300", "7430-2500", "7612-0300", "7612-0400", "7613-0075", "7613-0150", "7613-0300", "7613-0400", "7613-0600", "7613-0900", "7620-0002", "7730-0080", "7730-0100", "7730-0125", "7740-0100", "7770-0004", "7930-0001", "7930-0002", "7930-0003", "7930-0005", "7930-0007", "7930-0010", "7930-0015", "7930-0020", "7930-0025", "7930-0030", "7930-0040", "7930-0050", "7930-0075", "7930-0100", "7935-0180", "7991-0050", "7991-0100", "7991-0500", "7993-0020", "8201-0100", "8202-0100", "8203-0019", "8204-0100", "8205-0100", "9060-0060", "9060-0100", "9060-0200", "9060-0300", "9060-0500", "9060-0600", "9080-0050", "9080-0080", "9080-0100", "9080-0120", "9080-0150", "9080-0200", "9080-0300", "9080-0500", "9080-0700", "9080-1000", "9080-1100", "9080-1400", "9080-1500", "9080-1750", "9080-2000", "9080-3000"], "값": "7620-0003"}}, + {"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7620-0003", "7621-0100", "7995-0050", "5210-0013", "5210-0027", "5220-0015", "7730-0150", "6516-0060", "6516-0120", "6516-0250", "6516-0300", "6105-0190", "6202-0060", "0230-0002", "6410-0080", "6410-0100", "6410-0120", "6410-0150", "6410-0200", "6901-0010", "6530-0090", "6530-0120", "6517-0100", "6517-0150", "6517-0200", "6517-0250", "6517-0300", "6510-0100", "6510-0150", "6510-0200", "6510-0250", "6510-0300", "6518-0100", "6518-0150", "6518-0200", "6518-0250", "6518-0300", "5501-0080", "7750-0016", "7750-0025", "7730-0050", "5401-0017", "5202-0127", "5202-0240", "5202-0300", "7611-0200", "7612-0200", "5204-0200", "5204-0300", "5204-0400", "5204-0500", "5204-0600", "5702-0095", "3430-0400", "7431-1300", "7740-0080", "7740-0150", "2101-0010", "2101-0015", "2101-0020", "2101-0025", "2101-0030", "2101-0035", "2101-0040", "2101-0050", "2101-0070", "2101-0080", "2101-0100", "2101-0150", "2101-0220", "2101-0280", "2101-0300", "2104-0010", "2104-0015", "2104-0020", "2104-0025", "2104-0030", "2104-0035", "2104-0040", "2104-0045", "2104-0050", "2104-0060", "2104-0070", "2104-0080", "2104-0100", "2104-0130", "2104-0160", "2104-0200", "2104-0220", "2104-0250", "2104-0300", "2105-0002", "2105-0003", "2105-0005", "2105-0010", "2105-0015", "2105-0018", "0201-0012", "0201-0020", "0201-0040", "0201-0060", "0201-0070", "0201-0080", "0201-0100", "0201-0120", "0201-0200", "6105-0390", "6202-0125", "7830-0081", "7950-0149", "7951-0066", "7952-0246", "7953-3400", "7954-1119", "7611-0300", "7431-1100", "7431-2300", "7431-2500", "7611-0400", "7611-0500", "7614-0300", "0250-0100", "5210-0010", "5210-0019", "0103-0016", "0103-0019", "0103-0023", "0103-0027", "0103-0032", "0230-0004", "0230-0006", "0230-0007", "0230-0008", "0230-0010", "0240-0007", "0250-0080", "0407-0054", "0407-0092", "0407-0107", "0407-0161", "0407-0206", "0610-0150", "0610-0200", "0610-0240", "2115-0024", "2115-0031", "2115-0036", "2116-0031", "2116-0036", "2117-0022", "2117-0027", "2118-0010", "2210-0145", "3430-0300", "4115-0100", "4115-0150", "4115-0200", "4115-0300", "4505-0015", "4505-0026", "4611-0075", "5111-0040", "5111-0050", "5111-0060", "5111-0076", "5111-0091", "5112-0001", "5112-0002", "5112-0003", "5112-0004", "5112-0005", "5113-0001", "5113-0002", "5113-0003", "5113-0004", "5113-0005", "5113-0006", "5113-0007", "5113-0008", "5113-0009", "5113-0010", "5113-0011", "5114-0001", "5114-0002", "5114-0003", "5114-0004", "5114-0005", "5114-0006", "5114-0007", "5114-0008", "5115-0030", "5115-0055", "5115-0075", "5115-0095", "5116-0001", "5116-0002", "5116-0003", "5116-0004", "5116-0005", "5116-0006", "5116-0007", "5116-0008", "5117-0001", "5117-0002", "5117-0003", "5117-0004", "5117-0005", "5117-0006", "5117-0007", "5117-0008", "5118-0001", "5118-0002", "5118-0003", "5118-0004", "5118-0005", "5118-0006", "5118-0007", "5203-1800", "5203-2200", "5203-2700", "5203-3500", "5203-4500", "5330-0074", "5901-0006", "5901-0010", "5901-0016", "6202-0200", "6408-0015", "6408-0020", "6408-0030", "6408-0040", "6408-0050", "6408-0085", "6408-0100", "6515-0090", "6530-0030", "6530-0040", "6530-0045", "6530-0060", "6532-0220", "6550-0130", "7103-0010", "7104-0010", "7206-0020", "7206-0070", "7206-0100", "7430-1100", "7430-1300", "7430-1500", "7430-2300", "7430-2500", "7612-0300", "7612-0400", "7613-0075", "7613-0150", "7613-0300", "7613-0400", "7613-0600", "7613-0900", "7620-0002", "7730-0080", "7730-0100", "7730-0125", "7740-0100", "7770-0004", "7930-0001", "7930-0002", "7930-0003", "7930-0005", "7930-0007", "7930-0010", "7930-0015", "7930-0020", "7930-0025", "7930-0030", "7930-0040", "7930-0050", "7930-0075", "7930-0100", "7935-0180", "8201-0100", "8202-0100", "8203-0019", "8204-0100", "8205-0100", "9060-0060", "9060-0100", "9060-0200", "9060-0300", "9060-0500", "9060-0600", "9080-0050", "9080-0080", "9080-0100", "9080-0120", "9080-0150", "9080-0200", "9080-0300", "9080-0500", "9080-0700", "9080-1000", "9080-1100", "9080-1400", "9080-1500", "9080-1750", "9080-2000", "9080-3000"], "값": "7620-0003"}}, {"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}}, {"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}}, {"열쇠": "k_기계손료", "기호": "k", "아래": "기계손료", "위": "", "이름": "EQ:{기계}.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수"}} @@ -412,7 +412,7 @@ "갈래": "기계경비식" }, "변수": [ - {"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["5401-0017", "4108-0060", "4108-0090", "4108-0120", "4108-0150", "4108-0180", "4108-0210", "5401-0015"], "값": "5401-0017"}}, + {"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["5401-0017", "4108-0060", "4108-0090", "4108-0120", "4108-0150", "4108-0180", "4108-0210", "5401-0015", "2208-5008", "2208-5010", "2208-5012", "2208-5016", "2208-5020", "5105-0050", "5105-0100", "5105-0150", "5105-0200", "5119-0625", "7205-0125"], "값": "5401-0017"}}, {"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}}, {"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}}, {"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종"}}, @@ -2213,6 +2213,145 @@ "비목": {"방법": "버림", "자리": 0}, "끝전": "비목버림" } + }, + { + "꼴": "수식문서", + "판": 1, + "키": "GC001196", + "이름": "기계 시간당 기계경비(전력 기종)", + "결과단위": "원/시간", + "출처": "건설품셈 공통 8-1-5 · 8-1-6 · 8-1-6 3 가", + "부품": { + "갈래": "기계경비식" + }, + "변수": [ + {"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7991-0050", "7202-0008", "7202-0010", "7991-0100", "7991-0500", "7992-0001", "7993-0020"], "값": "7991-0050"}}, + {"열쇠": "전력 계약종별", "기호": "S", "아래": "전력계약종별", "위": "", "이름": "전력 계약종별", "단위": "", "출처": "유가전력 한전 전기요금표 2025-01-01", "연결": {"갈래": "선택", "목록": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "값": "일반용(갑)Ⅰ 저압"}}, + {"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}}, + {"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격", "계약종별": "S_전력계약종별"}}, + {"열쇠": "k_기계손료", "기호": "k", "아래": "기계손료", "위": "", "이름": "EQ:{기계}.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_기계전력", "기호": "P", "아래": "기계전력", "위": "", "이름": "EQ:{기계}.전력", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "전력", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_전력", "기호": "P", "아래": "전력", "위": "", "이름": "전력단가", "단위": "", "연결": {"갈래": "요소", "재료": {"이름": "전력량요금", "계약종별": "입력"}, "단위": "㎾h", "계약종별": "S_전력계약종별"}}, + {"열쇠": "r_기계잡재료", "기호": "r", "아래": "기계잡재료", "위": "", "이름": "EQ:{기계}.잡재료율", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "잡재료율", "계약종별": "S_전력계약종별"}} + ], + "식": [ + {"기호": "C_손료", "식": "버림(P_기계 × 1000 × k_기계손료 ÷ 10000000, 0)"} + ], + "금액": [ + {"이름": "전력", "단위": "㎾h", "비목": "재료비", "수량": "P_기계전력", "단가": "P_전력", "출처": "건설품셈 공통 8-1-6 3 가"}, + {"이름": "잡재료비", "단위": "식", "비목": "재료비", "단가": "재료비 × r_기계잡재료 ÷ 100", "출처": "건설품셈 공통 8-4 주②"}, + {"이름": "기계손료", "단위": "식", "비목": "경비", "단가": "C_손료", "출처": "건설품셈 공통 8-1-6 2"} + ], + "끝수": { + "줄": {"방법": "버림", "자리": 1}, + "비목": {"방법": "버림", "자리": 0}, + "끝전": "비목버림" + } + }, + { + "꼴": "수식문서", + "판": 1, + "키": "GC001197", + "이름": "기계 시간당 기계경비(전력 · 조종원 기종)", + "결과단위": "원/시간", + "출처": "건설품셈 공통 8-1-5 · 8-1-6 · 8-1-6 3 가", + "부품": { + "갈래": "기계경비식" + }, + "변수": [ + {"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["5805-0002", "5805-0003"], "값": "5805-0002"}}, + {"열쇠": "전력 계약종별", "기호": "S", "아래": "전력계약종별", "위": "", "이름": "전력 계약종별", "단위": "", "출처": "유가전력 한전 전기요금표 2025-01-01", "연결": {"갈래": "선택", "목록": ["일반용(갑)Ⅰ 저압", "주택용 저압"], "값": "일반용(갑)Ⅰ 저압"}}, + {"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}}, + {"열쇠": "직종", "기호": "S", "아래": "직종", "위": "", "이름": "직종", "단위": "", "출처": "건설품셈 공통 8-1-3 5", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원직종", "계약종별": "S_전력계약종별"}}, + {"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "출처": "건설품셈 공통 1-1-3 2(일일 작업시간 8시간)", "연결": {"갈래": "식"}}, + {"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격", "계약종별": "S_전력계약종별"}}, + {"열쇠": "k_기계손료", "기호": "k", "아래": "기계손료", "위": "", "이름": "EQ:{기계}.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수", "계약종별": "S_전력계약종별"}}, + {"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}}, + {"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모", "단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}}, + {"열쇠": "P_기계전력", "기호": "P", "아래": "기계전력", "위": "", "이름": "EQ:{기계}.전력", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "전력", "계약종별": "S_전력계약종별"}}, + {"열쇠": "P_전력", "기호": "P", "아래": "전력", "위": "", "이름": "전력단가", "단위": "", "연결": {"갈래": "요소", "재료": {"이름": "전력량요금", "계약종별": "입력"}, "단위": "㎾h", "계약종별": "S_전력계약종별"}}, + {"열쇠": "Q_기계조종원", "기호": "Q", "아래": "기계조종원", "위": "", "이름": "EQ:{기계}.조종원", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "조종원", "계약종별": "S_전력계약종별"}}, + {"열쇠": "w_기계운전사", "기호": "w", "아래": "기계운전사", "위": "", "이름": "건설기계운전사노임", "단위": "", "연결": {"갈래": "요소", "키": "LB:{S_직종}", "계약종별": "S_전력계약종별"}}, + {"열쇠": "r_기계잡재료", "기호": "r", "아래": "기계잡재료", "위": "", "이름": "EQ:{기계}.잡재료율", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "잡재료율", "계약종별": "S_전력계약종별"}} + ], + "식": [ + {"기호": "C_손료", "식": "버림(P_기계 × 1000 × k_기계손료 ÷ 10000000, 0)"}, + {"기호": "Q_시간환산", "식": "Q_분자^표 ÷ Q_분모^표"} + ], + "금액": [ + {"이름": "전력", "단위": "㎾h", "비목": "재료비", "수량": "P_기계전력", "단가": "P_전력", "출처": "건설품셈 공통 8-1-6 3 가"}, + {"이름": "조종원", "단위": "인", "비목": "노무비", "수량": "Q_기계조종원 × Q_시간환산", "단가": "w_기계운전사", "출처": "건설품셈 공통 8-1-3 6"}, + {"이름": "잡재료비", "단위": "식", "비목": "재료비", "단가": "재료비 × r_기계잡재료 ÷ 100", "출처": "건설품셈 공통 8-4 주②"}, + {"이름": "기계손료", "단위": "식", "비목": "경비", "단가": "C_손료", "출처": "건설품셈 공통 8-1-6 2"} + ], + "끝수": { + "줄": {"방법": "버림", "자리": 1}, + "비목": {"방법": "버림", "자리": 0}, + "끝전": "비목버림" + } + }, + { + "꼴": "수식문서", + "판": 1, + "키": "GC001198", + "이름": "기계 시간당 기계경비(조종원 없는 기종 · 손료계수 한 칸)", + "결과단위": "원/시간", + "출처": "건설품셈 공통 8-1-5 · 8-1-6", + "부품": { + "갈래": "기계경비식" + }, + "변수": [ + {"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": ["7994-0050"], "값": "7994-0050"}}, + {"열쇠": "지역", "기호": "S", "아래": "지역", "위": "", "이름": "지역", "단위": "", "출처": "건설품셈 공통 8-1-7 5", "연결": {"갈래": "선택", "목록": ["전국평균", "전국", "서울", "경기", "강원", "충북", "충남", "전북", "경북", "경남", "부산", "제주", "대구", "인천", "대전", "울산", "세종", "전남광주"], "값": "전국평균"}}, + {"열쇠": "손료", "기호": "C", "아래": "손료", "위": "", "이름": "손료", "단위": "", "출처": "건설품셈 공통 8-1-5 12 · 8-1-6 4", "연결": {"갈래": "식"}}, + {"열쇠": "연료", "기호": "S", "아래": "연료", "위": "", "이름": "연료", "단위": "", "출처": "건설품셈 공통 8-4 주①㉰", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료종류"}}, + {"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "EQ:{기계}.가격", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "가격"}}, + {"열쇠": "k_기계손료", "기호": "k", "아래": "기계손료", "위": "", "이름": "EQ:{기계}.손료계수", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "손료계수"}}, + {"열쇠": "P_기계주연료", "기호": "P", "아래": "기계주연료", "위": "", "이름": "EQ:{기계}.주연료", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "주연료"}}, + {"열쇠": "P_휘발유", "기호": "P", "아래": "휘발유", "위": "", "이름": "휘발유단가", "단위": "", "연결": {"갈래": "요소", "키": "MO:{S_연료}:{S_지역}"}}, + {"열쇠": "r_기계잡재료", "기호": "r", "아래": "기계잡재료", "위": "", "이름": "EQ:{기계}.잡재료율", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "잡재료율"}} + ], + "식": [ + {"기호": "C_손료", "식": "버림(P_기계 × 1000 × k_기계손료 ÷ 10000000, 0)"} + ], + "금액": [ + {"이름": "주연료", "단위": "ℓ", "비목": "재료비", "수량": "P_기계주연료", "단가": "P_휘발유", "출처": "건설품셈 공통 8-4-8"}, + {"이름": "잡재료비", "단위": "식", "비목": "재료비", "단가": "재료비 × r_기계잡재료 ÷ 100", "출처": "건설품셈 공통 8-4 주②"}, + {"이름": "기계손료", "단위": "식", "비목": "경비", "단가": "C_손료", "출처": "건설품셈 공통 8-1-6 2"} + ], + "끝수": { + "줄": {"방법": "버림", "자리": 1}, + "비목": {"방법": "버림", "자리": 0}, + "끝전": "비목버림" + } + }, + { + "꼴": "수식문서", + "판": 1, + "키": "GC001199", + "이름": "기계 시간당 기계경비(직접 값)", + "결과단위": "원/시간", + "출처": "자체", + "부품": { + "갈래": "기계경비식" + }, + "변수": [ + {"열쇠": "기계", "기호": "S", "아래": "기계", "위": "", "이름": "기계", "단위": "", "연결": {"갈래": "선택", "목록": [], "값": 0}}, + {"열쇠": "P_EQ기계", "기호": "P", "아래": "EQ기계", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "노무비"}}, + {"열쇠": "P_EQ기계2", "기호": "P", "아래": "EQ기계2", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "재료비"}}, + {"열쇠": "P_EQ기계3", "기호": "P", "아래": "EQ기계3", "위": "", "이름": "EQ:{기계}", "단위": "", "연결": {"갈래": "요소", "키": "EQ:{S_기계}", "칸": "경비"}} + ], + "식": [], + "금액": [ + {"이름": "시간당 노무비", "단위": "식", "비목": "노무비", "단가": "P_EQ기계", "출처": "자체"}, + {"이름": "시간당 재료비", "단위": "식", "비목": "재료비", "단가": "P_EQ기계2", "출처": "자체"}, + {"이름": "시간당 경비", "단위": "식", "비목": "경비", "단가": "P_EQ기계3", "출처": "자체"} + ], + "끝수": { + "줄": {"방법": "버림", "자리": 1}, + "비목": {"방법": "버림", "자리": 0}, + "끝전": "비목버림" + } } ] } From 062fb07668244468239b58dd97b96b292d463247 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:12:34 +0900 Subject: [PATCH 08/32] =?UTF-8?q?feat(822-2):=20=EC=8B=9D=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=ED=95=9C=20=EA=B3=B3(=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=E2=80=BA=20=EA=BC=B4)=20=C2=B7=20=EA=B9=8C=EB=8B=AD=20?= =?UTF-8?q?=EA=B8=80=20=C2=B7=20=EC=83=88=20=EA=B8=B0=EA=B3=84=20=EC=8B=9D?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20=C2=B7=20=EC=93=B0=EB=8A=94=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=8B=9D=20=C2=B7=20=EC=A7=81=EC=A0=91=20=EA=B0=92=20?= =?UTF-8?q?=C2=B7=20=EC=A0=84=EB=A0=A5=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Machine_Hourly.pick = 목록 › 그 기계 식 › 꼴(내 기계와 같은 규칙) · why = 값 모자란 까닭(가격 없음 · 범위 · 일 단위 손료 …) - 데이터 흐름 · 사본 모으기 · M01 · 불러오기 · 줄 쌓기 모두 pick 을 씀(데이터 흐름 옛 짝 고름 지움) - 내 기계 꼴: 전력 · 전력 + 조종원 · 직종 입력 · 손료계수 한 칸 · 직접(GC001199 — 줄 쌓기 회색 풀림) · 연료 + 전력 = 400 - M01 [새 기계] 저장 = 꼴 식 「기계」 목록에 등록 · M01 에서 만든 기계 지우면 목록에서 뺌 · 상세 「쓰는 계산식」(POST /machine/formula) · 목록 시간당 「—」 대신 까닭 글 - 미리보기 짝: 잡품 = (주연료 + 전력) × %(전력 기종 = 전력비의 %) - 시험 test_822_2 새로 · test_804_2 갈래에 전력 줄 더함(손료만 식과 전력 식은 다른 갈래 — 모르타르 믹서) Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Machine_Api.ts | 20 ++ M01_MasterData/M01_MasterData_Router.py | 6 + M01_MasterData/M01_MasterData_Store_Make.py | 1 + M01_MasterData/M01_MasterData_UI_Machine.ts | 3 +- .../M01_MasterData_UI_MachineDetail.ts | 28 +- M02_MasterTemplete/DataFlow/M02_DF_Values.py | 45 +--- M02_MasterTemplete/M02_Item_Prices.py | 9 +- M02_MasterTemplete/M02_Machine_Hourly.py | 87 +++++- .../M02_MasterTemplete_Find_Pick.ts | 4 +- M02_MasterTemplete/M02_MyMachine.py | 56 +++- M02_MasterTemplete/M02_MyMachine_Text.ts | 2 +- common_util/common_util_machine_coef.ts | 4 +- .../scripts/master_machine_coef.py | 5 +- resources/tester/test_804_2_line_stack.py | 4 +- .../tester/test_822_2_machine_hourly_all.py | 252 ++++++++++++++++++ 15 files changed, 458 insertions(+), 68 deletions(-) create mode 100644 resources/tester/test_822_2_machine_hourly_all.py diff --git a/M01_MasterData/M01_MasterData_Machine_Api.ts b/M01_MasterData/M01_MasterData_Machine_Api.ts index 6598fec05..8270f5854 100644 --- a/M01_MasterData/M01_MasterData_Machine_Api.ts +++ b/M01_MasterData/M01_MasterData_Machine_Api.ts @@ -53,5 +53,25 @@ export const fetchMachineUses = async (key: string): Promise => export const findMachines = async (q: string): Promise => (await get<{ rows: Row[] }>("/find", { q })).rows; +/** 쓰는 계산식(822-2) — 방법 = 목록 · 꼴 · 그 기계 식 · 까닭 = 값이 모자란 칸 */ +export interface MachineFormula { + 키?: string; + 이름?: string; + 방법: "목록" | "꼴" | "그 기계 식"; + 까닭: string; +} + +export async function fetchMachineFormula(row: Row): Promise { + const res = await fetch(`${API_BASE_URL}/m01/machine/formula`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(row), + }); + const body = (await res.json()) as MachineFormula & { detail?: unknown }; + if (!res.ok) throw new Error(String(body.detail ?? `HTTP ${res.status}`)); + return body; +} + export const fetchMachineRow = (key: string): Promise<{ version: string; row: Row }> => get("/row", { key }); diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index c9398209d..cc47eac9a 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -288,6 +288,12 @@ def get_machine_row(key: str) -> dict: return _call(machine.row, key) +@router.post("/machine/formula") +def post_machine_formula(row: dict[str, Any]) -> dict: + """기계 줄(저장 전 고친 값도) → 쓰는 계산식 {키 · 이름 · 방법 · 까닭}(822-2).""" + return _call(machine.formula, row) + + @router.post("/calc") def post_calc(body: CalcBody) -> dict: with store.cm.mf.mp.choosing(body.고름): diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 291b7b78c..944658852 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -294,6 +294,7 @@ def save(batch: list[dict], confirmed: bool = False) -> dict: row.update(별칭=text) if text else row.pop("별칭") machine.fix(part) # 기계 줄 = 계수 서버 재계산 · 단위 · 출처 채움(821-2) _keep_same(part) + machine.register(batch) # [새 기계] = 꼴 식 목록 등록(822-2 · 개정확인 밖 — 자동 고침) fresh = False for part in batch: if part.get("file") != OWN: diff --git a/M01_MasterData/M01_MasterData_UI_Machine.ts b/M01_MasterData/M01_MasterData_UI_Machine.ts index 5e0e3f6b8..14c218259 100644 --- a/M01_MasterData/M01_MasterData_UI_Machine.ts +++ b/M01_MasterData/M01_MasterData_UI_Machine.ts @@ -132,7 +132,8 @@ export function renderMachines( if (!want.length) return; try { const got = await fetchMachineHourly(want); - for (const [k, h] of Object.entries(got)) hourly.set(k, h.계 === undefined ? "—" : num(h.계)); + for (const [k, h] of Object.entries(got)) + hourly.set(k, h.계 === undefined ? (h.reason ?? "—") : num(h.계)); } catch { for (const k of want) hourly.set(k, "—"); } diff --git a/M01_MasterData/M01_MasterData_UI_MachineDetail.ts b/M01_MasterData/M01_MasterData_UI_MachineDetail.ts index d1cad8a5f..2ab3d096d 100644 --- a/M01_MasterData/M01_MasterData_UI_MachineDetail.ts +++ b/M01_MasterData/M01_MasterData_UI_MachineDetail.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_MachineDetail.ts * M01 기계 상세 페이지(PLAN 821-2 · 요구 790-2 · 791) — 기계 양식 부품 + 원문번호 · 원문 · 구분 · - * 상세구분 · 별칭 · 비고 · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인). + * 상세구분 · 별칭 · 비고 · 쓰는 계산식(822-2 · 서버 식 고름) · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인). * 고침은 초안(sessionStorage)에 바로 쌓임 → 페이지 [저장] 한 번에 서버로(자동저장 없음) · * 서버가 계수를 계수 짝으로 다시 셈 · 단위 · 출처 칸을 채움(`M01_MasterData_Machine.fix`). * 대상 = 키(EQ…) · 저장 전 새 줄 `add:<차례>` · 새 기계 `new`(비슷한 기계에서 시작이 펼쳐짐). @@ -22,6 +22,7 @@ import { setLeaveGuard } from "../A00_Common/router"; import type { Row } from "./M01_MasterData_Api_Fetch"; import { addRow, editRow, peek, removeAdd, setAdd, toggleDelete } from "./M01_MasterData_Draft"; import { + fetchMachineFormula, fetchMachineLists, fetchMachineRow, fetchMachineUses, @@ -154,6 +155,29 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => } cur = ordered; checkSame(s.name, s.spec); + paintFormula(); + }; + + /* --- 쓰는 계산식(서버 식 고름 · 고친 값으로) --- */ + const formulaBox = el("section", { className: "m01-machine-detail__uses" }); + let formulaTimer: number | undefined; + const paintFormula = (): void => { + window.clearTimeout(formulaTimer); + formulaTimer = window.setTimeout(async () => { + const got = await fetchMachineFormula(cur).catch(() => null); + if (!got || !alive()) return; + const how = + got.방법 === "목록" + ? L("M01_MachineFormulaListed") + : got.방법 === "그 기계 식" + ? L("M01_MachineFormulaOwn") + : L("M01_MachineFormulaShape"); + formulaBox.replaceChildren( + el("strong", { text: L("M01_MachineFormula") }), + createText(got.키 ? `${got.키} ${got.이름 ?? ""} · ${how}` : L("M01_MachineFormulaNone")), + ...(got.까닭 ? [createText(got.까닭, "warning")] : []), + ); + }, 300); }; /* --- 같은 이름 · 규격 알림 --- */ @@ -275,10 +299,12 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => notice, metaBox, form.root, + formulaBox, usesBox, ], }), ); + paintFormula(); if (opts.target === "new") host.querySelector("details")?.setAttribute("open", ""); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Values.py b/M02_MasterTemplete/DataFlow/M02_DF_Values.py index 060bad891..2d0224d59 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Values.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Values.py @@ -258,59 +258,32 @@ def _user(uid: str, local: list[dict]) -> dict: MACHINE_SEP = "#" # 입력 판(sub3) 기계 요소 ref 「EQ키#칸」 HOURLY = "시간당 경비" # 기계 칸 이름 — 기계 시간당 경비 로직으로 셈(기종 → 로직 짝) -_MACHINE_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용 - - -def _machine_family() -> list[dict]: - """기계 시간당 경비 로직 — 「건설기계」 장 파일 안 · 입력 「기계」 고르기가 있는 돈 로직.""" - files = store.loaded()[0] - return [ - row - for name, data in files.items() - if data.get("그룹") == "로직" and "건설기계" in name - for row in data.get("줄") or [] - if str(row.get("결과단위") or "").startswith("원") - and any(i.get("이름") == "기계" and i.get("고르기") for i in row.get("입력") or []) - ] - - -def _machine_rank(row: dict) -> tuple: - """짝 차례(옛 EasyCalc `pickMachine` 와 같음) — 부착 전용 · 손료만 · 보정 · 기종 수 · 키.""" - names = [i.get("이름") for i in row.get("입력") or []] - codes = next(i.get("고르기") for i in row["입력"] if i.get("이름") == "기계") - attach = any(n not in _MACHINE_PLAIN for n in names) - loss = "지역" not in names - return (attach, loss, loss and "보정작업" in names, len(codes), str(row.get("키"))) def _machine_hourly(key: str, inputs: dict) -> dict: - """기계 한 시간 값 — 기종을 받는 기계경비 로직 가운데 짝 차례 첫째 · 빈 입력은 첫 고르기.""" + """기계 한 시간 값 — 식 고름 = `M02_Machine_Hourly.pick`(목록 › 꼴 · 822-2) · 빈 입력은 첫 고르기.""" + from M02_MasterTemplete import M02_Machine_Hourly as mh + row, why = _brief(key, "기계") if row is None: return {"ok": False, "reason": why} code = str(row.get("원문번호") or "") - fit = [r for r in _machine_family() if code in [str(c) for c in _codes(r)]] - if not fit: - return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"} - logic = min(fit, key=_machine_rank) + logic = mh.pick(row) + if lack := mh.why(row, logic): + return {"ok": False, "reason": f"「{row.get('이름')}」 — {lack}"} given = {} for spec in logic.get("입력") or []: name = spec.get("이름") if name != "기계": given[name] = inputs.get(name, (spec.get("고르기") or [None])[0]) - args = "".join(f", {n}='{v}'" for n, v in given.items()) - ref = f"로직({logic['키']}, 기계='{code}'{args})" + args = "".join(f", {n if n.isidentifier() else repr(n)}='{v}'" for n, v in given.items()) + mine = f", 기계='{code}'" if mh.takes_machine(logic) else "" + ref = f"로직({logic['키']}{mine}{args})" line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"} got = _money(_calc({}, _row(line, ())), "계") return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got -def _codes(row: dict) -> list: - return next( - (i.get("고르기") or [] for i in row.get("입력") or [] if i.get("이름") == "기계"), [] - ) - - def one_value(item: dict, local: list[dict] | None = None) -> dict: """물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`. diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index 287b0e2fc..1c3c33176 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -186,10 +186,11 @@ class _Collector: return self.add(base) if field == dv.HOURLY: - code = str((self.whole.index.get(base[:2], {}).get(base) or {}).get("원문번호") or "") - for fit in dv._machine_family(): - if code in [str(c) for c in dv._codes(fit)]: - self.logic(str(fit.get("키"))) + from M02_MasterTemplete import M02_Machine_Hourly as mh + + row = self.whole.index.get(base[:2], {}).get(base) or {} + if got := mh.pick(row): # 식 고름 한 곳(822-2) + self.logic(str(got.get("키"))) def logic(self, key: str) -> None: if key in self.logics: diff --git a/M02_MasterTemplete/M02_Machine_Hourly.py b/M02_MasterTemplete/M02_Machine_Hourly.py index 03aa70c0b..0e4fb4952 100644 --- a/M02_MasterTemplete/M02_Machine_Hourly.py +++ b/M02_MasterTemplete/M02_Machine_Hourly.py @@ -7,17 +7,30 @@ 마스터 파일은 읽기만 · 로그인 사용자 모두. 기계표 = M01 + 지금 층 내 기계(821-1 · `M02_MyMachine` — 식은 채운 칸으로 고른 같은 GC 식 · 직접 = 그 값) · 층을 모르는 길(`/api/m02/logic-lib/machine-hourly`)은 M01 만. +식 고름 한 곳(822-2 · `pick`) = ㉠ 원문번호가 식 「기계」 목록에 있으면 그 식(짝 차례) ㉡ 그 기계만의 식 +(`_own`) ㉢ 없으면 꼴(채운 칸 · `M02_MyMachine.logic_key`) — M01 [새 기계] · 불러오기 · 데이터 흐름 · +줄 쌓기 · 단가산출 기초가 다 이것을 씀 · 못 고름 · 값 모자람 = 까닭 글(`why`). """ from __future__ import annotations +import re from typing import Any from M01_MasterData import M01_MasterData_Store as store from M02_MasterTemplete import M02_MyMachine as mym cm = store.cm -_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용 +_PLAIN = { + "기계", + "지역", + "보정작업", + "조종원직종", + "전력 계약종별", +} # 이 밖의 입력 = 부착 장비 전용 +_TEXT = {"주연료종류", "조종원직종"} # 글 칸 — 나머지 기계 줄 값 칸은 수 +_FIELD = re.compile(r"EQ:\{기계\}\.([^\s*/+()\-,]+)") +HOUR_UNIT = "10^-7/시간" def _family() -> list[dict]: @@ -54,12 +67,27 @@ def _operated(code: str) -> bool: def logic_of(code: str) -> dict | None: - """기계 원문번호 → 그 기계 시간당 경비 로직(짝 차례 첫째) · 없으면 None(813-2 M01 화면 · - 불러오기 `M02_Doc_Import.hourly_link` 도 씀). 기계표 조종원 > 0 = 조종원 호표 있는 식 먼저 - (「조종원 없는 기종」 목록에 겹친 기계 — 진동롤러(핸드가이드식) · 고소작업차 …).""" - fit = [r for r in _family() if code in [str(c) for c in _codes(r)]] + """기계 원문번호 → 그 기계 시간당 경비 로직 · 없으면 None(813-2 M01 화면 · 불러오기 + `M02_Doc_Import.hourly_link` 도 씀) — `pick` 과 같음(그 원문번호 기계 줄로).""" + index = mym.whole() + row = (index.numbers.get("EQ") or {}).get(code) or {"원문번호": code} + return pick(row) + + +def listed(code: str) -> bool: + """원문번호가 어느 기계경비식 「기계」 목록에 있는지(M01 [새 기계] 식 등록 · 상세 「쓰는 계산식」).""" + return bool(code) and any(code in [str(c) for c in _codes(r)] for r in _family()) + + +def pick(row: dict) -> dict | None: + """기계 줄 → 시간당 경비 식 — ㉠ 목록 ㉡ 그 기계만의 식 ㉢ 꼴 · 못 고름 = None(까닭 = `why`).""" + code = str(row.get("원문번호") or "") + fit = [r for r in _family() if code and code in [str(c) for c in _codes(r)]] if not fit: - return _own(code) or mym.logic_for(code) + if got := _own(code): + return got + key = None if _day_loss(row) else mym.logic_key(row) + return mym.whole().logic(key) if key else None op = _operated(code) def order(row: dict) -> tuple: @@ -84,6 +112,47 @@ def _own(code: str) -> dict | None: return None +def _day_loss(row: dict) -> str: + """손료계수 단위가 시간당(10⁻⁷)이 아님(산림품셈 「1일 1대당」) — 그 단위 글 · 아니면 빈 글.""" + unit = str((row.get("단위") or {}).get("손료계수") or HOUR_UNIT) + return "" if unit == HOUR_UNIT or "손료계수" not in (row.get("값") or {}) else unit + + +def _is_num(v: Any) -> bool: + try: + float(v) + except (TypeError, ValueError): + return False + return not isinstance(v, bool) + + +def why(row: dict, logic: dict | None) -> str: + """시간당 경비를 못 내는 까닭 한 줄(값이 정말 모자람) · 셀 수 있으면 빈 글.""" + vals = row.get("값") or {} + if logic is None: + if unit := _day_loss(row): + return f"손료계수 단위 「{unit}」 — 시간당 식 없음(일 단위 손료)" + if not _is_num(vals.get("주연료")) and vals.get("주연료") not in (None, ""): + return f"주연료 「{vals['주연료']}」 — 수 아님" + if float(vals.get("조종원") or 0) > 0 and not vals.get("조종원직종"): + return "조종원직종 없음" + if vals.get("주연료") and vals.get("전력"): + return "주연료 · 전력 둘 다 — 식 없음" + return "손료 계수 셋(상각 · 정비 · 관리) 없음" + text = " ".join(str(x.get(k)) for k in ("식", "수량") for p in ("중간", "호표", "덧줄") + for x in logic.get(p) or []) # fmt: skip + lost, odd = [], [] + for name in dict.fromkeys(_FIELD.findall(text)): + v = vals.get(name) + if v in (None, ""): + lost.append(name) + elif name not in _TEXT and not _is_num(v): + odd.append(f"{name} 「{v}」 — {'범위' if '∼' in str(v) else '수 아님'}") + elif name == "주연료종류" and f"{v}:전국평균" not in (mym.whole().numbers.get("MO") or {}): + odd.append(f"주연료종류 「{v}」 — 유가 표에 없음") + return " · ".join([*([f"{' · '.join(lost)} 없음"] if lost else []), *odd]) + + def takes_machine(logic: dict) -> bool: """식이 「기계」 입력(원문번호 고르기)을 받는지 — 안 받는 식(GF000145)엔 기계='…' 를 안 넘김.""" return any(i.get("이름") == "기계" for i in logic.get("입력") or []) @@ -100,9 +169,9 @@ def hourly(key: str) -> dict[str, Any]: if mym.mine(key): return mym.hourly(row, whole) code = str(row.get("원문번호") or "") - logic = logic_of(code) - if not logic: - return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"} + logic = pick(row) + if lack := why(row, logic): + return {"ok": False, "reason": lack, **({"로직": logic["키"]} if logic else {})} args = "".join( # 빈칸 든 입력 이름(「전력 계약종별」 · 820-1 파워트로웰)은 따옴표로 f", {n if (n := i['이름']).isidentifier() else repr(n)}='{(i.get('고르기') or [''])[0]}'" for i in logic.get("입력") or [] diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts index b1d684bd6..191987444 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -18,7 +18,7 @@ * · (785-3) `oil` = M01 유가전력(MO · `/logic-lib/elements?group=재료` 의 MO 줄) 고름 · 고정 줄 꼴 = 이음 유가 MO · * 재료비 = 지금 값(셈은 M01 값) · 「M01 전체」 탭 재료 아래 * · (821-3) `mine` = 개인 층 내 기계 묶음(품셈 기계 위 · 구분 「내 기계」 · 출처 「개인 키」 · 금액 = 서버 시간당) · - * 머리 [새 기계 만들기](양식 창 → 저장하면 그 기계로 고름) · 「시간당 값 직접」 내 기계 = 줄 쌓기 식 없음 → 회색 + * 머리 [새 기계 만들기](양식 창 → 저장하면 그 기계로 고름) · 「시간당 값 직접」 내 기계 = 직접 값 식 GC001199(822-2) · 식 없는 내 기계 = 회색 * ========================================================================== */ import { createDataTable } from "@ui/ui_template_data_table"; @@ -288,7 +288,7 @@ export function openFindPicker(opt: FindPickOptions): void { : take(() => put(e)), }; }); - /** 821-3 내 기계 줄 — 금액 = 서버 시간당 · 직접 = 회색(줄 쌓기 식 없음) */ + /** 821-3 내 기계 줄 — 금액 = 서버 시간당 · 식 없음(서버 로직 없음) = 회색 · 직접 = GC001199(822-2) */ const mineLines = (q: string): Line[] => { const words = q.toLowerCase().split(/\s+/).filter(Boolean); return mines diff --git a/M02_MasterTemplete/M02_MyMachine.py b/M02_MasterTemplete/M02_MyMachine.py index d7ec59cac..af0326b21 100644 --- a/M02_MasterTemplete/M02_MyMachine.py +++ b/M02_MasterTemplete/M02_MyMachine.py @@ -10,8 +10,9 @@ 내 기계 없는 층 = M01 그대로. 단가산출 사본 · [단가 갱신] · 「단가새값」(`M02_Sangeun_Copy`) · 기계 찾기(`M02_Machine_Find`) · 시간당(`M02_Machine_Hourly`) · 사본 모으기(`M02_Item_Prices.collect`)가 이 색인을 읽음 — 층을 모르는 길(`/api/m02/logic-lib/machine-hourly` 등)은 M01 만. -식 고름(채운 칸) = 연료 · 조종원 둘 다 GC000268 · 연료만 GC000272 · 조종원만 GC000276 · 없음 GC000273. -전력 칸은 담기만(M01 기계경비 식에 전력 줄 없음). +식 고름(채운 칸 · `logic_key` — M01 기계 목록 밖 줄도 같은 규칙 · 822-2) = 연료 + 조종원 GC000268(직종 없음 +GC000275) · 연료만 GC000272(손료계수 한 칸 GC001198) · 조종원만 GC000276 · 전력 GC001196(+ 조종원 GC001197) · +없음 GC000273 · 직접 GC001199(시간당 값 = 셈 꼴 값 칸 노무비 · 재료비 · 경비). 권한(본인 개인 층 · 맡은 회사 관리자)은 길(`M02_MyMachine_Router`) 몫. """ @@ -43,6 +44,8 @@ UNITS = { } # fmt: skip LOGIC = {(True, True): "GC000268", (True, False): "GC000272", (False, True): "GC000276", (False, False): "GC000273"} # fmt: skip +JOB_IN, ONE_FUEL = "GC000275", "GC001198" # 직종 입력 · 손료계수 한 칸 연료 +POWER, POWER_CREW, DIRECT_KEY = "GC001196", "GC001197", "GC001199" def mine(key: Any) -> bool: @@ -80,6 +83,8 @@ def rows(folder: Path | None = None) -> list[dict]: def engine(row: dict) -> dict: """셈 꼴 — 바로 = 상각비계수 ← 손료계수 · 정비 · 관리 0 · 빈 연료 · 조종원 칸 = 0 · 수 = Decimal.""" vals = dict(row.get("값") or {}) + if row.get("방식") == DIRECT: # 직접 식(GC001199)이 읽는 칸 + vals = {k: (row.get("시간당") or {}).get(k) or 0 for k in COSTS} if row.get("방식") == EASY: vals |= {"상각비계수": vals.get("손료계수") or 0, "정비비계수": 0, "관리비계수": 0} if row.get("방식") != DIRECT: @@ -123,18 +128,39 @@ def keys_in(doc: Any) -> list[str]: return sorted(set(FIND.findall(json.dumps(doc, ensure_ascii=False, default=str)))) -def logic_key(row: dict) -> str: +def _pos(v: Any) -> bool: + try: + return not isinstance(v, bool) and float(v) > 0 + except (TypeError, ValueError): + return False + + +def logic_key(row: dict) -> str | None: + """꼴(채운 칸) → 시간당 경비 식 키 · 못 고름(직종 없는 조종원만 · 연료 + 전력 · 수 아닌 연료 글) = None · + 계수 셋(상각 · 정비 · 관리) 없음 = 손료계수 한 칸 식(연료만) · 그 밖은 그 식 + 모자란 칸 까닭.""" + if row.get("방식") == DIRECT: + return DIRECT_KEY vals = row.get("값") or {} - return LOGIC[(float(vals.get("주연료") or 0) > 0, float(vals.get("조종원") or 0) > 0)] + fuel, crew, power = (_pos(vals.get(k)) for k in ("주연료", "조종원", "전력")) + job = bool(vals.get("조종원직종")) + three = all(vals.get(k) is not None for k in COEFS) + if vals.get("주연료") not in (None, "", 0) and not fuel or fuel and power: + return None + if power: + return POWER_CREW if crew and job else None if crew else POWER + if fuel and not crew: + return LOGIC[(True, False)] if three else ONE_FUEL + if crew: + return LOGIC[(fuel, True)] if job else JOB_IN if fuel else None + return LOGIC[(False, False)] def logic_for(code: str) -> dict | None: """내 기계 원문번호 → 시간당 경비 식(채운 칸으로 고름) · 내 기계 아님 = None.""" index = whole() row = (index.numbers.get("EQ") or {}).get(code) - if row is None or not mine(row.get("키")) or row.get("방식") == DIRECT: - return None - return index.logic(logic_key(row)) + key = logic_key(row) if row is not None and mine(row.get("키")) else None + return index.logic(key) if key else None def hourly(row: dict, index: Any) -> dict[str, Any]: @@ -142,11 +168,21 @@ def hourly(row: dict, index: Any) -> dict[str, Any]: head = {"이름": row.get("이름"), "규격": row.get("규격"), "원문번호": row.get("원문번호")} if row.get("방식") == DIRECT: sums = {k: float((row.get("시간당") or {}).get(k) or 0) for k in COSTS} - return {"ok": True, **head, "로직": None, "직접": True, **sums, "계": sum(sums.values())} + return { + "ok": True, + **head, + "로직": DIRECT_KEY, + "직접": True, + **sums, + "계": sum(sums.values()), + } import master_cut as mcut mf = m01.cm.mf - logic = index.logic(logic_key(row)) + key = logic_key(row) + if key is None: + return {"ok": False, **head, "reason": "식 없음 — 연료 · 전력은 하나만"} + logic = index.logic(key) given = {i["이름"]: (i.get("고르기") or [""])[0] for i in logic.get("입력") or []} given["기계"] = str(row.get("원문번호")) try: @@ -203,6 +239,8 @@ def clean(raw: Any) -> dict[str, Any]: need = ("가격", "손료계수") if how == EASY else ("가격", *RATIOS) if lost := [k for k in need if not vals.get(k)]: raise _bad(f"빈 칸 — {' · '.join(lost)}") + if vals.get("주연료") and vals.get("전력"): + raise _bad("주연료 · 전력은 하나만(원문 운전경비 표 한 칸)") drop = (*RATIOS, *COEFS) if how == EASY else () vals = {k: v for k, v in vals.items() if k not in drop} if how == BOOK: diff --git a/M02_MasterTemplete/M02_MyMachine_Text.ts b/M02_MasterTemplete/M02_MyMachine_Text.ts index 19b1e1732..20522733e 100644 --- a/M02_MasterTemplete/M02_MyMachine_Text.ts +++ b/M02_MasterTemplete/M02_MyMachine_Text.ts @@ -50,7 +50,7 @@ const TEXT = { // 기계 고르기 창 · 줄 쌓기 Pick_Group: "내 기계", Pick_New: "새 기계 만들기", - Pick_Direct: "시간당 값 직접 기계 — 줄 쌓기 식이 없어 못 넣음", + Pick_Direct: "시간당 경비 식이 없는 기계 — 줄 쌓기에 못 넣음", To_Mine: "복사해 내 기계로", // 내 품목 Items_Note: diff --git a/common_util/common_util_machine_coef.ts b/common_util/common_util_machine_coef.ts index b5a66f2d8..83316e0ee 100644 --- a/common_util/common_util_machine_coef.ts +++ b/common_util/common_util_machine_coef.ts @@ -5,7 +5,7 @@ * ⚠ 짝: `resources/master_data/scripts/master_machine_coef.py` — 같은 셈 · 같은 차례 * (거울 시험 `resources/tester/test_821_2_machine_coef.py`). 한쪽만 고치면 화면과 저장이 갈림. * 계수 = 건설품셈 공통 8-1-5(계수마다 정수 반올림 · 손료계수 = 합) · 미리보기 끝수 = 서버 - * 기계경비식(줄 0.1 원 아래 버림 · 잡품 = 버린 주연료 × % · 비목 합 원 미만 버림) · 정본은 서버 식. + * 기계경비식(줄 0.1 원 아래 버림 · 잡품 = 버린 (주연료 + 전력) × % · 비목 합 원 미만 버림) · 정본은 서버 식. * ========================================================================== */ export const MACHINE_INPUTS = [ @@ -73,8 +73,8 @@ export function machineHourly( ): MachineHourly { const loss = Math.floor((num(values["가격"]) * 1000 * num(values["손료계수"])) / 1e7 + E); const oil = tenth(num(values["주연료"]) * num(fuel)); - const misc = tenth((oil * num(values["잡재료율"])) / 100); const elec = tenth(num(values["전력"]) * num(power)); + const misc = tenth(((oil + elec) * num(values["잡재료율"])) / 100); const crew = tenth(num(values["조종원"]) * hour * num(wage)); const labor = Math.floor(crew + E); const material = Math.floor(oil + misc + elec + E); diff --git a/resources/master_data/scripts/master_machine_coef.py b/resources/master_data/scripts/master_machine_coef.py index 61ff5dc53..e9ae5f8c6 100644 --- a/resources/master_data/scripts/master_machine_coef.py +++ b/resources/master_data/scripts/master_machine_coef.py @@ -10,7 +10,8 @@ 계수마다 정수로 반올림 · 손료계수 = 셋의 합. 미리보기 = 서버 기계경비식(GC000267 ~)을 기본 옵션(보정 없음)으로 부른 값과 같은 끝수 — 손료 = 가격(천원) × 1000 × 손료계수 ÷ 10^7 원 미만 버림 · 줄 금액은 0.1 원 아래 버림 · - 잡품 = 버린 주연료 금액 × 잡재료율 % · 비목 합은 원 미만 버림(B09 끝수 `master_cut.engine`). + 잡품 = 버린 (주연료 + 전력) 금액 × 잡재료율 %(전력 기종 = 전력비의 % · 822-2 GC001196 · 7) · + 비목 합은 원 미만 버림(B09 끝수 `master_cut.engine`). 정본은 서버 식(`M02_Machine_Hourly`) — 이것은 화면 즉시 셈용. """ @@ -61,8 +62,8 @@ def preview( """값 묶음 + 유가(원/ℓ) · 노임(원/일) · 전력(원/kWh) · 시간환산 → 시간당 줄 · 비목 · 계.""" loss = math.floor(_num(values.get("가격")) * 1000 * _num(values.get("손료계수")) / 1e7 + _E) oil = _tenth(_num(values.get("주연료")) * _num(fuel)) - misc = _tenth(oil * _num(values.get("잡재료율")) / 100) elec = _tenth(_num(values.get("전력")) * _num(power)) + misc = _tenth((oil + elec) * _num(values.get("잡재료율")) / 100) crew = _tenth(_num(values.get("조종원")) * hour * _num(wage)) labor, material = math.floor(crew + _E), math.floor(oil + misc + elec + _E) return { diff --git a/resources/tester/test_804_2_line_stack.py b/resources/tester/test_804_2_line_stack.py index f0d9f6832..dedd53bee 100644 --- a/resources/tester/test_804_2_line_stack.py +++ b/resources/tester/test_804_2_line_stack.py @@ -116,7 +116,9 @@ def _hourly_inputs(logic_key: str) -> dict: def _kind(row: dict) -> tuple: - return (*mh._rank(row)[:3], any(x.get("이름") == "조종원" for x in row.get("호표") or [])) + """갈래 = 부착 · 손료만 · 보정 · 조종원 줄 · 전력 줄(822-2 전력 기종 식 — 손료만 식과 갈림).""" + lines = {x.get("이름") for x in row.get("호표") or []} + return (*mh._rank(row)[:3], "조종원" in lines, "전력" in lines) # 기계표 조종원 1 인 · 「조종원 없는 기종」 GC000272 목록에도 겹친 기계(815-3) — 조종원 든 식 diff --git a/resources/tester/test_822_2_machine_hourly_all.py b/resources/tester/test_822_2_machine_hourly_all.py new file mode 100644 index 000000000..3a2359d0c --- /dev/null +++ b/resources/tester/test_822_2_machine_hourly_all.py @@ -0,0 +1,252 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""822-2 기계 시간당 경비 식 완성 — 지킴 시험(정본 고침은 임시 사본 폴더 · 임시 storage 만). + +① 모든 기계 = 식 있음 또는 까닭 글(「셀 자료가 없음」 0) ② 식 고름 = 목록 › 꼴(채운 칸) 한 함수 +③ M01 [새 기계] 저장 = 꼴 식 목록 등록 · 지우면 목록에서 뺌 · 로직 파일 글 그대로(임시 마스터) +④ 직접 값 식(GC001199) — 내 기계 「직접」 시간당 · 줄 쌓기 식 ⑤ 전력 줄 — 점보드릴 135㎾ × 전력 단가 + +잡재료 6 % + 손료 + 조종원 · 계약종별 바꿈 · 내 기계 전력 ⑥ 옮긴 기계 견본(아스팔트 믹싱플랜트 · 타워크레인 · +직종 입력 = 아스팔트 스프레어) ⑦ 미리보기 = 서버 값 ⑧ 안 옮긴 기계 금액 그대로 ⑨ POWER TROWEL 이름. +""" + +from __future__ import annotations + +import json +import shutil +import sys +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util import common_util_auth as auth +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Machine_Hourly as mh +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MyMachine as mym +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "resources" / "master_data" / "scripts")) +import master_machine_coef as mc # noqa: E402 + +MACHINE, LOGIC = "기계.json", "로직_건설품셈_08장_건설기계.json" +POWER, POWER_CREW, ONE_FUEL, DIRECT = "GC001196", "GC001197", "GC001198", "GC001199" +NAME = "sub1임시기계822" +# 안 옮긴 기계 — 822-2 전 금액(전국평균 · 기본 옵션) +KEEP = {"EQ000001": 68550, "EQ000022": 108958} + + +def _rows() -> dict[str, dict]: + return {r["키"]: r for r in m01.read(MACHINE)[0]["줄"]} + + +def _wage(job: str) -> Decimal: + return Decimal(str(m01.loaded()[1].get(f"LB:{job}")["값"])) + + +def _codes(key: str) -> list[str]: + row = next(r for r in m01.read(LOGIC)[0]["줄"] if r["키"] == key) + return [str(c) for c in next(i for i in row["입력"] if i["이름"] == "기계").get("고르기", [])] + + +# ① · ② ─────────────────────────────────────────────────────────────── +def test_every_machine_has_formula_or_reason(): + """모든 기계 — 식을 고르고 값이 다 있음 · 아니면 까닭 글(빈 답 0).""" + silent = [] + for key, row in _rows().items(): + row = m01.loaded()[1].get(key) + logic = mh.pick(row) + if not logic and not mh.why(row, logic): + silent.append(key) + assert silent == [] + assert mh.hourly("EQ000343")["reason"] == "가격 없음" # TBM 3.0m + assert "일 단위" in mh.hourly("EQ000621")["reason"] # 산림품셈 예취기(1일 1대당) + assert mh.hourly("EQ000202")["reason"] == "주연료 「73.7+」 — 수 아님" # 표층재생기 · 안 풂 + assert "범위" in mh.hourly("EQ000552")["reason"] # 펌프 준설선 잡재료 13∼18 + assert ( + "중유" in mh.hourly("EQ000187")["reason"] + ) # 아스팔트 믹싱플랜트 40t — 유가 표에 중유 없음 + + +def test_pick_listed_then_shape(): + """목록에 있으면 그 식(짝 차례) · 없으면 꼴 — 내 기계와 같은 규칙.""" + assert mh.logic_of("0101-0007")["키"] in {"GC000268", "GC000269"} # 목록 + shape = mym.logic_key + full = {"가격": 1, "손료계수": 3, "상각비계수": 1, "정비비계수": 1, "관리비계수": 1} + assert shape({"값": {**full, "주연료": 1, "조종원": 1, "조종원직종": "1048"}}) == "GC000268" + assert shape({"값": {**full, "주연료": 1, "조종원": 1}}) == "GC000275" + assert shape({"값": {**full, "주연료": 1}}) == "GC000272" + assert shape({"값": {"가격": 1, "손료계수": 3, "주연료": 1}}) == ONE_FUEL + assert shape({"값": {**full, "조종원": 1, "조종원직종": "1048"}}) == "GC000276" + assert shape({"값": {**full, "전력": 5}}) == POWER + assert shape({"값": {**full, "전력": 5, "조종원": 1, "조종원직종": "1048"}}) == POWER_CREW + assert shape({"값": full}) == "GC000273" + assert shape({"방식": "직접", "값": {}}) == DIRECT + assert shape({"값": {**full, "주연료": 1, "전력": 5}}) is None + assert shape({"값": {**full, "조종원": 1}}) is None # 직종 없는 조종원만 — 까닭 + assert shape({"값": {**full, "주연료": "73.7+"}}) is None + + +# ③ M01 [새 기계] 식 등록 ────────────────────────────────────────────── +@pytest.fixture +def copy(tmp_path: Path, monkeypatch) -> Path: + for path in m01.FOLDER.glob("*.json"): + if not path.name.startswith("_") or path.name == m01.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(m01, "FOLDER", tmp_path) + return tmp_path + + +def _app() -> TestClient: + app = FastAPI() + app.dependency_overrides[auth.verify_session] = lambda: { + "user_id": 1, "name": "시험", "role": "SYSTEM_ADMIN", "company_id": 1, + } # fmt: skip + deps = [Depends(auth.verify_session), Depends(auth.require_system_admin)] + app.include_router(router_module.router, dependencies=deps) + return TestClient(app) + + +def _save(client: TestClient, changes: list[dict]) -> None: + version = client.get("/api/m01/machine/row", params={"key": "EQ000022"}).json()["version"] + body = {"files": [{"file": MACHINE, "version": version, "changes": changes}]} + res = client.post("/api/m01/save", json=body) + assert res.status_code == 200, res.text + + +def test_new_machine_registers_and_unregisters(copy: Path): + client = _app() + logic_text = (copy / LOGIC).read_text(encoding="utf-8") + seed = json.loads(json.dumps(_rows()["EQ000022"], default=float)) + row = {**seed, "키": "", "원문번호": "9999-0822", "이름": NAME, "출처": {}, "단위": {}} + del row["별칭"] + draft = client.post("/api/m01/machine/formula", json=row).json() + assert draft["키"] == "GC000268" and draft["방법"] == "꼴" and not draft["까닭"] + _save(client, [{"op": "add", "row": row}]) + assert "9999-0822" in _codes("GC000268") + key = next(k for k, r in _rows().items() if r["원문번호"] == "9999-0822") + hour = client.get("/api/m01/machine/hourly", params={"keys": key}).json()[key] + assert hour["로직"] == "GC000268" and hour["계"] == mh.hourly("EQ000022")["계"] + saved = json.loads(json.dumps(_rows()[key], default=float)) + got = client.post("/api/m01/machine/formula", json=saved).json() + assert got["방법"] == "목록" and got["키"] == "GC000268" + _save(client, [{"op": "delete", "key": key}]) + assert "9999-0822" not in _codes("GC000268") + assert (copy / LOGIC).read_text(encoding="utf-8") == logic_text + + +# ④ 직접 값 식 ───────────────────────────────────────────────────────── +def _at(folder, fn, *args): + with store.at(folder): + return fn(*args) + + +def test_direct_formula_my_machine(world): + me = world["personal"] + body = { + "이름": NAME, + "규격": "직접", + "방식": "직접", + "시간당": {"노무비": 10, "재료비": 20, "경비": 30}, + } + made = _at(me, mym.create, body) + assert made["시간당"]["로직"] == DIRECT and made["시간당"]["계"] == 60 + no = made["줄"]["원문번호"] + line = {"종류": "로직", "로직": DIRECT, "입력값": {"기계": no}, "수량": "2"} + name = _at( + me, lambda: refs.create("sangeun", {"이름": NAME, "단위": "시간", "더한줄": [line]})["이름"] + ) + doc = _at(me, lambda: store.read("sangeun", name)["문서"]) + got = _at(me, calc.calc_doc, doc, None, False)["기준"] + assert (got["노무비"], got["재료비"], got["경비"]) == (20, 40, 60) + + +# ⑤ 전력 줄 ──────────────────────────────────────────────────────────── +def _preview(key: str) -> dict: + v = json.loads(json.dumps(_rows()[key]["값"], default=float)) + fuel = m01.loaded()[1].numbers["MO"].get(f"{v.get('주연료종류')}:전국평균") + wage = float(_wage(v["조종원직종"])) if v.get("조종원직종") else 0 + return mc.preview(v, float((fuel or {}).get("값") or 0), wage, 114.4) + + +def test_power_line_jumbo_drill(): + h = mh.hourly("EQ000347") # 점보드릴 2붐 135㎾ · 잡재료 6 % · 조종원 1(건설기계운전사) + assert h["로직"] == POWER_CREW + loss = 605472 * 1000 * 2515 // 10**7 + elec = Decimal("135") * Decimal("114.4") + misc = int(elec * 6) / Decimal(100) + assert h["경비"] == loss and h["재료비"] == int(elec + misc) + assert h["노무비"] == int(_wage("1048") / 8) + p = _preview("EQ000347") + assert (p["노무비"], p["재료비"], p["경비"], p["계"]) == ( + h["노무비"], + h["재료비"], + h["경비"], + h["계"], + ) + home = m01.calc(POWER_CREW, {"기계": "5805-0002", "전력 계약종별": "주택용 저압"}) + assert home["ok"] and float(home["sums"]["재료비"]) > h["재료비"] # 120 원/kWh > 114.4 + + +def test_power_my_machine(world): + me = world["personal"] + body = { + "이름": NAME, + "규격": "전력", + "방식": "바로", + "값": {"가격": 1000, "손료계수": 2000, "전력": 10, "잡재료율": 5}, + } + hour = _at(me, mym.create, body)["시간당"] + assert hour["로직"] == POWER + want = mc.preview({"가격": 1000, "손료계수": 2000, "전력": 10, "잡재료율": 5}, 0, 0, 114.4) + assert (hour["재료비"], hour["경비"]) == (want["재료비"], want["경비"]) == (1201, 200) + with pytest.raises(store.StoreError): + _at(me, mym.create, {**body, "값": {**body["값"], "주연료": 1, "주연료종류": "경유"}}) + + +# ⑥ · ⑦ 옮긴 기계 견본 ───────────────────────────────────────────────── +def test_moved_samples(): + plant = mh.hourly("EQ000188") # 아스팔트 믹싱플랜트 60t — 연료 + 조종원 2 인 + assert plant["로직"] == "GC000268" and plant["노무비"] == int(_wage("1048") * 2 / 8) + assert plant["재료비"] > 0 + tower = mh.hourly("EQ000165") # 타워크레인 — 건설기계운전사 조종원 줄(요구 799) + assert tower["로직"] == "GC000276" and tower["노무비"] == int(_wage("1048") / 8) + spray = m01.loaded()[1].get("EQ000198") # 아스팔트 스프레어 — 직종 빈 칸 = 직종 입력 식 + assert mh.pick(spray)["키"] == "GC000275" and "조종원직종" in json.dumps( + mh.pick(spray)["입력"], ensure_ascii=False + ) + assert "가격" in mh.hourly("EQ000198")["reason"] + assert "3450-0642" not in _codes("GC000273") # 손료만 식에서 뺌 + for key, logic in ( + ("EQ000529", POWER), + ("EQ000534", ONE_FUEL), + ("EQ000532", POWER), + ("EQ000426", POWER), + ): + h = mh.hourly(key) + p = _preview(key) + assert h["로직"] == logic and (p["재료비"], p["경비"], p["계"]) == ( + h["재료비"], + h["경비"], + h["계"], + ), key + for key in ("EQ000188", "EQ000165"): + h, p = mh.hourly(key), _preview(key) + assert (p["노무비"], p["재료비"], p["경비"]) == (h["노무비"], h["재료비"], h["경비"]), key + + +# ⑧ · ⑨ ──────────────────────────────────────────────────────────────── +def test_unmoved_same_and_name(): + for key, total in KEEP.items(): + assert mh.hourly(key)["계"] == total + row = _rows()["EQ000534"] + assert (row["이름"], row["옛이름"], row["별칭"]) == ( + "POWER TROWEL", + "POWER TLOWEL", + "파워트로웰", + ) From f22b506d0eb88f3aa67402e93981bfd08f61402b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:12:43 +0900 Subject: [PATCH 09/32] =?UTF-8?q?feat(801-2):=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EC=96=91=EC=8B=9D=20=EC=A7=81=EC=A2=85=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EC=B0=BE=EA=B8=B0=20=ED=8C=90=20=C2=B7=20?= =?UTF-8?q?=EC=93=B0=EB=8A=94=20=EA=B3=84=EC=82=B0=EC=8B=9D=20=EA=B8=80=20?= =?UTF-8?q?=C2=B7=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=9E=A1?= =?UTF-8?q?=ED=92=88=20=EA=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 부품 ui_template_pick_search(새) — 항목 많은 고르기(열둘 넘음)를 열면 찾기 판(팝오버 + 찾기 목록 · 높이 240 · 스크롤) · 글자로 거름 · 비우면 전부 · 고른 항목 둘레로 열림 · Esc · 바깥 누름 닫음 · 연료처럼 적은 고르기는 그대로 - 찾기 목록 setRows 둘째 인자 = 처음 짚을 줄(가운데로) - 기계 양식(M01 상세 · 내 기계 · 줄 쌓기 새 기계 셋 다): 직종 고르기에 붙임 · 전력 줄을 잡품 위로 · 잡품 = (주연료 + 전력) × % - 글: 쓰는 계산식 · 목록에 있음 · 꼴로 고름 · 글자로 찾기 Co-Authored-By: Claude Opus 5.5 --- ui_template/ui_template_find_list.ts | 15 ++-- ui_template/ui_template_locale_machine.ts | 9 +++ ui_template/ui_template_machine_form.ts | 6 +- ui_template/ui_template_pick_search.css | 12 ++++ ui_template/ui_template_pick_search.ts | 88 +++++++++++++++++++++++ 5 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 ui_template/ui_template_pick_search.css create mode 100644 ui_template/ui_template_pick_search.ts diff --git a/ui_template/ui_template_find_list.ts b/ui_template/ui_template_find_list.ts index f4fcab62f..2be679a48 100644 --- a/ui_template/ui_template_find_list.ts +++ b/ui_template/ui_template_find_list.ts @@ -4,7 +4,7 @@ * 키보드 — 찾기 칸에서 ↑ ↓ 로 옮기고 Enter 로 고름. 창 겉틀은 부르는 쪽 몫. * * const find = createFindList({ placeholder, filters: [sub.root, detail.root] }); - * find.setRows([{ key, tag, name, sub, unit, onPick }]); + * find.setRows([{ key, tag, name, sub, unit, onPick }]); // 둘째 인자 = 처음 짚을 줄 key(가운데로 보임) * ========================================================================== */ import "./ui_template_find_list.css"; @@ -27,7 +27,8 @@ export interface FindList { root: HTMLElement; search: HTMLInputElement; hint: HTMLElement; - setRows: (rows: FindRow[]) => void; + /** active = 처음 짚을 줄 key(없으면 첫 줄) — 그 줄이 가운데로 보임 */ + setRows: (rows: FindRow[], active?: string) => void; } export function createFindList(opts: { @@ -42,12 +43,12 @@ export function createFindList(opts: { let rows: FindRow[] = []; let at = -1; - const mark = (i: number): void => { + const mark = (i: number, block: ScrollLogicalPosition = "nearest"): void => { list.children[at]?.classList.remove("is-active"); at = i; const row = list.children[at]; row?.classList.add("is-active"); - row?.scrollIntoView({ block: "nearest" }); + row?.scrollIntoView({ block }); }; field.input.addEventListener("keydown", (ev) => { if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { @@ -61,7 +62,7 @@ export function createFindList(opts: { } }); - const setRows = (next: FindRow[]): void => { + const setRows = (next: FindRow[], active?: string): void => { rows = next; at = -1; list.replaceChildren( @@ -89,7 +90,9 @@ export function createFindList(opts: { return row; }), ); - if (rows.length) mark(0); + const pin = active === undefined ? -1 : rows.findIndex((r) => r.key === active); + if (pin >= 0) mark(pin, "center"); + else if (rows.length) mark(0); }; const filters = opts.filters?.length diff --git a/ui_template/ui_template_locale_machine.ts b/ui_template/ui_template_locale_machine.ts index e45ba5d55..b5376cb30 100644 --- a/ui_template/ui_template_locale_machine.ts +++ b/ui_template/ui_template_locale_machine.ts @@ -40,6 +40,7 @@ export const ui_locales_machine = { "National average · instant · server recalculates on save", ], MF_NoPrice: ["단가 없음", "no price"], + MF_PickFind: ["글자로 찾기", "Type to filter"], MF_Similar: ["비슷한 기계에서 시작", "Start from a similar machine"], MF_SimilarFind: ["기계 이름 · 규격으로 찾기", "Find by name or spec"], MF_SimilarNone: ["찾은 기계 없음", "No machines found"], @@ -51,6 +52,14 @@ export const ui_locales_machine = { M01_MachineMeta: ["원문 · 구분", "Source · group"], M01_MachineUses: ["쓰는 곳", "Used by"], M01_MachineNoUses: ["쓰는 곳 없음", "Not used"], + M01_MachineFormula: ["쓰는 계산식", "Hourly formula"], + M01_MachineFormulaListed: ["목록에 있음", "listed"], + M01_MachineFormulaShape: [ + "꼴로 고름(채운 칸) — 저장하면 그 식 목록에 등록", + "picked by filled fields — registered on save", + ], + M01_MachineFormulaOwn: ["그 기계만의 식", "machine's own formula"], + M01_MachineFormulaNone: ["식 없음", "no formula"], M01_MachineSame: [ "같은 이름 · 규격 기계가 있음 — {value}", "A machine with the same name and spec exists — {value}", diff --git a/ui_template/ui_template_machine_form.ts b/ui_template/ui_template_machine_form.ts index ac1c3019c..7060d5757 100644 --- a/ui_template/ui_template_machine_form.ts +++ b/ui_template/ui_template_machine_form.ts @@ -20,6 +20,7 @@ import { import { createCalcFormula, createCalcFormulaRow } from "./ui_template_calc_row"; import { el, createInputField, createSelectField } from "./ui_template_elements"; import { createFindList } from "./ui_template_find_list"; +import { attachPickSearch } from "./ui_template_pick_search"; import { t as L } from "./ui_template_locale"; import { createSegment } from "./ui_template_segment"; import { createText } from "./ui_template_text"; @@ -237,6 +238,7 @@ export function createMachineForm(opts: MachineFormOptions): MachineFormHandle { const field = createSelectField({ label: tr(label), options, value: now, size: 28 }); field.select.dataset.key = key; field.select.disabled = Boolean(opts.readOnly); + attachPickSearch(field.select, { placeholder: L("MF_PickFind") }); // 항목 많은 고르기(직종) = 찾기 판(801-2) const mark = (): void => { field.root.classList.toggle("is-changed", !same(values[key] ?? null, init[key] ?? null)); }; @@ -448,11 +450,11 @@ export function createMachineForm(opts: MachineFormOptions): MachineFormHandle { const rows = [ line(L("MF_Loss"), `${num("가격")}천원 × 1,000 × ${num("손료계수")} × 10^-7^`, p.손료), line(L("MF_Fuel"), `${num("주연료")}ℓ × ${fmt(fuel)}원`, p.주연료), - line(L("MF_Misc"), `${fmt(p.주연료)} × ${num("잡재료율")}%`, p.잡품), - line(L("MF_Crew"), `${num("조종원")}인 × ${fmt(wage)}원 × ${fmt(hour)}`, p.조종원), ...(values["전력"] ? [line(L("MF_Power"), `${num("전력")}kWh × ${fmt(opts.power ?? 0)}원`, p.전력)] : []), + line(L("MF_Misc"), `${fmt(p.주연료 + p.전력)} × ${num("잡재료율")}%`, p.잡품), + line(L("MF_Crew"), `${num("조종원")}인 × ${fmt(wage)}원 × ${fmt(hour)}`, p.조종원), ]; side.replaceChildren( ...head, diff --git a/ui_template/ui_template_pick_search.css b/ui_template/ui_template_pick_search.css new file mode 100644 index 000000000..097dc7c1b --- /dev/null +++ b/ui_template/ui_template_pick_search.css @@ -0,0 +1,12 @@ +/* 고르기 찾기 판(ui_template_pick_search.ts · 801-2) — 고르기 칸 바로 아래 · 목록 높이 제한 */ +.ui-pick-search-host { + position: relative; +} + +.ui-pick-search .ui-float-panel__body { + padding: var(--spacing-8); +} + +.ui-pick-search .ui-find__list { + max-height: 240px; +} diff --git a/ui_template/ui_template_pick_search.ts b/ui_template/ui_template_pick_search.ts new file mode 100644 index 000000000..9aa9b14f6 --- /dev/null +++ b/ui_template/ui_template_pick_search.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * ui_template_pick_search.ts + * 고르기 칸에 찾기 붙이기(요구 801-2) — 항목이 많은 `createSelectField` 고르기를 열면 바로 아래에 + * 찾기 판(팝오버 `createFloatPanel` + 찾기 목록 `createFindList` · 높이 제한 · 스크롤)이 뜸. + * 글자를 치면 이름에 든 것만 · 찾기를 비우면 다시 전부 · 고른 항목이 있으면 그 줄 둘레로 열림 + * 고름 = select 값 바꿈 + change 알림(부르는 쪽 코드 그대로) · Esc · 바깥 누름 = 닫음 + * 항목이 적으면(`min` 이하) 아무것도 안 함 — 보통 고르기 그대로 + * 쓰는 법 = attachPickSearch(field.select, { placeholder: "글자로 찾기" }) + * ========================================================================== */ + +import { createFindList } from "./ui_template_find_list"; +import { createFloatPanel } from "./ui_template_float_panel"; +import "./ui_template_pick_search.css"; + +const squash = (s: string): string => s.replace(/\s+/g, "").toLowerCase(); + +export function attachPickSearch( + select: HTMLSelectElement, + opts: { placeholder: string; min?: number }, +): void { + if (select.options.length <= (opts.min ?? 12)) return; + const host = select.parentElement; + if (!host) return; + host.classList.add("ui-pick-search-host"); + let panel: HTMLElement | null = null; + + const close = (): void => { + panel?.remove(); + panel = null; + document.removeEventListener("pointerdown", outside, true); + }; + const outside = (ev: PointerEvent): void => { + if (panel && !panel.contains(ev.target as Node) && ev.target !== select) close(); + }; + + const open = (): void => { + if (panel || select.disabled) return; + const fp = createFloatPanel({ popover: true, width: Math.max(select.offsetWidth, 260) }); + fp.root.classList.add("ui-pick-search"); + fp.root.style.left = `${select.offsetLeft}px`; + fp.root.style.top = `${select.offsetTop + select.offsetHeight + 2}px`; + const find = createFindList({ placeholder: opts.placeholder }); + fp.body.append(find.root); + const all = [...select.options].map((o) => ({ value: o.value, text: o.text })); + const show = (): void => { + const q = squash(find.search.value); + const rows = q ? all.filter((o) => squash(o.text).includes(q)) : all; + find.setRows( + rows.map((o) => ({ + key: o.value, + name: o.text, + onPick: () => { + select.value = o.value; + select.dispatchEvent(new Event("change", { bubbles: true })); + close(); + select.focus(); + }, + })), + q ? undefined : select.value, + ); + }; + find.search.addEventListener("input", show); + find.search.addEventListener("keydown", (ev) => { + if (ev.key === "Escape") { + ev.preventDefault(); + close(); + select.focus(); + } + }); + host.append(fp.root); + panel = fp.root; + show(); + find.search.focus(); + document.addEventListener("pointerdown", outside, true); + }; + + select.addEventListener("mousedown", (ev) => { + ev.preventDefault(); + if (panel) close(); + else open(); + }); + select.addEventListener("keydown", (ev) => { + if (["Enter", " ", "ArrowDown"].includes(ev.key)) { + ev.preventDefault(); + open(); + } + }); +} From 00352b83c91f0ec893253e8a622573af43e11173 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:14:23 +0900 Subject: [PATCH 10/32] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC805):=20?= =?UTF-8?q?=EB=82=B4=EC=9E=A5=20=EC=BA=90=EB=93=9C=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=20=E2=80=94=20=EB=8F=84=EB=A9=B4=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=20=C2=B7=20=EC=A0=81=EC=9D=91=20=EA=B0=84=EA=B2=A9=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/사용자요구_1104.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index 49014f0cb..ba95e3798 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -1058,3 +1058,7 @@ M02에서 내 기계와 내 품목은 개인관리 컨테이너로 병합함. ## 요구 804 (11-12 · 사용자 · 영구저장소 데이터 형태 조사 — M01 기계 · 단가산출 기초 · M02 단가산출 · 일위대가 · 구조물 조합 · 내 기계가 마스터 · 회사 · 개인마다 각각 하나의 파일로 저장 → 불러오기 · 업데이트가 느린 것으로 판단 · 조사부터 하고 판단) 다음 조사는 영구저장소의 데이터 형태에 대한 고민, 내가 보기에는 현재 영구저장소에 저장되는 M01 기계, 단가산출 기초, M02의 단가산출, 일위대가, 구조물조합, 내 기계 등이 각각 마스터, 회사, 개인 항목에 따라 각각 하나의 파일로 저장되어있어. 불러오기나 업데이트에 속도가 느린걸로 판단됨. 너의 생각은 조사부터 해보고 판단하자. + +## 요구 805 (11-12 · 사용자 · 내장 캐드 그리드 — 오토캐드처럼 줌 인 · 아웃에 따라 크기 · 표현되는 수를 제어 · 많이 줌 아웃해도 느려지지 않고 보기 좋게 · 확인하고 반영) + +내장 캐드에서 그리드 표현은 오토캐드를 참고해보면 줌인아웃에 따라 크기나 표현되는 숫자를 제어하여 효과적으로 보여줌. 그래야 많이 줌아웃하면 느려지지 않고 보기 좋을테니까. 확인해보고 반영해줘. From 4ea3acdcc320a9b156ee14040e573bc07c3e4698 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:16:48 +0900 Subject: [PATCH 11/32] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC804):=20?= =?UTF-8?q?=EC=98=81=EA=B5=AC=EC=A0=80=EC=9E=A5=EC=86=8C=20=EA=BC=B4=20?= =?UTF-8?q?=C2=B7=20=EC=86=8D=EB=8F=84=20=EC=A1=B0=EC=82=AC(=EC=8B=A4?= =?UTF-8?q?=EC=B8=A1=20+=20=EC=BD=94=EB=93=9C)=20=E2=80=94=20=5F=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=5F804=5F=EC=A0=80=EC=9E=A5=EC=86=8C=EC=86=8D=EB=8F=84?= =?UTF-8?q?.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/_조사_804_저장소속도.md | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/raw/요구_1104/_조사_804_저장소속도.md diff --git a/docs/raw/요구_1104/_조사_804_저장소속도.md b/docs/raw/요구_1104/_조사_804_저장소속도.md new file mode 100644 index 000000000..872c0a23f --- /dev/null +++ b/docs/raw/요구_1104/_조사_804_저장소속도.md @@ -0,0 +1,49 @@ +# 조사 804 — 영구저장소 꼴과 속도 (요구 804 · 실측 + 코드 조사 · 결정 전) + +## 1. 저장 꼴(실측) + +| 자료 | 꼴 | 크기 | +|---|---|---| +| M01 기계 | 파일 하나 | 1.5 MB | +| M01 자재 | 파일 하나(30,711 줄) | 15 MB | +| M01 로직 · 수식 문서 | 품셈 장마다(118 파일) | 11.7 MB | +| M02 단가산출(SK) | 항목마다 파일 | 평균 2.2 MB · 개인 층 47 건 102 MB · 마스터 층 34 건 126 MB | +| M02 일위대가 · 사전조합 | 항목마다 파일 | 1 ~ 3 MB(같은 사본을 품음) | +| DB(MariaDB) | 회사 · 사용자 · 프로젝트 행만 | 목록 · 색인 · 잠금은 전부 파일 | + +- SK 한 건 속(SK-000048): 문서 본체 2.5 천 자 · 가져온 정보 3.6 천 자 · M01 값 사본 15 만 자가 `사본` · `수식` 두 자리에 같은 내용으로 두 번 → 파일의 약 99 % 가 사본. +- 같은 지문(단가판)의 값 묶음도 SK 마다 따로 저장(공유 없음) · 사본에는 기계 고르기 후보 줄까지 모두 들어감(177 줄). + +## 2. 시간(메인 서버 · 따뜻한 상태 · 읽기만 잼) + +| 조작 | 시간 | +|---|---| +| M01 기계 표 한 쪽 | 0.03 초 | +| M01 자재 표 한 쪽 · 찾기 | 0.2 초 | +| M01 단가산출 기초 목록 | 0.1 ~ 0.4 초 | +| M02 SK 한 건 열기 | 0.07 초 | +| M02 개인 층 목록(47 건) | 3.1 ~ 3.5 초 | + +- 쓰기 시간은 사용자 자료를 건드리게 되어 재지 않음(코드로만 확인). + +## 3. 느린 까닭(코드) + +1. 층 목록 — 판을 만들려고 SK 파일을 통째로 읽고(sha256) · 요약 때 다시 파싱 · 기준 금액 셈 때 또 파싱 → SK 한 건에 읽기 세 번 + 셈 · 요청 사이 기억 없음 · 목록용 요약 파일 없음(`M02_Template_Layers.list_templates` · `M02_Labor_Ref.summaries`). +2. M01 저장(별칭 한 칸도) — 15 MB 파일 다시 씀 · 폴더 전체(26 MB) 두 번 읽음 · 전체 검사 두 번(모든 층 일위대가 · 사전조합 파일을 두 번 다 파싱) · 그 뒤 기억 다시 지음(`M01_MasterData_Store.save` · `Store_Cache.check_saved`). +3. SK 저장 — M01 줄 전체 훑기(사본 모으기)를 저장 한 번에 두 번 이상 · SK 파일을 디스크에서 여러 번 다시 읽음 · 셈 반복 · 2 MB 를 들여쓰기 + fsync 로 씀(`clean_doc` → `settle` → `stamp` · `grow` → `prices.collect`). +4. 사본 중복 — 위 1 ~ 3 의 읽기 · 쓰기 비용을 모두 키움. +5. M01 표 읽기(`rows` · `tables` · `groups`) — 기억을 안 쓰고 요청마다 파일 파싱(지금은 0.2 초라 급하지 않음). + +## 4. 판단 + +- 「층마다 한 파일」 이 아님 — M02 는 항목마다 파일이고 · 문제는 그 한 건이 사본으로 부풀어 있는 것 + 목록 · 저장이 큰 파일을 되풀이 읽고 쓰는 것. +- 저장 꼴을 통째로 바꾸기(DB 이전)보다 아래 차례가 효과 대비 위험이 작음. + +| 차례 | 안 | 기대 | 위험 | +|---|---|---|---| +| A | 층마다 목록 요약 색인(이름 · 판 · 기준 금액 · 쓰는 곳 수 — 저장 때 갱신 · 어긋나면 다시 지음) | 층 목록 3.3 초 → 0.1 ~ 0.3 초 | 작음 | +| B | 저장 길 줄이기 — M01 저장: 폴더 두 번 읽기 · 전체 검사 두 번을 바뀐 것만 · SK 저장: 모으기 · 다시 읽기 · 셈 한 번씩 | 저장 · 불러오기(항목 수 × 저장) 시간 | 중간 | +| C | 사본 줄이기 — ① `사본` · `수식` 한 번만 ② 실제로 읽은 줄만 ③ 같은 지문 묶음은 한 번만 저장하고 SK 는 지문만 가리킴 | SK 2.2 MB → 수십 KB · 층 100 MB → 수 MB · 열기 · 저장 · 복사 · 시놀로지 동기화 모두 가벼워짐 | 중간(기존 파일 옮기기 · 안 쓰는 묶음 치우기 · 프로젝트 복사본) | +| D | 목록 · 색인 · 잠금을 DB 로 | 여러 서버 · 동시 저장 안전 | 큼 · 지금 속도 문제엔 필요 없음 → 보류 | + +- 「만들 때 복사 · 그 뒤 연결 끊김」 원칙은 A ~ C 어느 것에서도 그대로(C ③ 의 묶음은 지문으로 굳은 사본). From 2ac6c9808a0dc7ea9f7d16932f3d9c012d6331ad Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:21:38 +0900 Subject: [PATCH 12/32] =?UTF-8?q?feat(825-1):=20B07=20=EB=82=B4=EC=9E=A5?= =?UTF-8?q?=20=EC=BA=90=EB=93=9C=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20=E2=80=94?= =?UTF-8?q?=20=EB=8F=84=EB=A9=B4=20=EC=A2=8C=ED=91=9C=EC=97=90=20=EB=B6=99?= =?UTF-8?q?=EC=9E=84=20=C2=B7=201=C2=B72=C2=B75=20=EC=A0=81=EC=9D=91=20?= =?UTF-8?q?=EA=B0=84=EA=B2=A9=20=C2=B7=20=EC=A3=BC=EC=84=A0=20=C2=B7=20?= =?UTF-8?q?=ED=95=9C=20=EC=B9=B8=20=ED=81=AC=EA=B8=B0=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 그리드를 화면 고정 24px 에서 원점 기준 도면 좌표 선으로 바꿈(팬·줌을 따라감 · 보이는 범위만 계산) - 간격 단계 1·2·5 × 10^k · 보조선 사이 12~30px · 한 방향 선 수 상한 200 · 주선 5·10 × 10^k - 순수 함수 helpers/grid-steps.ts + 시험 · zoom-out-perf 에 그리드 켠 clear() 선 수 · 시간 시험 추가 - 주선 색 --draw-grid-major 를 공통 테마(밝음 · 어두움)에 더함 · 상태줄에 「그리드 1 칸 = ○ m」 표시 Co-Authored-By: Claude Sonnet 5.5 --- .../openwebcad/src/App_Layers.css | 6 ++ .../openwebcad/src/components/StatusBar.tsx | 12 +++- .../screenCanvas.drawController.ts | 48 +++++++++++----- .../openwebcad/src/helpers/grid-steps.test.ts | 43 ++++++++++++++ .../openwebcad/src/helpers/grid-steps.ts | 57 +++++++++++++++++++ .../src/helpers/zoom-out-perf.test.ts | 19 +++++++ ui_template/ui_template_theme.css | 3 + 7 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/helpers/grid-steps.test.ts create mode 100644 B07_DesignDetail/openwebcad/src/helpers/grid-steps.ts diff --git a/B07_DesignDetail/openwebcad/src/App_Layers.css b/B07_DesignDetail/openwebcad/src/App_Layers.css index fabf5e440..c35e2aa0c 100644 --- a/B07_DesignDetail/openwebcad/src/App_Layers.css +++ b/B07_DesignDetail/openwebcad/src/App_Layers.css @@ -184,6 +184,12 @@ body.cad-file-dragging::after { border-top: 1px solid var(--cad-line); } /* 켜기 = 켜기 칩 부품(`ui-toggle--chip` · 단 22) */ +.cad-statusbar__grid { + margin-left: 8px; + color: var(--cad-text-muted); + font-size: 10px; + white-space: nowrap; +} .cad-statusbar__hint { margin-left: auto; color: var(--cad-text-muted); diff --git a/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx b/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx index cf8188167..a81750c2e 100644 --- a/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx @@ -4,12 +4,14 @@ import { getAngleStep, getGridEnabled, getSnapEnabled, + getScreenCanvasDrawController, getSnapTrackingEnabled, setAngleStep, setGridEnabled, setSnapEnabled, setSnapTrackingEnabled, } from '../state'; +import { formatGridStep, pickGridStep } from '../helpers/grid-steps'; interface StatusBarProps { commandLineVisible: boolean; @@ -22,7 +24,12 @@ export const StatusBar: FC = ({ commandLineVisible, onToggleComm const ortho = getAngleStep() === 90; const polar = getAngleStep() === 45; const snapTracking = getSnapTrackingEnabled(); - + // 그리드 한 칸 크기(825-1) — 지금 확대 정도의 보조선 간격 + const view = getScreenCanvasDrawController(); + const gridStep = + grid && view + ? pickGridStep(view.getScreenScale(), Math.max(view.getCanvasSize().x, view.getCanvasSize().y)) + : null; return (
+ {gridStep && ( + 그리드 1 칸 = {formatGridStep(gridStep.minor)} + )} 휠: 줌 · 휠 드래그: 초점이동 · Esc: 취소
); diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 496e0ab02..c4c69d5c2 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -1,5 +1,6 @@ import { Point, type Vector } from '@flatten-js/core'; import { WHEEL_ZOOM_EXPONENT } from '../App.consts'; +import { gridLines, pickGridStep } from '../helpers/grid-steps.ts'; import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts'; import { mapNumberRange } from '../helpers/map-number-range.ts'; import { StateVariable } from '../helpers/undo-stack.ts'; @@ -338,21 +339,40 @@ export class ScreenCanvasDrawController implements DrawController { this.context.fillStyle = themeColor('--cad-canvas', CANVAS_BACKGROUND_FALLBACK); this.context.fillRect(0, 0, this.canvasSize?.x, this.canvasSize?.y); - if (getGridEnabled()) { - this.context.strokeStyle = themeColor('--draw-grid', '#242b35'); - this.context.lineWidth = 1; - this.context.setLineDash([]); - this.context.beginPath(); - for (let x = 0.5; x < this.canvasSize.x; x += 24) { - this.context.moveTo(x, 0); - this.context.lineTo(x, this.canvasSize.y); - } - for (let y = 0.5; y < this.canvasSize.y; y += 24) { - this.context.moveTo(0, y); - this.context.lineTo(this.canvasSize.x, y); - } - this.context.stroke(); + if (getGridEnabled()) this.drawGrid(); + } + + /** 도면 좌표에 붙은 그리드(825-1) — 팬 · 줌을 따라가고 확대 정도에 간격이 바뀜. 보이는 범위의 선만 긋는다. */ + private drawGrid() { + const { x: width, y: height } = this.canvasSize; + const scale = this.screenScale; + const step = pickGridStep(scale, Math.max(width, height)); + const minorPath = new Path2D(); + const majorPath = new Path2D(); + for (const { value, major } of gridLines( + this.screenOffset.x, + this.screenOffset.x + width / scale, + step + )) { + const x = Math.round((value - this.screenOffset.x) * scale) + 0.5; + (major ? majorPath : minorPath).moveTo(x, 0); + (major ? majorPath : minorPath).lineTo(x, height); } + for (const { value, major } of gridLines( + this.screenOffset.y, + this.screenOffset.y + height / scale, + step + )) { + const y = Math.round(height - (value - this.screenOffset.y) * scale) + 0.5; + (major ? majorPath : minorPath).moveTo(0, y); + (major ? majorPath : minorPath).lineTo(width, y); + } + this.context.lineWidth = 1; + this.context.setLineDash([]); + this.context.strokeStyle = themeColor('--draw-grid', '#242b35'); + this.context.stroke(minorPath); + this.context.strokeStyle = themeColor('--draw-grid-major', '#3a4352'); + this.context.stroke(majorPath); } /** diff --git a/B07_DesignDetail/openwebcad/src/helpers/grid-steps.test.ts b/B07_DesignDetail/openwebcad/src/helpers/grid-steps.test.ts new file mode 100644 index 000000000..0e6d13c31 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/grid-steps.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { formatGridStep, gridLines, MAX_LINES, MIN_MINOR_PX, pickGridStep } from './grid-steps'; + +describe('그리드 간격(825-1)', () => { + it('여러 확대 정도에서 화면 간격이 범위 안 · 선 수 상한', () => { + for (let exp = -4; exp <= 5; exp += 0.25) { + const scale = 10 ** exp; // px/m + for (const extent of [800, 1600, 3840]) { + const step = pickGridStep(scale, extent); + const px = step.minor * scale; + const floor = Math.max(MIN_MINOR_PX, extent / MAX_LINES); + expect(px).toBeGreaterThanOrEqual(floor * 0.999); + expect(px).toBeLessThanOrEqual(floor * 2.5 * 1.001); + const lines = gridLines(-1e6, -1e6 + extent / scale, step); + expect(lines.length).toBeLessThanOrEqual(MAX_LINES + 1); + } + } + }); + + it('주선은 늘 5 · 10 × 10^k 위', () => { + for (const scale of [0.001, 0.07, 0.3, 1, 4, 20, 900]) { + const step = pickGridStep(scale, 1600); + const major = step.minor * step.majorEvery; + expect(Math.log10(major / 5) % 1 === 0 || Math.log10(major / 10) % 1 === 0).toBe(true); + } + }); + + it('팬해도 같은 도면 좌표에 선 — 원점 기준', () => { + const step = pickGridStep(2, 1600); // 12 px → 6 m 이상 → 10 m + expect(step.minor).toBe(10); + const a = gridLines(3, 95, step).map((l) => l.value); + const b = gridLines(-47, 45, step).map((l) => l.value); + expect(a.filter((v) => v <= 45)).toEqual(b.filter((v) => v >= 3)); + expect(gridLines(-20, 20, step).filter((l) => l.major).map((l) => l.value)).toEqual([0]); + }); + + it('문구', () => { + expect(formatGridStep(1)).toBe('1 m'); + expect(formatGridStep(0.5)).toBe('0.5 m'); + expect(formatGridStep(5000)).toBe('5 km'); + expect(formatGridStep(0.002)).toBe('2 mm'); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/helpers/grid-steps.ts b/B07_DesignDetail/openwebcad/src/helpers/grid-steps.ts new file mode 100644 index 000000000..8fc1373a9 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/helpers/grid-steps.ts @@ -0,0 +1,57 @@ +/** + * 그리드 간격 계산(825-1) — 화면 보조 표시 전용 순수 함수. 도면 단위 = m. + * 간격 단계 = 1 · 2 · 5 × 10^k. 화면에서 보조선 사이가 MIN_MINOR_PX 이상이 되는 가장 작은 단계를 고름 + * (→ 보조선 사이 12~30 px · 확대하면 나뉘고 축소하면 커짐). 주선 = 1·2 단계는 5 칸마다 · 5 단계는 2 칸마다(늘 5·10 × 10^k). + * 한 방향 선 수 상한 MAX_LINES — 화면이 아주 커도 지킴. + */ +export const MIN_MINOR_PX = 12; +export const MAX_LINES = 200; + +const MANTISSAS = [1, 2, 5]; + +export interface GridStep { + /** 보조선 간격 (m) */ + minor: number; + /** 주선 = 보조선 몇 칸마다 */ + majorEvery: number; +} + +/** scale = 화면 px / m · extentPx = 화면 긴 변 px */ +export function pickGridStep(scale: number, extentPx: number): GridStep { + if (!(scale > 0)) return { minor: 1, majorEvery: 5 }; + const minPx = Math.max(MIN_MINOR_PX, extentPx / MAX_LINES); + const minWorld = minPx / scale; + let exponent = Math.floor(Math.log10(minWorld)) - 1; + for (let guard = 0; guard < 40; guard++, exponent++) { + for (const mantissa of MANTISSAS) { + const minor = Number(`${mantissa}e${exponent}`); // 1e-3 꼴 — 곱셈 부동소수 오차 없음 + if (minor >= minWorld) return { minor, majorEvery: mantissa === 5 ? 2 : 5 }; + } + } + return { minor: minWorld, majorEvery: 5 }; +} + +export interface GridLine { + /** 도면 좌표 값 */ + value: number; + major: boolean; +} + +/** [lo, hi] 안에서 원점 기준 minor 간격의 선 목록 */ +export function gridLines(lo: number, hi: number, step: GridStep): GridLine[] { + const lines: GridLine[] = []; + const first = Math.ceil(lo / step.minor); + const last = Math.floor(hi / step.minor); + for (let index = first; index <= last; index++) { + lines.push({ value: index * step.minor, major: index % step.majorEvery === 0 }); + } + return lines; +} + +/** 상태줄 문구용 — 1 · 0.5 · 10 · 1 km · 5 mm */ +export function formatGridStep(minor: number): string { + if (minor >= 1000) return `${Number((minor / 1000).toPrecision(6))} km`; + if (minor >= 1) return `${Number(minor.toPrecision(6))} m`; + if (minor >= 0.1) return `${Number(minor.toPrecision(6))} m`; + return `${Number((minor * 1000).toPrecision(6))} mm`; +} diff --git a/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts b/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts index 754eae362..8793f2db8 100644 --- a/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts +++ b/B07_DesignDetail/openwebcad/src/helpers/zoom-out-perf.test.ts @@ -47,6 +47,7 @@ import { MeasurementEntity } from '../entities/MeasurementEntity'; import { PolyLineEntity } from '../entities/PolyLineEntity'; import { TextEntity } from '../entities/TextEntity'; import { setEntities, setGridEnabled, setLayers } from '../state'; +import { MAX_LINES } from './grid-steps'; import { drawScene, invalidateSceneCache, scenePerf } from './scene-cache'; import { queryEntitiesInBox } from './spatial-index'; @@ -188,4 +189,22 @@ describe('줌아웃 그리기 비용(227-1)', () => { setGridEnabled(false); expect(scenePerf.sceneRebuilds).toBe(rebuilds); }); + + it('그리드를 켠 채 배율별 clear() — 선 수 상한(825-1) · 한 장 시간(옛 24px 고정 = 가로 67 + 세로 38 = 105 줄)', () => { + setGridEnabled(true); + const rows = []; + for (const factor of [100, 10, 1, 0.1, 0.01, 0.0001]) { + viewAt(controller, factor, fitScale); + for (const key of Object.keys(counts)) counts[key as keyof typeof counts] = 0; + controller.clear(); + const lines = counts.segments; + const startedAt = performance.now(); + for (let rep = 0; rep < 200; rep++) controller.clear(); + const ms = (performance.now() - startedAt) / 200; + rows.push({ factor, lines, ms: Number(ms.toFixed(3)) }); + expect(lines).toBeLessThanOrEqual(2 * (MAX_LINES + 1)); + } + setGridEnabled(false); + console.table(rows); + }); }); diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 04cd70a2b..4d9b75a6e 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -548,6 +548,7 @@ html.is-bm-route .m01lab-src__body * { --draw-text-secondary: #615e6e; --draw-text-muted: #9491a1; --draw-grid: #e6e2e3; /* 눈금 · CAD 격자 */ + --draw-grid-major: #d2cccf; /* CAD 격자 주선 */ --draw-surface: #f6f7fa; --draw-surface-raised: #ffffff; /* 그림 바탕 · 3D 바탕 */ --draw-canvas: #ffffff; /* CAD 바탕 */ @@ -632,6 +633,7 @@ html.is-bm-route .m01lab-src__body * { --draw-text-secondary: #b3aec6; --draw-text-muted: #8b86a0; --draw-grid: #322a45; + --draw-grid-major: #453b61; --draw-surface: #1e1930; --draw-surface-raised: #251f38; --draw-canvas: #16121f; @@ -648,6 +650,7 @@ html.is-bm-route .m01lab-src__body * { --draw-text-secondary: #b3aec6; --draw-text-muted: #8b86a0; --draw-grid: #322a45; + --draw-grid-major: #453b61; --draw-surface: #1e1930; --draw-surface-raised: #251f38; --draw-canvas: #16121f; From b1561b64c04f7988651913005b03d10c7b5b666d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:22:09 +0900 Subject: [PATCH 13/32] =?UTF-8?q?chore(master):=20=EB=8F=84=EB=A9=B4=20?= =?UTF-8?q?=EC=96=91=EC=8B=9D=20=ED=85=8C=EC=8A=A4=ED=8A=B8.json=20?= =?UTF-8?q?=E2=80=94=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- resources/master_template/drawing/테스트.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 resources/master_template/drawing/테스트.json diff --git a/resources/master_template/drawing/테스트.json b/resources/master_template/drawing/테스트.json new file mode 100644 index 000000000..19401f5cc --- /dev/null +++ b/resources/master_template/drawing/테스트.json @@ -0,0 +1,12 @@ +{ + "format": 6, + "entities": [], + "layers": [ + { + "id": "기본", + "name": "기본", + "isVisible": true, + "isLocked": false + } + ] +} From fc8c36ac9aefc48122b7f7ab0f2d63547309ce5f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:24:34 +0900 Subject: [PATCH 14/32] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC806):=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=EC=97=90=20=EC=83=81=EA=B4=80=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EB=82=B4=EC=9A=A9=EC=9D=B4=20=EB=93=A0=20=EA=B2=83?= =?UTF-8?q?=20=EC=95=84=EB=8B=8C=EC=A7=80=20=EB=AC=BC=EC=9D=8C=20=EC=9B=90?= =?UTF-8?q?=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/사용자요구_1104.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index ba95e3798..c172296f4 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -1062,3 +1062,7 @@ M02에서 내 기계와 내 품목은 개인관리 컨테이너로 병합함. ## 요구 805 (11-12 · 사용자 · 내장 캐드 그리드 — 오토캐드처럼 줌 인 · 아웃에 따라 크기 · 표현되는 수를 제어 · 많이 줌 아웃해도 느려지지 않고 보기 좋게 · 확인하고 반영) 내장 캐드에서 그리드 표현은 오토캐드를 참고해보면 줌인아웃에 따라 크기나 표현되는 숫자를 제어하여 효과적으로 보여줌. 그래야 많이 줌아웃하면 느려지지 않고 보기 좋을테니까. 확인해보고 반영해줘. + +## 요구 806 (11-12 · 사용자 · 속도 조사에서 이상한 점 — M01 정보를 사본으로 가져가는 건 좋은데 상관없는 내용도 같이 갖고 있는 것 아닌지) + +나는 너가해준 속도 느림에서 이상하다고 느낀 부분이 M01의 정보를 사본으로 가져가는건 좋은데 내가 보기에는 상관없는 내용도 같이 가지고 있는거 아니야? From d31dfba9fb2bb1a10b451d15e571de782e8e03fd Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:25:02 +0900 Subject: [PATCH 15/32] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC806):=20?= =?UTF-8?q?=EC=82=AC=EB=B3=B8=20177=20=EC=A4=84=20=EA=B0=80=EC=9A=B4?= =?UTF-8?q?=EB=8D=B0=20=EC=8B=A4=EC=A0=9C=20=EC=93=B0=EB=8A=94=20=EA=B2=83?= =?UTF-8?q?=204=20=EC=A4=84=20=E2=80=94=20=EC=A1=B0=EC=82=AC=20804=20?= =?UTF-8?q?=EC=97=90=20=EB=8D=A7=EB=B6=99=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/_조사_804_저장소속도.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/raw/요구_1104/_조사_804_저장소속도.md b/docs/raw/요구_1104/_조사_804_저장소속도.md index 872c0a23f..1a7102248 100644 --- a/docs/raw/요구_1104/_조사_804_저장소속도.md +++ b/docs/raw/요구_1104/_조사_804_저장소속도.md @@ -47,3 +47,10 @@ | D | 목록 · 색인 · 잠금을 DB 로 | 여러 서버 · 동시 저장 안전 | 큼 · 지금 속도 문제엔 필요 없음 → 보류 | - 「만들 때 복사 · 그 뒤 연결 끊김」 원칙은 A ~ C 어느 것에서도 그대로(C ③ 의 묶음은 지문으로 굳은 사본). + +## 5. 사본에 든 줄의 실제 쓰임(요구 806 · SK-000048 실측) + +- 이 단가산출이 실제 쓰는 값 = 기계 둘(굴착기(무한궤도) 0.7㎥ · 진동롤러(자주식) 12ton) + 경유 전국평균 한 줄 + 건설기계운전사 한 줄 = 4 줄. +- 사본에 든 값 = 177 줄: 기계 138(두 계산식의 「기계」 고르기 후보 전부 — GC000267 9 대 + GC000269 129 대 · 14.6 만 자) · 유가 36(휘발유 · 경유 × 지역 18) · 직종 3 → 173 줄(약 98 %)은 이 단가산출이 안 쓰는 값. +- 까닭 = 사본 모으기가 「식이 고를 수 있는 후보 전부」 를 닫힌 묶음으로 박음(옵션을 바꿔도 사본 안에서 셈하려는 뜻). +- 안 C ② 가 이것 — 실제로 읽은 줄만 박고 · 옵션 · 기계를 바꿔 새로 필요한 줄은 바꾸는 때의 M01 값으로 가져와 저장 때 박음(822-1 이 넣은 「읽은 줄을 저장 때 박기」 길 재사용). From 4bf556311c5db8d14e53afd9c353373506ed46eb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:25:05 +0900 Subject: [PATCH 16/32] =?UTF-8?q?fix(822-2):=20ORCA=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=20=ED=9D=A0=20=E2=80=94=20=EC=A7=81=EC=A0=91=20=EA=B0=92=20?= =?UTF-8?q?=EC=8B=9D=EC=9D=B4=20=EB=82=B4=20=EA=B8=B0=EA=B3=84=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=9B=EC=9D=8C=20=C2=B7=20=EC=83=88=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EB=B9=88=20=EC=9B=90=EB=AC=B8=EB=B2=88=ED=98=B8=20=3D=20?= =?UTF-8?q?=E3=80=8C=EC=9E=90=EC=B2=B4=20EQ=E2=80=A6=E3=80=8D=20=C2=B7=20?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=20=ED=8C=90=20=EC=9E=98=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - master_pick.machine: 빈 고르기 「기계」 입력(GC001199) = 기계표에 있는 어느 번호든(줄 쌓기 단가산출 셈이 「고르기 밖」 으로 멈췄음) - M01 [새 기계] 원문번호 빈 칸 = 「자체 EQ{받을 키}」 — 식 목록에 넣을 번호(빈 번호면 시간당 「고르기 밖」) - 찾기 판 높이가 떠 있는 판 max-height 100%(칸 높이)에 잘림 → none - 쓰는 계산식 꼴 글: 「새 기계는 저장 때 그 식 목록에 등록」(있던 기계 고침엔 등록 없음) - 시험 822_2: 내 기계 원문번호로 직접 값 식 셈 · 빈 원문번호 새 기계 등록 · 지움 Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Machine.py | 13 ++++++++++++- resources/master_data/scripts/master_pick.py | 7 +++++-- resources/tester/test_822_2_machine_hourly_all.py | 8 ++++++++ ui_template/ui_template_locale_machine.ts | 4 ++-- ui_template/ui_template_pick_search.css | 4 ++++ 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/M01_MasterData/M01_MasterData_Machine.py b/M01_MasterData/M01_MasterData_Machine.py index fa6061d72..6de6ac86b 100644 --- a/M01_MasterData/M01_MasterData_Machine.py +++ b/M01_MasterData/M01_MasterData_Machine.py @@ -3,7 +3,8 @@ 계수 = `master_machine_coef`(TS 짝 `common_util/common_util_machine_coef.ts`) · 시간당 = 서버 기계경비식(`M02_Machine_Hourly.hourly` · 전국평균 · 기본 옵션) · -쓰는 계산식 = `M02_Machine_Hourly.pick`(목록 › 꼴 · 822-2) · [새 기계] 저장 = 꼴에 맞는 식 목록에 원문번호 등록 · +쓰는 계산식 = `M02_Machine_Hourly.pick`(목록 › 꼴 · 822-2) · [새 기계] 저장 = 꼴에 맞는 식 목록에 원문번호 등록 +(원문번호 빈 칸 = 「자체 EQ…」 받을 키) · M01 에서 만든 기계(출처 모두 「자체」)를 지우면 그 번호 기계가 더 없을 때 목록에서 뺌(`register`) · 마스터 파일은 읽기만 — 쓰기는 `Store_Make.save`. """ @@ -275,8 +276,18 @@ def fix(part: dict) -> None: return stored = {str(r.get("키")): r for r in _rows()} units = _units() + issue = int(store.mk.load_book(store.FOLDER / store.mk.BOOK.name)["다음"].get("EQ", 1)) for ch in part.get("changes") or []: row = ch.get("row") + if ( + ch.get("op") == "add" + and isinstance(row, dict) + and not str(row.get("원문번호") or "").strip() + ): + row["원문번호"] = ( + f"{OWN_SOURCE} EQ{issue:06d}" # 822-2 식 목록에 넣을 번호(빈 칸 = 받을 키) + ) + issue += 1 if ( ch.get("op") == "delete" or not isinstance(row, dict) diff --git a/resources/master_data/scripts/master_pick.py b/resources/master_data/scripts/master_pick.py index 6f6a2b7c1..61b09a4c0 100644 --- a/resources/master_data/scripts/master_pick.py +++ b/resources/master_data/scripts/master_pick.py @@ -52,9 +52,12 @@ def resolve(spec: dict, value, master=None): def machine(spec: dict, value, master=None) -> bool: - """기계 입력 — 고르기 목록 밖 원문 분류번호도 기계 마스터에 있으면 받음.""" + """기계 입력 — 고르기 목록 밖 원문 분류번호도 기계 마스터에 있으면 받음 · 빈 고르기(직접 값 식 + GC001199 · 822-2) = 어느 기계든.""" known = _numbers(master) - picks = spec.get("고르기") or [] + picks = spec.get("고르기") + if picks == [] and spec.get("이름") == "기계": + return str(value).strip() in known return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks) diff --git a/resources/tester/test_822_2_machine_hourly_all.py b/resources/tester/test_822_2_machine_hourly_all.py index 3a2359d0c..6194dbbbe 100644 --- a/resources/tester/test_822_2_machine_hourly_all.py +++ b/resources/tester/test_822_2_machine_hourly_all.py @@ -138,6 +138,11 @@ def test_new_machine_registers_and_unregisters(copy: Path): _save(client, [{"op": "delete", "key": key}]) assert "9999-0822" not in _codes("GC000268") assert (copy / LOGIC).read_text(encoding="utf-8") == logic_text + _save(client, [{"op": "add", "row": {**row, "원문번호": ""}}]) # 빈 원문번호 = 「자체 EQ…」 받을 키 + key = max(k for k, r in _rows().items() if r["이름"] == NAME) + assert _rows()[key]["원문번호"] == f"자체 {key}" and f"자체 {key}" in _codes("GC000268") + _save(client, [{"op": "delete", "key": key}]) + assert (copy / LOGIC).read_text(encoding="utf-8") == logic_text # ④ 직접 값 식 ───────────────────────────────────────────────────────── @@ -157,6 +162,9 @@ def test_direct_formula_my_machine(world): made = _at(me, mym.create, body) assert made["시간당"]["로직"] == DIRECT and made["시간당"]["계"] == 60 no = made["줄"]["원문번호"] + whole = _at(me, mym.whole) + run = m01.cm.mf.run(whole, DIRECT, {"기계": no}) # 직접 값 식 — 내 기계 원문번호를 받음 + assert (run["노무비"], run["재료비"], run["경비"]) == (10, 20, 30) line = {"종류": "로직", "로직": DIRECT, "입력값": {"기계": no}, "수량": "2"} name = _at( me, lambda: refs.create("sangeun", {"이름": NAME, "단위": "시간", "더한줄": [line]})["이름"] diff --git a/ui_template/ui_template_locale_machine.ts b/ui_template/ui_template_locale_machine.ts index b5376cb30..3650252ae 100644 --- a/ui_template/ui_template_locale_machine.ts +++ b/ui_template/ui_template_locale_machine.ts @@ -55,8 +55,8 @@ export const ui_locales_machine = { M01_MachineFormula: ["쓰는 계산식", "Hourly formula"], M01_MachineFormulaListed: ["목록에 있음", "listed"], M01_MachineFormulaShape: [ - "꼴로 고름(채운 칸) — 저장하면 그 식 목록에 등록", - "picked by filled fields — registered on save", + "꼴로 고름(채운 칸 · 새 기계는 저장 때 그 식 목록에 등록)", + "picked by filled fields (new machines are listed on save)", ], M01_MachineFormulaOwn: ["그 기계만의 식", "machine's own formula"], M01_MachineFormulaNone: ["식 없음", "no formula"], diff --git a/ui_template/ui_template_pick_search.css b/ui_template/ui_template_pick_search.css index 097dc7c1b..e9f7e175d 100644 --- a/ui_template/ui_template_pick_search.css +++ b/ui_template/ui_template_pick_search.css @@ -3,6 +3,10 @@ position: relative; } +.ui-float-panel.ui-pick-search { + max-height: none; +} + .ui-pick-search .ui-float-panel__body { padding: var(--spacing-8); } From 33bf9aa2cc02f3edafcd708087c7580762b693e6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:33:56 +0900 Subject: [PATCH 17/32] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC807):=20?= =?UTF-8?q?=EC=86=8D=EB=8F=84=20=EB=AC=B6=EC=9D=8C=20A=C2=B7B=C2=B7C=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=C2=B7=20=ED=95=AD=EB=AA=A9=EC=9D=B4=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=ED=95=98=EB=82=98=EC=9D=B8=EC=A7=80=20?= =?UTF-8?q?=EB=AC=BC=EC=9D=8C=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/사용자요구_1104.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index c172296f4..e4db2e3e0 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -1066,3 +1066,7 @@ M02에서 내 기계와 내 품목은 개인관리 컨테이너로 병합함. ## 요구 806 (11-12 · 사용자 · 속도 조사에서 이상한 점 — M01 정보를 사본으로 가져가는 건 좋은데 상관없는 내용도 같이 갖고 있는 것 아닌지) 나는 너가해준 속도 느림에서 이상하다고 느낀 부분이 M01의 정보를 사본으로 가져가는건 좋은데 내가 보기에는 상관없는 내용도 같이 가지고 있는거 아니야? + +## 요구 807 (11-12 · 사용자 · A · B · C 는 잠시 대기 · 물음 먼저 — 단가산출의 항목 · 기계의 항목이 하나의 파일인지) + +ABC는 잠시대기, 내 질문에 대한 대답부터 단가산출의 항목, 기계의 항목이 하나의 파일이야? From 892285843d4051c786098b0167d397e2e2c3a234 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:35:50 +0900 Subject: [PATCH 18/32] =?UTF-8?q?feat(est-test):=20=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=ED=98=95=20=EC=88=98=EB=9F=89=EC=82=B0=EC=B6=9C=EC=84=9C=20?= =?UTF-8?q?=ED=91=9C=20=EB=B3=B4=EC=A1=B4=EA=B3=BC=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가져오기가 WPT 표를 버리던 결함 고침: 표 줄(칸·수량 표지·수식·세로병합) 보존, 문서 읽기 native-quantity-wpt.cjs 로 분리 - 표 셀 합·수량 지정·병합/분리·표 만들기·표 모양, 문단·그림·쪽 삽입/삭제·품명/규격 지정·공종명칭 입력·테두리선·산출근거 양식 - 수량 불러오기·수량목록보기·선택수량복사·내역서 수량붙여넣기(연결) - ESTX 저장은 표 줄을 건너 문단만 맞춤, 표·서식 편집은 거부 Co-Authored-By: Claude Opus 5.5 --- resources/tester/helper_est_native.cjs | 1 + resources/tester/test_est_bid.py | 1 + resources/tester/test_est_cleanup.py | 1 + resources/tester/test_est_docs.py | 1 + resources/tester/test_est_est_tools.py | 1 + resources/tester/test_est_estimate_print.py | 1 + resources/tester/test_est_excel_map.py | 1 + resources/tester/test_est_find.py | 1 + resources/tester/test_est_jungsang.py | 1 + resources/tester/test_est_name_reg.py | 1 + resources/tester/test_est_pick.py | 1 + resources/tester/test_est_print_batch.py | 1 + resources/tester/test_est_progress_wonga.py | 1 + resources/tester/test_est_quantity_table.py | 71 +++++ resources/tester/test_est_quantity_total.py | 1 + resources/tester/test_est_sources.py | 1 + resources/tester/test_est_tidy.py | 1 + resources/tester/test_est_transfer.py | 1 + resources/tester/test_est_wonga_tools.py | 1 + tools/est-test/PLAN.md | 3 + tools/est-test/export-quantity.cjs | 53 +++- tools/est-test/index.html | 5 + tools/est-test/native-actions-more.js | 9 + tools/est-test/native-business-actions.js | 4 +- tools/est-test/native-quantity-list.js | 157 ++++++++++++ tools/est-test/native-quantity-sheets.js | 4 +- tools/est-test/native-quantity-table.js | 270 ++++++++++++++++++++ tools/est-test/native-quantity-wpt.cjs | 190 ++++++++++++++ tools/est-test/native-quantity.cjs | 64 ++--- tools/est-test/server.cjs | 14 +- 30 files changed, 799 insertions(+), 63 deletions(-) create mode 100644 resources/tester/test_est_quantity_table.py create mode 100644 tools/est-test/native-actions-more.js create mode 100644 tools/est-test/native-quantity-list.js create mode 100644 tools/est-test/native-quantity-table.js create mode 100644 tools/est-test/native-quantity-wpt.cjs diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 9b5f65fc9..4113dbad9 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -9,6 +9,7 @@ for (const file of [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_bid.py b/resources/tester/test_est_bid.py index 2893b3ce2..65c83adee 100644 --- a/resources/tester/test_est_bid.py +++ b/resources/tester/test_est_bid.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_cleanup.py b/resources/tester/test_est_cleanup.py index 11b77cfc0..582d22668 100644 --- a/resources/tester/test_est_cleanup.py +++ b/resources/tester/test_est_cleanup.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_docs.py b/resources/tester/test_est_docs.py index e31f03f4a..8021b753e 100644 --- a/resources/tester/test_est_docs.py +++ b/resources/tester/test_est_docs.py @@ -14,6 +14,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_est_tools.py b/resources/tester/test_est_est_tools.py index b4b68eac9..883144df4 100644 --- a/resources/tester/test_est_est_tools.py +++ b/resources/tester/test_est_est_tools.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_estimate_print.py b/resources/tester/test_est_estimate_print.py index 3062c54a1..ddca91de7 100644 --- a/resources/tester/test_est_estimate_print.py +++ b/resources/tester/test_est_estimate_print.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_excel_map.py b/resources/tester/test_est_excel_map.py index 1c389cdfd..c8d8ef7ba 100644 --- a/resources/tester/test_est_excel_map.py +++ b/resources/tester/test_est_excel_map.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_find.py b/resources/tester/test_est_find.py index 5c2cc80d3..377e6fe6e 100644 --- a/resources/tester/test_est_find.py +++ b/resources/tester/test_est_find.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_jungsang.py b/resources/tester/test_est_jungsang.py index 71650c642..d380e02a2 100644 --- a/resources/tester/test_est_jungsang.py +++ b/resources/tester/test_est_jungsang.py @@ -12,6 +12,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-jungsang.js", diff --git a/resources/tester/test_est_name_reg.py b/resources/tester/test_est_name_reg.py index ba807b62b..d5323eb07 100644 --- a/resources/tester/test_est_name_reg.py +++ b/resources/tester/test_est_name_reg.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_pick.py b/resources/tester/test_est_pick.py index 282bbc673..2361a5fb3 100644 --- a/resources/tester/test_est_pick.py +++ b/resources/tester/test_est_pick.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_print_batch.py b/resources/tester/test_est_print_batch.py index b49dddc08..62a916746 100644 --- a/resources/tester/test_est_print_batch.py +++ b/resources/tester/test_est_print_batch.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_progress_wonga.py b/resources/tester/test_est_progress_wonga.py index be4c1f60f..e1715f56e 100644 --- a/resources/tester/test_est_progress_wonga.py +++ b/resources/tester/test_est_progress_wonga.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_quantity_table.py b/resources/tester/test_est_quantity_table.py new file mode 100644 index 000000000..a907ddaf4 --- /dev/null +++ b/resources/tester/test_est_quantity_table.py @@ -0,0 +1,71 @@ +"""EST 테스트 앱 워드형 수량산출서 표(원본 WPT 보존 · 방향 셀 합 · 수량 표지 · 세로 병합).""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MAIN = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") + + +def node(code: str, *args: str): + return json.loads( + subprocess.check_output(["node", "-e", code, *args], cwd=ROOT).decode("utf-8") + ) + + +def test_table_parse_sum_values_merge(): + out = node(r""" + const W = require(process.cwd() + '/tools/est-test/native-quantity-wpt.cjs'); + const wpt = '' + + '
단가 9
' + + '
' + + '' + + '
공종수량
절단1,000.5
깨기2
계
'; + const s = W.fromWpt('N1', '시험', wpt, [], {}); + const t = s.rows[1].table; + const div = s.rows[0].LineData; + t[3][1].formula = W.sumFormula(t, 3, 1, 'down'); + const values = Object.fromEntries(W.values(s)); + const right = W.sumFormula(t, 3, 1, 'right'); + W.vmerge(t, 1, 2, 0); + const merged = [t[1][0].text, t[2][0].vmerge, t[1][0].vtop]; + W.vsplit(t, 1, 0); + process.stdout.write(JSON.stringify({ rows: s.rows.length, div, cell: t[1][1], name: t[1][0].name, + formula: t[3][1].formula, sum: t[3][1].text, values, right, merged, split: t[2][0].vmerge ?? null, + created: W.create(2, 3).map((r) => r.length) })); + """) + assert out["rows"] == 2 + assert out["div"] == "단가 9" # 문단의 G 표지는 표지로 보지 않음 + assert out["cell"] == {"text": "1,000.5", "mark": "G00000203"} + assert out["name"] is True + assert out["formula"] == "SUM(B2:B3)" and out["sum"] == "1,002.5" + assert out["values"] == {"G00000203": 1000.5} + assert out["right"] == "SUM(A4:A4)" + assert out["merged"] == ["절단 깨기", True, 2] + assert out["split"] is None + assert out["created"] == [3, 3] + + +def test_installed_main_tables_survive_import(): + if not MAIN.exists(): + pytest.skip("installed EST main sample unavailable") + out = node( + r""" + const { importEstx } = require(process.cwd() + '/tools/est-test/import-estx.cjs'); + const p = importEstx(require('fs').readFileSync(process.argv[1])); + const tables = p.quantitySheets.filter((s) => s.rows.some((r) => r.table)) + .map((s) => { const t = s.rows.find((r) => r.table).table; return [s.Code, t.length, t[0].length]; }); + process.stdout.write(JSON.stringify(tables)); + """, + str(MAIN), + ) + assert out == [ + ["N0000000097", 9, 13], + ["N0000000108", 7, 5], + ["N0000000109", 5, 5], + ["N0000000110", 7, 5], + ["N0000000111", 8, 5], + ] diff --git a/resources/tester/test_est_quantity_total.py b/resources/tester/test_est_quantity_total.py index c9873a046..4b7e24b23 100644 --- a/resources/tester/test_est_quantity_total.py +++ b/resources/tester/test_est_quantity_total.py @@ -10,6 +10,7 @@ FILES = [ "native-formula.js", "native-text.js", "native-quantity-total.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", ] SCRIPT = r"""(() => { diff --git a/resources/tester/test_est_sources.py b/resources/tester/test_est_sources.py index 0d8627572..c1065e40d 100644 --- a/resources/tester/test_est_sources.py +++ b/resources/tester/test_est_sources.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-sources.js", diff --git a/resources/tester/test_est_tidy.py b/resources/tester/test_est_tidy.py index 38cdd15ca..bf7d8981f 100644 --- a/resources/tester/test_est_tidy.py +++ b/resources/tester/test_est_tidy.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_transfer.py b/resources/tester/test_est_transfer.py index d581fc95d..70c1a8055 100644 --- a/resources/tester/test_est_transfer.py +++ b/resources/tester/test_est_transfer.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_wonga_tools.py b/resources/tester/test_est_wonga_tools.py index 4175df2f8..f64eb1396 100644 --- a/resources/tester/test_est_wonga_tools.py +++ b/resources/tester/test_est_wonga_tools.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 37ed2916b..673b9f8c7 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,9 @@ 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] 워드형 수량산출서 표와 편집 도구(원본 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, 샘플 복구. + - 남음: 글자모양(글꼴·색), 수량산출서 원본 모양 인쇄(쪽·간지), 엑셀형 시트 수량붙여넣기(연결). - [x] 연차 내역서·상단·하단데이타 불러오기(원본 TFRMESTMULTIEDIT popLoadEst·popUpDataLoad·popDownLoad 0xedb784 → 0xf11a60 — spec_part §7) — 차수 줄의 세 단추: 고른 .estx 내역서 줄을 차수에 짝지어 옮기고(행 수는 현재 내역), 수식칸에 파일 이름. 상단·하단은 설계변경 내역만(당초 「_」 칸 있는 것), 상단 = 당초값·하단 = 변경값. 그 차수의 상세 줄은 수식 대신 불러온 값(금액 고정, 다시 셈 없음). 서버 계산도 같은 코드(`native-annual-load.js`). - 검증완료: 기성·연차 시험 13개(불러온 줄 수량 3·2·직접공사비 400, 수식 무시) 통과. 당초/변경 고르기·설계변경 아닌 파일 거부 확인. ORCA 브라우저에서 차수 줄에 불러오기 단추 3개 표시(파일 선택은 수동 조작 필요). - [x] 현재데이타 갱신(원본 TFRMDATAUPDATE btnApply 0x10bc3d4 → 엔진 0x10e12c8 — spec_update) — 원천: Main Data(원본 설치 main.estx = BufDat\Main\Main_1, 서버 `/api/main-data`, `EST_MAIN_DATA` 로 경로 변경) 또는 고른 .estx. 항목: 노임·자재(유류 제외)·유류(이름 8종)·표준(코드 일치)·기계(손료계수·환율 표 교체, 기계 호표 이름·규격 일치, 산출근거 줄 교체 뒤 자재·노임 다시 잇기)·환율(36·89·69·77·70·76). 맞춤 규칙·복사 칸·단가미갱신 표시·안내 문구 원본대로. 행 추가·삭제 없음. 갱신 뒤 재계산. 하지 않음: 최신(웹접속 — 원본 회사 서버·유지보수 인증), 자재 단가열 출처 재배치(OtherTables — 같은 열 순서 가정), 원본 결함(환율 끔 때 파운드를 76 값으로 덮음). diff --git a/tools/est-test/export-quantity.cjs b/tools/est-test/export-quantity.cjs index 1b92530de..0ca3e0e0e 100644 --- a/tools/est-test/export-quantity.cjs +++ b/tools/est-test/export-quantity.cjs @@ -211,7 +211,12 @@ function editDocument(text, oldSheet, sheet) { ); }); if (rowIndex !== oldSheet.rows.length) throw Error("수량산출서 행 수 불일치"); - if (!same(quantity.fromWpt(sheet.Code, sheet.Name, result, sheet.items, sheet.options), sheet)) + if ( + !same( + withoutTables(quantity.fromWpt(sheet.Code, sheet.Name, result, sheet.items, sheet.options)), + sheet, + ) + ) throw Error("수량산출서 문서 재읽기 검산 실패"); return result; } @@ -414,10 +419,34 @@ function reorderRows(text, sheet, rows) { return { text: document, rows: order.map((source) => sheet.rows[source]) }; } +// 워드형 산출서의 표 줄(row.table)은 문단 줄과 따로 다룸: 표는 고칠 수 없으므로(tableEdited 거부) 같은 자리에 그대로 있어야 하고, +// 문단 줄 처리 동안은 빼 두었다가 저장 결과에 다시 넣는다. +const tableRows = (sheet) => sheet.rows.flatMap((row, k) => (row.table ? [[k, row]] : [])); +const withoutTables = (sheet) => + sheet.rows.some((row) => row.table) + ? { ...sheet, rows: sheet.rows.filter((row) => !row.table) } + : sheet; +function withTables(sheet, tables) { + if (!tables.length) return sheet; + const rows = sheet.rows.slice(); + for (const [k, row] of tables) rows.splice(k, 0, row); + return { ...sheet, rows }; +} + function updateQuantities(project, base, cp949, memory) { - const sheets = project.quantitySheets || [], - originals = base.quantitySheets || []; + const fullSheets = project.quantitySheets || [], + fullOriginals = base.quantitySheets || []; + fullSheets.forEach((sheet, i) => { + if (fullOriginals[i] && !same(tableRows(sheet), tableRows(fullOriginals[i]))) + throw Error( + "표가 있는 산출서의 줄 추가·삭제·이동은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.", + ); + }); + const sheets = fullSheets.map(withoutTables), + originals = fullOriginals.map(withoutTables); if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다."); + if (sheets.some((sheet) => sheet.tableEdited)) + throw Error("워드형 산출서의 표 편집은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요."); if (sheets.some((sheet) => sheet.excelEdited)) throw Error("엑셀형 수량산출서 편집은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요."); if (sheets.some((sheet) => sheet.markerEdited)) @@ -570,12 +599,14 @@ function updateQuantities(project, base, cp949, memory) { document = insertRows(document, sheets[i], inserts); if ( !same( - quantity.fromWpt( - sheets[i].Code, - sheets[i].Name, - document, - sheets[i].items, - sheets[i].options, + withoutTables( + quantity.fromWpt( + sheets[i].Code, + sheets[i].Name, + document, + sheets[i].items, + sheets[i].options, + ), ), sheets[i], ) @@ -667,7 +698,9 @@ function updateQuantities(project, base, cp949, memory) { } return { ...project, - quantitySheets: savedSheets, + quantitySheets: savedSheets.map((sheet, i) => + sheet === sheets[i] ? fullSheets[i] : withTables(sheet, tableRows(fullSheets[i])), + ), tables: { ...project.tables, MulMainDat: rows, MulTitleDat: titles }, }; } diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 29f6f57cd..0d0212ecb 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -13,7 +13,11 @@ + + + + @@ -304,6 +308,7 @@ > { + const modules = () => [typeof EstQuantityList !== "undefined" && EstQuantityList].filter(Boolean); + function handle(name, state, node) { + for (const module of modules()) if (module.handle(name, state, node)) return true; + return false; + } + return { handle }; +})(); diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index a2c660209..677b2cb56 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -639,7 +639,9 @@ const EstBusinessActions = (() => { (typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state)) || (typeof EstBook !== "undefined" && EstBook.handle(name, state, node)) || (typeof EstDataUpdate !== "undefined" && EstDataUpdate.handle(name, state)) || - (typeof EstAnnualLoad !== "undefined" && EstAnnualLoad.handle(name, state, node)) + (typeof EstAnnualLoad !== "undefined" && EstAnnualLoad.handle(name, state, node)) || + (typeof EstQuantityTable !== "undefined" && EstQuantityTable.handle(name, state, node)) || + (typeof EstActionsMore !== "undefined" && EstActionsMore.handle(name, state, node)) ) { EstApp.render(); } else if (name === "insert-annual") { diff --git a/tools/est-test/native-quantity-list.js b/tools/est-test/native-quantity-list.js new file mode 100644 index 000000000..0b776eeb4 --- /dev/null +++ b/tools/est-test/native-quantity-list.js @@ -0,0 +1,157 @@ +// 원본 수량 목록(TFRMMULLANGLIST — spec_pa §2·3)·수량붙여넣기(연결)(TFRMEST ActionMullangInput §8.2)· +// 품명·규격 지정(TFRMMULNAMEINPUT btnSetName·btnSetSize §4)·공종명칭 입력(TFRMMULTITLENAMEINPUT §5). +// 수량 목록: 왼쪽 모든 산출서(검색 = 명칭·규격에서 빈칸·|·괄호 뺀 부분일치, 전체보기) · 가운데 고른 산출서 수량 항목 · 오른쪽 원문. +// 선택수량복사: 고른 수량 항목을 앱 안에 담아 둠(클립보드 아님). 내역서 「수량붙여넣기(연결)」: 고른 줄부터 아래로 차례로 +// 수량을 넣고 그 항목을 내역 줄에 연결(「품명,규격입력」 체크면 품명·규격·단위도). 행은 더하지 않음. +const EstQuantityList = (() => { + const norm = (s) => String(s ?? "").replace(/[\s|()]/g, ""); + const RESULT_NAMES = { name: ["\x0e", "품명"], size: ["\x13", "규격"] }; + + function open(state) { + const l = (state.qList ||= { word: "", code: null, nameSize: false, picked: new Set() }); + const { esc, number } = EstApp, + sheets = (state.project.quantitySheets || []).filter( + (s) => !l.word || norm(s.Name).includes(l.word) || norm(s.Size).includes(l.word), + ), + sheet = state.project.quantitySheets?.find((s) => s.Code === l.code) || sheets[0]; + l.code = sheet?.Code; + const items = sheet ? EstQuantity.calculate(sheet).items : []; + const preview = sheet + ? sheet.rows + .map((r) => + r.table + ? `
${r.table.map((tr) => `${tr.map((c) => ``).join("")}`).join("")}
${esc(c.text)}
` + : `
${esc(EstText.clean(r.LineData || ""))}
`, + ) + .join("") + : ""; + EstApp.showDialog( + "수량 목록", + `
${sheets.map((s) => ``).join("")}
${items.map((i, k) => ``).join("")}
명칭규격단위수량
${esc(i.Name)}${esc(i.Size)}${esc(i.Unite)}${number(i.GN)}
${preview}
`, + ); + } + + function paste(state) { + const clip = state.mulClip; + if (!clip?.items.length) throw Error("수량 목록에서 선택수량복사를 먼저 하세요."); + if (state.view !== "EstMainDat") throw Error("내역서에서 붙여넣을 줄을 고르세요."); + const rows = state.project.tables.EstMainDat, + start = [...state.selection].sort((a, b) => a - b)[0]; + if (!Number.isInteger(start)) throw Error("내역서에서 붙여넣을 줄을 고르세요."); + EstApp.change((project) => { + const sheet = project.quantitySheets.find((s) => s.Code === clip.code); + clip.items.forEach((src, k) => { + const row = project.tables.EstMainDat[Math.min(start + k, rows.length - 1)], + item = sheet?.items.find((i) => i.Code === src.Code); + if (clip.nameSize) Object.assign(row, { Name: src.Name, Size: src.Size, Unite: src.Unite }); + row.GN = Number(src.GN) || 0; + row.MullangIndex = 9999; + if (item && row.PkID != null) + Object.assign(item, { TargetKey: `id:${row.PkID}`, TargetCode: row.Code || "" }); + }); + }); + state.notice = `수량 ${clip.items.length}개를 붙여넣고 연결했습니다.`; + } + + // 품명·규격 지정: 산출 줄 글에서 고른 글자를 ⟦품명⟧·⟦규격⟧ 표지로(한 줄에 하나, 40자 이하, 제목 표지 위 금지) + function nameSize(state, kind, index) { + const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet), + editor = document.querySelector(`[data-quantity-line="${index}"]`); + if (!sheet?.rows[index] || !editor) throw Error("산출 줄을 고르세요."); + const [mark, label] = RESULT_NAMES[kind], + text = editor.value; + let a = editor.selectionStart ?? 0, + b = editor.selectionEnd ?? a; + if (a === b) { + while (a > 0 && /[^\s⟦⟧]/.test(text[a - 1])) a--; + while (b < text.length && /[^\s⟦⟧]/.test(text[b])) b++; + } + const picked = text.slice(a, b); + if (EstText.encode(text).includes(mark)) + throw Error(`한라인에 ${label}을두번이상 지정할수 없습니다.`); + if (sheet.rows[index].title) throw Error("제목표시에는 지정할수 없습니다."); + if (picked.length > 40) throw Error("선택한 범위가 너무 큽니다."); + if (!picked.trim()) + throw Error( + `${label}으로 지정할 문자를 드래그하여 선택하던지커서를 선택할위치로 옮겨 놓으세요.`, + ); + const token = `⟦${label}⟧`; + EstApp.change(() => { + sheet.rows[index].LineData = EstText.encode( + text.slice(0, a) + token + picked + token + text.slice(b), + ); + sheet.markerEdited = true; + }, false); + } + + // 공종명칭 입력: 목록 명칭·규격을 바꾸고 문서 머리에 「공백5 + 명칭(규격) + 공백5」 제목 줄(굵게 큰 글자)을 넣거나 바꿈 + function title(state) { + const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); + if (!sheet) throw Error("수량산출서를 고르세요."); + const head = sheet.rows[0]?.title ? sheet.rows[0] : null; + const name = prompt("명칭", sheet.Name ?? ""); + if (name == null) return; + const size = prompt("규격", sheet.Size ?? "") ?? ""; + EstApp.change(() => { + sheet.Name = name.trim(); + sheet.Size = size.trim(); + const text = ` ${name.trim()}${size.trim() ? `(${size.trim()})` : ""} `; + if (head) head.LineData = text; + else + sheet.rows.unshift({ LineData: text, markers: [], title: true, para: { align: "center" } }); + sheet.tableEdited = true; + }, false); + } + + function install() { + document.addEventListener("click", (event) => { + const a = event.target.closest("[data-ql-sheet]"); + if (!a) return; + event.preventDefault(); + const l = EstApp.state.qList; + l.code = a.dataset.qlSheet; + l.picked = new Set(); + open(EstApp.state); + }); + document.addEventListener("change", (event) => { + const t = event.target, + l = EstApp.state.qList; + if (!l) return; + if (t.dataset.qlItem !== undefined) { + const k = Number(t.dataset.qlItem); + if (t.checked) l.picked.add(k); + else l.picked.delete(k); + } else if (t.dataset.qlNamesize !== undefined) l.nameSize = t.checked; + else if (t.dataset.qlWord !== undefined) { + l.word = norm(t.value); + open(EstApp.state); + } + }); + } + if (typeof document !== "undefined" && document.addEventListener) install(); + + function handle(name, state, node) { + if (name === "quantity-list") open(state); + else if (name === "ql-search") { + state.qList.word = norm(document.querySelector("[data-ql-word]")?.value); + open(state); + } else if (name === "ql-all") { + state.qList.word = ""; + open(state); + } else if (name === "ql-copy") { + const l = state.qList, + sheet = state.project.quantitySheets.find((s) => s.Code === l.code), + items = EstQuantity.calculate(sheet).items.filter((_, k) => l.picked.has(k)); + if (!items.length) throw Error("복사할 수량 항목을 고르세요."); + state.mulClip = { code: sheet.Code, items, nameSize: l.nameSize }; + state.notice = `수량 ${items.length}개를 담았습니다. 내역서에서 수량붙여넣기(연결)를 누르세요.`; + EstApp.render(); + } else if (name === "mul-paste") paste(state); + else if (name === "quantity-name" || name === "quantity-size") + nameSize(state, name === "quantity-name" ? "name" : "size", Number(node.dataset.index)); + else if (name === "quantity-title") title(state); + else return false; + return true; + } + return { handle }; +})(); diff --git a/tools/est-test/native-quantity-sheets.js b/tools/est-test/native-quantity-sheets.js index 0e07d6441..ca8bc1b96 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) { @@ -274,7 +274,7 @@ const EstQuantitySheets = (() => { function excelView(sheet) { if (typeof EstExcelView === "undefined") return ""; const on = sheet.excel?.length && !sheet.excelOff; - return `

${on ? EstExcelView.render(sheet, EstApp.state) : ""}`; + return `${typeof EstQuantityTable !== "undefined" ? EstQuantityTable.banner(sheet) : ""}

${on ? EstExcelView.render(sheet, EstApp.state) : ""}`; } function excelMode(state) { const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); diff --git a/tools/est-test/native-quantity-table.js b/tools/est-test/native-quantity-table.js new file mode 100644 index 000000000..a04548625 --- /dev/null +++ b/tools/est-test/native-quantity-table.js @@ -0,0 +1,270 @@ +// 워드형 수량산출서 표 화면·편집 — 칸 고르기(Shift 로 범위), 칸 글·수식(=…) 고치기, 방향 셀 합(ActionAddRight·Left·Up·Down), +// 수량으로 지정·지정 해제(칸 수량 표지 → 수량 항목), 셀수직병합·셀분리(btnCellMergeVer·btnCellSplitV), 표 만들기(mnuTableCreate). +// 계산·표 자료는 native-quantity-wpt.cjs. 표를 고친 산출서는 원본 WPT 로 다시 쓰지 못하므로 ESTX 저장을 막는다(tableEdited). +const EstQuantityTable = (() => { + const W = () => EstQuantityWpt; + function sheetOf(state) { + return state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); + } + const PAGE = 45; // 웹 산출서 한 쪽 줄 수(원본은 편집기 쪽 배치) + function paraStyle(row) { + const p = row.para || {}; + return [ + p.align && `text-align:${p.align}`, + p.indent && `padding-left:${p.indent * 8}px`, + row.title && "font-weight:bold;font-size:20px;color:#800000", + ] + .filter(Boolean) + .join(";"); + } + // 테두리선(popFormatOutLine)·산출근거 양식(popFormatGungu): 화면에만 그림(원본도 글로 넣지 않음) + function banner(sheet) { + return sheet.gunguForm + ? `
공 종산 출 내 역수 량
` + : sheet.outline + ? '

테두리선 켬(인쇄 쪽 둘레)

' + : ""; + } + function html(sheet, index, state) { + const { esc } = EstApp; + if (sheet.rows[index].image) + return `산출서 그림`; + const table = sheet.rows[index].table, + sel = state.qtCell?.sheet === sheet.Code && state.qtCell.row === index ? state.qtCell : null; + W().recalc(table); + const inBox = (r, c) => + sel && + r >= Math.min(sel.r, sel.r2 ?? sel.r) && + r <= Math.max(sel.r, sel.r2 ?? sel.r) && + c >= Math.min(sel.c, sel.c2 ?? sel.c) && + c <= Math.max(sel.c, sel.c2 ?? sel.c); + const cur = sel ? table[sel.r]?.[sel.c] : null; + const tools = sel + ? `
${W().letters(sel.c)}${sel.r + 1} ${[ + ["right", "우측 합"], + ["left", "좌측 합"], + ["up", "위 합"], + ["down", "아래 합"], + ] + .map(([d, l]) => ``) + .join( + "", + )}
` + : ""; + const body = table + .map( + (row, r) => + `${row + .map((cell, c) => + cell.vmerge + ? "" + : `${esc(cell.text) || " "}`, + ) + .join("")}`, + ) + .join(""); + const border = sheet.rows[index].tableBorder ?? 1; + return `${tools}${body}
`; + } + + function edit(state, fn) { + const sheet = sheetOf(state), + sel = state.qtCell; + if (!sheet || !sel || sel.sheet !== sheet.Code) throw Error("표에서 칸을 고르세요."); + const table = sheet.rows[sel.row]?.table; + if (!table) throw Error("표에서 칸을 고르세요."); + EstApp.change(() => { + fn(table, sel, sheet); + W().recalc(table); + sheet.tableEdited = true; + }, false); + } + + function handle(name, state, node) { + if (name === "qt-sum") + edit(state, (t, s) => { + t[s.r][s.c].formula = W().sumFormula(t, s.r, s.c, node.dataset.dir); + }); + else if (name === "qt-mark") + edit(state, (t, s, sheet) => { + const cell = t[s.r][s.c]; + if (cell.mark) throw Error("이미 수량으로 지정된 칸입니다."); + const code = EstQuantity.nextItemCode(sheet); + cell.mark = code; + const name = t[s.r].find((c) => c.name)?.text ?? ""; + sheet.items.push({ + Code: code, + Name: name, + Size: t[s.r].find((c) => c.size)?.text ?? "", + GN: 0, + }); + }); + else if (name === "qt-free") + edit(state, (t, s, sheet) => { + const cell = t[s.r][s.c]; + if (!cell.mark) throw Error("수량으로 지정된 칸이 아닙니다."); + const item = sheet.items.find((i) => i.Code === cell.mark); + if (item?.TargetCode || item?.TargetKey) + throw Error("내역에 연결된 수량 항목입니다. 연결 내역코드를 먼저 비우세요."); + sheet.items = sheet.items.filter((i) => i.Code !== cell.mark); + delete cell.mark; + }); + else if (name === "qt-vmerge") + edit(state, (t, s) => + W().vmerge(t, Math.min(s.r, s.r2 ?? s.r), Math.max(s.r, s.r2 ?? s.r), s.c), + ); + else if (name === "qt-vsplit") edit(state, (t, s) => W().vsplit(t, s.r, s.c)); + else if (name === "qt-create") { + const sheet = sheetOf(state); + if (!sheet) throw Error("수량산출서를 고르세요."); + const rows = Number(prompt("줄 수", "3")), + cols = Number(prompt("칸 수", "5")); + if (!rows || !cols) return true; + const at = Number(node.dataset.index); + EstApp.change(() => { + sheet.rows.splice(Number.isInteger(at) ? at + 1 : sheet.rows.length, 0, { + LineData: "", + markers: [], + table: W().create(rows, cols), + }); + sheet.tableEdited = true; + }, false); + } else if (name === "qt-style") + edit(state, (t, s, sheet) => { + const w = Number(prompt("표 테두리 굵기(0~5)", String(sheet.rows[s.row].tableBorder ?? 1))); + if (!(w >= 0 && w <= 5)) throw Error("테두리 굵기는 0~5 입니다."); + sheet.rows[s.row].tableBorder = w; + }); + else if (name.startsWith("qt-page") || name === "qt-para" || name === "qt-image") + lineTool(name, state, Number(node.dataset.index)); + else if (name === "qt-outline" || name === "qt-gungu-form") { + const sheet = sheetOf(state); + if (!sheet) throw Error("수량산출서를 고르세요."); + const key = name === "qt-outline" ? "outline" : "gunguForm"; + EstApp.change(() => { + sheet[key] = !sheet[key]; + sheet.tableEdited = true; + }, false); + } else if (name === "qt-mul-load" || name === "qt-mul-take") mulLoad(name, state, node); + else return false; + return true; + } + + // 문단(btnsetPara·mnuFormatPara)·그림 넣기(btnJPGInput)·한페이지 삽입/삭제(popPageInsert·popPageDel) + function lineTool(name, state, index) { + const sheet = sheetOf(state); + if (!sheet?.rows?.[index]) throw Error("산출 줄을 고르세요."); + const change = (fn) => + EstApp.change(() => { + fn(); + sheet.tableEdited = true; + }, false); + if (name === "qt-para") { + const align = prompt("정렬(left·center·right)", sheet.rows[index].para?.align || "left"); + if (align == null) return; + if (!["left", "center", "right"].includes(align)) + 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 })); + } else if (name === "qt-image") { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = "image/*"; + picker.addEventListener("change", () => { + const file = picker.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => + change(() => + sheet.rows.splice(index + 1, 0, { LineData: "", markers: [], image: reader.result }), + ); + reader.readAsDataURL(file); + }); + picker.click(); + } else if (name === "qt-page-insert") + change(() => + sheet.rows.splice( + index + 1, + 0, + ...Array.from({ length: PAGE }, () => ({ LineData: " ", markers: [] })), + ), + ); + else if (name === "qt-page-delete") { + const start = Math.floor(index / PAGE) * PAGE, + rows = sheet.rows.slice(start, start + PAGE); + if (rows.some((r) => r.markers?.length || r.table?.some((x) => x.some((c) => c.mark)))) + throw Error("수량으로 지정된 줄이 있는 쪽은 지울 수 없습니다. 먼저 지정 해제하세요."); + if (rows.some((r) => r.image) && !confirm("그림이 있는 쪽입니다. 지우시겠습니까?")) return; + if (!confirm(`${start + 1}~${start + rows.length}줄(한 쪽)을 지우시겠습니까?`)) return; + change(() => sheet.rows.splice(start, PAGE)); + } + } + + // 수량 불러오기(btnMulLoad): Main Data(원본 main.estx) 수량산출서 목록에서 골라 새 산출서로(새 코드, 본문 그대로) + async function mulLoad(name, state, node) { + const { esc } = EstApp; + if (name === "qt-mul-load") { + state.mulLoad ||= (await EstApp.api("/api/main-data?part=quantity")).quantitySheets; + EstApp.showDialog( + "수량 불러오기", + `
${state.mulLoad.map((s, i) => `
${esc(s.Code)} ${esc(s.Name)}${s.excel ? " (엑셀형)" : ""}
`).join("")}
`, + ); + return; + } + const src = state.mulLoad[Number(node.dataset.i)]; + EstApp.change((project) => { + const copy = structuredClone(src); + copy.Code = `Q${EstApp.uid()}`; + for (const item of copy.items || []) delete item.TargetCode; + (project.quantitySheets ||= []).push(copy); + state.quantitySheet = copy.Code; + }, false); + EstApp.byId("dialog").close(); + state.notice = `「${src.Name}」 산출서를 불러왔습니다.`; + } + + function install() { + document.addEventListener("click", (event) => { + const td = event.target.closest("[data-qt]"); + if (!td) return; + const [row, r, c] = td.dataset.qt.split(",").map(Number), + state = EstApp.state, + old = state.qtCell; + state.qtCell = + event.shiftKey && old && old.row === row && old.sheet === state.quantitySheet + ? { ...old, r2: r, c2: c } + : { sheet: state.quantitySheet, row, r, c }; + EstApp.render(); + }); + document.addEventListener("change", (event) => { + if (event.target.dataset.qtBar === undefined) return; + const v = event.target.value, + state = EstApp.state; + try { + edit(state, (t, s) => { + const cell = t[s.r][s.c]; + if (v.trim().startsWith("=")) cell.formula = v.trim().slice(1); + else { + delete cell.formula; + cell.text = v; + } + }); + } catch (error) { + state.notice = error.message; + EstApp.render(); + } + }); + } + if (typeof document !== "undefined" && document.addEventListener) install(); + return { html, banner, paraStyle, handle }; +})(); diff --git a/tools/est-test/native-quantity-wpt.cjs b/tools/est-test/native-quantity-wpt.cjs new file mode 100644 index 000000000..7272783c6 --- /dev/null +++ b/tools/est-test/native-quantity-wpt.cjs @@ -0,0 +1,190 @@ +// 워드형 수량산출서(TFRMMULWORD, WPTools 문서) 읽기와 표 — spec_word. +// 문단(
) 은 산출 줄, 표() 는 한 줄 { table: [[칸]] } 로 둔다. 칸 = { text, mark(수량 표지 이름 r·L·G), name·size(품명·규격 표지), +// formula(=SUM(B3:B5) — 원본 PAR_COMMAND), vmerge(세로 병합으로 가려짐) }. +// 방향 셀 합(ActionAddRight·Left·Up·Down 0xd3c5f0): 옆 칸부터 밖으로 — 빈칸·숫자 칸은 범위를 늘리고 글자 칸은 건너뛰며, +// 품명·규격 표지 칸이나 표 끝에서 멈춤. 칸에 SUM 수식을 두고 바로 계산(값 글자 = 1,234.5 꼴, 소수 9자리까지, 끝수 처리 없음). +// 계산 때마다 수식 칸을 왼쪽 위부터 한 번씩 다시 셈. 수량 표지 칸의 수는 그 항목 수량. +const EstQuantityWpt = (() => { + function entities(text) { + return String(text) + .replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { + const code = n[0].toLowerCase() === "x" ? parseInt(n.slice(1), 16) : Number(n); + return code <= 0x10ffff ? String.fromCodePoint(code) : ""; + }) + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/ /g, " ") + .replace(/&/g, "&"); + } + const tag = (name) => + /^[rLG]\d+$/.test(name) + ? "\x12" + : /^[Fa]\d+$/.test(name) + ? "\x16" + : /^N\d+$/.test(name) + ? "\x0e" + : /^S\d+$/.test(name) + ? "\x13" + : ""; + + function fromWpt(code, name, text, items, options) { + const tags = new Map(), + rows = []; + const superscripts = new Set( + [...text.matchAll(/]*\bnr=(\d+)[^>]*>/gi)] + .filter((m) => (Number(m[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0) + .map((m) => m[1]), + ); + for (const match of text.matchAll(/]*\bTag=(\d+)[^>]*>/gi)) { + const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1]; + if (name) tags.set(match[1], name); + } + // 문단·칸 글 → 표지 글자(\x12 결과·\x16 누계·\x0e 품명·\x13 규격) + function convert(html, cell = false) { + // 문단의 G 표지는 원본처럼 표지로 보지 않음(표 칸에서만 수량) + const tagOf = (n) => (!cell && /^G/.test(n) ? "" : tag(n)); + const markers = [], + totals = [], + names = []; + let raised = false; + let body = html.replace(//gi, (_, id) => { + const next = superscripts.has(id), + value = next && !raised ? "^(" : !next && raised ? ")" : ""; + raised = next; + return value; + }); + if (raised) body += ")"; + body = body + .replace(/]*\bTag=(\d+)[^>]*>/gi, (_, id) => { + const name = tags.get(id) || ""; + names.push(name); + if (tagOf(name) === "\x12" && !/^G/.test(name)) markers.push(name); + if (tagOf(name) === "\x16") totals.push(name); + return tagOf(name); + }) + .replace(/]*\bTag=(\d+)[^>]*>/gi, (_, id) => tagOf(tags.get(id) || "")); + return { + body: entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, "")), + markers, + totals, + names, + }; + } + for (const match of text.matchAll( + /|]*>([\s\S]*?)<\/div>/gi, + )) { + if (/^
]*>([\s\S]*?)<\/tr>/gi)].map((tr) => + [...tr[1].matchAll(/]*>([\s\S]*?)<\/td>/gi)].map((td) => { + const c = convert(td[1], true), + mark = c.names.find((n) => tag(n) === "\x12"); + return { + text: c.body.replace(/[\x0e\x12\x13\x16]/g, "").trim(), + ...(mark && { mark }), + ...(c.names.some((n) => /^N\d+$/.test(n)) && { name: true }), + ...(c.names.some((n) => /^S\d+$/.test(n)) && { size: true }), + }; + }), + ); + if (table.length) rows.push({ LineData: "", markers: [], table }); + continue; + } + const c = convert(match[1]); + if (c.body.trim()) + rows.push({ + LineData: c.body, + markers: c.markers, + ...(c.totals.length && { totals: c.totals }), + }); + } + return { Code: code, Name: name, rows, items, options }; + } + + // ── 표 계산 ── + const L = (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 numeric = (t) => /^[+-]?[\d,]*\.?\d+$/.test(String(t).trim()); + const value = (t) => (numeric(t) ? Number(String(t).replaceAll(",", "")) : 0); + const show = (v) => v.toLocaleString("en-US", { maximumFractionDigits: 9 }); + function recalc(table) { + const errors = []; + table.forEach((row) => + row.forEach((cell) => { + if (!cell.formula) return; + try { + const X = + typeof EstExcelSheet !== "undefined" + ? EstExcelSheet + : require("./native-excel-sheet.cjs"); + const v = X.evaluate(cell.formula, (r, c) => value(table[r]?.[c]?.text ?? "")); + cell.text = show(typeof v === "number" ? v : Number(v) || 0); + } catch (error) { + errors.push(error.message); + } + }), + ); + return errors; + } + // 항목 수량: 수량 표지 칸의 수 + function values(sheet) { + const out = new Map(); + for (const row of sheet.rows || []) { + if (!row.table) continue; + recalc(row.table); + for (const r of row.table) + for (const c of r) if (c.mark && !c.vmerge) out.set(c.mark, value(c.text)); + } + return out; + } + function sumFormula(table, r, c, dir) { + const [dr, dc] = { right: [0, -1], left: [0, 1], up: [1, 0], down: [-1, 0] }[dir] || []; + if (dr == null) throw Error("방향을 고르세요."); + // 방향 = 결과 칸이 있는 쪽(원본 Ctrl+← 는 오른쪽 칸들을 더함) + let rr = r + dr, + cc = c + dc, + last = null; + const first = [rr, cc]; + while (table[rr]?.[cc]) { + const cell = table[rr][cc]; + if (cell.name || cell.size) break; + if (!cell.text.trim() || numeric(cell.text)) last = [rr, cc]; + rr += dr; + cc += dc; + } + if (!table[first[0]]?.[first[1]]) throw Error("더할 셀이 없습니다."); + if (!last) last = dir === "right" ? [r, 0] : first; + const a = [Math.min(first[0], last[0]), Math.min(first[1], last[1])], + b = [Math.max(first[0], last[0]), Math.max(first[1], last[1])]; + return `SUM(${L(a[1])}${a[0] + 1}:${L(b[1])}${b[0] + 1})`; + } + function vmerge(table, r1, r2, c) { + if (r2 <= r1) throw Error("세로로 여러 칸을 고르세요."); + const top = table[r1][c]; + for (let r = r1 + 1; r <= r2; r++) { + const cell = table[r][c]; + if (cell.text.trim()) top.text = `${top.text} ${cell.text}`.trim(); + cell.text = ""; + cell.vmerge = true; + } + top.vtop = r2 - r1 + 1; + } + function vsplit(table, r, c) { + const top = table[r][c]; + for (let i = r + 1; i < r + (top.vtop || 1); i++) if (table[i]?.[c]) delete table[i][c].vmerge; + delete top.vtop; + } + function create(rows, cols) { + if (!(rows >= 1 && rows <= 100 && cols >= 1 && cols <= 30)) + throw Error("줄 1~100, 칸 1~30 입니다."); + return Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ text: "" }))); + } + + return { entities, fromWpt, recalc, values, sumFormula, vmerge, vsplit, create, letters: L }; +})(); +if (typeof module !== "undefined" && module.exports) module.exports = EstQuantityWpt; diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index f0ffd7ccf..16bb77624 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -13,52 +13,10 @@ const EstQuantity = (() => { .replace(/ /g, " ") .replace(/&/g, "&"); } - function fromWpt(code, name, text, items, options) { - const tags = new Map(), - rows = []; - const superscripts = new Set( - [...text.matchAll(/]*\bnr=(\d+)[^>]*>/gi)] - .filter((m) => (Number(m[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0) - .map((m) => m[1]), - ); - for (const match of text.matchAll(/]*\bTag=(\d+)[^>]*>/gi)) { - const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1]; - if (name) tags.set(match[1], name); - } - for (const match of text.matchAll(/]*>([\s\S]*?)<\/div>/gi)) { - const markers = [], - totals = []; - let raised = false; - const tag = (name) => - /^r\d+$/.test(name) || /^L\d+$/.test(name) - ? "\x12" - : /^[Fa]\d+$/.test(name) - ? "\x16" - : /^N\d+$/.test(name) - ? "\x0e" - : /^S\d+$/.test(name) - ? "\x13" - : ""; - let body = match[1].replace(//gi, (_, id) => { - const next = superscripts.has(id), - value = next && !raised ? "^(" : !next && raised ? ")" : ""; - raised = next; - return value; - }); - if (raised) body += ")"; - body = body - .replace(/]*\bTag=(\d+)[^>]*>/gi, (_, id) => { - const name = tags.get(id) || ""; - if (tag(name) === "\x12") markers.push(name); - if (tag(name) === "\x16") totals.push(name); - return tag(name); - }) - .replace(/]*\bTag=(\d+)[^>]*>/gi, (_, id) => tag(tags.get(id) || "")); - body = entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, "")); - if (body.trim()) rows.push({ LineData: body, markers, ...(totals.length && { totals }) }); - } - return { Code: code, Name: name, rows, items, options }; - } + // 워드형 문서 읽기·표는 native-quantity-wpt.cjs + const wpt = () => + typeof EstQuantityWpt !== "undefined" ? EstQuantityWpt : require("./native-quantity-wpt.cjs"); + const fromWpt = (...args) => wpt().fromWpt(...args); function calculate(sheet) { const markerErrors = sheet.rows.flatMap((row, index) => { const count = [...String(row.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)].length; @@ -82,7 +40,10 @@ const EstQuantity = (() => { }; return { ...row, - LineData: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴|\x16[^\x16]*\x16/g, ""), + LineData: + row.table || row.image + ? "" + : row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴|\x16[^\x16]*\x16/g, ""), Index: 0, SCheck: 0, LinkIndex: 0, @@ -118,13 +79,18 @@ const EstQuantity = (() => { }); if (typeof EstQuantityTotal !== "undefined") EstQuantityTotal.apply(sheet, result.rows.slice(1)); + const tableValues = sheet.rows.some((row) => row.table) ? wpt().values(sheet) : null; const cellValues = sheet.excel && typeof EstExcelSheet !== "undefined" ? EstExcelSheet.itemValues(sheet) : null; return { rows: result.rows.slice(1), items: sheet.items.map((item) => ({ ...item, - GN: values.get(item.Code) ?? cellValues?.get(item.Code) ?? item.GN, + GN: + values.get(item.Code) ?? + tableValues?.get(item.Code) ?? + cellValues?.get(item.Code) ?? + item.GN, })), checks, errors: [...result.errors, ...markerErrors], @@ -325,7 +291,7 @@ const EstQuantity = (() => { { esc, number, byId } = EstApp; byId("work-content").innerHTML = selector(state) + - `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}
${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; + `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}
${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}${(sheet.rows[index].table || sheet.rows[index].image) && typeof EstQuantityTable !== "undefined" ? EstQuantityTable.html(sheet, index, state) : ``}
`; } // 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수. function resultFormat(code) { diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 248e3fb1a..e5be16fe4 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -28,6 +28,7 @@ const scripts = [ "native-text.js", "native-excel-sheet.cjs", "native-quantity-total.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-jungsang.js", @@ -94,6 +95,9 @@ const allowed = new Set([ "native-new-estimate.js", "native-book.js", "native-data-update.js", + "native-quantity-table.js", + "native-quantity-list.js", + "native-actions-more.js", "native-wonga-tools.js", "native-estimate-print.js", "native-xml-check.js", @@ -271,9 +275,17 @@ const server = http.createServer(async (req, res) => { mainData = { name: "Main", tables: Object.fromEntries(keys.map((k) => [k, project.tables[k] || []])), + quantitySheets: project.quantitySheets || [], }; } - reply(res, 200, mainData); + // 수량 불러오기(TFRMMULLANG btnMulLoad)는 수량산출서만 + reply( + res, + 200, + url.searchParams.get("part") === "quantity" + ? { quantitySheets: mainData.quantitySheets } + : { name: mainData.name, tables: mainData.tables }, + ); return; } if (req.method === "GET" && url.pathname.startsWith("/api/book")) { From e452a4b46ba587ec68a9f9db48dac7c46836b366 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:39:09 +0900 Subject: [PATCH 19/32] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC808):=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=20=ED=95=AD=EB=AA=A9=EB=B3=84=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=C2=B7=20A=C2=B7B=C2=B7C=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=E2=80=94=20=EC=A1=B0=EC=82=AC=20804=206=20=EC=A0=88(=EC=B0=A8?= =?UTF-8?q?=EB=A1=80=20=C2=B7=20=EB=8D=B0=EC=8A=A4=ED=81=AC=ED=83=91=20?= =?UTF-8?q?=EC=98=9B=20=EC=BD=94=EB=93=9C=20=EC=A3=BC=EC=9D=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/_조사_804_저장소속도.md | 7 +++++++ docs/raw/요구_1104/사용자요구_1104.md | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/docs/raw/요구_1104/_조사_804_저장소속도.md b/docs/raw/요구_1104/_조사_804_저장소속도.md index 1a7102248..66dfcce43 100644 --- a/docs/raw/요구_1104/_조사_804_저장소속도.md +++ b/docs/raw/요구_1104/_조사_804_저장소속도.md @@ -54,3 +54,10 @@ - 사본에 든 값 = 177 줄: 기계 138(두 계산식의 「기계」 고르기 후보 전부 — GC000267 9 대 + GC000269 129 대 · 14.6 만 자) · 유가 36(휘발유 · 경유 × 지역 18) · 직종 3 → 173 줄(약 98 %)은 이 단가산출이 안 쓰는 값. - 까닭 = 사본 모으기가 「식이 고를 수 있는 후보 전부」 를 닫힌 묶음으로 박음(옵션을 바꿔도 사본 안에서 셈하려는 뜻). - 안 C ② 가 이것 — 실제로 읽은 줄만 박고 · 옵션 · 기계를 바꿔 새로 필요한 줄은 바꾸는 때의 M01 값으로 가져와 저장 때 박음(822-1 이 넣은 「읽은 줄을 저장 때 박기」 길 재사용). + +## 6. 사용자 결정(요구 808) · 진행 차례 + +- 기계 = 항목 하나가 파일 하나(M01 기계 · 내 기계) · 자재는 한 파일 그대로 · A · B · C 진행. +- C 는 ②(실제로 읽은 줄만)부터 — 177 줄 → 4 줄이면 ①(두 번 든 것) · ③(공유 저장)의 남는 효과가 작고 · ② 는 옛 코드가 읽어도 무해(없는 줄은 지금 M01 값으로 읽음) · ① · ③ 은 파일 꼴이 바뀌어 **데스크탑(임시 배포 · 옛 코드)이 같은 storage 를 읽는 동안**은 깨질 수 있어 전역 깃 싱크와 묶을 때만. +- 바꿔 끼운 기계 · 옵션의 값 = 바꾸는 때의 M01 값(정함 · 브레인). +- 걸음: 826-1 층 목록 요약(sub2 · 바로) → 826-2 M01 기계 항목별 파일 · 내 기계 항목별 파일 · M01 저장 길 줄이기(sub1 · 822-2b 뒤) → 826-3 사본 = 읽은 줄만 · SK 저장 길 줄이기(sub3 · 823-1 뒤). diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index e4db2e3e0..4ae390500 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -1070,3 +1070,9 @@ M02에서 내 기계와 내 품목은 개인관리 컨테이너로 병합함. ## 요구 807 (11-12 · 사용자 · A · B · C 는 잠시 대기 · 물음 먼저 — 단가산출의 항목 · 기계의 항목이 하나의 파일인지) ABC는 잠시대기, 내 질문에 대한 대답부터 단가산출의 항목, 기계의 항목이 하나의 파일이야? + +## 요구 808 (11-12 · 사용자 · 1 기계 하나의 항목은 하나의 파일로 분리하는 게 맞아 보임 · 2 자재는 어쩔 수 없음 · 3 A · B · C 진행) + +1. 기계 하나의 항목은 하나의 파일로 분리하는게 맞아보임. +2. 자재는 어절수 없음. +3. ABC 진행 From c05916369ce670616293c92f9ec048923a3b11e3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:40:42 +0900 Subject: [PATCH 20/32] =?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 21/32] =?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 22/32] =?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 23/32] =?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 24/32] =?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 25/32] =?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 26/32] =?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 30/32] =?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 31/32] =?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 32/32] 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 열 배치)는 웹 격자와 호환되지 않아 하지 않음.