371 lines
13 KiB
JavaScript
371 lines
13 KiB
JavaScript
// 원본 「엑셀데이타를 EST로 변환」(mnuExcelToEst → TfrmExcelToEst btnOkClick 0x109c3bc — spec_pc §1) 7개 탭:
|
||
// 노임 → 자재 → 견적 → 표준시장 → 단가산출 → 일위대가 → 내역서 차례로 한 Excel 파일의 시트들을 새 공사로 옮긴다(금액은 Excel 값 그대로,
|
||
// 다시 셈 끔). 셀 규칙: 문자 Trim·줄바꿈 제거·품명/규격 앞 「.」 제거·단위 M2→㎡ M3→㎥·산출근거 「*」→「×」, 수치는 쉼표 빼고,
|
||
// 노무비율 ≤ 1 이면 ×100, 열 「B:D」 는 이어 붙임. 단가산출·일위대가는 머리 줄 꼴(#.산근 · #| · 제n호표 · 산근 n 호표 : … (… 당) ·
|
||
// (산근 · No n 호표 · No.n)을 찾아 호표를 만든다(원본의 글자 굵기 판정은 웹 Excel 읽기가 굵기를 몰라 하지 않음).
|
||
const EstExcelToEst = (() => {
|
||
const TABS = [
|
||
["LaborcDat", "노임단가", ["Code", "Name", "Size", "Unite", "Cost1", "Bigo"]],
|
||
[
|
||
"StuffcDat",
|
||
"자재단가",
|
||
[
|
||
"Code",
|
||
"Name",
|
||
"Size",
|
||
"Unite",
|
||
"Cost1",
|
||
"Page1",
|
||
"Cost2",
|
||
"Page2",
|
||
"Cost3",
|
||
"Page3",
|
||
"Cost4",
|
||
"Page4",
|
||
"Cost5",
|
||
"Page5",
|
||
"Cost6",
|
||
"Page6",
|
||
"TC",
|
||
"Bigo",
|
||
],
|
||
],
|
||
[
|
||
"EstiMateDat",
|
||
"견적단가",
|
||
["Code", "Name", "Size", "GN", "Unite", "TC", "NC", "GC", "JC", "Bigo"],
|
||
],
|
||
[
|
||
"SilJukDat",
|
||
"표준시장단가",
|
||
["Code", "Name", "Size", "Unite", "TC", "NC", "GC", "JC", "NCRatio", "EXPLEAN", "Bigo"],
|
||
],
|
||
["CostMainDat", "단가산출", ["Code", "LineData", "TC", "NC", "GC", "JC"]],
|
||
[
|
||
"HeadMainDat",
|
||
"일위대가",
|
||
[
|
||
"Code",
|
||
"Name",
|
||
"Size",
|
||
"GN",
|
||
"Unite",
|
||
"TU",
|
||
"TC",
|
||
"NU",
|
||
"NC",
|
||
"GU",
|
||
"GC",
|
||
"JU",
|
||
"JC",
|
||
"Bigo",
|
||
],
|
||
],
|
||
[
|
||
"EstMainDat",
|
||
"내역서",
|
||
[
|
||
"Code",
|
||
"Pr",
|
||
"Name",
|
||
"Size",
|
||
"GN",
|
||
"Unite",
|
||
"TU",
|
||
"TC",
|
||
"NU",
|
||
"NC",
|
||
"GU",
|
||
"GC",
|
||
"JU",
|
||
"JC",
|
||
"Bigo",
|
||
],
|
||
],
|
||
];
|
||
const TEXT = new Set([
|
||
"Code",
|
||
"Pr",
|
||
"Name",
|
||
"Size",
|
||
"Unite",
|
||
"Bigo",
|
||
"LineData",
|
||
"EXPLEAN",
|
||
"Page1",
|
||
"Page2",
|
||
"Page3",
|
||
"Page4",
|
||
"Page5",
|
||
"Page6",
|
||
]);
|
||
const num = (v) => Number(v) || 0;
|
||
const letter = (n) => {
|
||
let s = "";
|
||
for (n += 1; n > 0; n = Math.floor((n - 1) / 26))
|
||
s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
|
||
return s;
|
||
};
|
||
const col = (s) =>
|
||
[...String(s).toUpperCase()].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
|
||
const unit = (s) =>
|
||
String(s)
|
||
.replace(/^[Mm]2$/, "㎡")
|
||
.replace(/^[Mm]3$/, "㎥");
|
||
|
||
function cell(table, r, spec) {
|
||
const [a, b] = String(spec).toUpperCase().split(":");
|
||
if (!b) return table[r]?.[col(a)];
|
||
let out = "";
|
||
for (let c = col(a); c <= col(b); c++) {
|
||
const v = table[r]?.[c];
|
||
if (v != null && String(v) !== "") out += String(v);
|
||
else if (out.length <= 1) out += " ";
|
||
}
|
||
return out;
|
||
}
|
||
function value(field, raw) {
|
||
if (TEXT.has(field)) {
|
||
let t = String(raw ?? "").replace(/\r?\n/g, "");
|
||
t = field === "LineData" ? t.replaceAll("*", "×") : t.trim();
|
||
if (field === "Unite") t = unit(t);
|
||
if (field === "Name" || field === "Size") t = t.replace(/^\.+/, "");
|
||
return t;
|
||
}
|
||
let v = typeof raw === "number" ? raw : Number(String(raw ?? "").replaceAll(",", "")) || 0;
|
||
if (field === "NCRatio" && v <= 1) v *= 100;
|
||
return v;
|
||
}
|
||
function readRows(table, tab, fields, columns) {
|
||
const rows = [];
|
||
for (let r = tab.start - 1; r <= tab.end - 1; r++) {
|
||
const row = { Index: 0 };
|
||
fields.forEach((f, i) => {
|
||
if (columns[i]) row[f] = value(f, cell(table, r, columns[i]));
|
||
});
|
||
rows.push(row);
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
// 머리 줄 꼴 → { Name, Size, Unite, skipNext }
|
||
function costHead(text, next) {
|
||
const t = String(text ?? "").trim(),
|
||
split = (s) => {
|
||
const parts = s.trim().split(/\s+/);
|
||
return {
|
||
Name: parts[0] || "",
|
||
Size: parts.slice(1, -1).join(" "),
|
||
Unite: parts.length > 1 ? parts.at(-1) : "",
|
||
};
|
||
};
|
||
if (/^#\./.test(t) && t.includes("산근")) {
|
||
if (t.includes("|")) {
|
||
const [, n = "", s = "", u = ""] = t.split("|");
|
||
return { Name: n.trim(), Size: s.trim(), Unite: u.trim() };
|
||
}
|
||
return split(t.replace(/^.*?산근\s*\d*/, "").replace(/\//g, " "));
|
||
}
|
||
if (/^#/.test(t) && t.includes("|")) {
|
||
const [, n = "", s = "", u = ""] = t.split("|");
|
||
return { Name: n.trim(), Size: s.trim(), Unite: u.trim() };
|
||
}
|
||
if (/산근.*호표/.test(t) && t.includes("당")) {
|
||
const body = t.split(/[::]/).slice(1).join(":"),
|
||
u = /\(\s*[\d.]*\s*([^()]*?)\s*당\s*\)/.exec(body)?.[1] || "",
|
||
rest = body
|
||
.replace(/\([^()]*당\s*\)/, "")
|
||
.trim()
|
||
.split(/\s+/);
|
||
return { Name: rest[0] || "", Size: rest.slice(1).join(" "), Unite: u };
|
||
}
|
||
if (/제\s*\d+\s*호표/.test(t) && next != null)
|
||
return { ...split(String(next)), skipNext: true };
|
||
if (t.replace(/\s/g, "").includes("(산근")) return split(t.replace(/\(산근[^)]*\)/, ""));
|
||
return null;
|
||
}
|
||
function headHead(name) {
|
||
const t = String(name ?? "").trim();
|
||
if (/No/.test(t) && t.includes("호표"))
|
||
return { Name: t.split("호표").slice(1).join("호표").trim(), Size: "", Unite: "" };
|
||
if (/^No\.\s*[1-9]/.test(t))
|
||
return { Name: t.replace(/^No\.\s*\d+\s*/, ""), Size: "", Unite: "" };
|
||
if (/\(호표/.test(t)) {
|
||
const parts = t
|
||
.replace(/\(호표[^)]*\)/, "")
|
||
.trim()
|
||
.split(/\s+/);
|
||
return { Name: parts[0] || "", Size: parts.slice(1).join(" "), Unite: "" };
|
||
}
|
||
return null;
|
||
}
|
||
function groups(rows, titles, kind, headOf) {
|
||
let pk = 0;
|
||
const out = [];
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const row = rows[i];
|
||
if (kind === 103 && String(row.Name ?? "").replace(/\s/g, "") === "[합계]") continue;
|
||
const h = headOf(row, rows[i + 1]);
|
||
if (!h) {
|
||
out.push(row);
|
||
continue;
|
||
}
|
||
const index = titles.length + 1,
|
||
code = `N${String(++pk).padStart(10, "0")}`;
|
||
titles.push({
|
||
Index: index,
|
||
Code: code,
|
||
Name: h.Name,
|
||
Size: h.Size,
|
||
Unite: unit(h.Unite),
|
||
TC: num(row.TC),
|
||
NC: num(row.NC),
|
||
GC: num(row.GC),
|
||
JC: num(row.JC),
|
||
});
|
||
Object.assign(row, { Code: code, TitleCode: code, Index: index });
|
||
if (kind === 104)
|
||
row.LineData = `\x0e${h.Name}\x0e${h.Size ? `\x13${h.Size}\x13` : ""}${h.Unite ? `\x15${unit(h.Unite)}\x15` : ""}`;
|
||
out.push(row);
|
||
if (h.skipNext) i++;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function convert(sheets, tabs, template) {
|
||
const project = EstNative.empty();
|
||
project.name = template.name;
|
||
project.options = { ...project.options, AutoCalc: false, SourceRefresh: false };
|
||
project.tables.Wonga = structuredClone(template.wonga || []);
|
||
const failed = [];
|
||
for (const [table, label, fields] of TABS) {
|
||
const tab = tabs[table];
|
||
if (!tab?.sheet || !(tab.start > 0) || !(tab.end > 0)) continue;
|
||
const data = sheets[tab.sheet];
|
||
if (!data) {
|
||
failed.push(`${tab.sheet || label} 시트가 없습니다.`);
|
||
continue;
|
||
}
|
||
let rows = readRows(data, tab, fields, tab.columns);
|
||
if (table === "LaborcDat") rows.forEach((r) => (r.TC = r.Cost1));
|
||
if (table === "SilJukDat" || table === "EstiMateDat")
|
||
rows.forEach((r) =>
|
||
Object.assign(r, {
|
||
OTC: r.TC,
|
||
ONC: r.NC,
|
||
OGC: r.GC,
|
||
OJC: r.JC,
|
||
...(table === "SilJukDat" && { Cost1: r.TC }),
|
||
}),
|
||
);
|
||
if (table === "CostMainDat") {
|
||
project.tables.CostTitleDat = [];
|
||
rows = groups(rows, project.tables.CostTitleDat, 104, (r, n) =>
|
||
costHead(r.LineData, n?.LineData),
|
||
);
|
||
} else if (table === "HeadMainDat") {
|
||
project.tables.HeadTitleDat = [];
|
||
rows = groups(rows, project.tables.HeadTitleDat, 103, (r) => headHead(r.Name));
|
||
}
|
||
project.tables[table] = rows;
|
||
}
|
||
while (project.tables.EstMainDat.length < 36) project.tables.EstMainDat.push({ Index: 0 });
|
||
let pk = 0;
|
||
for (const t of Object.keys(project.tables))
|
||
for (const r of project.tables[t])
|
||
if (r && typeof r === "object" && r.PkID == null) r.PkID = ++pk;
|
||
for (const t of ["LaborcDat", "StuffcDat", "SilJukDat", "EstiMateDat"])
|
||
for (const r of project.tables[t] || [])
|
||
if (!String(r.Code ?? "").trim()) r.Code = `N${String(r.PkID).padStart(10, "0")}`;
|
||
return { project, failed };
|
||
}
|
||
|
||
// 화면
|
||
function form(state) {
|
||
const x = (state.e2e ||= {
|
||
tab: "LaborcDat",
|
||
tabs: Object.fromEntries(
|
||
TABS.map(([t, label, fields]) => [
|
||
t,
|
||
{ sheet: label, start: 0, end: 0, columns: fields.map((_, i) => letter(i)) },
|
||
]),
|
||
),
|
||
});
|
||
const { esc } = EstApp,
|
||
[, , fields] = TABS.find(([t]) => t === x.tab),
|
||
tab = x.tabs[x.tab];
|
||
EstApp.showDialog(
|
||
"엑셀데이타를 EST로 변환",
|
||
`<div class="business-tools">${TABS.map(([t, l]) => `<button data-action="e2e-tab" data-tab="${t}"${t === x.tab ? ' style="font-weight:bold"' : ""}>${l}</button>`).join("")}</div><p><button data-action="e2e-open">엑셀파일열기</button> ${esc(x.file || "")} ${x.sheets ? `(시트 ${Object.keys(x.sheets).length}개)` : ""}</p><div class="form-fields"><label>시트명<input name="e2e-sheet" value="${esc(tab.sheet)}"></label><label>시작행<input name="e2e-start" type="number" value="${tab.start}"></label><label>끝행<input name="e2e-end" type="number" value="${tab.end}"></label></div><p>해당 칼럼명칭 입력(엑셀 열 글자, 비우면 건너뜀, 「B:D」 는 이어 붙임)</p><div class="form-fields">${fields.map((f, i) => `<label>${f}<input name="e2e-col" data-i="${i}" value="${esc(tab.columns[i] || "")}" size="4"></label>`).join("")}</div><button data-action="e2e-run"${x.sheets ? "" : " disabled"}>변 환</button>`,
|
||
);
|
||
}
|
||
function read(state) {
|
||
const x = state.e2e,
|
||
tab = x.tabs[x.tab],
|
||
q = (n) => document.querySelector(`#dialog-content [name="${n}"]`);
|
||
if (!q("e2e-sheet")) return;
|
||
tab.sheet = q("e2e-sheet").value.trim();
|
||
tab.start = Number(q("e2e-start").value) || 0;
|
||
tab.end = Number(q("e2e-end").value) || 0;
|
||
document
|
||
.querySelectorAll('#dialog-content [name="e2e-col"]')
|
||
.forEach((n) => (tab.columns[Number(n.dataset.i)] = n.value.trim().toUpperCase()));
|
||
}
|
||
async function run(name, state, node) {
|
||
if (name === "excel-to-est") return form(state);
|
||
read(state);
|
||
const x = state.e2e;
|
||
if (name === "e2e-tab") x.tab = node.dataset.tab;
|
||
else if (name === "e2e-open") {
|
||
const file = await new Promise((resolve) => {
|
||
const picker = document.createElement("input");
|
||
picker.type = "file";
|
||
picker.accept = ".xlsx,.xls";
|
||
picker.addEventListener("change", () => resolve(picker.files[0]));
|
||
picker.click();
|
||
});
|
||
if (!file) return;
|
||
const body = await file.arrayBuffer();
|
||
x.sheets = /\.xls$/i.test(file.name)
|
||
? EstJdlCompare.xlsTables(await EstApp.api("/api/read-xls", { method: "POST", body }))
|
||
: EstExcelMap.sheetTables(await EstApp.api("/api/import-xlsx", { method: "POST", body }));
|
||
x.file = file.name;
|
||
} else if (name === "e2e-run") {
|
||
const tabs = Object.values(x.tabs);
|
||
if (
|
||
tabs.every((t) => !t.start) ||
|
||
tabs.every((t) => !t.end) ||
|
||
tabs.some((t) => t.end < t.start)
|
||
)
|
||
throw Error("변환할 엑셀자료의 범위설정이 잘못되었습니다.");
|
||
if (!confirm("현재 작업을 엑셀 자료로 만든 새 공사로 바꾸시겠습니까?")) return;
|
||
const out = convert(x.sheets, x.tabs, {
|
||
name: x.file.replace(/\.xlsx?$/i, ""),
|
||
wonga: (state.project.tables.Wonga || []).map(({ Cost, ...rest }) => rest),
|
||
});
|
||
if (
|
||
out.failed.length &&
|
||
!confirm(`${out.failed.join("\n")}\n변환을 못한 시트가 있습니다.\n\n종료하시겠습니까?`)
|
||
)
|
||
return;
|
||
EstApp.byId("dialog").close();
|
||
EstApp.setProject(out.project);
|
||
state.notice =
|
||
"엑셀 역변환 작업이 완료되었습니다. 금액은 엑셀 값 그대로이며 자동계산은 꺼 두었습니다.";
|
||
EstApp.render();
|
||
return;
|
||
}
|
||
form(state);
|
||
}
|
||
const names = new Set(["excel-to-est", "e2e-tab", "e2e-open", "e2e-run"]);
|
||
function handle(name, state, node) {
|
||
if (!names.has(name)) return false;
|
||
run(name, state, node).catch((error) => {
|
||
state.notice = error.message;
|
||
EstApp.render();
|
||
});
|
||
return true;
|
||
}
|
||
return { convert, costHead, headHead, handle };
|
||
})();
|