Files
Aislo/tools/est-test/native-estimate-print.js
T
2026-11-12 17:42:55 +09:00

310 lines
13 KiB
JavaScript

// 원본 견적서작성(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", "견적일자")}<label>견적일자 선택<input type="date" data-date-target="ep-Esti_Date" data-date-format="estimate"></label>${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-excel">엑셀변환</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;
}
// 엑셀변환(TFRMEXCELCONVERTESTIPRN btnConvert): 견적서 머리(제목·공사명·귀하·견적일자·합계금액·공급자)와 내역 줄을 .xlsx 로.
// 원본의 인쇄비율·셀 수식(TRUNC·합) 은 웹 Excel 내보내기가 값만 쓰므로 하지 않는다.
async function excel(state) {
const s = read(state),
list = rows(state.project, num(s.Level)).filter((r) => r.Name || r.Code),
sum = total(state.project);
const columns = [
"품명",
"규격",
"단위",
"수량",
"재료단가",
"재료금액",
"노무단가",
"노무금액",
"경비단가",
"경비금액",
"합계단가",
"합계금액",
"비고",
];
const v = (x) => (typeof x === "number" ? x : Number(x) || 0);
const body = list.map((r) => [
String(r.Name ?? ""),
String(r.Size ?? ""),
String(r.Unite ?? ""),
v(r.GN),
v(r.GU),
v(r.GC),
v(r.NU),
v(r.NC),
v(r.JU),
v(r.JC),
v(r.TU),
v(r.TC),
s.Bigo_Show ? String(r.Bigo ?? "") : "",
]);
const head = [
s.Title_Name,
`공사명 : ${String(s.Project_Name).replaceAll("|", " ")}`,
`${s.Order_name} 귀하`,
s.Esti_Date,
"아래와 같이 견적합니다",
`합계금액${totalText(sum)}`,
s.Company_Show
? `공급자 등록번호 ${s.Reg_Num} · 상호 ${s.ComPany_Name} · 성명 ${s.Ceo_Name} · 사업장주소 ${s.Addr} · 업태 ${s.Uptae} · 종목 ${s.Jongmog} · 전화 ${s.Tel}`
: "",
]
.filter(Boolean)
.join(String.fromCharCode(10));
const response = await fetch("/api/export-report-xlsx", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: `${state.project.name}_견적서`,
columns,
rows: body,
detail: head,
errors: [],
}),
});
if (!response.ok) throw Error((await response.json()).error);
EstApp.download(
`${state.project.name || "견적서"}_견적서.xlsx`,
await response.blob(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
);
}
function handle(name, state) {
if (name === "estimate-print-excel") {
excel(state).catch((error) => {
state.notice = error.message;
EstApp.render();
});
return true;
}
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 };
})();