feat(est-test): 묶음 인쇄와 간지

- 원본 처리 근거대로 인쇄표 차례·고른 서식을 한 문서로 이어 인쇄
- 서식마다 간지(제목 가운데·겹테두리), 공사별 선택 저장

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 21:05:42 +09:00
co-authored by Claude Opus 5.5
parent ceab1ab8c5
commit fea0c4aa77
7 changed files with 176 additions and 2 deletions
+59
View File
@@ -0,0 +1,59 @@
"""EST 테스트 앱 묶음 인쇄·간지(원본 TEstPrint.Print 0x10fef98 — Checked 서식 차례, 간지 = 가운데 제목)."""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FILES = [
"native-math.js",
"native-formula.js",
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-row-match.js",
"native-escalation.js",
"native-progress.js",
"native-business.js",
"native-review.js",
"native-resources.js",
"native-schedule.js",
"native-model.js",
"native-verify.js",
"native-docs.js",
"native-forms.js",
"native-print-batch.js",
]
def test_checked_forms_in_order_with_gangi():
runner = f"""
const fs = require('fs'), vm = require('vm');
const ctx = vm.createContext({{ structuredClone, console, Intl }});
for (const f of {json.dumps(FILES)})
vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
ctx.source = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8'));
ctx.catalog = JSON.parse(fs.readFileSync('tools/est-test/print-catalog.json', 'utf8'));
process.stdout.write(JSON.stringify(vm.runInContext(`(() => {{
const p = EstNative.calculate(EstModel.validate(structuredClone(source)));
const list = EstPrintBatch.forms(catalog, p);
const pick = list.map((f) => ({{ ...f, checked: [9, 8, 1].includes(Number(f.Index)) }}));
const out = EstPrintBatch.document(pick, p, {{ view: 'EstMainDat' }}, true);
const order = [...out.html.matchAll(/<section class="(gangi[^"]*|form)">(?:<div>|<h2>)([^<]*)/g)].map((m) => m[1].split(' ')[0] + ':' + m[2]);
p.printBatch = {{ checked: [9], gangi: false }};
const saved = EstPrintBatch.forms(catalog, p).filter((f) => f.checked).map((f) => f.Index);
return {{ zero: list.some((f) => Number(f.Index) === 0), order, skipped: out.skipped, saved,
gangiOff: !EstPrintBatch.document(pick, p, {{}}, false).html.includes('class="gangi') }};
}})()`, ctx)));
"""
result = json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
assert result["zero"] is False
names = [x.split(":")[1] for x in result["order"]]
kinds = [x.split(":")[0] for x in result["order"]]
assert kinds[:2] == ["gangi", "form"] and names[0] == names[1]
assert len(result["order"]) + 2 * len(result["skipped"]) == 6
assert result["saved"] == [9]
assert result["gangiOff"] is True
+3 -1
View File
@@ -497,12 +497,14 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 남음: 전역변수(`` 표지 — 설치 자료 사용 0건, 단가산출 엔진 깊은 변경이 필요해 보류).
- [x] 하단/상단으로 집계·공종 다듬기·단가산출 들여쓰기·레벨 확인(원본 f01a90·f018d0·f01750, 0x113eb44, 0xf0770c, 0xd10840 — 디스어셈블 근거) — 「소 계」(53, Level −10) 넣기 규칙, 내역 엔진에 53 행 합(닫는 공종 안쪽부터, 상세 행) 추가, LeftSpace 계산·이름 칸 들여 보이기, 들여쓰기 n 칸·분수 세 줄 함께, 레벨 나무(60−Level 칸 [레벨]·비목 소계). 다시 집계 시 겹침 방지는 웹 보완.
- 검증완료: `test_est_est_tools.py` 2개(소계 넣기·재실행 0·첫 소계 = 공종 합·도급액/순공사비 불변·상단 집계로 원상, 들여쓰기·LeftSpace·레벨 나무) 통과, 대조·업무·설계변경·연차·ESTX 저장 시험 111개 통과(엔진 변경 회귀). ORCA 브라우저에서 하단 집계 11줄(토공 2,382,484 등 공종 합과 같음)·도급액 유지, 공종 다듬기 59칸 들여쓰기, 레벨 나무 표시, 샘플 복구.
- [x] 묶음 인쇄·간지(원본 TEstPrint.Print 0x10fef98, TFRMESTPRINT2·TFRMPRINTLISTCHECK·TFRMESTPRINTPRIVATE — 디스어셈블 근거) — 인쇄표 차례·Checked 서식, 간지(Name2 가운데·겹테두리), 한 문서 이어 인쇄, 간지 쪽 번호 숨김, 공사별 선택 저장(`project.printBatch`). 원본은 서식마다 따로 인쇄 작업·간지는 쪽 번호를 소모하지 않음 — 웹은 브라우저 쪽 번호라 간지도 쪽 수에 들어감(번호만 숨김).
- 검증완료: `test_est_print_batch.py`(공통 행 제외·간지→서식 차례·간지 끔·선택 저장) 통과. ORCA 브라우저에서 인쇄표 40개 목록·4개 선택 → 간지 4·서식 4·표 3, 샘플 복구.
- 남음: 설정값 일괄적용(간지 글꼴·여백 13항목), 번호자동매김·시작 쪽, 끌어서 차례 바꾸기.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리.
8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집.
9. 묶음 인쇄(설계서 한 벌).
- 도메인 값이 필요해 사용자 협의 뒤: 산업안전보건관리비 비교, 조달청 표준공사코드, 현재데이타 갱신(단가 DB), 품셈 열람.
- [x] 연차 수수료의 원본 연결값 `LinkIndex_`가 57로 보존된 경우 ESTX 재열기에서 원가 계산에 반영하고, 없는 경우 저장 차단을 유지한다.
+2
View File
@@ -32,6 +32,8 @@
`파일 → 조달청 XML 검사·자리수 자동 수정`은 원본 XML 변환 전 검사처럼 단가산출 비목 값·재료/노임 연결 줄의 행별 자리수 지정, 단가 표 코드 중복·소문자, 기계경비 코드 17자리 아님을 목록으로 보입니다. `자리수 자동 수정`은 첫 항목의 자리수 지정만 풀고 다시 계산합니다. 기존 XML 내보내기는 막지 않습니다.
`파일 → 묶음 인쇄`는 원본처럼 인쇄표 차례대로 고른 서식을 한 번에 이어 인쇄합니다. `간지 인쇄`를 켜면 서식마다 앞에 서식 제목을 가운데 크게 쓴 간지(겹테두리)를 넣고, 간지 쪽에는 쪽 번호를 찍지 않습니다. 화면 전용 서식은 빼고 알립니다. 고른 서식은 공사마다 저장됩니다.
`파일 → 견적서 작성`은 원본처럼 제목·공사명·발주처·견적일자와 공급자(등록번호·상호·대표자·주소·업태·종목·전화)를 넣고, 설계내역 레벨(0 = 전체, N = N 이상 공종과 순공사비·비율 줄)·첫/나머지 페이지 줄수·정렬·비고란을 정해 미리보기·인쇄합니다. 합계금액은 원가계산서 아래에서 위로 첫 체크된 금액을 `일금:…원정 (₩…원정)`으로 적습니다. 공급자정보는 원본과 같은 `.einf`(INI) 파일로 저장·불러오기 합니다. 설정은 공사마다 저장됩니다.
`파일 → 갑지·표지·공사설명서·시방서 편집`은 원본 ESTX의 문서 레코드(DatGabgi·DatCover·DatExplean·DatSiBang)를 읽어 공사명·공사위치·발주처·설계일자·공사개요·공사금액·결재란, 표지의 부제·설계년도를 고칩니다. 공사명은 공사 이름과 함께 바뀝니다. `공사금액 만들기`는 원가계산서에서 고른 비목을 `금 일천오백만원(₩15,000,000)` 또는 `금15,000,000원(금일천오백만원)` 방식으로 적습니다. 설명서·시방서에서는 갑지의 공사명·위치·개요·금액을 커서 자리에 붙입니다. 인쇄 서식 8·9·10·11 미리보기는 이 내용을 씁니다. 서식·여백·글꼴은 다루지 않고, 문서를 고친 작업은 ESTX 저장을 거부합니다.
+2
View File
@@ -58,6 +58,7 @@
<script defer src="native-review-auto.js"></script>
<script defer src="native-autolink.js"></script>
<script defer src="native-est-tools.js"></script>
<script defer src="native-print-batch.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -82,6 +83,7 @@
<hr />
<button data-action="csv">현재 자료 CSV 내보내기</button
><button data-action="print">인쇄</button>
<button data-action="print-batch">묶음 인쇄(설계서 일체·간지)</button>
<button data-action="export-xlsx">전체 자료 Excel 내보내기</button>
<button data-action="import-xlsx">Excel 표 불러오기</button>
<button data-action="excel-map-open">Excel 내역 열 맞춰 가져오기</button>
+2 -1
View File
@@ -618,7 +618,8 @@ const EstBusinessActions = (() => {
(typeof EstXmlCheck !== "undefined" && EstXmlCheck.handle(name, state)) ||
(typeof EstReviewAuto !== "undefined" && EstReviewAuto.handle(name, state)) ||
(typeof EstAutoLink !== "undefined" && EstAutoLink.handle(name, state)) ||
(typeof EstEstTools !== "undefined" && EstEstTools.handle(name, state))
(typeof EstEstTools !== "undefined" && EstEstTools.handle(name, state)) ||
(typeof EstPrintBatch !== "undefined" && EstPrintBatch.handle(name, state))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
+107
View File
@@ -0,0 +1,107 @@
// 원본 묶음 인쇄(TEstPrint.Print 0x10fef98, TFRMESTPRINT2·TFRMPRINTLISTCHECK·TFRMESTPRINTPRIVATE 간지).
// 인쇄표 차례대로(공통 설정 행 Index 0 제외) 고른(Checked) 서식마다 간지(PrintGangiShow, 가운데 제목 Name2 · 겹테두리) → 서식 본문.
// 웹은 한 문서로 이어 인쇄하고 쪽 번호는 브라우저 쪽 번호를 쓴다(원본은 간지가 쪽 번호를 쓰지 않음 — 웹은 간지 쪽에 번호만 숨김).
// 서식 본문은 인쇄 미리보기와 같은 자료(EstForms.data · EstDocs.html). 화면 전용 서식은 건너뛰고 알린다.
const EstPrintBatch = (() => {
const esc = (v) =>
String(v ?? "").replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
function forms(catalog, project) {
const saved = project.printBatch?.checked;
return catalog
.filter((row) => Number(row.Index) !== 0)
.map((row) => ({
Index: row.Index,
Name: row.Name2 || row.Name || String(row.Index),
checked: saved ? saved.includes(row.Index) : Number(row.Checked) === 1,
gangi: project.printBatch?.gangi ?? Number(row.PrintGangiShow) === 1,
outline: Number(row.PrintGangiOutLineShow ?? 1) === 1,
}));
}
function body(index, project, state) {
const output = EstForms.data(index, project, state);
if (output) return EstForms.grid(output);
const doc = typeof EstDocs !== "undefined" ? EstDocs.html(Number(index), project) : "";
return doc || null;
}
function document_(list, project, state, gangi) {
const skipped = [],
parts = [];
for (const form of list.filter((f) => f.checked)) {
const html = body(form.Index, project, state);
if (html == null) {
skipped.push(form.Name);
continue;
}
if (gangi)
parts.push(
`<section class="gangi${form.outline ? " outline" : ""}"><div>${esc(form.Name)}</div></section>`,
);
parts.push(`<section class="form"><h2>${esc(form.Name)}</h2>${html}</section>`);
}
const html = `<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>${esc(project.name)} 설계서</title><style>
@page{size:A4 landscape;margin:12mm;@bottom-right{content:counter(page)}}@page gangi{@bottom-right{content:none}}
body{font:9pt "Malgun Gothic",sans-serif;margin:0}table{width:100%;border-collapse:collapse}th,td{border:1px solid #777;padding:1.2mm}
th{background:#eee}.numeric{text-align:right}.form{break-after:page}.form:last-child{break-after:auto}
.gangi{page:gangi;break-after:page;height:170mm;display:flex;align-items:center;justify-content:center;font-size:30pt;font-weight:bold}
.gangi.outline div{border:4px double #222;padding:12mm 30mm}.document-text{white-space:pre-wrap}</style></head><body>${parts.join("")}</body></html>`;
return { html, skipped, count: parts.length };
}
let catalog = [];
async function open(state) {
const defaults = await EstApp.api("/api/print-catalog");
const original = new Map((state.project.printCatalog || []).map((row) => [row.Index, row]));
catalog = defaults.map((row) => ({ ...row, ...original.get(row.Index) }));
render(state);
}
function render(state) {
const list = forms(catalog, state.project);
EstApp.showDialog(
"묶음 인쇄(설계서 일체)",
`<p>인쇄할 서식을 고르세요. 인쇄표 차례대로 이어 인쇄합니다.</p><label><input type="checkbox" name="pb-gangi" ${list[0]?.gangi ? "checked" : ""}>간지 인쇄</label><div style="max-height:260px;overflow:auto;columns:2">${list.map((f) => `<label style="display:block"><input type="checkbox" name="pb-form" value="${esc(f.Index)}" ${f.checked ? "checked" : ""}>${esc(f.Name)}</label>`).join("")}</div><button data-action="print-batch-preview">미리보기</button> <button data-action="print-batch-print">인쇄</button><p id="print-batch-note"></p><iframe id="print-batch-frame" style="width:100%;height:360px;border:1px solid #ccc"></iframe>`,
);
}
function read(state) {
const checked = [...document.querySelectorAll('#dialog-content [name="pb-form"]:checked')].map(
(n) => (/^\d+$/.test(n.value) ? Number(n.value) : n.value),
);
const gangi = document.querySelector('#dialog-content [name="pb-gangi"]').checked;
if (!checked.length) throw Error("인쇄할 서식을 하나 이상 고르세요.");
const next = { checked, gangi };
if (JSON.stringify(next) !== JSON.stringify(state.project.printBatch))
EstApp.change((project) => (project.printBatch = next), false);
return next;
}
function build(state) {
const { gangi } = read(state),
list = forms(catalog, state.project),
out = document_(list, state.project, state, gangi),
frame = EstApp.byId("print-batch-frame");
frame.srcdoc = out.html;
EstApp.byId("print-batch-note").textContent =
`서식·간지 ${out.count}개${out.skipped.length ? ` · 화면 전용이라 뺀 서식: ${out.skipped.join(", ")}` : ""}`;
return frame;
}
function handle(name, state) {
if (name === "print-batch")
open(state).catch((error) => {
state.notice = error.message;
EstApp.render();
});
else if (name === "print-batch-preview") build(state);
else if (name === "print-batch-print") {
const frame = build(state);
frame.onload = () => frame.contentWindow.print();
} else return false;
return true;
}
return { forms, document: document_, handle };
})();
+1
View File
@@ -80,6 +80,7 @@ const allowed = new Set([
"native-review-auto.js",
"native-autolink.js",
"native-est-tools.js",
"native-print-batch.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",