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) => ``).join("")}${list + .map((r) => { + const bold = + num(r.Level) > 0 || num(r.LinkIndex) === 54 ? ' style="font-weight:bold"' : ""; + return `${s.Bigo_Show ? `` : ""}`; + }) + .join("")}
${h}
${esc(`${r.Pr ?? ""}${r.Name ?? ""}`)}${esc(r.Size)}${fmt(r.GN)}${esc(r.Unite)}${fmt(r.TU)}${fmt(r.TC)}${esc(typeof r.Bigo === "string" ? r.Bigo : "")}
`; + const company = s.Company_Show + ? `${[ + ["상 호", s.ComPany_Name], + ["성 명", s.Ceo_Name], + ["사업장주소", s.Addr], + ["업 태", s.Uptae], + ["종 목", s.Jongmog], + ["전화(FAX)", s.Tel], + ] + .map(([k, v]) => ``) + .join("")}
공
급
자
등록번호${esc(s.Reg_Num)}
${k}${esc(v)}
` + : ""; + 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",