From 26a18a1709832d2a09b45478f26c8762da9dae6a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 19:24:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=EC=84=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=96=91?= =?UTF-8?q?=EC=8B=9D=20=EB=93=B1=EB=A1=9D=C2=B7=EB=AA=A9=EB=A1=9D=C2=B7?= =?UTF-8?q?=EC=97=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 보관함 data/forms.json 에 원가계산서 양식 등록·삭제·목록 - 양식 열기·파일 내려받기/불러오기, 금액 제외하고 열 때 다시 계산 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_forms_library.py | 56 +++++++++++ tools/est-test/PLAN.md | 3 + tools/est-test/README.md | 2 + tools/est-test/forms-store.cjs | 54 ++++++++++ tools/est-test/index.html | 2 + tools/est-test/native-business-actions.js | 3 +- tools/est-test/native-forms-library.js | 111 +++++++++++++++++++++ tools/est-test/server.cjs | 12 +++ 8 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 resources/tester/test_est_forms_library.py create mode 100644 tools/est-test/forms-store.cjs create mode 100644 tools/est-test/native-forms-library.js diff --git a/resources/tester/test_est_forms_library.py b/resources/tester/test_est_forms_library.py new file mode 100644 index 000000000..1a0cb73a4 --- /dev/null +++ b/resources/tester/test_est_forms_library.py @@ -0,0 +1,56 @@ +"""EST 테스트 앱 사용자 양식 보관함(원본 양식목록 등록·삭제·열기).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def node(script: str, *args: str): + out = subprocess.check_output(["node", "-e", script, *args], cwd=ROOT) + return json.loads(out.decode("utf-8")) + + +def test_store_saves_replaces_lists_and_deletes(tmp_path): + result = node( + """ + const f = require('./tools/est-test/forms-store.cjs'), dir = process.argv[1]; + const errors = []; + const t = (fn) => { try { fn(); errors.push('ok'); } catch (e) { errors.push(e.message.slice(0, 6)); } }; + f.save(dir, { kind: 'wonga', name: '회사 기본', data: [{ Name: 'a' }] }); + f.save(dir, { kind: 'wonga', name: '회사 기본', data: [{ Name: 'b' }] }); + f.save(dir, { kind: 'document', name: '표지', form: 1, data: 'x' }); + const listed = f.read(dir).map((x) => [x.kind, x.name, x.data]); + t(() => f.save(dir, { kind: 'bad', name: 'x', data: 1 })); + t(() => f.save(dir, { kind: 'wonga', name: ' ', data: 1 })); + t(() => f.save(dir, { kind: 'wonga', name: 'x' })); + t(() => f.remove(dir, 'wonga', '없는 양식')); + f.remove(dir, 'wonga', '회사 기본'); + process.stdout.write(JSON.stringify({ listed, errors, left: f.read(dir).map((x) => x.name) })); + """, + str(tmp_path), + ) + assert result["listed"] == [["wonga", "회사 기본", [{"Name": "b"}]], ["document", "표지", "x"]] + assert [e[:5] for e in result["errors"]] == ["양식 종류", "양식 이름", "양식 내용", "지울 양식"] + assert result["left"] == ["표지"] + + +def test_template_drops_amounts_and_rejects_bad_rows(): + result = node( + """ + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({ structuredClone }); + vm.runInContext(fs.readFileSync('tools/est-test/native-forms-library.js', 'utf8'), ctx); + const out = vm.runInContext(`(() => { + const rows = EstFormsLibrary.strip([{ Gubun: 'N', Name: '직접 노무비', Cost: 100, VSusic: 'N' }]); + const errors = []; + for (const bad of [[], null, [1]]) { try { EstFormsLibrary.validRows(bad); } catch (e) { errors.push(e.message.slice(0, 9)); } } + return { rows, errors, other: EstFormsLibrary.handle('other', {}, null) }; + })()`, ctx); + process.stdout.write(JSON.stringify(out)); + """ + ) + assert result["rows"] == [{"Gubun": "N", "Name": "직접 노무비", "VSusic": "N"}] + assert len(result["errors"]) == 3 + assert result["other"] is False diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 84f2a61ba..038c2fa5a 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -454,6 +454,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 근거자료낙찰 탭(공종별 낙찰율 목록), 「연금·건강보험료·안전관리비 고정」. - [x] 예정공정표 눈금 단위(원본 TFRMSCHEDULE mnu1Day·5Day·10Day·15Day·20Day·1Week·1Month) — 기간 칸 머리에 눈금 날짜, 막대 뒤 눈금선, 60개 초과 시 앞 60개. - 검증완료: `test_est_schedule_scale.py`(1주·1개월·10일 눈금 위치·날짜, 60개 자르기, 빈 기간, 잘못된 단위 거부) 통과. ORCA 브라우저에서 1개월 → 1주 전환 시 머리 날짜 03-01·03-08… 11개, 글자 간격 47px 이상 확인 후 샘플 복구. + - [x] 원가계산서 사용자 양식(원본 TFRMWONGABASE popupSaveAs·popupFormats·popupLoadFrom, TFRMFORMATFILELIST 등록·삭제·열기·파일) — 서버 보관함 `data/forms.json`(`forms-store.cjs`, 종류 wonga·document), 등록·목록·열기·삭제·파일 내려받기/불러오기. 금액 제외, 열 때 도급액 고정 해제(저장 금액을 쓰는 옵션이라 금액 없는 양식에서 0이 됨을 확인). + - 검증완료: `test_est_forms_library.py` 2개(등록·같은 이름 교체·목록·삭제·잘못된 종류/이름/내용 거부, 금액 제외·잘못된 행 거부·다른 동작 무시) 통과. ORCA 브라우저에서 등록 → 목록 31행 → 원가 표를 3행으로 줄인 뒤 열기 → 31행 복구·도급액 79,603,324(고정 해제) → 삭제·보관함 비움, 샘플 복구. + - 남음: 기본양식으로 저장(새 공사 기본값), 갑지·표지 문서 양식. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 0d0e74cea..3ccc9a25f 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +`원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다. + 예정공정표의 `눈금`은 원본처럼 1일·5일·10일·15일·20일·1주·1개월 단위로 기간 칸 위에 날짜를 적고 막대 뒤에 세로 눈금선을 그립니다. 눈금이 60개를 넘으면 앞 60개만 그립니다. `편집 → 낙찰률 적용`은 원본 낙찰율 창처럼 전체낙찰율(한 비율) 또는 비목별낙찰율을 고르고, 전체 줄·선택한 줄·선택한 공종(하위 줄 모두)에 적용합니다. 이미 낙찰이 적용된 줄은 제외할 수 있고, `당초단가(100%) 복원`으로 되돌립니다. diff --git a/tools/est-test/forms-store.cjs b/tools/est-test/forms-store.cjs new file mode 100644 index 000000000..3b8ddd43e --- /dev/null +++ b/tools/est-test/forms-store.cjs @@ -0,0 +1,54 @@ +// 원본 양식목록(TFRMFORMATFILELIST: 등록·삭제·열기)의 사용자 양식 보관함 — 이 앱 data/forms.json 한 파일. +// 종류(kind)별 이름 목록. 같은 종류·이름이면 바꿔 쓴다. +const fs = require("node:fs"); +const path = require("node:path"); + +const kinds = new Set(["wonga", "document"]); +const LIMIT = 2 * 1024 * 1024; + +function file(directory) { + return path.join(directory, "forms.json"); +} +function read(directory) { + const target = file(directory); + if (!fs.existsSync(target)) return []; + const list = JSON.parse(fs.readFileSync(target, "utf8")); + return Array.isArray(list) ? list : []; +} +function write(directory, list) { + fs.mkdirSync(directory, { recursive: true }); + const target = file(directory), + temporary = `${target}.tmp`; + fs.writeFileSync(temporary, JSON.stringify(list), "utf8"); + fs.renameSync(temporary, target); +} +function check(kind, name) { + if (!kinds.has(kind)) throw Error("양식 종류가 올바르지 않습니다."); + if (typeof name !== "string" || !name.trim() || name.length > 100) + throw Error("양식 이름은 1~100자입니다."); +} +function save(directory, entry) { + check(entry?.kind, entry?.name); + if (entry.data == null) throw Error("양식 내용이 없습니다."); + if (JSON.stringify(entry.data).length > LIMIT) throw Error("양식은 2MB 이하만 등록합니다."); + const list = read(directory).filter((f) => !(f.kind === entry.kind && f.name === entry.name)); + list.push({ + kind: entry.kind, + name: entry.name.trim(), + form: entry.form ?? null, + data: entry.data, + saved: new Date().toISOString(), + }); + write(directory, list); + return list; +} +function remove(directory, kind, name) { + check(kind, name); + const list = read(directory), + next = list.filter((f) => !(f.kind === kind && f.name === name)); + if (next.length === list.length) throw Error("지울 양식이 없습니다."); + write(directory, next); + return next; +} + +module.exports = { read, save, remove }; diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 8d914406d..a6f077424 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -44,6 +44,7 @@ + @@ -150,6 +151,7 @@ > +
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 16cb25921..3046a4050 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -605,7 +605,8 @@ const EstBusinessActions = (() => { (typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) || (typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) || (typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) || - (typeof EstBid !== "undefined" && EstBid.handle(name, state)) + (typeof EstBid !== "undefined" && EstBid.handle(name, state)) || + (typeof EstFormsLibrary !== "undefined" && EstFormsLibrary.handle(name, state, node)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-forms-library.js b/tools/est-test/native-forms-library.js new file mode 100644 index 000000000..f5b14a3e7 --- /dev/null +++ b/tools/est-test/native-forms-library.js @@ -0,0 +1,111 @@ +// 원본 원가계산서 양식(TFRMWONGABASE popupSaveAs·popupFormats·popupLoadFrom)과 양식목록(TFRMFORMATFILELIST) +// — 현재 원가 표를 이름 붙여 서버 보관함(data/forms.json)에 등록하고, 목록에서 열기·삭제, 파일로 내려받기·불러오기. +const EstFormsLibrary = (() => { + // 원가 표 양식으로 남길 필드 — 금액(Cost)은 계산 결과라 남기지 않는다. + function strip(rows) { + return rows.map((row) => { + const { Cost, ...rest } = row; + return rest; + }); + } + function validRows(rows) { + if (!Array.isArray(rows) || !rows.length) throw Error("원가계산서 양식에 행이 없습니다."); + if (rows.some((row) => !row || typeof row !== "object" || Array.isArray(row))) + throw Error("원가계산서 양식 행이 올바르지 않습니다."); + return rows; + } + + async function list() { + return (await EstApp.api("/api/forms")).filter((f) => f.kind === "wonga"); + } + async function show(state) { + const { esc } = EstApp, + forms = await list(); + EstApp.showDialog( + "원가계산서 양식 목록", + `

현재 원가계산서의 항목·수식·요율을 양식으로 등록하고, 다른 공사에서 열어 씁니다. 금액은 열 때 다시 계산합니다.

${forms.map((f) => ``).join("") || ''}
양식 이름행등록작업
${esc(f.name)}${f.data.length}${esc(f.saved.slice(0, 10))}
등록된 양식이 없습니다.
`, + ); + state.formsLibrary = forms; + } + function apply(state, rows, name) { + validRows(rows); + if (!confirm(`현재 원가계산서를 「${name}」 양식으로 바꾸시겠습니까?`)) return; + EstApp.byId("dialog").close(); + // 도급액 고정은 저장된 금액을 그대로 쓰므로 금액 없는 양식에서는 풀어야 다시 계산된다(기초 뼈대와 같음). + const fixed = state.project.options.TotalFix; + EstApp.change((project) => { + project.options.TotalFix = false; + project.tables.Wonga = structuredClone(rows); + }); + state.notice = `원가계산서를 「${name}」 양식으로 바꿨습니다.${fixed ? " 도급액 고정은 풀었으니 필요하면 다시 맞추세요." : ""}`; + } + + async function run(name, state, node) { + if (name === "wonga-forms") await show(state); + else if (name === "wonga-form-register") { + const title = prompt("양식 이름", state.project.name || "원가계산서"); + if (!title) return; + const data = strip(validRows(state.project.tables.Wonga)); + await EstApp.api("/api/forms", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "wonga", name: title, data }), + }); + await show(state); + } else if (name === "wonga-form-open") { + const form = state.formsLibrary?.find((f) => f.name === node.dataset.name); + if (!form) throw Error("양식을 찾을 수 없습니다. 목록을 다시 여세요."); + apply(state, form.data, form.name); + } else if (name === "wonga-form-delete") { + if (!confirm(`「${node.dataset.name}」 양식을 지우시겠습니까?`)) return; + await EstApp.api("/api/forms/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind: "wonga", name: node.dataset.name }), + }); + await show(state); + } else if (name === "wonga-form-export") { + const data = strip(validRows(state.project.tables.Wonga)); + EstApp.download( + `${state.project.name || "원가계산서"}.wonga.json`, + JSON.stringify({ kind: "est-web-wonga-1", rows: data }, null, 2), + ); + } else if (name === "wonga-form-import") { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + input.addEventListener("change", async () => { + try { + const file = input.files[0]; + if (!file) return; + const parsed = JSON.parse(await file.text()); + if (parsed?.kind !== "est-web-wonga-1") throw Error("원가계산서 양식 파일이 아닙니다."); + apply(state, parsed.rows, file.name); + } catch (error) { + state.notice = error.message; + } + EstApp.render(); + }); + input.click(); + } + } + const names = new Set([ + "wonga-forms", + "wonga-form-register", + "wonga-form-open", + "wonga-form-delete", + "wonga-form-export", + "wonga-form-import", + ]); + // 동작 사슬은 동기 — 이름만 먼저 가리고 서버 요청은 뒤에서 돌린다. + 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 { strip, validRows, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index a82541fe8..aeb685f74 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -9,6 +9,7 @@ const exchange = require("./exchange.cjs"); const { exportEstx } = require("./export-estx.cjs"); const jdl = require("./jdl.cjs"); const { inspectAccom } = require("./inspect-accom.cjs"); +const forms = require("./forms-store.cjs"); const base = __dirname, port = Number(process.env.EST_TEST_PORT || 5175), host = process.env.EST_TEST_HOST || "100.65.141.15"; @@ -64,6 +65,7 @@ const allowed = new Set([ "native-excel-map.js", "native-transfer.js", "native-bid.js", + "native-forms-library.js", "native-issues.js", "native-attachments.js", "native-standard.js", @@ -152,6 +154,16 @@ const server = http.createServer(async (req, res) => { } return; } + if (url.pathname === "/api/forms" && ["GET", "POST"].includes(req.method)) { + const input = req.method === "POST" ? JSON.parse((await body(req)).toString("utf8")) : null; + reply(res, 200, input ? forms.save(dataDirectory, input) : forms.read(dataDirectory)); + return; + } + if (req.method === "POST" && url.pathname === "/api/forms/delete") { + const input = JSON.parse((await body(req)).toString("utf8")); + reply(res, 200, forms.remove(dataDirectory, input.kind, input.name)); + return; + } if (req.method === "GET" && url.pathname === "/api/projects") { reply(res, 200, list()); return;