// 원본 엑셀데이타를 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 (/ [...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) => `${columnName(i)}`).join(""); const body = shown .map( (cells, i) => `${map.start + i}${Array.from({ length: width }, (_, c) => `${esc(cells?.[c] ?? "")}`).join("")}`, ) .join(""); return `
${head}${body}
`; } function form(state) { const map = state.excelMap, { esc } = EstApp; const sheets = Object.keys(map.tables) .map( (name) => ``, ) .join(""); const mode = (value, label) => ``; EstApp.showDialog( "Excel 내역 열 맞춰 가져오기", `

각 항목이 있는 Excel 열 문자(A, B …)를 적으세요. 비우면 가져오지 않습니다. 금액은 단가×수량으로 다시 계산합니다.

${fields.map(([key, label]) => ``).join("")}
${preview(state)}
`, ); } 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 }; })();