feat(est-test): 견적서 작성과 공급자정보 .einf

- 원본 처리 근거대로 레벨 행 선택, 7열, 1쪽 머리·공급자·한글 합계금액, 쪽 나눔
- 공사별 설정 저장, 원본 INI 형식 공급자정보 저장·불러오기, 미리보기·인쇄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-11 20:24:31 +09:00
co-authored by Claude Opus 5.5
parent 83e5eca1a3
commit f8f4be7c75
7 changed files with 313 additions and 1 deletions
@@ -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('공<br>급'), 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 이상입니다."
+3
View File
@@ -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방향 합계, 제출용 전체 인쇄.
+2
View File
@@ -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 저장을 거부합니다.
`편집 → 통합 검색`은 원본 통합 검색처럼 일위대가·단가산출·기계경비·견적·실적·자재·노임 중 고른 자료를 검색어로 찾습니다. 검색 위치는 이 앱에 저장된 다른 작업입니다. 찾은 자료를 고르면 하위 연결 자료와 함께 현재 공사로 가져오고(같은 코드는 현재 자료 유지), 단가 고르기로 행에 넣습니다.
+2
View File
@@ -53,6 +53,7 @@
<script defer src="native-multi-search.js"></script>
<script defer src="native-docs.js"></script>
<script defer src="native-wonga-tools.js"></script>
<script defer src="native-estimate-print.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
@@ -85,6 +86,7 @@
<button data-action="export-estx">원본 구조 유지 ESTX 수정 저장</button>
<button data-action="blank-save">공내역 파일로 저장</button>
<hr />
<button data-action="estimate-print">견적서 작성</button>
<button data-action="doc-edit" data-kind="gabgi">갑지 편집</button
><button data-action="doc-edit" data-kind="cover">표지 편집</button
><button data-action="doc-edit" data-kind="explan">공사설명서 편집</button
+2 -1
View File
@@ -613,7 +613,8 @@ const EstBusinessActions = (() => {
(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") {
+233
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
const fmt = (v) => (num(v) ? num(v).toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "");
const lines = (text) => esc(text).split("|").join("<br>");
const align = num(s.Aligment) ? "center" : "left";
const head = [
"품 명",
"규 격",
"수 량",
"단위",
"단 가",
"공급가액",
...(s.Bigo_Show ? ["비 고"] : []),
];
const table = (list) =>
`<table class="esti"><thead><tr>${head.map((h) => `<th>${h}</th>`).join("")}</tr></thead><tbody>${list
.map((r) => {
const bold =
num(r.Level) > 0 || num(r.LinkIndex) === 54 ? ' style="font-weight:bold"' : "";
return `<tr${bold}><td style="text-align:${align}">${esc(`${r.Pr ?? ""}${r.Name ?? ""}`)}</td><td style="text-align:${align}">${esc(r.Size)}</td><td class="n">${fmt(r.GN)}</td><td style="text-align:center">${esc(r.Unite)}</td><td class="n">${fmt(r.TU)}</td><td class="n">${fmt(r.TC)}</td>${s.Bigo_Show ? `<td>${esc(typeof r.Bigo === "string" ? r.Bigo : "")}</td>` : ""}</tr>`;
})
.join("")}</tbody></table>`;
const company = s.Company_Show
? `<table class="supplier"><tr><th rowspan="7">공<br>급<br>자</th><th>등록번호</th><td>${esc(s.Reg_Num)}</td></tr>${[
["상 호", s.ComPany_Name],
["성 명", s.Ceo_Name],
["사업장주소", s.Addr],
["업 태", s.Uptae],
["종 목", s.Jongmog],
["전화(FAX)", s.Tel],
]
.map(([k, v]) => `<tr><th>${k}</th><td>${esc(v)}</td></tr>`)
.join("")}</table>`
: "";
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) =>
`<section class="page">${
i === 0
? `<h1>${esc(s.Title_Name)}</h1><div class="top"><div><p>공사명: ${lines(s.Project_Name)}</p><p>${lines(s.Order_name)} 귀하</p><p>${esc(s.Esti_Date)}</p><p>아래와 같이 견적합니다</p></div>${company}</div><p class="sum">합계금액 ${esc(totalText(sum))}</p>`
: ""
}${table(list)}<div class="footer">${i + 1} / ${all.length}</div></section>`,
)
.join("");
return `<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:굴림체,monospace;font-size:10pt}.page{page-break-after:always;padding:10mm}h1{text-align:center;letter-spacing:8px}.top{display:flex;justify-content:space-between;gap:12px}table{border-collapse:collapse;width:100%}th,td{border:1px solid #333;padding:2px 4px}.n{text-align:right}.supplier{width:55%}.sum{font-weight:bold;font-size:12pt;border:1px solid #333;padding:6px}.footer{text-align:center;margin-top:6px}</style></head><body>${body}</body></html>`;
}
// 화면
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") =>
`<label>${label}<input name="ep-${k}" type="${type}" value="${esc(s[k])}"></label>`;
const check = (k, label) =>
`<label><input name="ep-${k}" type="checkbox" ${s[k] ? "checked" : ""}>${label}</label>`;
EstApp.showDialog(
"견적서 작성",
`<div class="form-fields">${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")}<label>품명·규격 정렬<select name="ep-Aligment"><option value="0" ${num(s.Aligment) ? "" : "selected"}>왼쪽</option><option value="1" ${num(s.Aligment) ? "selected" : ""}>가운데</option></select></label>${check("Bigo_Show", "비고란 보기")}${check("Company_Show", "공급자 정보 인쇄")}</div><h4>공급자</h4><div class="form-fields">${input("Reg_Num", "등록번호")}${input("ComPany_Name", "상호")}${input("Ceo_Name", "대표자")}${input("Addr", "사업장주소")}${input("Uptae", "업태")}${input("Jongmog", "종목")}${input("Tel", "전화,(FAX)")}</div><button data-action="estimate-print-preview">미리보기</button> <button data-action="estimate-print-print">인쇄</button> <button data-action="estimate-print-save">공급자정보 저장하기(.einf)</button> <button data-action="estimate-print-load">공급자정보 불러오기</button><iframe id="estimate-print-frame" style="width:100%;height:420px;border:1px solid #ccc;margin-top:8px"></iframe>`,
);
}
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 };
})();
+1
View File
@@ -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",