- 원본 엑셀 변환 내역서 탭처럼 시트·행 범위·항목별 열 문자로 남의 Excel 내역을 읽음 - 미리보기, 순공사비 위 추가·바꾸기, 단위·수량 없는 줄은 공종 제목 - 수식 셀은 저장된 계산값 사용, 합계단가만 있는 줄은 비목 배분 없이 거부 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
288 lines
12 KiB
JavaScript
288 lines
12 KiB
JavaScript
// 원본 엑셀데이타를 EST로 변환(TFRMEXCELTOEST) 내역서 탭 — 시트·시작행·끝행과 필드별 열 문자로 남의 Excel 내역을 가져온다.
|
|
// 필드는 원본 tvViewEst 열(Code·Pr·Name·Size·GN·Unite·TU·TC·NU·NC·GU·GC·JU·JC·Bigo). 금액은 다시 계산하고 단가만 받는다.
|
|
// 단위·수량이 없는 줄은 공종 제목(Level 1). 합계단가만 있고 노무·재료·경비 단가가 없으면 비목을 정할 수 없어 거부한다.
|
|
const EstExcelMap = (() => {
|
|
const fields = [
|
|
["Code", "코드"],
|
|
["Pr", "번호"],
|
|
["Name", "품명"],
|
|
["Size", "규격"],
|
|
["GN", "수량"],
|
|
["Unite", "단위"],
|
|
["TU", "합계단가"],
|
|
["TC", "합계금액"],
|
|
["NU", "노무단가"],
|
|
["NC", "노무금액"],
|
|
["GU", "재료단가"],
|
|
["GC", "재료금액"],
|
|
["JU", "경비단가"],
|
|
["JC", "경비금액"],
|
|
["Bigo", "비고"],
|
|
];
|
|
const numeric = new Set(["GN", "TU", "TC", "NU", "NC", "GU", "GC", "JU", "JC"]);
|
|
const letters = (value) =>
|
|
String(value ?? "")
|
|
.trim()
|
|
.toUpperCase();
|
|
|
|
function columnIndex(value) {
|
|
const text = letters(value);
|
|
if (!text) return -1;
|
|
if (!/^[A-Z]{1,3}$/.test(text)) throw Error(`열 문자가 올바르지 않습니다: ${value}`);
|
|
const index = [...text].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
|
|
if (index >= 16384) throw Error(`열 범위를 넘었습니다: ${value}`);
|
|
return index;
|
|
}
|
|
function columnName(index) {
|
|
let name = "";
|
|
for (let n = index + 1; n > 0; n = Math.floor((n - 1) / 26))
|
|
name = String.fromCharCode(65 + ((n - 1) % 26)) + name;
|
|
return name;
|
|
}
|
|
function number(value, where) {
|
|
if (value == null || value === "") return null;
|
|
if (typeof value === "number") return value;
|
|
const text = String(value).replace(/,/g, "").trim();
|
|
if (!text) return null;
|
|
const parsed = Number(text);
|
|
if (!Number.isFinite(parsed)) throw Error(`${where}: 숫자가 아닙니다 (${value})`);
|
|
return parsed;
|
|
}
|
|
|
|
function records(table, start, end, columns) {
|
|
if (!Number.isInteger(start) || start < 1) throw Error("시작행을 1 이상으로 입력하세요.");
|
|
if (!Number.isInteger(end) || end < start)
|
|
throw Error("끝행은 시작행보다 크거나 같아야 합니다.");
|
|
const map = Object.entries(columns)
|
|
.map(([key, value]) => [key, columnIndex(value)])
|
|
.filter(([, index]) => index >= 0);
|
|
if (!map.some(([key]) => key === "Name")) throw Error("품명 열을 지정하세요.");
|
|
const out = [];
|
|
for (let line = start; line <= Math.min(end, table.length); line++) {
|
|
const cells = table[line - 1] || [],
|
|
record = { line };
|
|
for (const [key, index] of map) {
|
|
const value = cells[index];
|
|
record[key] = numeric.has(key)
|
|
? number(value, `${columnName(index)}${line}`)
|
|
: value == null
|
|
? ""
|
|
: String(value).trim();
|
|
}
|
|
if (map.some(([key]) => record[key] !== "" && record[key] != null)) out.push(record);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function rows(project, list) {
|
|
const built = [],
|
|
rejected = [],
|
|
warnings = [];
|
|
const table = [...(project.tables.EstMainDat || [])];
|
|
for (const record of list) {
|
|
if (!record.Name && !record.Size && record.GN == null) continue;
|
|
const row = EstModel.newRow("EstMainDat", table, project);
|
|
table.push(row);
|
|
Object.assign(row, { Name: record.Name || "", Size: record.Size || "", Pr: record.Pr || "" });
|
|
if (record.Code) row.Code = record.Code;
|
|
if (record.Bigo) row.Bigo = record.Bigo;
|
|
if (!record.Unite && record.GN == null) {
|
|
Object.assign(row, { Level: 1, GN: null, Unite: "" });
|
|
built.push(row);
|
|
continue;
|
|
}
|
|
const split = ["NU", "GU", "JU"].some((key) => record[key] != null);
|
|
if (!split && record.TU) {
|
|
rejected.push(record.line);
|
|
continue;
|
|
}
|
|
Object.assign(row, {
|
|
Unite: record.Unite || "",
|
|
GN: record.GN ?? 0,
|
|
NU: record.NU ?? 0,
|
|
GU: record.GU ?? 0,
|
|
JU: record.JU ?? 0,
|
|
});
|
|
if (record.TU != null && Math.abs(row.NU + row.GU + row.JU - record.TU) > 0.5)
|
|
warnings.push(record.line);
|
|
built.push(row);
|
|
}
|
|
if (rejected.length)
|
|
throw Error(
|
|
`${rejected.slice(0, 5).join(", ")}행${rejected.length > 5 ? ` 외 ${rejected.length - 5}개` : ""}: 합계단가만 있습니다. 노무·재료·경비 단가 열을 지정하거나 합계단가 열을 비우세요.`,
|
|
);
|
|
return { rows: built, warnings };
|
|
}
|
|
|
|
function insert(project, built, mode) {
|
|
const list = project.tables.EstMainDat;
|
|
let boundary = list.findIndex((row) => Number(row.LinkIndex) === 54);
|
|
if (boundary < 0) boundary = list.length;
|
|
if (mode === "replace") {
|
|
list.splice(0, boundary, ...built);
|
|
return built.length;
|
|
}
|
|
list.splice(boundary, 0, ...built);
|
|
return built.length;
|
|
}
|
|
|
|
// 화면: Excel 시트 XML을 읽어 행 배열로 바꾼다. 수식 셀은 Excel이 저장한 계산값을 쓴다.
|
|
function sheetTables(files) {
|
|
const parse = (source) => {
|
|
if (/<!DOCTYPE|<!ENTITY/i.test(source)) throw Error("XML 외부 선언은 허용하지 않습니다.");
|
|
const doc = new DOMParser().parseFromString(source, "application/xml");
|
|
if (doc.querySelector("parsererror")) throw Error("Excel XML 구문 오류");
|
|
return doc;
|
|
};
|
|
const shared = files["xl/sharedStrings.xml"]
|
|
? [...parse(files["xl/sharedStrings.xml"]).getElementsByTagNameNS("*", "si")].map((si) =>
|
|
[...si.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join(""),
|
|
)
|
|
: [];
|
|
const rels = [
|
|
...parse(files["xl/_rels/workbook.xml.rels"]).getElementsByTagNameNS("*", "Relationship"),
|
|
];
|
|
const tables = {};
|
|
for (const sheet of parse(files["xl/workbook.xml"]).getElementsByTagNameNS("*", "sheet")) {
|
|
const id = sheet.getAttributeNS(
|
|
"http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
|
"id",
|
|
),
|
|
target = rels.find((r) => r.getAttribute("Id") === id)?.getAttribute("Target") || "",
|
|
path = target.startsWith("/") ? target.slice(1) : "xl/" + target;
|
|
if (!files[path]) continue;
|
|
const table = [];
|
|
for (const row of parse(files[path]).getElementsByTagNameNS("*", "row")) {
|
|
const line = Number(row.getAttribute("r")) || table.length + 1,
|
|
values = [];
|
|
for (const cell of row.getElementsByTagNameNS("*", "c")) {
|
|
const at = columnIndex(cell.getAttribute("r")?.match(/^[A-Z]+/)?.[0] || "");
|
|
if (at < 0) continue;
|
|
const type = cell.getAttribute("t"),
|
|
v = cell.getElementsByTagNameNS("*", "v")[0]?.textContent;
|
|
if (type === "s") values[at] = shared[Number(v)];
|
|
else if (type === "inlineStr")
|
|
values[at] = [...cell.getElementsByTagNameNS("*", "t")]
|
|
.map((t) => t.textContent)
|
|
.join("");
|
|
else if (type === "e") values[at] = null;
|
|
else if (type === "str" || type === "b") values[at] = v ?? "";
|
|
else values[at] = v == null ? null : Number(v);
|
|
}
|
|
table[line - 1] = values;
|
|
}
|
|
tables[sheet.getAttribute("name")] = table;
|
|
}
|
|
return tables;
|
|
}
|
|
|
|
function preview(state) {
|
|
const map = state.excelMap,
|
|
table = map.tables[map.sheet] || [],
|
|
shown = table.slice(map.start - 1, map.start + 7),
|
|
{ esc } = EstApp;
|
|
const width = Math.min(16, Math.max(1, ...shown.map((r) => (r || []).length)));
|
|
const head = Array.from({ length: width }, (_, i) => `<th>${columnName(i)}</th>`).join("");
|
|
const body = shown
|
|
.map(
|
|
(cells, i) =>
|
|
`<tr><th>${map.start + i}</th>${Array.from({ length: width }, (_, c) => `<td>${esc(cells?.[c] ?? "")}</td>`).join("")}</tr>`,
|
|
)
|
|
.join("");
|
|
return `<div style="max-height:200px;overflow:auto;border:1px solid #ccc"><table class="sheet" style="font-size:11px"><thead><tr><th></th>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
|
|
}
|
|
|
|
function form(state) {
|
|
const map = state.excelMap,
|
|
{ esc } = EstApp;
|
|
const sheets = Object.keys(map.tables)
|
|
.map(
|
|
(name) =>
|
|
`<option value="${esc(name)}" ${name === map.sheet ? "selected" : ""}>${esc(name)}</option>`,
|
|
)
|
|
.join("");
|
|
const mode = (value, label) =>
|
|
`<option value="${value}" ${map.mode === value ? "selected" : ""}>${label}</option>`;
|
|
EstApp.showDialog(
|
|
"Excel 내역 열 맞춰 가져오기",
|
|
`<div class="form-fields"><label>시트<select name="xm-sheet">${sheets}</select></label><label>시작행<input name="xm-start" type="number" value="${map.start}"></label><label>끝행<input name="xm-end" type="number" value="${map.end}"></label><label>가져오기<select name="xm-mode">${mode("append", "순공사비 위에 추가")}${mode("replace", "순공사비 위 행을 이것으로 바꾸기")}</select></label></div><p>각 항목이 있는 Excel 열 문자(A, B …)를 적으세요. 비우면 가져오지 않습니다. 금액은 단가×수량으로 다시 계산합니다.</p><div class="form-fields">${fields.map(([key, label]) => `<label>${label}<input name="xm-${key}" value="${esc(map.columns[key] || "")}" size="3"></label>`).join("")}</div><button data-action="excel-map-preview">미리보기 새로고침</button> <button data-action="excel-map-apply">가져오기</button><div id="excel-map-preview">${preview(state)}</div>`,
|
|
);
|
|
}
|
|
|
|
function read(state) {
|
|
const value = (name) => document.querySelector(`#dialog-content [name="${name}"]`)?.value;
|
|
const map = state.excelMap;
|
|
map.sheet = value("xm-sheet") ?? map.sheet;
|
|
map.start = Number(value("xm-start")) || 1;
|
|
map.end = Number(value("xm-end")) || map.start;
|
|
map.mode = value("xm-mode") || "append";
|
|
for (const [key] of fields) map.columns[key] = letters(value(`xm-${key}`));
|
|
}
|
|
|
|
function load(state, files) {
|
|
const tables = sheetTables(files),
|
|
sheet = Object.keys(tables)[0];
|
|
if (!sheet) throw Error("읽을 시트가 없습니다.");
|
|
state.excelMap = {
|
|
start: 1,
|
|
mode: "append",
|
|
columns: {},
|
|
...state.excelMap,
|
|
tables,
|
|
sheet,
|
|
end: tables[sheet].length,
|
|
documentKey: state.documentKey,
|
|
};
|
|
form(state);
|
|
}
|
|
|
|
function open(state) {
|
|
if (state.view !== "EstMainDat") throw Error("내역서 화면에서 Excel 내역을 가져오세요.");
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = ".xlsx";
|
|
input.addEventListener("change", async () => {
|
|
try {
|
|
if (!input.files[0]) return;
|
|
const body = await input.files[0].arrayBuffer();
|
|
load(state, await EstApp.api("/api/import-xlsx", { method: "POST", body }));
|
|
} catch (error) {
|
|
state.notice = error.message;
|
|
EstApp.render();
|
|
}
|
|
});
|
|
input.click();
|
|
}
|
|
|
|
function apply(state) {
|
|
read(state);
|
|
const map = state.excelMap;
|
|
if (map.documentKey !== state.documentKey) throw Error("작업이 바뀌었습니다. 다시 여세요.");
|
|
const list = records(map.tables[map.sheet] || [], map.start, map.end, map.columns),
|
|
{ rows: built, warnings } = rows(state.project, list);
|
|
if (!built.length) throw Error("가져올 행이 없습니다. 시작행·끝행과 품명 열을 확인하세요.");
|
|
const headings = built.filter((row) => Number(row.Level) > 0).length,
|
|
where = map.mode === "replace" ? "순공사비 위 행을 바꿔" : "순공사비 위에",
|
|
differ = warnings.length
|
|
? `\n합계단가와 노무·재료·경비 단가 합이 다른 행: ${warnings.slice(0, 5).join(", ")}`
|
|
: "";
|
|
if (!confirm(`${built.length}행(공종 제목 ${headings}행)을 ${where} 넣습니다.${differ}`))
|
|
return;
|
|
EstApp.byId("dialog").close();
|
|
EstApp.change((project) => insert(project, built, map.mode));
|
|
state.notice = `Excel에서 ${built.length}행을 가져왔습니다. 단가 고르기로 기초자료에 연결할 수 있습니다.`;
|
|
}
|
|
|
|
function handle(name, state) {
|
|
if (name === "excel-map-open") open(state);
|
|
else if (name === "excel-map-preview") {
|
|
read(state);
|
|
form(state);
|
|
} else if (name === "excel-map-apply") apply(state);
|
|
else return false;
|
|
return true;
|
|
}
|
|
|
|
return { fields, columnIndex, columnName, records, rows, insert, load, handle };
|
|
})();
|