feat(est-test): 공내역 파일로 저장

- 원본 처리 근거대로 내역·일위대가 금액을 비우고 공람 방법에 따라 단가 또는 수량을 비움
- JSON 또는 원본 구조 ESTX 로 내려받기, 사본은 자동계산 꺼 빈칸 유지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 15:01:13 +09:00
co-authored by Claude Opus 5.5
parent 982ba07fea
commit fc3bab4ea7
7 changed files with 165 additions and 2 deletions
+77
View File
@@ -0,0 +1,77 @@
"""EST 테스트 앱 공내역 파일 저장(원본 mnuFileNullSave · PrintGongMethod 0/1/2)."""
import json
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SAMPLE = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/sample.estx")
LOAD = """
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({ structuredClone, console });
vm.runInContext(fs.readFileSync('tools/est-test/native-blank.js', 'utf8'), ctx);
"""
def node(script: str, *args: str):
out = subprocess.check_output(["node", "-e", LOAD + script, *args], cwd=ROOT)
return json.loads(out.decode("utf-8"))
def test_methods_clear_amounts_and_chosen_values_only():
result = node("""
ctx.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
const out = vm.runInContext(`(() => {
const pick = (p) => p.tables.EstMainDat.find((r) => Number(r.GN) > 0 && Number(r.TU) > 0);
const src = pick(project), at = project.tables.EstMainDat.indexOf(src);
const r = (m) => { const b = EstBlank.make(project, m), x = b.tables.EstMainDat[at];
return [x.TC, x.NC, x.TU > 0, x.GN > 0, b.options.AutoCalc, b.name.endsWith('(공내역)'), 'id' in b]; };
const head = EstBlank.make(project, 0).tables.HeadMainDat.every((x) => !Number(x.TC) && !Number(x.TU));
const wonga = EstBlank.make(project, 2).tables.Wonga.every((x) => !Number(x.Cost));
let bad = ''; try { EstBlank.make(project, 3); } catch (e) { bad = e.message; }
return { m0: r(0), m1: r(1), m2: r(2), head, wonga, bad,
untouched: Number(src.TC) > 0 && Number(src.TU) > 0 };
})()`, ctx);
process.stdout.write(JSON.stringify(out));
""")
assert result == {
"m0": [0, 0, False, True, False, True, False],
"m1": [0, 0, True, False, False, True, False],
"m2": [0, 0, True, True, False, True, False],
"head": True,
"wonga": True,
"bad": "공내역 방법을 고르세요.",
"untouched": True,
}
def test_blank_estx_round_trip_keeps_quantities_and_clears_prices():
if not SAMPLE.exists():
pytest.skip("installed EST sample unavailable")
result = node(
"""
const imp = require('./tools/est-test/import-estx.cjs'), ex = require('./tools/est-test/export-estx.cjs');
const project = imp.importEstx(fs.readFileSync(process.argv[1]));
ctx.project = project;
const blank = vm.runInContext('EstBlank.make(project, 0)', ctx);
const back = imp.importEstx(ex.exportEstx(blank));
const rows = (p) => p.tables.EstMainDat.filter((r) => Number(r.GN) > 0);
process.stdout.write(JSON.stringify({
count: rows(back).length === rows(project).length,
quantities: rows(back).every((r, i) => Number(r.GN) === Number(rows(project)[i].GN)),
prices: back.tables.EstMainDat.every((r) => !Number(r.TU) && !Number(r.TC)),
heads: back.tables.HeadMainDat.every((r) => !Number(r.TC)),
sources: back.tables.LaborcDat.length === project.tables.LaborcDat.length,
}));
""",
str(SAMPLE),
)
assert result == {
"count": True,
"quantities": True,
"prices": True,
"heads": True,
"sources": True,
}
+2 -1
View File
@@ -435,10 +435,11 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- [x] 찾기 창(원본 TFRMESTSERCH·TFRMCOSTWORDCHANGE) — 검색어·처음부터/현재위치부터·다음찾기로 행 이동(호표 자동 전환·쪽 이동), 단가산출 한 건씩 바꾸기 후 다음 위치, Ctrl+F. 이전 값(`*_`) 필드는 찾지 않음.
- 검증완료: `test_est_find.py` 3개, 단추·단가 고르기·행 동작 포함 24개 통과. ORCA 브라우저에서 Ctrl+F로 창 열기, 내역 「합판거푸집」 8·15·22·26행 순서 이동, 단가산출 마지막 호표 행 찾기(호표 D0000000006 전환·선택 표시), 바꾸기 후 다음 위치(323행)·되돌리기 원상 확인.
- 남음: 통합검색(TFRMMULTISERCH — 다른 공사·폴더 자료 검색).
- [x] 공내역 파일로 저장(원본 mnuFileNullSaveClick 0x10cdc44 → 0x113cf38) — 디스어셈블 근거: EstMainDat·HeadTitleDat·HeadMainDat의 TC·NC·GC·JC·SC 비움, PrintGongMethod 0 = TU~SU 비움·1 = GN 비움·2 = 금액만. 웹 사본은 자동계산 끔·원가 금액 비움(원본 WongaDat 처리는 미확인). JSON 또는 원본 구조 ESTX로 내려받기.
- 검증완료: `test_est_blank.py` 2개(세 방법·원본 불변·잘못된 방법 거부, 설치 sample.estx 공내역 ESTX 저장·재읽기에서 수량 유지·단가·금액 0·기초자료 유지), 관련 11개 통과. ORCA 브라우저에서 메뉴 → 수량만 남김 → JSON 내려받기(GN 133·TU 0·TC 0), 현재 작업 불변, 사본을 열어 합계 0 유지 확인 후 샘플 복구.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기.
2. 공내역 파일로 저장(단가 비운 사본).
3. 수량산출서 — 기존 줄 수량 지정·해제, 누계·4방향 합계, 산출서 복사·붙여넣기·삽입·순서, 제출용 전체 인쇄.
4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel).
5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기).
+2
View File
@@ -22,6 +22,8 @@
내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다.
`파일 → 공내역 파일로 저장`은 원본처럼 내역·일위대가의 금액을 비운 사본을 내려받습니다. 남길 값은 수량만(단가 비움)·단가만(수량 비움)·수량·단가(금액만 비움) 중에서 고르고, 원본 ESTX를 가져온 작업은 ESTX로도 받을 수 있습니다. 사본은 자동계산을 끈 상태로 저장해 열어도 빈칸이 유지되며, 원가계산서 금액도 비웁니다. 현재 작업은 바뀌지 않습니다.
`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
수량산출 화면의 수량 항목마다 `끝수 처리`에서 AUTO(산출서 기본)·절사·올림·반올림 0~5자리를 고릅니다. 원본처럼 결과 코드 뒤에 방식과 자리수를 붙이며(`r000001` → `r00000131`), 산출 행의 결과 표지도 함께 바꿉니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부하고 JSON 저장을 안내합니다.
+2
View File
@@ -39,6 +39,7 @@
<script defer src="native-block-actions.js"></script>
<script defer src="native-pick.js"></script>
<script defer src="native-find.js"></script>
<script defer src="native-blank.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -68,6 +69,7 @@
<button data-action="export-xml">조달청 XML 내보내기</button>
<button data-action="import-xml">조달청 XML 불러오기</button>
<button data-action="export-estx">원본 구조 유지 ESTX 수정 저장</button>
<button data-action="blank-save">공내역 파일로 저장</button>
<button data-action="export-jdl">JDL 기본 표 내보내기</button>
<button data-action="import-jdl">JDL 불러오기</button>
</div>
+79
View File
@@ -0,0 +1,79 @@
// 원본 공내역 파일로저장(TfrmEstMain.mnuFileNullSaveClick 0x10cdc44 → 0x113cf38).
// 내역·일위대가 제목·일위대가 상세의 금액(TC·NC·GC·JC·SC)을 비우고, 공람 방법(PrintGongMethod)에 따라
// 0 = 단가(TU·NU·GU·JU·SU)도 비움(수량만 남김) · 1 = 수량(GN)을 비움(단가만 남김) · 2 = 금액만 비움.
const EstBlank = (() => {
const tables = ["EstMainDat", "HeadTitleDat", "HeadMainDat"];
const amounts = ["TC", "NC", "GC", "JC", "SC"];
const units = ["TU", "NU", "GU", "JU", "SU"];
const methods = [
[0, "수량만 남김(단가·금액 비움)"],
[1, "단가만 남김(수량·금액 비움)"],
[2, "수량·단가 남김(금액만 비움)"],
];
function make(project, method) {
if (![0, 1, 2].includes(method)) throw Error("공내역 방법을 고르세요.");
const blank = structuredClone(project);
const clear =
method === 0 ? [...amounts, ...units] : method === 1 ? [...amounts, "GN"] : amounts;
for (const table of tables)
for (const row of blank.tables[table] || [])
for (const key of clear) if (row[key] != null) row[key] = 0;
// 금액을 비운 원가계산서가 옛 합계를 보이지 않도록 원가 금액도 비운다(원본 WongaDat 처리 확인 전 웹 보조).
for (const row of blank.tables.Wonga || []) if (row.Cost != null) row.Cost = 0;
delete blank.id;
blank.name = `${project.name || "내역서"} (공내역)`;
blank.options = { ...blank.options, AutoCalc: false, SourceRefresh: false };
blank.calculation = { errors: [], warnings: [], direct: {}, total: 0 };
blank.blankMethod = method;
return blank;
}
function open(state) {
const project = state.project;
EstActions.form(
"공내역 파일로 저장",
[
{
name: "method",
label: "남길 값",
value: Number(project.printSettings?.PrintGongMethod ?? 0),
choices: methods,
},
{
name: "format",
label: "파일 형식",
value: project.originalArchive ? "estx" : "json",
choices: project.originalArchive
? [
["estx", "원본 구조 ESTX"],
["json", "웹 작업 JSON"],
]
: [["json", "웹 작업 JSON"]],
},
],
async ({ method, format }) => {
const blank = make(project, Number(method));
if (format === "estx") {
const response = await fetch("/api/export-estx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(blank),
});
if (!response.ok) throw Error((await response.json()).error);
EstApp.download(`${blank.name}.estx`, await response.blob(), "application/octet-stream");
} else EstApp.download(`${blank.name}.estweb.json`, JSON.stringify(blank, null, 2));
state.notice = "공내역 파일을 내려받았습니다. 현재 작업은 바뀌지 않았습니다.";
EstApp.render();
},
);
}
function handle(name, state) {
if (name !== "blank-save") return false;
open(state);
return true;
}
return { methods, make, handle };
})();
+2 -1
View File
@@ -600,7 +600,8 @@ const EstBusinessActions = (() => {
} else if (
EstBlockActions.handle(name, state) ||
(typeof EstPick !== "undefined" && EstPick.handle(name, state)) ||
(typeof EstFind !== "undefined" && EstFind.handle(name, state))
(typeof EstFind !== "undefined" && EstFind.handle(name, state)) ||
(typeof EstBlank !== "undefined" && EstBlank.handle(name, state))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
+1
View File
@@ -59,6 +59,7 @@ const allowed = new Set([
"native-block-actions.js",
"native-pick.js",
"native-find.js",
"native-blank.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",