Files
Aislo/tools/est-test/native-quantity.cjs
T
eomsangdonandClaude Opus 5.5 507b5a5166 feat(est-test): 수량산출서 검색과 전체보기
- 원본처럼 이름·항목 명칭·규격에서 빈칸·|·괄호를 빼고 찾아 목록을 좁힘
- 고르기 줄을 산출서 모듈로 옮겨 700줄 제한 유지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 20:18:46 +09:00

692 lines
32 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 = [],
totals = [];
let raised = false;
const tag = (name) =>
/^r\d+$/.test(name) || /^L\d+$/.test(name)
? "\x12"
: /^[Fa]\d+$/.test(name)
? "\x16"
: /^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);
if (tag(name) === "\x16") totals.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, ...(totals.length && { totals }) });
}
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*계|∴|\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));
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
? `<option value="${esc(item.TargetKey)}" selected>${esc(item.TargetCode || "연결 내역")} · ${esc(item.TargetKey)}</option>`
: "";
return `<select data-quantity-select="${section}" data-index="${index}" aria-label="연결 내역 행"><option value="">내역 행 선택</option>${selected}</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) {
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 || /<!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();
if (!validExcelAddress(address)) 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 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 셀 수량 반영",
`<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) => (excelTarget(row) ? `<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>파일명·시트·셀 연결은 작업 JSON에 저장합니다. 저장된 숫자 값을 사용하므로 Excel 파일이 바뀌면 연결 관리에서 다시 선택하세요.</p>`,
);
}
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 수량 연결 관리",
`<p>연결된 파일을 다시 선택하면 해당 파일의 모든 연결을 갱신합니다.</p><div class="form-fields"><label>Excel 파일 <input type="file" accept=".xlsx" id="quantity-excel-refresh-file"></label><button data-quantity-excel-refresh>연결 수량 갱신</button><span id="quantity-excel-links-error" role="alert"></span></div><table class="sheet"><thead><tr><th>파일</th><th>시트</th><th>셀</th><th>내역</th><th>작업</th></tr></thead><tbody>${links
.map((link, index) => {
const row = state.project.tables.EstMainDat.find((r) => r.PkID === link.target);
return `<tr><td>${esc(link.file)}</td><td>${esc(link.sheet)}</td><td>${esc(link.cell)}</td><td>${esc(row?.Name || link.target)}</td><td><button data-quantity-excel-remove="${index}">연결 해제</button></td></tr>`;
})
.join("")}</tbody></table>`,
);
}
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-item>수량 항목 추가</button><button data-quantity-add>산출 행 추가</button><button data-quantity-apply>내역 수량 반영</button><button data-action="quantity-sheet-copy">산출서 복사</button><button data-action="quantity-sheet-up">산출서 위로</button><button data-action="quantity-sheet-down">산출서 아래로</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><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><input data-quantity-item="Unite" data-index="${index}" value="${esc(item.Unite || "")}" aria-label="단위"></td><td>${number(sheet.items[index]?.GN ?? 0)}</td><td>${number(item.GN)}</td><td style="white-space:normal"><input data-quantity-target="${index}" value="${esc(item.TargetCode ?? item.LinkCode ?? "")}" aria-label="연결 내역코드">${targetSelect(state.project, item, "sheet", index)}</td><td>${formatSelect(sheet.items[index], index)}</td><td><button data-quantity-delete-item="${index}">삭제</button></td></tr>`).join("")}</tbody></table>${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}<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-action="quantity-mark" data-index="${index}">수량 지정</button><button data-action="quantity-free" data-index="${index}">지정 해제</button><button data-action="quantity-total" data-index="${index}">누계</button><button data-quantity-delete-row="${index}">삭제</button></td></tr>`).join("")}</tbody></table>`;
}
// 원본 수량목록 자리수 메뉴(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 `<select data-quantity-format="${index}" aria-label="끝수 처리">${options
.map(
([value, label]) =>
`<option value="${value}" ${value === current ? "selected" : ""}>${label}</option>`,
)
.join("")}</select>`;
}
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 =
'<option value="">내역 행 선택</option>' +
state.project.tables.EstMainDat.filter((row) => row.PkID != null && !Number(row.Level))
.map((row) => {
const key = `id:${row.PkID}`;
return `<option value="${esc(key)}">${esc(row.Name || "내역")} · ${esc(row.Code || "코드 없음")} · ${esc(row.PkID)}</option>`;
})
.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;