Files
Aislo/tools/est-test/native-quantity.cjs
T

452 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(/&quot;/g, '"')
.replace(/&apos;|&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&");
}
function fromWpt(code, name, text, items, options) {
const tags = new Map(),
rows = [];
const superscripts = new Set(
[...text.matchAll(/<cs\b[^>]*\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(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi)) {
const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1];
if (name) tags.set(match[1], name);
}
for (const match of text.matchAll(/<div\b[^>]*>([\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(/<c\s+nr=(\d+)\s*\/>/gi, (_, id) => {
const next = superscripts.has(id),
value = next && !raised ? "^(" : !next && raised ? ")" : "";
raised = next;
return value;
});
if (raised) body += ")";
body = body
.replace(/<begin\b[^>]*\bTag=(\d+)[^>]*>/gi, (_, id) => {
const name = tags.get(id) || "";
if (tag(name) === "\x12") markers.push(name);
return tag(name);
})
.replace(/<end\b[^>]*\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 totals(project, selected, allowEmpty = false) {
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 quantities = new Map();
for (const item of items) {
const code = item.TargetCode ?? item.LinkCode;
if (!code && !item.TargetKey) continue;
const targets = project.tables.EstMainDat.map((row, index) => ({ row, index })).filter(
({ row }) =>
!Number(row.Level) &&
(item.TargetKey
? row.PkID != null && `id:${row.PkID}` === item.TargetKey
: row.Code === code),
);
if (!targets.length) throw Error(`연결 내역 없음: ${item.TargetKey || code}`);
if (targets.length > 1) throw Error(`연결 내역 중복: ${item.TargetKey || code}`);
const index = targets[0].index;
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("수량산출서 코드가 없거나 중복됩니다.");
for (const selected of ["manual", ...codes])
for (const [index, value] of totals(project, selected, true))
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 rows = project.tables.EstMainDat.filter((row) => row.PkID != null && !Number(row.Level));
return `<select data-quantity-select="${section}" data-index="${index}" aria-label="연결 내역 행"><option value="">내역 행 선택</option>${rows
.map((row) => {
const key = `id:${row.PkID}`;
return `<option value="${esc(key)}" ${item.TargetKey === key ? "selected" : ""}>${esc(row.Name || "내역")} · ${esc(row.Code || "코드 없음")} · ${esc(row.PkID)}</option>`;
})
.join("")}</select>`;
}
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 `<div class="business-tools"><label>수량산출서 <select id="quantity-sheet-choice"><option value="manual">수식표 직접 작성</option>${(state.project.quantitySheets || []).map((sheet) => `<option value="${esc(sheet.Code)}" ${state.quantitySheet === sheet.Code ? "selected" : ""}>${esc(sheet.Name || sheet.Code)}</option>`).join("")}</select></label><button data-quantity-new>새 산출서</button><button data-quantity-excel>Excel 셀 수량 반영</button></div>`;
}
function excelCell(files, name, address) {
const parse = (text) => {
if (!text || /<!DOCTYPE|<!ENTITY/i.test(text)) throw Error("Excel XML 형식 오류");
const doc = new DOMParser().parseFromString(text, "application/xml");
if (doc.querySelector("parsererror")) throw Error("Excel XML 형식 오류");
return doc;
};
address = address.trim().toUpperCase();
const parts = address.match(/^([A-Z]{1,3})([1-9]\d*)$/);
if (
!parts ||
[...parts[1]].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) > 16384 ||
Number(parts[2]) > 1048576
)
throw Error("Excel 셀 주소를 확인하세요. 예: B12");
const workbook = parse(files["xl/workbook.xml"]),
sheet = [...workbook.getElementsByTagNameNS("*", "sheet")].find(
(s) => 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 excelDialog() {
const { state, esc, showDialog } = EstApp;
const rows = state.project.tables.EstMainDat;
showDialog(
"Excel 셀 수량 반영",
`<div class="form-fields"><label>Excel 파일 <input type="file" accept=".xlsx" id="quantity-excel-file"></label><label>시트 이름 <input id="quantity-excel-sheet"></label><label>셀 주소 <input id="quantity-excel-cell" placeholder="B12"></label><label>내역 행 <select id="quantity-excel-target"><option value="">내역 행 선택</option>${rows.map((row, index) => (!Number(row.Level) && ![51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(Number(row.LinkIndex)) && !["%", "%"].includes(row.Unite) ? `<option value="${index}">${esc(row.Code || "코드 없음")} · ${esc(row.Name || "내역")} · ${index + 1}행</option>` : "")).join("")}</select></label><button data-quantity-excel-apply>수량 반영</button><span id="quantity-excel-error" role="alert"></span></div><p>저장된 숫자 값을 한 번 반영합니다. Excel에서 값이 바뀌면 다시 반영하세요.</p>`,
);
}
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) +
`<div class="business-tools"><label>산출서명 <input data-quantity-name value="${esc(sheet.Name)}"></label><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-quantity-delete-sheet>산출서 삭제</button></div><table class="sheet"><thead><tr><th>산출코드</th><th>품명</th><th>규격</th><th>저장 수량</th><th>재계산 수량</th><th>연결 내역코드</th></tr></thead><tbody>${output.items.map((item, index) => `<tr><td>${esc(item.Code)}</td><td><input data-quantity-item="Name" data-index="${index}" value="${esc(item.Name || "")}" aria-label="품명"></td><td><input data-quantity-item="Size" data-index="${index}" value="${esc(item.Size || "")}" aria-label="규격"></td><td>${number(sheet.items[index].GN)}</td><td>${number(item.GN)}</td><td><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 행 변경은 JSON으로 저장합니다.</p><table class="sheet"><thead><tr><th>줄</th><th>산출근거 편집</th><th>작업</th></tr></thead><tbody>${sheet.rows.map((row, index) => `<tr><td>${index + 1}</td><td><textarea data-quantity-line="${index}" rows="1" style="min-width:750px" aria-label="산출근거 ${index + 1}">${esc(EstText.display(output.rows[index].LineData))}</textarea></td><td><button data-quantity-add data-index="${index}">아래 추가</button><button data-quantity-move="-1" data-index="${index}" ${index === 0 ? "disabled" : ""}>↑</button><button data-quantity-move="1" data-index="${index}" ${index === sheet.rows.length - 1 ? "disabled" : ""}>↓</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
}
function install() {
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"].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;
if (event.target.closest("[data-quantity-excel]")) {
excelDialog();
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("파일·시트·셀·내역 행을 선택하세요.");
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 ||
project.tables.EstMainDat[Number(selected)] !== row
)
throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요.");
EstApp.change(() => (row.GN = value));
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 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;
try {
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,
targetSelect,
newCode,
selector,
render,
install,
};
})();
if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity;