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

358 lines
16 KiB
JavaScript

// 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();
for (const selected of ["manual", ...(project.quantitySheets || []).map((sheet) => sheet.Code)])
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></div>`;
}
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-new]")) {
EstApp.change(() => {
const code = `Q${crypto.randomUUID()}`;
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${crypto.randomUUID()}`;
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,
targetSelect,
newCode,
selector,
render,
install,
};
})();
if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity;