diff --git a/resources/tester/test_est_quantity_total.py b/resources/tester/test_est_quantity_total.py
new file mode 100644
index 000000000..c9873a046
--- /dev/null
+++ b/resources/tester/test_est_quantity_total.py
@@ -0,0 +1,55 @@
+"""EST 테스트 앱 수량산출 누계(원본 ActionAdd Ctrl+U 0x11155c4, 재계산 때 F·a 표지 다시 계산)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+FILES = [
+ "native-math.js",
+ "native-formula.js",
+ "native-text.js",
+ "native-quantity-total.js",
+ "native-quantity.cjs",
+]
+SCRIPT = r"""(() => {
+ const sheet = { Code: 'N1', options: { digits: 2, roundMode: 2 }, items: [
+ { Code: 'r000001' }, { Code: 'r000002' }, { Code: 'r000003' }, { Code: 'r000004' }],
+ rows: [
+ { LineData: ' \x0e터파기\x0e 2×3 = \x126\x12', markers: ['r000001'] },
+ { LineData: ' 1.5×2 = \x123\x12', markers: ['r000002'] },
+ { LineData: ' \x16999\x16', markers: [], totals: ['F000001'] },
+ { LineData: ' 4×1 = \x124\x12', markers: ['r000003'] },
+ { LineData: ' 0.3333×1 = \x120.33\x12', markers: ['r000004'] },
+ { LineData: ' \x160\x16', markers: [], totals: ['F00000222'] } ] };
+ const out = EstQuantity.calculate(sheet);
+ const totals = out.rows.map((r) => (r.LineData.match(/\x16([^\x16]*)\x16/) || [])[1]).filter(Boolean);
+ sheet.rows[0].LineData = sheet.rows[0].LineData.replace('2×3', '200×30');
+ const big = EstQuantity.calculate(sheet).rows[2].LineData.match(/\x16([^\x16]*)\x16/)[1];
+ const fresh = { Code: 'N2', options: { digits: 1, roundMode: 1 }, items: [], rows: [{ LineData: ' 끝', markers: [] }] };
+ const name = EstQuantityTotal.insert(fresh, 0, 1);
+ let inside = ''; try { EstQuantityTotal.insert(fresh, 0, 4); } catch (e) { inside = e.message.slice(0, 5); }
+ const parsed = EstQuantity.fromWpt('N9', '시험',
+ '
7
', [], {});
+ return { totals, big, name, line: EstText.display(fresh.rows[0].LineData), edited: fresh.markerEdited,
+ inside, parsed: [parsed.rows[0]?.LineData, parsed.rows[0]?.totals], errors: out.errors };
+})()"""
+
+
+def test_total_is_subtotal_since_name_or_previous_total():
+ runner = f"""
+ const fs = require('fs'), vm = require('vm');
+ const ctx = vm.createContext({{ structuredClone, console }});
+ for (const f of {json.dumps(FILES)})
+ vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
+ process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(SCRIPT)}, ctx)));
+ """
+ result = json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8"))
+ assert result["errors"] == []
+ assert result["totals"] == ["9", "4.33"]
+ assert result["big"] == "6,003"
+ assert result["name"] == "F00000111"
+ assert result["line"] == " ⟦누계⟧0⟦누계⟧끝"
+ assert result["edited"] is True
+ assert result["inside"] == "누계 표지"
+ assert result["parsed"] == ["\x167\x16", ["F00000312"]]
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index e04bbc76f..6eba15caa 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -473,6 +473,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- [x] 1단계 읽기 — `xls-read.cjs`(복합문서·SST/CONTINUE·LABELSST·NUMBER·RK·MULRK·LABEL·BOOLERR·FORMULA 저장값), 가져올 때 `sheet.excel`, 수량산출 화면 읽기 전용 격자(수식 셀 굵게).
- 검증완료: `test_est_xls_read.py` 2개(설치 main.estx 엑셀형 6개·토적계산표 B2·No.1·20·수식 16개, OLE 아님 거부·격자 그리기), 수량 시험 포함 22개 통과. 엑셀형 붙인 뒤에도 ESTX 원본 그대로 저장 동일. ORCA 브라우저에서 토적계산표 격자 표시·수식 칸 16개 확인 후 샘플 복구.
- 남음: 셀 편집·수식 계산·4방향 합계·품명/규격/수량 지정(항목과 셀 대응 근거 미확인), .xls 저장.
+ - [x] 수량산출 누계(원본 TfrmMulWord ActionAdd Ctrl+U → 0x11155c4, 재계산 0x111c0b0 — 디스어셈블 근거) — 원본 F·a 표지를 `` 쌍·`row.totals` 로 읽고, 위쪽 품명 줄(포함)/앞 누계(제외)부터 줄마다 첫 결과 합을 표지 반올림(F+6자리+방식+자리)으로 매 계산 갱신, 커서 자리 누계 넣기. 원본 TFRMMULNAMEINPUT 「누계계산」 단추는 실제로 지정해제와 같은 처리라 따로 만들지 않음.
+ - 검증완료: `test_est_quantity_total.py`(소계 범위·앞 누계 제외·반올림 표지·값 갱신 6,003·넣기 이름·표지 안 거부·원본 F 표지 읽기) 통과, 수량 시험 포함 26개 통과. ORCA 브라우저에서 「소계」 줄 끝에 누계 → ⟦누계⟧9⟦누계⟧(6+3), 샘플 복구.
- 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133.
- [ ] 남은 핵심(실무 빈도순):
3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄.
diff --git a/tools/est-test/README.md b/tools/est-test/README.md
index 8e5978469..37d686994 100644
--- a/tools/est-test/README.md
+++ b/tools/est-test/README.md
@@ -48,6 +48,8 @@
`편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다.
+산출 줄 옆 `누계`는 원본 누계(Ctrl+U)처럼 커서 자리에 누계 값을 넣습니다. 값은 가장 가까운 위쪽 품명 줄(포함) 또는 앞 누계 줄(제외)부터 바로 위 줄까지 줄마다 첫 결과 수량의 합(소계)이며, 계산할 때마다 다시 구합니다. 원본 파일의 누계 표지도 읽어 다시 계산합니다.
+
원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값을 읽어 수량산출 화면 아래에 읽기 전용 격자로 보입니다. 수식 셀은 Excel이 저장해 둔 계산값을 굵게 보이며, 셀 편집·다시 계산은 아직 하지 않습니다.
산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다.
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index bc17909b4..132ca1951 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -10,6 +10,7 @@
+
diff --git a/tools/est-test/native-quantity-sheets.js b/tools/est-test/native-quantity-sheets.js
index 9d91345b7..b799e50b8 100644
--- a/tools/est-test/native-quantity-sheets.js
+++ b/tools/est-test/native-quantity-sheets.js
@@ -120,6 +120,15 @@ const EstQuantitySheets = (() => {
if (index < 0 || index + step < 0 || index + step >= sheets.length)
throw Error("더 옮길 수 없습니다.");
EstApp.change((project) => move(project, code, step), false);
+ } else if (name === "quantity-total") {
+ const sheet = state.project.quantitySheets?.find((next) => next.Code === code),
+ index = Number(node?.dataset.index),
+ editor = lineEditor(index);
+ if (!sheet) throw Error("수량산출서를 고르세요.");
+ EstApp.change(
+ () => EstQuantityTotal.insert(sheet, index, editor.selectionStart ?? 0, editor.value),
+ false,
+ );
} else if (name === "quantity-mark" || name === "quantity-free") {
const sheet = state.project.quantitySheets?.find((next) => next.Code === code),
index = Number(node?.dataset.index),
diff --git a/tools/est-test/native-quantity-total.js b/tools/est-test/native-quantity-total.js
new file mode 100644
index 000000000..a282a20ee
--- /dev/null
+++ b/tools/est-test/native-quantity-total.js
@@ -0,0 +1,61 @@
+// 원본 수량산출 누계(TfrmMulWord ActionAdd Ctrl+U → 0x11155c4, 재계산 0x111c0b0 에서 F·a 표지 다시 계산).
+// 누계 값 = 가장 가까운 위쪽 품명 줄(포함) 또는 앞 누계 줄(제외)부터 누계 줄 바로 위까지, 줄마다 첫 결과 수량의 합.
+// 앞 누계는 더하지 않는 소계다. 반올림은 표지 이름 뒤 방식·자리(F000003 + 1 + 2), 없으면 산출서 설정.
+// 웹은 누계를 \x16 쌍으로 적고 이름을 row.totals 에 둔다. 「누계」·「∴」 같은 글자는 넣지 않는다.
+const EstQuantityTotal = (() => {
+ const first = (line) => {
+ const match = String(line ?? "").match(/\x12([-\d.,]+)\x12/);
+ return match ? Number(match[1].replaceAll(",", "")) || 0 : 0;
+ };
+ function format(value, digits) {
+ return value.toLocaleString("en-US", { maximumFractionDigits: digits, useGrouping: true });
+ }
+ function rounding(sheet, name) {
+ const match = String(name ?? "").match(/^[Fa]\d{6}([123])(\d)$/);
+ return {
+ method: match ? Number(match[1]) : (sheet.options?.roundMode ?? 2),
+ digits: match ? Number(match[2]) : (sheet.options?.digits ?? 2),
+ };
+ }
+ // rows: 계산이 끝난 줄(LineData 의 결과 값이 갱신된 것). 누계 줄의 \x16 값을 고쳐 쓴다.
+ function apply(sheet, rows) {
+ let start = 0;
+ rows.forEach((row, i) => {
+ const line = String(row.LineData ?? "");
+ if (line.includes("\x16")) {
+ let sum = 0;
+ for (let j = start; j < i; j++) sum += first(rows[j].LineData);
+ const names = sheet.rows[i]?.totals || [];
+ let part = 0;
+ row.LineData = line.replace(/\x16[^\x16]*\x16/g, () => {
+ const { method, digits } = rounding(sheet, names[part++]);
+ return `\x16${format(EstMath.fixed(sum, digits, method), digits)}\x16`;
+ });
+ start = i + 1;
+ } else if (line.includes("\x0e")) start = i;
+ });
+ return rows;
+ }
+ // 커서 자리에 새 누계를 넣는다. text 는 화면 표기(⟦누계⟧) 줄, caret 은 그 안의 위치.
+ function insert(sheet, rowIndex, caret, text) {
+ const row = sheet.rows[rowIndex];
+ if (!row) throw Error("누계를 넣을 산출 줄을 고르세요.");
+ const used = new Set(sheet.rows.flatMap((r) => (r.totals || []).map((n) => n.slice(1, 7))));
+ let n = 1;
+ while (used.has(String(n).padStart(6, "0"))) n++;
+ const { method, digits } = rounding(sheet, "");
+ const name = `F${String(n).padStart(6, "0")}${method}${digits}`,
+ shown = text ?? EstText.display(row.LineData),
+ at = Math.max(0, Math.min(caret, shown.length)),
+ position = shown.slice(0, at).split("⟦누계⟧").length - 1;
+ const before = shown.slice(0, at);
+ if (position % 2 || before.lastIndexOf("⟦") > before.lastIndexOf("⟧"))
+ throw Error("누계 표지 안에는 넣을 수 없습니다.");
+ row.LineData = EstText.encode(`${shown.slice(0, at)}⟦누계⟧0⟦누계⟧${shown.slice(at)}`);
+ row.totals = [...(row.totals || [])];
+ row.totals.splice(position / 2, 0, name);
+ sheet.markerEdited = true;
+ return name;
+ }
+ return { apply, insert, rounding };
+})();
diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs
index 54717dff3..3c21105c9 100644
--- a/tools/est-test/native-quantity.cjs
+++ b/tools/est-test/native-quantity.cjs
@@ -26,16 +26,19 @@ const EstQuantity = (() => {
if (name) tags.set(match[1], name);
}
for (const match of text.matchAll(/]*>([\s\S]*?)<\/div>/gi)) {
- const markers = [];
+ const markers = [],
+ totals = [];
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"
- : "";
+ : /^[Fa]\d+$/.test(name)
+ ? "\x16"
+ : /^N\d+$/.test(name)
+ ? "\x0e"
+ : /^S\d+$/.test(name)
+ ? "\x13"
+ : "";
let body = match[1].replace(/
/gi, (_, id) => {
const next = superscripts.has(id),
value = next && !raised ? "^(" : !next && raised ? ")" : "";
@@ -47,11 +50,12 @@ const EstQuantity = (() => {
.replace(/
]*\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(/]*\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 });
+ if (body.trim()) rows.push({ LineData: body, markers, ...(totals.length && { totals }) });
}
return { Code: code, Name: name, rows, items, options };
}
@@ -78,7 +82,7 @@ const EstQuantity = (() => {
};
return {
...row,
- LineData: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴/g, ""),
+ LineData: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴|\x16[^\x16]*\x16/g, ""),
Index: 0,
SCheck: 0,
LinkIndex: 0,
@@ -112,6 +116,8 @@ const EstQuantity = (() => {
(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 })),
@@ -315,7 +321,7 @@ const EstQuantity = (() => {
{ 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} | | |
`).join("")}
`;
+ `${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.
| 줄 | 산출근거 편집 | 작업 |
${sheet.rows.map((row, index) => `| ${index + 1} | | |
`).join("")}
`;
}
// 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수.
function resultFormat(code) {
diff --git a/tools/est-test/native-text.js b/tools/est-test/native-text.js
index e80a27772..71d0c8ed5 100644
--- a/tools/est-test/native-text.js
+++ b/tools/est-test/native-text.js
@@ -615,11 +615,12 @@ const EstText = (() => {
"\x12": "결과",
"\x13": "규격",
"\x15": "단위",
+ "\x16": "누계",
"\x1a": "합계",
};
const display = (text) =>
String(text || "").replace(
- /[\x03\x06\x07\x0c\x0e\x11\x12\x13\x15\x1a]/g,
+ /[\x03\x06\x07\x0c\x0e\x11\x12\x13\x15\x16\x1a]/g,
(ch) => `⟦${names[ch]}⟧`,
);
const encode = (text) =>
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index 678d80657..006d5279d 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -21,6 +21,7 @@ const scripts = [
"native-machine.js",
"native-rows.js",
"native-text.js",
+ "native-quantity-total.js",
"native-quantity.cjs",
"native-wonga.js",
"native-jungsang.js",