diff --git a/resources/tester/test_est_estimate_print.py b/resources/tester/test_est_estimate_print.py
new file mode 100644
index 000000000..3062c54a1
--- /dev/null
+++ b/resources/tester/test_est_estimate_print.py
@@ -0,0 +1,70 @@
+"""EST 테스트 앱 견적서 작성(원본 TfrmEstiMatePrn — 레벨 행 선택·열·합계금액·쪽 나눔·.einf)."""
+
+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-estimate-print.js",
+]
+
+
+def run_js(code: str):
+ runner = f"""
+ const fs = require('fs'), vm = require('vm');
+ const ctx = vm.createContext({{ structuredClone, console }});
+ 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'));
+ process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
+ """
+ return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
+
+
+def test_rows_total_pages_and_ini():
+ result = run_js(
+ """(() => {
+ const p = EstNative.calculate(EstModel.validate(structuredClone(source)));
+ const all = EstEstimatePrint.rows(p, 0).length, est = p.tables.EstMainDat.length;
+ const lvl = EstEstimatePrint.rows(p, 2);
+ const okLevel = lvl.every((r) => Number(r.Level) >= 2 || [54, 55, 58].includes(Number(r.LinkIndex)));
+ const pages = EstEstimatePrint.pages(Array.from({ length: 70 }, (_, i) => i), 27, 36).map((x) => x.length);
+ const s = { ...EstEstimatePrint.settings(p), ComPany_Name: '(주)시험', Company_Show: false, F_PageCount: 20 };
+ const ini = EstEstimatePrint.toIni(s);
+ const back = EstEstimatePrint.fromIni(ini);
+ const html = EstEstimatePrint.html(p, { ...s, Level: 2 });
+ let bad = ''; try { EstEstimatePrint.pages([1], 0, 1); } catch (e) { bad = e.message; }
+ return { all: all === est, okLevel, some: lvl.length > 0 && lvl.length < est, pages,
+ total: EstEstimatePrint.totalText(EstEstimatePrint.total(p)),
+ ini: ini.includes('[EstiPrnCompanyInfo]') && ini.includes('ComPany_Name=(주)시험') && ini.includes('Company_Show=0'),
+ back: [back.ComPany_Name, back.Company_Show, back.F_PageCount, back.Project_Name === undefined],
+ html: [html.includes('견 적 서'), html.includes('아래와 같이 견적합니다'), html.includes('공
급'), html.includes('공급가액')],
+ bad };
+ })()"""
+ )
+ assert result["all"] is True and result["okLevel"] is True and result["some"] is True
+ assert result["pages"] == [27, 36, 7]
+ assert result["total"] == " 일금:구천칠백일십구만구천원정 (₩97,199,000원정)"
+ assert result["ini"] is True
+ assert result["back"] == ["(주)시험", False, 20, True]
+ assert result["html"] == [True, True, False, True]
+ assert result["bad"] == "쪽당 줄 수는 1 이상입니다."
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index 91162134e..046f4bf24 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -484,6 +484,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지.
- 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구.
- [ ] 원가 행 자리수(Diget·DigetIndex) 반영 — 웹 원가 엔진은 행 자리수를 계산에 쓰지 않음(선택항목 자리수 조정은 값만 기록). 설치 자료 중 sample.estx 만 Diget 사용(도급액 3·산업안전 3, DigetIndex 0)이고 산업안전 1,660,273 처럼 천단위가 아닌 값이 있어 DigetIndex 0 의 뜻(사사오입/없음) 확정 불가 — 근거 확보 전 엔진 변경 보류. 기성 원가계산서 편집(절하·한줄 삽입 등)도 같은 근거가 필요해 보류.
+ - [x] 견적서 작성(원본 mnuEstiMatePrn → TfrmEstiMatePrn 0x10c1994·그리기 0x10c4768 — 디스어셈블 근거) — 공사별 설정(`project.estimatePrint`), 원본 `.einf` INI 세 구역 저장·두 구역 불러오기, 레벨 행 선택, 7열, 1쪽 머리·공급자·합계금액(아래에서 위로 Checked 금액, 한글), 첫/나머지 쪽 줄수, 미리보기·인쇄. 수탁공사변환·XML 역변환 비교는 원본에서 숨긴 개발용 메뉴라 범위 밖.
+ - 검증완료: `test_est_estimate_print.py`(전체/레벨 2 행, 쪽 27·36·7, 합계 「일금:구천칠백일십구만구천원정 (₩97,199,000원정)」, INI 왕복, 미리보기 글) 통과. ORCA 브라우저에서 상호·레벨 2 → 2쪽·28줄·합계·공급자 표, 공사 설정 저장 확인 후 샘플 복구.
+ - 남음: 견적서 Excel 변환, 글꼴·크기·여백 세부값.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
diff --git a/tools/est-test/README.md b/tools/est-test/README.md
index 86f85ae37..090161cfb 100644
--- a/tools/est-test/README.md
+++ b/tools/est-test/README.md
@@ -24,6 +24,8 @@
`편집 → 선택 행 자재단가 등록`·`견적단가 등록`은 원본처럼 내역·일위대가 행을 새 자재(M)·견적(W) 단가로 등록하고 그 행을 새 코드에 연결합니다. 같은 품명·규격이 있으면 거부하고, 일위대가 호표 제목 줄은 등록하지 않습니다. `품명정리`는 고른 일위대가 호표의 품명을 커서 자리에서 나눠 뒤쪽을 규격 앞에 붙이고, 단위가 비어 있으면 채웁니다.
+`파일 → 견적서 작성`은 원본처럼 제목·공사명·발주처·견적일자와 공급자(등록번호·상호·대표자·주소·업태·종목·전화)를 넣고, 설계내역 레벨(0 = 전체, N = N 이상 공종과 순공사비·비율 줄)·첫/나머지 페이지 줄수·정렬·비고란을 정해 미리보기·인쇄합니다. 합계금액은 원가계산서 아래에서 위로 첫 체크된 금액을 `일금:…원정 (₩…원정)`으로 적습니다. 공급자정보는 원본과 같은 `.einf`(INI) 파일로 저장·불러오기 합니다. 설정은 공사마다 저장됩니다.
+
`파일 → 갑지·표지·공사설명서·시방서 편집`은 원본 ESTX의 문서 레코드(DatGabgi·DatCover·DatExplean·DatSiBang)를 읽어 공사명·공사위치·발주처·설계일자·공사개요·공사금액·결재란, 표지의 부제·설계년도를 고칩니다. 공사명은 공사 이름과 함께 바뀝니다. `공사금액 만들기`는 원가계산서에서 고른 비목을 `금 일천오백만원(₩15,000,000)` 또는 `금15,000,000원(금일천오백만원)` 방식으로 적습니다. 설명서·시방서에서는 갑지의 공사명·위치·개요·금액을 커서 자리에 붙입니다. 인쇄 서식 8·9·10·11 미리보기는 이 내용을 씁니다. 서식·여백·글꼴은 다루지 않고, 문서를 고친 작업은 ESTX 저장을 거부합니다.
`편집 → 통합 검색`은 원본 통합 검색처럼 일위대가·단가산출·기계경비·견적·실적·자재·노임 중 고른 자료를 검색어로 찾습니다. 검색 위치는 이 앱에 저장된 다른 작업입니다. 찾은 자료를 고르면 하위 연결 자료와 함께 현재 공사로 가져오고(같은 코드는 현재 자료 유지), 단가 고르기로 행에 넣습니다.
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index c165f45ce..b8b0443fa 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -53,6 +53,7 @@
+
@@ -85,6 +86,7 @@
+
{
(typeof EstTidy !== "undefined" && EstTidy.handle(name, state)) ||
(typeof EstMultiSearch !== "undefined" && EstMultiSearch.handle(name, state)) ||
(typeof EstDocs !== "undefined" && EstDocs.handle(name, state, node)) ||
- (typeof EstWongaTools !== "undefined" && EstWongaTools.handle(name, state))
+ (typeof EstWongaTools !== "undefined" && EstWongaTools.handle(name, state)) ||
+ (typeof EstEstimatePrint !== "undefined" && EstEstimatePrint.handle(name, state))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
diff --git a/tools/est-test/native-estimate-print.js b/tools/est-test/native-estimate-print.js
new file mode 100644
index 000000000..8863d8e20
--- /dev/null
+++ b/tools/est-test/native-estimate-print.js
@@ -0,0 +1,233 @@
+// 원본 견적서작성(mnuEstiMatePrn → TfrmEstiMatePrn 0x10c1994, 그리기 0x10c4768).
+// 설정은 공사마다 project.estimatePrint(원본 EstiPrintInfo.einf 세 구역), 공급자정보는 원본과 같은 INI(.einf)로 내보내기·불러오기
+// (불러오기는 EstiPrnCompanyInfo·EstiPrnInfo 두 구역만). 행: 레벨 0 = 전 행, N = Level ≥ N 공종 머리 + LinkIndex 54·55·58.
+// 열: 품명(Pr+Name)·규격·수량·단위·단가·공급가액·비고(선택). 1쪽만 머리·공급자·합계금액, 1쪽 F_PageCount·이후 L_PageCount 줄.
+// 합계금액 = 원가계산서를 아래에서 위로 훑어 Checked 이고 금액 있는 첫 행(없으면 도급액 55 행) — 「일금: …원정 (₩…원정)」.
+const EstEstimatePrint = (() => {
+ const num = (value) => Number(value) || 0;
+ const defaults = {
+ Title_Name: "견 적 서",
+ Project_Name: "",
+ Order_name: "",
+ Esti_Date: "",
+ Reg_Num: "",
+ ComPany_Name: "",
+ Ceo_Name: "",
+ Addr: "",
+ Uptae: "",
+ Jongmog: "",
+ Tel: "",
+ Company_Show: true,
+ F_PageCount: 27,
+ L_PageCount: 36,
+ Aligment: 0,
+ Bigo_Show: false,
+ Level: 0,
+ };
+ const sections = {
+ EstiProjectInfo: ["Title_Name", "Project_Name", "Order_name", "Esti_Date"],
+ EstiPrnCompanyInfo: [
+ "Reg_Num",
+ "ComPany_Name",
+ "Ceo_Name",
+ "Addr",
+ "Uptae",
+ "Jongmog",
+ "Tel",
+ "Company_Show",
+ ],
+ EstiPrnInfo: ["F_PageCount", "L_PageCount", "Aligment", "Bigo_Show"],
+ };
+
+ function settings(project) {
+ const today = new Date();
+ return {
+ ...defaults,
+ Project_Name: project.name || "",
+ Esti_Date: `${today.getFullYear()}년 ${String(today.getMonth() + 1).padStart(2, "0")}월 ${String(today.getDate()).padStart(2, "0")}일`,
+ ...(project.estimatePrint || {}),
+ };
+ }
+
+ function rows(project, level) {
+ const list = project.tables.EstMainDat || [];
+ return level > 0
+ ? list.filter((r) => num(r.Level) >= level || [54, 55, 58].includes(num(r.LinkIndex)))
+ : list;
+ }
+
+ function total(project) {
+ const wonga = project.tables.Wonga || [];
+ for (let i = wonga.length - 1; i >= 0; i--)
+ if (wonga[i].Checked === true && num(wonga[i].Cost)) return num(wonga[i].Cost);
+ return num(wonga.find((r) => num(r.LinkIndex) === 55)?.Cost ?? project.calculation?.total);
+ }
+
+ function totalText(value) {
+ return ` 일금:${EstDocs.korean(value)}원정 (₩${Math.round(value).toLocaleString("en-US")}원정)`;
+ }
+
+ function pages(list, first, rest) {
+ if (!(first > 0 && rest > 0)) throw Error("쪽당 줄 수는 1 이상입니다.");
+ const out = [list.slice(0, first)];
+ for (let at = first; at < list.length; at += rest) out.push(list.slice(at, at + rest));
+ return out;
+ }
+
+ // 원본 TIniFile 형식(.einf)
+ function toIni(s, keys = Object.keys(sections)) {
+ return keys
+ .map(
+ (section) =>
+ `[${section}]\r\n${sections[section].map((key) => `${key}=${typeof s[key] === "boolean" ? (s[key] ? "1" : "0") : (s[key] ?? "")}`).join("\r\n")}`,
+ )
+ .join("\r\n\r\n");
+ }
+ function fromIni(text, keys = ["EstiPrnCompanyInfo", "EstiPrnInfo"]) {
+ const out = {};
+ let section = "";
+ for (const line of String(text).split(/\r?\n/)) {
+ const head = line.match(/^\s*\[([^\]]+)\]\s*$/);
+ if (head) {
+ section = head[1];
+ continue;
+ }
+ const pair = line.match(/^\s*([^=]+?)\s*=(.*)$/);
+ if (!pair || !keys.includes(section) || !sections[section].includes(pair[1])) continue;
+ const key = pair[1],
+ value = pair[2].trim(),
+ base = defaults[key];
+ out[key] =
+ typeof base === "boolean"
+ ? ["1", "true", "-1"].includes(value.toLowerCase())
+ : typeof base === "number"
+ ? Number(value) || 0
+ : value;
+ }
+ return out;
+ }
+
+ function html(project, s) {
+ const esc = (v) =>
+ String(v ?? "").replace(
+ /[&<>"]/g,
+ (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
+ );
+ const fmt = (v) => (num(v) ? num(v).toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "");
+ const lines = (text) => esc(text).split("|").join("
");
+ const align = num(s.Aligment) ? "center" : "left";
+ const head = [
+ "품 명",
+ "규 격",
+ "수 량",
+ "단위",
+ "단 가",
+ "공급가액",
+ ...(s.Bigo_Show ? ["비 고"] : []),
+ ];
+ const table = (list) =>
+ `${head.map((h) => `| ${h} | `).join("")}
${list
+ .map((r) => {
+ const bold =
+ num(r.Level) > 0 || num(r.LinkIndex) === 54 ? ' style="font-weight:bold"' : "";
+ return `| ${esc(`${r.Pr ?? ""}${r.Name ?? ""}`)} | ${esc(r.Size)} | ${fmt(r.GN)} | ${esc(r.Unite)} | ${fmt(r.TU)} | ${fmt(r.TC)} | ${s.Bigo_Show ? `${esc(typeof r.Bigo === "string" ? r.Bigo : "")} | ` : ""}
`;
+ })
+ .join("")}
`;
+ const company = s.Company_Show
+ ? `공 급 자 | 등록번호 | ${esc(s.Reg_Num)} |
${[
+ ["상 호", s.ComPany_Name],
+ ["성 명", s.Ceo_Name],
+ ["사업장주소", s.Addr],
+ ["업 태", s.Uptae],
+ ["종 목", s.Jongmog],
+ ["전화(FAX)", s.Tel],
+ ]
+ .map(([k, v]) => `| ${k} | ${esc(v)} |
`)
+ .join("")}
`
+ : "";
+ const all = pages(rows(project, num(s.Level)), num(s.F_PageCount), num(s.L_PageCount));
+ const sum = total(project);
+ const body = all
+ .map(
+ (list, i) =>
+ `${
+ i === 0
+ ? `${esc(s.Title_Name)}
공사명: ${lines(s.Project_Name)}
${lines(s.Order_name)} 귀하
${esc(s.Esti_Date)}
아래와 같이 견적합니다
${company}
합계금액 ${esc(totalText(sum))}
`
+ : ""
+ }${table(list)}`,
+ )
+ .join("");
+ return `${body}`;
+ }
+
+ // 화면
+ const field = (name) => document.querySelector(`#dialog-content [name="ep-${name}"]`);
+ function open(state) {
+ const s = settings(state.project),
+ { esc } = EstApp;
+ const input = (k, label, type = "text") =>
+ ``;
+ const check = (k, label) =>
+ ``;
+ EstApp.showDialog(
+ "견적서 작성",
+ `${input("Title_Name", "제목")}${input("Project_Name", "공사명(| 줄바꿈)")}${input("Order_name", "발주처")}${input("Esti_Date", "견적일자")}${input("Level", "설계내역 레벨표시(0~9)", "number")}${input("F_PageCount", "첫 페이지 줄수", "number")}${input("L_PageCount", "나머지 페이지 줄수", "number")}${check("Bigo_Show", "비고란 보기")}${check("Company_Show", "공급자 정보 인쇄")}
공급자
${input("Reg_Num", "등록번호")}${input("ComPany_Name", "상호")}${input("Ceo_Name", "대표자")}${input("Addr", "사업장주소")}${input("Uptae", "업태")}${input("Jongmog", "종목")}${input("Tel", "전화,(FAX)")}
`,
+ );
+ }
+ function read(state) {
+ const s = settings(state.project);
+ for (const key of Object.keys(defaults)) {
+ const node = field(key);
+ if (!node) continue;
+ s[key] =
+ node.type === "checkbox"
+ ? node.checked
+ : typeof defaults[key] === "number"
+ ? Number(node.value) || 0
+ : node.value;
+ }
+ if (!(s.Level >= 0 && s.Level <= 9)) throw Error("레벨은 0~9 입니다.");
+ if (JSON.stringify(s) !== JSON.stringify(settings(state.project)))
+ EstApp.change((project) => (project.estimatePrint = s), false);
+ return s;
+ }
+ function preview(state) {
+ const s = read(state),
+ frame = EstApp.byId("estimate-print-frame");
+ frame.srcdoc = html(state.project, s);
+ return frame;
+ }
+
+ function handle(name, state) {
+ if (name === "estimate-print") open(state);
+ else if (name === "estimate-print-preview") preview(state);
+ else if (name === "estimate-print-print") {
+ const frame = preview(state);
+ frame.onload = () => frame.contentWindow.print();
+ } else if (name === "estimate-print-save")
+ EstApp.download("견적서 기본정보.einf", toIni(read(state)), "text/plain");
+ else if (name === "estimate-print-load") {
+ const picker = document.createElement("input");
+ picker.type = "file";
+ picker.accept = ".einf,.ini,.txt";
+ picker.addEventListener("change", async () => {
+ const file = picker.files[0];
+ if (!file) return;
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ let text;
+ try {
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
+ } catch {
+ text = new TextDecoder("euc-kr").decode(bytes);
+ }
+ const next = { ...read(state), ...fromIni(text) };
+ EstApp.change((project) => (project.estimatePrint = next), false);
+ open(state);
+ });
+ picker.click();
+ } else return false;
+ return true;
+ }
+
+ return { settings, rows, total, totalText, pages, toIni, fromIni, html, handle };
+})();
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index 3fee22390..43aa2757f 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -75,6 +75,7 @@ const allowed = new Set([
"native-multi-search.js",
"native-docs.js",
"native-wonga-tools.js",
+ "native-estimate-print.js",
"native-issues.js",
"native-attachments.js",
"native-standard.js",