feat(est-test): 원가계산서 사용자 양식 등록·목록·열기
- 서버 보관함 data/forms.json 에 원가계산서 양식 등록·삭제·목록 - 양식 열기·파일 내려받기/불러오기, 금액 제외하고 열 때 다시 계산 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
|
||||
|
||||
`원가계산서 → 원가계산서 양식 목록·등록`은 현재 원가계산서의 항목·수식·요율을 이름 붙여 이 앱 `data/forms.json`에 등록하고, 목록에서 열기·삭제하며, 파일로 내려받거나 불러옵니다. 금액은 남기지 않고 열 때 다시 계산하므로 도급액 고정은 풀립니다.
|
||||
|
||||
예정공정표의 `눈금`은 원본처럼 1일·5일·10일·15일·20일·1주·1개월 단위로 기간 칸 위에 날짜를 적고 막대 뒤에 세로 눈금선을 그립니다. 눈금이 60개를 넘으면 앞 60개만 그립니다.
|
||||
|
||||
`편집 → 낙찰률 적용`은 원본 낙찰율 창처럼 전체낙찰율(한 비율) 또는 비목별낙찰율을 고르고, 전체 줄·선택한 줄·선택한 공종(하위 줄 모두)에 적용합니다. 이미 낙찰이 적용된 줄은 제외할 수 있고, `당초단가(100%) 복원`으로 되돌립니다.
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user