feat(est-test): 원가계산서 사용자 양식 등록·목록·열기

- 서버 보관함 data/forms.json 에 원가계산서 양식 등록·삭제·목록
- 양식 열기·파일 내려받기/불러오기, 금액 제외하고 열 때 다시 계산

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 19:24:21 +09:00
co-authored by Claude Opus 5.5
parent d998772176
commit 26a18a1709
8 changed files with 242 additions and 1 deletions
@@ -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
+3
View File
@@ -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. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
+2
View File
@@ -22,6 +22,8 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
`원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다.
예정공정표의 `눈금`은 원본처럼 1일·5일·10일·15일·20일·1주·1개월 단위로 기간 칸 위에 날짜를 적고 막대 뒤에 세로 눈금선을 그립니다. 눈금이 60개를 넘으면 앞 60개만 그립니다.
`편집 → 낙찰률 적용`은 원본 낙찰율 창처럼 전체낙찰율(한 비율) 또는 비목별낙찰율을 고르고, 전체 줄·선택한 줄·선택한 공종(하위 줄 모두)에 적용합니다. 이미 낙찰이 적용된 줄은 제외할 수 있고, `당초단가(100%) 복원`으로 되돌립니다.
+54
View File
@@ -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 };
+2
View File
@@ -44,6 +44,7 @@
<script defer src="native-excel-map.js"></script>
<script defer src="native-transfer.js"></script>
<script defer src="native-bid.js"></script>
<script defer src="native-forms-library.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -150,6 +151,7 @@
><button data-action="template">원가 서식 선택</button
><button data-action="settings">자릿수·도급액 고정</button
><button data-action="reset-wonga">원가계산서 초기화</button>
<button data-action="wonga-forms">원가계산서 양식 목록·등록</button>
</div>
</details>
<details>
+2 -1
View File
@@ -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") {
+111
View File
@@ -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(
"원가계산서 양식 목록",
`<p>현재 원가계산서의 항목·수식·요율을 양식으로 등록하고, 다른 공사에서 열어 씁니다. 금액은 열 때 다시 계산합니다.</p><button data-action="wonga-form-register">현재 원가계산서 등록</button> <button data-action="wonga-form-export">파일로 내려받기</button> <button data-action="wonga-form-import">파일에서 불러오기</button><table class="sheet" style="margin-top:8px"><thead><tr><th>양식 이름</th><th>행</th><th>등록</th><th>작업</th></tr></thead><tbody>${forms.map((f) => `<tr><td>${esc(f.name)}</td><td>${f.data.length}</td><td>${esc(f.saved.slice(0, 10))}</td><td><button data-action="wonga-form-open" data-name="${esc(f.name)}">열기</button><button data-action="wonga-form-delete" data-name="${esc(f.name)}">삭제</button></td></tr>`).join("") || '<tr><td colspan="4">등록된 양식이 없습니다.</td></tr>'}</tbody></table>`,
);
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 };
})();
+12
View File
@@ -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;