feat(est): connect original quantity sheets

This commit is contained in:
2026-11-05 06:19:15 +09:00
parent 60ccedcd9a
commit 1856aa897d
13 changed files with 284 additions and 15 deletions
File diff suppressed because one or more lines are too long
+9
View File
@@ -9,6 +9,7 @@ for (const file of [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-business.js",
@@ -63,6 +64,14 @@ else if (command === "extended") {
})`,
context,
);
} else if (command === "quantity") {
context.sheets = JSON.parse(
fs.readFileSync(path.join(__dirname, "fixtures/est_quantity_sheets.json"), "utf8"),
);
result = vm.runInContext(
"sheets.map(sheet=>({Code:sheet.Code,original:sheet.items,...EstQuantity.calculate(sheet)}))",
context,
);
} else if (command === "business")
result = vm.runInContext(
`
+27
View File
@@ -238,3 +238,30 @@ def test_installed_main_archive_all_stored_values_when_available():
assert result["differences"] == []
assert all(item["checked"] == item["matched"] for item in result["counts"].values())
assert sum(item["checked"] for item in result["counts"].values()) > 100000
def test_original_quantity_sheets_and_three_saved_formula_disagreements():
sheets = run("quantity")
assert len(sheets) == 75
# Original editor formulas disagree with MulList saved numbers in three
# places. Keep both visible instead of changing the formulas to replay them.
expected = {
("N0000000083", "r000004"): (286, 0.35 * 1.3 * 220),
("N0000000086", "r000004"): (0, 1 * 1),
("N0000000106", "r000000"): (138.72, 1.25 * 2 * 68 * 1.02),
}
differences = set()
checked = 0
for sheet in sheets:
assert sheet["errors"] == []
for before, after in zip(sheet["original"], sheet["items"]):
checked += 1
key = (sheet["Code"], before["Code"])
if key in expected:
assert before["GN"] == expected[key][0]
assert abs(after["GN"] - expected[key][1]) < 1e-6
differences.add(key)
else:
assert abs((before.get("GN") or 0) - (after.get("GN") or 0)) < 1e-6, key
assert checked == 449
assert differences == set(expected)
+3 -1
View File
@@ -14,7 +14,9 @@
설치된 대용량 `main.estx`에서도 일위 52,530개, 기계 17,675개, 내역 205개, 산출식 결과 23,889개, 호표 목록 19,381개를 대조해 **113,680개 모두 일치**했습니다. 원본 파일은 읽기만 합니다. 같은 파일이 있는 환경에서는 이 대조를 자동 시험하며, 다른 환경에서는 저장소의 원본 발췌 행을 시험합니다. 원천 단가 전체 갱신 후 오류 0 및 반복 계산 값의 안정성도 확인했습니다.
**전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 수량 편집기, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
원본 수량산출서 75개의 WPTools 글·결과 표지·위첨자를 읽어 편집하고 내역 수량에 연결합니다. 저장 수량 449개 중 446개가 재계산 결과와 일치합니다. 나머지 3개는 원본 글의 산식과 저장 수량이 서로 다릅니다(예: `0.35×1.3×220`의 저장값 286, 계산값 100.1). 화면에 원본 저장 수량과 재계산 수량을 함께 표시합니다. 원본 수치를 맞추려고 산식을 변경하지 않았습니다.
**전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다.
검사:
+22
View File
@@ -1,5 +1,6 @@
// Read-only ESTX import. Layout: knowledge/다산소프트/ESTX_파일구조.md.
const zlib = require("node:zlib");
const quantity = require("./native-quantity.cjs");
const decoder = new TextDecoder("euc-kr", { fatal: true });
const LIMIT = 256 * 1024 * 1024;
const crcTable = Array.from({ length: 256 }, (_, n) => {
@@ -319,6 +320,26 @@ function importEstx(buffer, name = "가져온 내역서") {
JDLCalcMethod: props.TCommonOption?.JDLCalcMethod || false,
OldRecalcMethod: props.TChangeOption?.OldRecalcMethod || 0,
};
const quantitySheets = [];
for (const row of tables.MulMainDat || []) {
if (!row.Editor?.binary || !row.MulList?.binary) continue;
try {
const packed = Buffer.from(row.Editor.binary, "base64");
const bytes =
packed[0] === 0x78 ? zlib.inflateSync(packed, { maxOutputLength: LIMIT }) : packed;
const text = new TextDecoder("euc-kr").decode(bytes);
const items = memoryRows(Buffer.from(row.MulList.binary, "base64"));
const name = tables.MulTitleDat?.find((r) => r.Code === row.Code)?.Name || row.Code;
quantitySheets.push(
quantity.fromWpt(row.Code, name, text, items, {
digits: props.TMulLangOption?.Diget ?? 2,
roundMode: (props.TMulLangOption?.CalcMethod ?? 1) + 1,
}),
);
} catch (error) {
warnings.add(`수량산출 ${row.Code}: ${error.message}`);
}
}
return {
schema: "est-web-2",
name: props.TCommonOption?.ProjectName || name,
@@ -327,6 +348,7 @@ function importEstx(buffer, name = "가져온 내역서") {
originalOptions: objects,
options,
quantities: [],
quantitySheets,
progress: [],
annual: [],
drawings: [],
+1
View File
@@ -10,6 +10,7 @@
<script defer src="native-machine.js"></script>
<script defer src="native-rows.js"></script>
<script defer src="native-text.js"></script>
<script defer src="native-quantity.cjs"></script>
<script defer src="native-wonga.js"></script>
<script defer src="native-engine.js"></script>
<script defer src="native-business.js"></script>
+8 -1
View File
@@ -143,8 +143,14 @@ const EstApp = (() => {
const errors = calculated.errors || [];
byId("messages").hidden = !errors.length && !state.notice;
byId("messages").textContent = state.notice || errors.join(" / ");
const rowCount =
state.project.tables[state.view]?.length ??
(state.view === "quantity"
? (state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet)?.items
.length ?? state.project.quantities.length)
: 0);
byId("status").textContent =
`${state.project.name} · ${titles[state.view]} · ${state.project.tables[state.view]?.length || 0}행 · ${errors.length ? `계산 오류 ${errors.length}건` : "계산 오류 없음"}`;
`${state.project.name} · ${titles[state.view]} · ${rowCount}행 · ${errors.length ? `계산 오류 ${errors.length}건` : "계산 오류 없음"}`;
EstViews.render(state);
byId("work-content").scrollLeft = scroll;
const next = focus?.table
@@ -211,6 +217,7 @@ const EstApp = (() => {
const saved = sessionStorage.getItem(CACHE);
setProject(saved ? JSON.parse(saved) : await api("/api/sample"));
EstActions.install();
EstQuantity.install();
} catch (error) {
byId("messages").hidden = false;
byId("messages").textContent = `시작 실패: ${error.message}`;
+12 -6
View File
@@ -16,6 +16,10 @@ const EstExtra = (() => {
{ esc, number, byId } = EstApp;
let html = "";
if (view === "quantity") {
if (project.quantitySheets?.length && state.quantitySheet !== "manual") {
EstQuantity.render(state);
return;
}
const values = Object.create(null),
rows = project.quantities.map((row, index) => {
let result,
@@ -40,12 +44,14 @@ const EstExtra = (() => {
button("delete-extra", "삭제", `data-section="quantities" data-row="${index}"`),
];
});
html = box(
"수량산출서",
button("add-quantity", "산출 항목 추가") + button("apply-quantity", "내역에 수량 반영"),
table(["산출코드", "품명", "연결 내역코드", "수식", "소수자리", "결과", ""], rows),
"앞선 산출 항목을 [산출코드]로 참조합니다. 내역 반영은 연결된 코드의 산출값을 합산하여 수량에 반영합니다.",
);
html =
EstQuantity.selector(state) +
box(
"수량산출서",
button("add-quantity", "산출 항목 추가") + button("apply-quantity", "내역에 수량 반영"),
table(["산출코드", "품명", "연결 내역코드", "수식", "소수자리", "결과", ""], rows),
"앞선 산출 항목을 [산출코드]로 참조합니다. 내역 반영은 연결된 코드의 산출값을 합산하여 수량에 반영합니다.",
);
} else if (view === "change") {
const changes = EstBusiness.changes(project);
html = box(
+1
View File
@@ -8,6 +8,7 @@ const EstExpression = (() => {
.replace(/+/g, "+")
.replace(/%/g, "%")
.replace(/√\s*\(/g, "SQRT(")
.replace(/π/g, "PI")
.replace(/(?<=\d),(?=\d{3}(?:\D|$))/g, "");
const tokens = [];
const pattern =
+184
View File
@@ -0,0 +1,184 @@
// 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 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,
};
}
function selector(state) {
if (!state.project.quantitySheets?.length) return "";
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></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"><b>${esc(sheet.Name)}</b><button data-quantity-apply>내역 수량 반영</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>${esc(item.Name)}</td><td>${esc(item.Size)}</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="연결 내역코드"></td></tr>`).join("")}</tbody></table><p class="business-note">${esc(output.errors.join(" / "))} 원본 결과 표지의 이름을 유지해 수량을 연결합니다. 원본 ESTX의 편집기·도면 바이너리는 별도로 보존합니다.</p><table class="sheet"><thead><tr><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></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;
}
const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet);
if (!sheet) return;
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(
() => (sheet.items[Number(node.dataset.quantityTarget)].TargetCode = node.value),
false,
);
});
document.addEventListener("click", (event) => {
if (!event.target.closest("[data-quantity-apply]")) return;
try {
const { state } = EstApp,
sheet = state.project.quantitySheets.find((s) => s.Code === state.quantitySheet),
output = calculate(sheet);
if (output.errors.length) throw Error(output.errors.join(" / "));
const quantities = new Map();
for (const item of output.items)
if (item.TargetCode || item.LinkCode) {
const code = item.TargetCode || item.LinkCode;
if (!state.project.tables.EstMainDat.some((r) => r.Code === code))
throw Error(`연결 내역코드 없음: ${code}`);
quantities.set(code, (quantities.get(code) || 0) + Number(item.GN || 0));
}
if (!quantities.size) throw Error("연결할 내역코드를 먼저 입력하세요.");
EstApp.change(() => {
for (const row of state.project.tables.EstMainDat)
if (quantities.has(row.Code) && !Number(row.Level)) row.GN = quantities.get(row.Code);
});
} catch (error) {
EstApp.state.notice = error.message;
EstApp.render();
}
});
}
return { fromWpt, calculate, selector, render, install };
})();
if (typeof module !== "undefined" && module.exports) module.exports = EstQuantity;
+12 -7
View File
@@ -15,15 +15,16 @@ const EstText = (() => {
function expression(text) {
return clean(
String(text).replace(
/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|\([^()]*[가-힣][^()]*\)/g,
/\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|\([가-힣][^()]*\)/g,
"",
),
)
.replace(
/(\d)\s*(?:공\/?㎥|공|면|경간|개월|월|인|㎥|㎢|㎡|hr|sec|min|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|μ|EA|mm|cm|m|g|L)(?:\/(?:개소|개|대|본|인|분|포|\$|hr|일|㎥|㎡|m|a))?/gi,
/(\d)\s*(?:공\/?㎥|공|단|면|경간|개월|월|인|㎥|㎢|㎡|hr|sec|min|km|ton|kg|㎏|㎞|ℓ|회|개소|개|대|분|초|본|장|매|병|조|식|일|시간|원|포|동|μ|EA|mm|cm|m|g|L)(?:\/(?:개소|개|대|본|인|분|포|\$|hr|일|㎥|㎡|m|a))?/gi,
"$1",
)
.replace(/(?<=\d)\s*\/(?:일|㎥|㎡)(?=\s|$)/g, "")
.replace(/(?<=\))\s*(?:개|인|본|면|단)(?:\/(?:㎡|㎥|hr))?/g, "")
.replace(/[①-⑳]/g, "")
.replace(/=\s*$/, "");
}
@@ -159,15 +160,18 @@ const EstText = (() => {
body = body.split("=")[0];
calculated = EstExpression.evaluate(expression(body), (key) => variables[key]);
}
const assignmentText = text.replace(/\{[^}]*\}/g, (value) => " ".repeat(value.length));
const assignmentText = text.replace(
/\{[^}]*\}|\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15/g,
(value) => " ".repeat(value.length),
);
const assignments =
sc < 0
? []
: [
...assignmentText.matchAll(
/(?:^| {2,}|[,,]\s*|(?<=[가-힣])\s+|[::]\s*)([A-Za-z][A-Za-z0-9]*)\s*=/g,
/(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|[::]\s*)([A-Za-z][A-Za-z0-9]*)\s*=/g,
),
].filter((a) => !text.includes("\x0e") || a.index < text.indexOf("\x0e"));
];
for (let j = 0; j < assignments.length; j++) {
const assignment = assignments[j],
end = assignments[j + 1]?.index ?? text.length;
@@ -241,6 +245,7 @@ const EstText = (() => {
for (let part = 1; part < results.length; part++) {
const previous = results[part - 1],
next = results[part];
const format = row.ResultFormats?.[part];
const operation = expression(
text.slice(previous.index + previous[0].length, next.index),
).trim();
@@ -251,8 +256,8 @@ const EstText = (() => {
expression(`${calculated}${operation}`),
(key) => variables[key],
),
digits,
method,
format?.digits ?? digits,
format?.method ?? method,
);
assignmentName = assignedNames.get(next.index) || assignmentName;
if (assignmentName) variables[assignmentName] = calculated;
+3
View File
@@ -1,6 +1,9 @@
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
html,
body {
height: 100%;
+1
View File
@@ -14,6 +14,7 @@ const scripts = [
"native-machine.js",
"native-rows.js",
"native-text.js",
"native-quantity.cjs",
"native-wonga.js",
"native-engine.js",
"native-business.js",