From c0d49e0bbbe22236baa4ebc8184a97fe2a43a37e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 11 Nov 2026 20:06:29 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EC=88=98=EB=9F=89=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=88=84=EA=B3=84=20=EA=B3=84=EC=82=B0=EA=B3=BC=20?= =?UTF-8?q?=EB=84=A3=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원본 누계 표지(F·a)를 읽어 계산마다 소계로 다시 구함 - 산출 줄 커서 자리에 누계 넣기, 표지 반올림 방식·자리 반영 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_quantity_total.py | 55 +++++++++++++++++++ tools/est-test/PLAN.md | 2 + tools/est-test/README.md | 2 + tools/est-test/index.html | 1 + tools/est-test/native-quantity-sheets.js | 9 +++ tools/est-test/native-quantity-total.js | 61 +++++++++++++++++++++ tools/est-test/native-quantity.cjs | 24 +++++--- tools/est-test/native-text.js | 3 +- tools/est-test/server.cjs | 1 + 9 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 resources/tester/test_est_quantity_total.py create mode 100644 tools/est-test/native-quantity-total.js 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) + - `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}
${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; + `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}
${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; } // 원본 수량목록 자리수 메뉴(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",