// WPTools bookmarks retain the original quantity sheet's result identities. const EstQuantity = (() => { function entities(text) { return String(text) .replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { const code = n[0].toLowerCase() === "x" ? parseInt(n.slice(1), 16) : Number(n); return code <= 0x10ffff ? String.fromCodePoint(code) : ""; }) .replace(/"/g, '"') .replace(/'|'/g, "'") .replace(/</g, "<") .replace(/>/g, ">") .replace(/ /g, " ") .replace(/&/g, "&"); } function fromWpt(code, name, text, items, options) { const tags = new Map(), rows = []; const superscripts = new Set( [...text.matchAll(/]*\bnr=(\d+)[^>]*>/gi)] .filter((m) => (Number(m[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0) .map((m) => m[1]), ); for (const match of text.matchAll(/]*\bTag=(\d+)[^>]*>/gi)) { const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1]; if (name) tags.set(match[1], name); } for (const match of text.matchAll(/]*>([\s\S]*?)<\/div>/gi)) { const markers = []; let raised = false; const tag = (name) => /^r\d+$/.test(name) || /^L\d+$/.test(name) ? "\x12" : /^N\d+$/.test(name) ? "\x0e" : /^S\d+$/.test(name) ? "\x13" : ""; let body = match[1].replace(//gi, (_, id) => { const next = superscripts.has(id), value = next && !raised ? "^(" : !next && raised ? ")" : ""; raised = next; return value; }); if (raised) body += ")"; body = body .replace(/]*\bTag=(\d+)[^>]*>/gi, (_, id) => { const name = tags.get(id) || ""; if (tag(name) === "\x12") markers.push(name); return tag(name); }) .replace(/]*\bTag=(\d+)[^>]*>/gi, (_, id) => tag(tags.get(id) || "")); body = entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, "")); if (body.trim()) rows.push({ LineData: body, markers }); } return { Code: code, Name: name, rows, items, options }; } function calculate(sheet) { const markerErrors = sheet.rows.flatMap((row, index) => { const count = [...String(row.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)].length; return count === (row.markers || []).length ? [] : [`${index + 1}행: 결과 표지 수가 다릅니다.`]; }); const input = [ { Index: 1, Code: sheet.Code, LineData: "" }, ...sheet.rows.map((row) => { const ResultFormats = (row.markers || []).map((name) => { const override = name.match(/^[LrG]\d{6}([123])(\d)$/); return { digits: override ? Number(override[2]) : (sheet.options?.digits ?? 2), method: override ? Number(override[1]) : (sheet.options?.roundMode ?? 2), }; }); const format = ResultFormats[0] || { digits: sheet.options?.digits ?? 2, method: sheet.options?.roundMode ?? 2, }; return { ...row, LineData: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴/g, ""), Index: 0, SCheck: 0, LinkIndex: 0, Precision: format.digits, RoundMode: format.method, DigetIndex: format.method, Diget: format.digits + 1, ResultFormats, }; }), ]; const result = EstText.calculate(input), values = new Map(), checks = []; result.rows.slice(1).forEach((row, index) => { const actual = [...String(row.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)]; const stored = [...String(sheet.rows[index].LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)]; (sheet.rows[index].markers || []).forEach((name, part) => { if (!actual[part]) return; const value = Number(actual[part][1].replaceAll(",", "")); values.set(name, value); checks.push({ name, stored: Number(stored[part]?.[1].replaceAll(",", "")), calculated: value, }); }); let part = 0; row.LineData = sheet.rows[index].LineData.replace( /\x12([-\d.,]+)\x12/g, (whole) => actual[part++]?.[0] || whole, ); }); return { rows: result.rows.slice(1), items: sheet.items.map((item) => ({ ...item, GN: values.get(item.Code) ?? item.GN })), checks, errors: [...result.errors, ...markerErrors], }; } function manual(items) { const values = Object.create(null), codes = new Set(), errors = []; const rows = items.map((row) => { try { if (!row.Code || codes.has(row.Code)) throw Error("산출코드가 비어 있거나 중복됩니다."); codes.add(row.Code); const GN = EstMath.floating( EstExpression.evaluate(row.formula || "0", (key) => values[key]), row.digits ?? 2, 1, ); values[row.Code] = GN; return { ...row, GN }; } catch (error) { errors.push(`${row.Code || "수량"}: ${error.message}`); return { ...row, GN: null, Error: error.message }; } }); return { rows, errors }; } function estimateTargets(project) { const byKey = new Map(), byCode = new Map(); project.tables.EstMainDat.forEach((row, index) => { if (Number(row.Level)) return; const add = (map, key) => { const entries = map.get(key) || []; entries.push(index); map.set(key, entries); }; if (row.PkID != null) add(byKey, `id:${row.PkID}`); add(byCode, row.Code); }); return { byKey, byCode }; } function totals(project, selected, allowEmpty = false, targets = estimateTargets(project)) { let items; if (selected && selected !== "manual") { const sheet = project.quantitySheets?.find((s) => s.Code === selected); if (!sheet) throw Error("선택한 수량산출서가 없습니다."); const output = calculate(sheet); if (output.errors.length) throw Error(output.errors.join(" / ")); items = output.items; } else { const output = manual(project.quantities || []); if (output.errors.length) throw Error(output.errors.join(" / ")); items = output.rows; } const { byKey, byCode } = targets; const quantities = new Map(); for (const item of items) { const code = item.TargetCode ?? item.LinkCode; if (!code && !item.TargetKey) continue; const targets = (item.TargetKey ? byKey.get(item.TargetKey) : byCode.get(code)) || []; if (!targets.length) throw Error(`연결 내역 없음: ${item.TargetKey || code}`); if (targets.length > 1) throw Error(`연결 내역 중복: ${item.TargetKey || code}`); const index = targets[0]; quantities.set(index, (quantities.get(index) || 0) + Number(item.GN || 0)); } if (!quantities.size && !allowEmpty) throw Error("연결할 내역코드를 먼저 입력하세요."); return quantities; } function allTotals(project) { const combined = new Map(); const codes = (project.quantitySheets || []).map((sheet) => sheet.Code); if ( codes.some((code) => typeof code !== "string" || !code || code === "manual") || new Set(codes).size !== codes.length ) throw Error("수량산출서 코드가 없거나 중복됩니다."); const targets = estimateTargets(project); for (const selected of ["manual", ...codes]) for (const [index, value] of totals(project, selected, true, targets)) combined.set(index, (combined.get(index) || 0) + value); if (!combined.size) throw Error("연결할 내역코드를 먼저 입력하세요."); return combined; } function applyTotals(project, quantities) { for (const [index, value] of quantities) project.tables.EstMainDat[index].GN = value; } function targetSelect(project, item, section, index) { const { esc } = EstApp; const selected = item.TargetKey ? `` : ""; return ``; } function newCode(items) { const codes = new Set(items.map((row) => row.Code)); for (const row of items) for (const match of String(row.formula || "").matchAll(/\[([^\]]+)\]/g)) codes.add(match[1]); let index = 1; while (codes.has(`Q${index}`)) index++; return `Q${index}`; } function selector(state) { const { esc } = EstApp; return `
`; } function validExcelAddress(address) { const parts = typeof address === "string" && address.match(/^([A-Z]{1,3})([1-9]\d*)$/); return Boolean( parts && [...parts[1]].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) <= 16384 && Number(parts[2]) <= 1048576, ); } function excelCell(files, name, address) { const parse = (text) => { if (!text || / s.getAttribute("name") === name, ); if (!sheet) throw Error(`Excel 시트가 없습니다: ${name}`); const id = sheet.getAttributeNS( "http://schemas.openxmlformats.org/officeDocument/2006/relationships", "id", ), rels = parse(files["xl/_rels/workbook.xml.rels"]), rel = [...rels.getElementsByTagNameNS("*", "Relationship")].find( (r) => r.getAttribute("Id") === id, ), target = rel?.getAttribute("Target") || "", path = target.startsWith("/") ? target.slice(1) : `xl/${target}`; const doc = parse(files[path]), cell = [...doc.getElementsByTagNameNS("*", "c")].find((c) => c.getAttribute("r") === address); if (!cell || (cell.getAttribute("t") && cell.getAttribute("t") !== "n")) throw Error(`${name} ${address}: 숫자 셀이 아닙니다.`); const value = cell.getElementsByTagNameNS("*", "v")[0]?.textContent; if ( value == null || !value.trim() || !Number.isFinite(Number(value)) || Math.abs(Number(value)) > Number.MAX_SAFE_INTEGER ) throw Error(`${name} ${address}: 저장된 숫자 값이 없습니다.`); return Number(value); } function excelTarget(row) { return ( row && !Number(row.Level) && ![51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(Number(row.LinkIndex)) && !["%", "%"].includes(row.Unite) ); } function excelDialog() { const { state, esc, showDialog } = EstApp; const rows = state.project.tables.EstMainDat; showDialog( "Excel 셀 수량 반영", `

파일명·시트·셀 연결은 작업 JSON에 저장합니다. 저장된 숫자 값을 사용하므로 Excel 파일이 바뀌면 연결 관리에서 다시 선택하세요.

`, ); } function excelUpdates(project, filename, files) { const links = (project.excelQuantityLinks || []).filter((link) => link.file === filename); if (!links.length) throw Error("선택한 파일에 연결된 내역이 없습니다."); return links.map((link) => { const rows = project.tables.EstMainDat.filter((row) => row.PkID === link.target); if (rows.length !== 1 || !excelTarget(rows[0])) throw Error(`연결 내역이 없거나 중복되었거나 상세 행이 아닙니다: ${link.target}`); return { row: rows[0], value: excelCell(files, link.sheet, link.cell) }; }); } function excelLinksDialog() { const { state, esc, showDialog } = EstApp, links = state.project.excelQuantityLinks || []; showDialog( "Excel 수량 연결 관리", `

연결된 파일을 다시 선택하면 해당 파일의 모든 연결을 갱신합니다.

${links .map((link, index) => { const row = state.project.tables.EstMainDat.find((r) => r.PkID === link.target); return ``; }) .join("")}
파일시트셀내역작업
${esc(link.file)}${esc(link.sheet)}${esc(link.cell)}${esc(row?.Name || link.target)}
`, ); } function render(state) { state.quantitySheet ||= state.project.quantitySheets[0]?.Code; const sheet = state.project.quantitySheets.find((s) => s.Code === state.quantitySheet) || state.project.quantitySheets[0]; state.quantitySheet = sheet.Code; const output = calculate(sheet), { esc, number, byId } = EstApp; byId("work-content").innerHTML = selector(state) + `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; } function install() { document.addEventListener("focusin", (event) => { const node = event.target; if (node.dataset.quantitySelect === undefined || node.dataset.quantityOptionsLoaded) return; const value = node.value; const { esc, state } = EstApp; node.innerHTML = '' + state.project.tables.EstMainDat.filter((row) => row.PkID != null && !Number(row.Level)) .map((row) => { const key = `id:${row.PkID}`; return ``; }) .join(""); node.value = value; node.dataset.quantityOptionsLoaded = "1"; }); document.addEventListener("change", (event) => { const node = event.target, { state } = EstApp; if (node.id === "quantity-sheet-choice") { state.quantitySheet = node.value; EstApp.render(); return; } if (node.dataset.quantitySelect !== undefined) { const item = node.dataset.quantitySelect === "manual" ? state.project.quantities[Number(node.dataset.index)] : state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet)?.items[ Number(node.dataset.index) ]; if (!item) return; const row = state.project.tables.EstMainDat.find( (r) => r.PkID != null && `id:${r.PkID}` === node.value && !Number(r.Level), ); EstApp.change(() => { item.TargetKey = row ? node.value : ""; item.TargetCode = row?.Code || ""; }, false); return; } const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); if (!sheet) return; if (node.dataset.quantityName !== undefined) EstApp.change(() => (sheet.Name = node.value), false); if (["Name", "Size", "Unite"].includes(node.dataset.quantityItem)) EstApp.change( () => (sheet.items[Number(node.dataset.index)][node.dataset.quantityItem] = node.value), false, ); if (node.dataset.quantityLine !== undefined) EstApp.change( () => (sheet.rows[Number(node.dataset.quantityLine)].LineData = EstText.encode(node.value)), false, ); if (node.dataset.quantityTarget !== undefined) EstApp.change(() => { const item = sheet.items[Number(node.dataset.quantityTarget)]; item.TargetCode = node.value; item.TargetKey = ""; }, false); }); document.addEventListener("click", (event) => { const { state } = EstApp; try { if (event.target.closest("[data-quantity-excel]")) { excelDialog(); return; } if (event.target.closest("[data-quantity-excel-links]")) { excelLinksDialog(); return; } const removeLink = event.target.closest("[data-quantity-excel-remove]"); if (removeLink) { EstApp.change( () => state.project.excelQuantityLinks.splice( Number(removeLink.dataset.quantityExcelRemove), 1, ), false, ); document.getElementById("dialog").close(); excelLinksDialog(); return; } if (event.target.closest("[data-quantity-excel-refresh]")) { const project = state.project, revision = state.revision, input = document.getElementById("quantity-excel-refresh-file"), file = input.files[0]; (async () => { if (!file) throw Error("Excel 파일을 선택하세요."); const files = await EstApp.api("/api/import-xlsx", { method: "POST", body: await file.arrayBuffer(), }); if ( state.project !== project || state.revision !== revision || document.getElementById("quantity-excel-refresh-file") !== input || input.files[0] !== file ) throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요."); const updates = excelUpdates(project, file.name, files); EstApp.change(() => updates.forEach(({ row, value }) => (row.GN = value))); document.getElementById("dialog").close(); })().catch((error) => { const output = document.getElementById("quantity-excel-links-error"); if (output) output.textContent = error.message; }); return; } if (event.target.closest("[data-quantity-excel-apply]")) { const project = state.project, revision = state.revision, fileInput = document.getElementById("quantity-excel-file"), file = fileInput.files[0], name = document.getElementById("quantity-excel-sheet").value.trim(), address = document.getElementById("quantity-excel-cell").value, selected = document.getElementById("quantity-excel-target").value, row = selected === "" ? null : project.tables.EstMainDat[Number(selected)]; (async () => { if (!file || !name || !row) throw Error("파일·시트·셀·내역 행을 선택하세요."); if ( !Number.isSafeInteger(row.PkID) || project.tables.EstMainDat.filter((item) => item.PkID === row.PkID).length !== 1 ) throw Error("내역 행 식별자가 없거나 중복되어 연결할 수 없습니다."); const files = await EstApp.api("/api/import-xlsx", { method: "POST", body: await file.arrayBuffer(), }), value = excelCell(files, name, address); if ( state.project !== project || state.revision !== revision || document.getElementById("quantity-excel-file") !== fileInput || fileInput.files[0] !== file || document.getElementById("quantity-excel-sheet")?.value.trim() !== name || document.getElementById("quantity-excel-cell")?.value !== address || document.getElementById("quantity-excel-target")?.value !== selected || project.tables.EstMainDat[Number(selected)] !== row ) throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요."); EstApp.change(() => { row.GN = value; project.excelQuantityLinks ||= []; project.excelQuantityLinks = project.excelQuantityLinks.filter( (link) => link.target !== row.PkID, ); project.excelQuantityLinks.push({ target: row.PkID, file: file.name, sheet: name, cell: address.trim().toUpperCase(), }); }); document.getElementById("dialog").close(); })().catch((error) => { const output = document.getElementById("quantity-excel-error"); if (output) output.textContent = error.message; }); return; } if (event.target.closest("[data-quantity-new]")) { EstApp.change(() => { const code = `Q${EstApp.uid()}`; state.project.quantitySheets ||= []; state.project.quantitySheets.push({ Code: code, Name: "새 수량산출서", rows: [], items: [], options: { digits: 2, roundMode: 2 }, }); state.quantitySheet = code; }, false); return; } const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); const addItem = event.target.closest("[data-quantity-add-item]"); if (addItem && sheet) { EstApp.change(() => { const codes = new Set(sheet.items.map((it) => it.Code)); let index = 0; while (codes.has(`r${String(index).padStart(6, "0")}`)) index++; const code = `r${String(index).padStart(6, "0")}`; sheet.items.push({ Code: code, Name: "새 수량항목", Size: "", Unite: "", GN: 0, TargetCode: "", }); }, false); return; } const deleteItem = event.target.closest("[data-quantity-delete-item]"); if (deleteItem && sheet) { const itemIdx = Number(deleteItem.dataset.quantityDeleteItem); const targetItem = sheet.items[itemIdx]; if (!targetItem) return; const code = targetItem.Code; const referringRows = sheet.rows.filter((row) => (row.markers || []).includes(code)); if (referringRows.length > 0) { throw Error( `산출 행에서 참조 중인 수량 항목(${targetItem.Name || code})은 직접 삭제할 수 없습니다. 연결된 산출 행을 삭제하거나, 미참조 항목만 삭제할 수 있습니다.`, ); } if (confirm("이 수량 항목을 삭제하시겠습니까?")) EstApp.change(() => { sheet.items.splice(itemIdx, 1); }, false); return; } const addRow = event.target.closest("[data-quantity-add]"); if (addRow && sheet) { EstApp.change(() => { const code = `r${EstApp.uid()}`; const at = addRow.dataset.index === undefined ? sheet.rows.length : Number(addRow.dataset.index) + 1; sheet.rows.splice(at, 0, { LineData: "1 = \x120\x12", markers: [code] }); sheet.items.push({ Code: code, Name: "새 항목", Size: "", GN: 0, TargetCode: "" }); }, false); return; } const moveRow = event.target.closest("[data-quantity-move]"); if (moveRow && sheet) { const index = Number(moveRow.dataset.index), next = index + Number(moveRow.dataset.quantityMove); if (index >= 0 && index < sheet.rows.length && next >= 0 && next < sheet.rows.length) EstApp.change(() => { [sheet.rows[index], sheet.rows[next]] = [sheet.rows[next], sheet.rows[index]]; }, false); return; } const deleteRow = event.target.closest("[data-quantity-delete-row]"); if (deleteRow && sheet) { if (confirm("이 산출 행과 연결된 수량 항목을 삭제하시겠습니까?")) EstApp.change(() => { const [removed] = sheet.rows.splice(Number(deleteRow.dataset.quantityDeleteRow), 1); const remaining = new Set(sheet.rows.flatMap((row) => row.markers || [])); sheet.items = sheet.items.filter( (item) => !removed.markers.includes(item.Code) || remaining.has(item.Code), ); }, false); return; } if (event.target.closest("[data-quantity-delete-sheet]") && sheet) { if (confirm("이 수량산출서를 삭제하시겠습니까?")) EstApp.change(() => { state.project.quantitySheets = state.project.quantitySheets.filter( (item) => item !== sheet, ); state.quantitySheet = state.project.quantitySheets[0]?.Code || "manual"; }, false); return; } if (!event.target.closest("[data-quantity-apply]")) return; const quantities = totals(state.project, state.quantitySheet); EstApp.change(() => applyTotals(state.project, quantities)); } catch (error) { EstApp.state.notice = error.message; EstApp.render(); } }); } return { entities, fromWpt, calculate, manual, totals, allTotals, applyTotals, excelCell, excelTarget, validExcelAddress, excelUpdates, targetSelect, newCode, selector, render, install, }; })(); if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity;