// 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, "&");
}
// 워드형 문서 읽기·표는 native-quantity-wpt.cjs
const wpt = () =>
typeof EstQuantityWpt !== "undefined" ? EstQuantityWpt : require("./native-quantity-wpt.cjs");
const fromWpt = (...args) => wpt().fromWpt(...args);
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.table || row.image
? ""
: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴|\x16[^\x16]*\x16/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,
);
});
if (typeof EstQuantityTotal !== "undefined")
EstQuantityTotal.apply(sheet, result.rows.slice(1));
const tableValues = sheet.rows.some((row) => row.table) ? wpt().values(sheet) : null;
const cellValues =
sheet.excel && typeof EstExcelSheet !== "undefined" ? EstExcelSheet.itemValues(sheet) : null;
return {
rows: result.rows.slice(1),
items: sheet.items.map((item) => ({
...item,
GN:
values.get(item.Code) ??
tableValues?.get(item.Code) ??
cellValues?.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) {
return typeof EstQuantitySheets === "undefined" ? "" : EstQuantitySheets.selector(state);
}
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 `| ${esc(link.file)} | ${esc(link.sheet)} | ${esc(link.cell)} | ${esc(row?.Name || link.target)} | |
`;
})
.join("")}
`,
);
}
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) +
`${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.
| 줄 | 산출근거 편집 | 작업 |
${sheet.rows.map((row, index) => `| ${index + 1} | ${(sheet.rows[index].table || sheet.rows[index].image) && typeof EstQuantityTable !== "undefined" ? EstQuantityTable.html(sheet, index, state) : ``} | |
`).join("")}
`;
}
// 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수.
function resultFormat(code) {
const match = String(code ?? "").match(/^([LrG]\d{6})(?:([123])(\d))?$/);
return match
? {
base: match[1],
method: Number(match[2] || 0),
digits: match[3] ? Number(match[3]) : null,
}
: null;
}
function setResultFormat(sheet, index, method, digits) {
const item = sheet.items[index],
parsed = resultFormat(item?.Code);
if (!parsed) throw Error("이 수량 항목은 결과 자리수를 지정할 수 없는 코드입니다.");
if (![0, 1, 2, 3].includes(method)) throw Error("끝수 처리 방식을 고르세요.");
if (method && !(Number.isInteger(digits) && digits >= 0 && digits <= 5))
throw Error("자리수는 0~5만 지정할 수 있습니다.");
const old = item.Code,
code = method ? `${parsed.base}${method}${digits}` : parsed.base;
if (code === old) return code;
if (
sheet.items.some((other, i) => i !== index && other.Code === code) ||
sheet.rows.some((row) => (row.markers || []).includes(code))
)
throw Error(`같은 결과 코드가 이미 있습니다: ${code}`);
item.Code = code;
for (const row of sheet.rows)
if (row.markers) row.markers = row.markers.map((marker) => (marker === old ? code : marker));
return code;
}
function formatSelect(item, index) {
const parsed = resultFormat(item.Code);
if (!parsed) return "";
const current = parsed.method ? `${parsed.method}:${parsed.digits}` : "0";
const options = [["0", "AUTO(산출서 기본)"]];
for (const [method, label] of [
[2, "절사"],
[3, "올림"],
[1, "반올림"],
])
for (let digits = 0; digits <= 5; digits++)
options.push([`${method}:${digits}`, `${label} ${digits}자리`]);
return ``;
}
function nextItemCode(sheet) {
const codes = new Set(
[...sheet.items.map((item) => item.Code), ...sheet.rows.flatMap((row) => row.markers || [])]
.filter(Boolean)
.map((code) => resultFormat(code)?.base || code),
);
for (let index = 0; index < 1000000; index++) {
const code = `r${String(index).padStart(6, "0")}`;
if (!codes.has(code)) return code;
}
throw Error("수량 항목 코드를 더 만들 수 없습니다.");
}
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.quantityFormat !== undefined) {
const [method, digits] = node.value.split(":").map(Number);
try {
EstApp.change(() =>
setResultFormat(sheet, Number(node.dataset.quantityFormat), method, digits),
);
} catch (error) {
state.notice = error.message;
EstApp.render();
}
}
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 code = nextItemCode(sheet);
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 = nextItemCode(sheet);
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,
resultFormat,
nextItemCode,
setResultFormat,
selector,
render,
install,
};
})();
if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity;