From d38d2a289344b0ccd8124c6343c3018e4efa9e0a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 12:24:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EC=97=91=EC=85=80=ED=98=95?= =?UTF-8?q?=20=EC=88=98=EB=9F=89=EC=82=B0=EC=B6=9C=EC=84=9C=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=C2=B7=ED=8E=B8=EC=A7=91=EA=B3=BC=20=EC=9B=90=EA=B0=80?= =?UTF-8?q?=20=ED=96=89=20=EC=9E=90=EB=A6=AC=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엑셀형 산출서 BIFF 수식 해석·다시 계산·수식줄 편집·4방향 합·수량으로 지정(가져올 때 값이 같은 합계 셀 추정 연결), 셀 편집 시 ESTX 저장 거부 - 원가 행 계산을 원본 c9d278 끝부분대로: 소수 4자리 반올림 뒤 원 미만 절사, Diget>0 행 자리맞춤(0x11293e8) - 원가계산서 「선택 행 자릿수 설정」을 원본 선택항목 절하하기 대화로(도급액 행 거부, 입력 행 원금액 기준) Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_est_excel_sheet.py | 72 ++++++ resources/tester/test_est_wonga_tools.py | 61 +++++ resources/tester/test_est_xls_read.py | 5 +- tools/est-test/PLAN.md | 7 +- tools/est-test/README.md | 4 +- tools/est-test/export-quantity.cjs | 2 + tools/est-test/import-estx.cjs | 5 +- tools/est-test/index.html | 2 + tools/est-test/native-business-actions.js | 4 +- tools/est-test/native-excel-sheet.cjs | 259 ++++++++++++++++++++++ tools/est-test/native-excel-view.js | 137 ++++++++++++ tools/est-test/native-quantity-sheets.js | 29 +-- tools/est-test/native-quantity.cjs | 7 +- tools/est-test/native-wonga-tools.js | 61 ++++- tools/est-test/native-wonga.js | 43 +++- tools/est-test/server.cjs | 2 + tools/est-test/xls-read.cjs | 120 +++++++++- 17 files changed, 775 insertions(+), 45 deletions(-) create mode 100644 resources/tester/test_est_excel_sheet.py create mode 100644 tools/est-test/native-excel-sheet.cjs create mode 100644 tools/est-test/native-excel-view.js diff --git a/resources/tester/test_est_excel_sheet.py b/resources/tester/test_est_excel_sheet.py new file mode 100644 index 000000000..b20e39ea3 --- /dev/null +++ b/resources/tester/test_est_excel_sheet.py @@ -0,0 +1,72 @@ +"""EST 테스트 앱 엑셀형 수량산출서 계산·편집(원본 TFRMMULEXCEL, .xls BIFF 수식).""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MAIN = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") + + +def node(script: str, *args: str): + return json.loads( + subprocess.check_output(["node", "-e", script, *args], cwd=ROOT).decode("utf-8") + ) + + +def test_evaluate_and_directional_sum(): + out = node(""" + const X = require(process.cwd() + '/tools/est-test/native-excel-sheet.cjs'); + const cells = { '0,0': { value: '품명' }, '1,0': { value: 2 }, '2,0': { value: 3 }, '3,0': {}, + '1,1': { value: 0, formula: '(A2+A3)/2*4' }, '1,2': { value: 0, formula: 'SUM(A2:B2)' }, + '4,4': { value: 0, formula: 'ROUND(10/3,2)+ROUNDUP(1.21,1)+ROUNDDOWN(1.29,1)' }, '5,5': { value: 0, formula: 'F6+1' } }; + const errors = X.recalc(cells); + const down = X.sumFormula(cells, 3, 0, 'up'); + let none = ''; try { X.sumFormula({ '0,0': { value: 'x' } }, 1, 0, 'up'); } catch (e) { none = e.message; } + process.stdout.write(JSON.stringify({ b2: cells['1,1'].value, c2: cells['1,2'].value, e5: cells['4,4'].value, + cycle: [cells['5,5'].value, errors.length], down, none })); + """) + assert out["b2"] == 10 and out["c2"] == 12 + assert out["e5"] == 3.33 + 1.3 + 1.2 + assert out["cycle"] == ["#오류", 1] + assert out["down"] == "SUM(A2:A3)" + assert out["none"] == "더할 셀이 없습니다." + + +def test_real_sheets_recompute_and_link_items(): + if not MAIN.exists(): + pytest.skip("installed EST main sample unavailable") + out = node( + """ + const fs = require('fs'), vm = require('vm'); + const imp = require(process.cwd() + '/tools/est-test/import-estx.cjs'), ex = require(process.cwd() + '/tools/est-test/export-estx.cjs'); + const p = imp.importEstx(fs.readFileSync(process.argv[1])); + const s = p.quantitySheets.find((x) => x.Code === 'N0000000136'); + const formulas = p.quantitySheets.filter((x) => x.excel).flatMap((x) => Object.values(x.excel[0].cells)).filter((c) => c.formula); + const same = Buffer.compare(ex.exportEstx(p), fs.readFileSync(process.argv[1])) === 0; + const ctx = vm.createContext({ structuredClone }); + for (const f of ['native-math.js', 'native-formula.js', 'native-text.js', 'native-excel-sheet.cjs', 'native-quantity.cjs']) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, { filename: f }); + ctx.s = s; + const before = vm.runInContext('EstQuantity.calculate(s).items.map((i) => i.GN)', ctx); + vm.runInContext("s.excel[0].cells['6,2'].value = 40; EstExcelSheet.recalc(s.excel[0].cells)", ctx); + const after = vm.runInContext('EstQuantity.calculate(s).items.map((i) => i.GN)', ctx); + s.excelEdited = true; + let message = ''; try { ex.exportEstx(p); } catch (e) { message = e.message; } + process.stdout.write(JSON.stringify({ decoded: formulas.every((c) => typeof c.formula === 'string'), count: formulas.length, + links: Object.fromEntries(Object.entries(s.excelLinks).map(([k, l]) => [k, [l.row, l.col, l.inferred]])), before, after, same, message })); + """, + str(MAIN), + ) + assert out["decoded"] is True and out["count"] == 221 + assert out["links"] == { + "V000001": [32, 4, True], + "V000002": [32, 6, True], + "V000003": [32, 8, True], + } + assert out["before"][:3] == [17, 12, 58] + assert out["after"][:3] != out["before"][:3] + assert out["same"] is True + assert out["message"].startswith("엑셀형 수량산출서 편집은 ESTX") diff --git a/resources/tester/test_est_wonga_tools.py b/resources/tester/test_est_wonga_tools.py index 39f133e46..202c54af8 100644 --- a/resources/tester/test_est_wonga_tools.py +++ b/resources/tester/test_est_wonga_tools.py @@ -87,3 +87,64 @@ def test_gita_picks_smaller_base_and_validates(): assert result["errors"] == ["1 이하", "기타경비"] assert result["none"] == "산업안전보건관리비 비목이 없습니다." assert result["stored"] == [0.4, 0.6] + + +def test_diget_round_follows_original_engine(): + result = run_js( + r"""(() => { + const r = (Diget, DigetIndex, v) => EstWonga.digetRound({ Diget, DigetIndex }, v); + const row = (Diget, DigetIndex, LinkIndex, v) => EstWonga.rowCost({ Diget, DigetIndex, LinkIndex }, v); + return { + none: r(3, 0, 1660273), half: r(3, 1, 1660500), cut: r(3, 2, 1660999), up: r(3, 3, 1660001), + other: r(2, 7, 1234), neg: r(3, 2, -1660999), dec: r(-2, 1, 1.236), + nearly: row(0, 0, 0, 1660273.99996), keep55: row(3, 2, 55, 79603324), cut1: row(1, 2, 0, 1234.9), + p: (() => { + const p = EstNative.calculate(EstModel.validate(structuredClone(source))); + const plain = (t) => String(t).replace(/\s+/g, ''); + return p.tables.Wonga.filter((w) => /산업안전|이윤|도급액/.test(plain(w.Name))).map((w) => [plain(w.Name), w.Cost]); + })(), + }; + })()""" + ) + assert [result[k] for k in ("none", "half", "cut", "up", "other", "neg")] == [ + 1660273, + 1661000, + 1660000, + 1661000, + 1200, + -1660000, + ] + assert abs(result["dec"] - 1.24) < 1e-9 + assert result["nearly"] == 1660274 + assert result["keep55"] == 79603324 + assert result["cut1"] == 1230 + costs = [cost for _, cost in result["p"]] + assert {1660273, 1887446, 7620104, 79603000} <= set(costs) + + +def test_position_sets_row_digits_like_original(): + result = run_js( + r"""(() => { + const calc = (p) => EstNative.calculate(EstModel.validate(p)); + const p = calc(structuredClone(source)); + const plain = (t) => String(t).replace(/\s+/g, ''); + const rows = p.tables.Wonga; + const anjun = rows.findIndex((r) => plain(r.Name).includes('산업안전') && Number(r.CalcIndex) !== 2); + const total = rows.findIndex((r) => Number(r.LinkIndex) === 55); + EstWongaTools.position(p, [anjun], 2, 3); + const cut = calc(p).tables.Wonga[anjun].Cost; + EstWongaTools.position(p, [anjun], 0, 3); + const reset = calc(p).tables.Wonga[anjun].Cost; + let refused = ''; + try { EstWongaTools.position(p, [total], 2, 3); } catch (e) { refused = e.message; } + const input = { options: {}, tables: { Wonga: [{ Name: '관급자재대', Cost: 1234567 }] } }; + EstWongaTools.position(input, [0], 1, 2); + return { cut, reset, refused, input: input.tables.Wonga[0], last: input.options.WongaLastDiget }; + })()""" + ) + assert result["cut"] == 1660000 + assert result["reset"] == 1660273 + assert result["refused"].startswith("도급액 역산 자리수") + assert result["input"]["Cost"] == 1234600 and result["input"]["TC"] == 1234567 + assert result["input"]["Diget"] == 2 and result["input"]["DigetIndex"] == 1 + assert result["last"] == 2 diff --git a/resources/tester/test_est_xls_read.py b/resources/tester/test_est_xls_read.py index 18f58600c..477527b74 100644 --- a/resources/tester/test_est_xls_read.py +++ b/resources/tester/test_est_xls_read.py @@ -42,8 +42,9 @@ def test_rejects_non_ole_and_renders_grid(): const fs = require('fs'), vm = require('vm'); const { readXls } = require(process.cwd() + '/tools/est-test/xls-read.cjs'); let message = ''; try { readXls(Buffer.from('not an xls file at all')); } catch (e) { message = e.message; } - const ctx = vm.createContext({ structuredClone, EstApp: { esc: (v) => String(v), number: (v) => String(v) } }); - vm.runInContext(fs.readFileSync('tools/est-test/native-quantity-sheets.js', 'utf8'), ctx); + const ctx = vm.createContext({ structuredClone, EstApp: { esc: (v) => String(v), number: (v) => String(v), state: {} } }); + for (const f of ['native-excel-sheet.cjs', 'native-excel-view.js', 'native-quantity-sheets.js']) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx); ctx.sheet = { excel: [{ name: 'S', cells: { '0,0': { value: '측점' }, '1,1': { value: 3, formula: true } } }] }; const html = vm.runInContext('EstQuantitySheets.excelView(sheet)', ctx); process.stdout.write(JSON.stringify({ message, html: [html.includes('측점'), html.includes('B'), html.includes('font-weight:bold'), vm.runInContext('EstQuantitySheets.excelView({})', ctx)] })); diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 0c48226fe..c57b68e0b 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -472,7 +472,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [ ] 엑셀형 수량산출서(원본 TFRMMULEXCEL, MulMainDat.Editor = OLE2 .xls BIFF8) - [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] 2단계 계산·편집 — BIFF 수식 토큰(참조·범위·사칙·괄호·숫자·문자·SUM 등) A1 식으로 읽기(`decodeFormula`), `native-excel-sheet.cjs` 계산기(설치 6개 시트 수식 221개 모두 해석), 수식줄 편집·다시 계산, 4방향 합(원본 셀 합 규칙), 수량으로 지정/해제(`sheet.excelLinks`), 가져올 때 추정 연결(값이 같은 합계 셀), 편집 시 ESTX 저장 거부. + - 검증완료: `test_est_excel_sheet.py` 2개(수식·함수·순환 오류·셀 합, 설치 main.estx 수식 221개 해석·토적계산표 항목 V000001~3 추정 연결 E33·G33·I33·수량 17·12·58·거리 바꾸면 수량 변함·원본 재저장 동일·편집 시 ESTX 거부) 통과. 다시 계산한 값이 Excel 저장값(0, 갱신 안 됨)이 아니라 EST 저장 수량(17·12·58·25.16·32.76·15.92·2.17)과 맞음. ORCA 브라우저에서 거리 C7 20→40 → 항목 17→34·12→24·58→116, 위 합 → SUM(E6:E33), 샘플 복구. + - 남음: 행·열 넣기/지우기·셀 서식·병합, 원본 셀↔항목 대응 근거, .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), 샘플 복구. - [x] 갑지·표지·공사설명서·시방서(원본 TFRMGABGI·TFRMCOVER·TFRMESTEDITOR, DatGabgi/DatCover/DatExplean/DatSiBang.opt — 디스어셈블 근거) — `midas-read.cjs` MIDAS 레코드·WPTools 글자 읽기 → `project.docs`, 편집 창, 공사명↔공사 이름 동기화, 공사금액 자동입력 두 방식·한글금액(일 생략 없음), 설명서·시방서 갑지 붙여넣기, 인쇄 미리보기 8·9·10·11. 문서 편집 작업은 ESTX 저장 거부. @@ -483,7 +485,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일). - [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지. - 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구. - - [ ] 원가 행 자리수(Diget·DigetIndex) 반영 — 웹 원가 엔진은 행 자리수를 계산에 쓰지 않음(선택항목 자리수 조정은 값만 기록). 설치 자료 중 sample.estx 만 Diget 사용(도급액 3·산업안전 3, DigetIndex 0)이고 산업안전 1,660,273 처럼 천단위가 아닌 값이 있어 DigetIndex 0 의 뜻(사사오입/없음) 확정 불가 — 근거 확보 전 엔진 변경 보류. 기성 원가계산서 편집(절하·한줄 삽입 등)도 같은 근거가 필요해 보류. + - [x] 원가 행 자리수(Diget·DigetIndex) 반영(원본 행 계산 c9d278·c9d818·기성 ca6a6c 끝부분, 자리맞춤 0x11293e8, 선택항목 절하 확인 0xd094dc — 디스어셈블 근거) — 수식 결과 소수 4자리 반올림 뒤 원 미만 절사, 도급액(55) 아니고 Diget>0 이면 10^Diget 단위 자리맞춤(DigetIndex 0 처리 없음 · 1 사사오입 · 2 절하 · 3 절상 · 4+ 절하). 원가계산서 화면의 「선택 행 자릿수 설정」은 원본 「선택항목 절하하기」 대화(사사오입·절하·절상·초기화, 0 원~6 백만원, 도급액 행 거부, 입력 행은 원금액 TC 기준, 마지막 자리 기억). 기성 원가도 같은 엔진. + - 검증완료: `test_est_wonga_tools.py` 4개(자리맞춤 방식별·음수·소수·4자리 반올림·도급액 제외, 샘플 산업안전 1,660,273·비교 1,887,446·이윤 7,620,104·도급액 79,603,000 그대로, 대화 절하 1,660,000·초기화 복귀·도급액 행 거부·입력 행 TC) 통과. ORCA 브라우저에서 샘플 산업안전 행 천원 절하 → 1,660,000, 도급액 79,603,000 유지, 복구. - [x] 견적서 작성(원본 mnuEstiMatePrn → TfrmEstiMatePrn 0x10c1994·그리기 0x10c4768 — 디스어셈블 근거) — 공사별 설정(`project.estimatePrint`), 원본 `.einf` INI 세 구역 저장·두 구역 불러오기, 레벨 행 선택, 7열, 1쪽 머리·공급자·합계금액(아래에서 위로 Checked 금액, 한글), 첫/나머지 쪽 줄수, 미리보기·인쇄. 수탁공사변환·XML 역변환 비교는 원본에서 숨긴 개발용 메뉴라 범위 밖. - 검증완료: `test_est_estimate_print.py`(전체/레벨 2 행, 쪽 27·36·7, 합계 「일금:구천칠백일십구만구천원정 (₩97,199,000원정)」, INI 왕복, 미리보기 글) 통과. ORCA 브라우저에서 상호·레벨 2 → 2쪽·28줄·합계·공급자 표, 공사 설정 저장 확인 후 샘플 복구. - 남음: 견적서 Excel 변환, 글꼴·크기·여백 세부값. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 5ce2092b4..24619084d 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -70,7 +70,9 @@ 산출 줄 옆 `누계`는 원본 누계(Ctrl+U)처럼 커서 자리에 누계 값을 넣습니다. 값은 가장 가까운 위쪽 품명 줄(포함) 또는 앞 누계 줄(제외)부터 바로 위 줄까지 줄마다 첫 결과 수량의 합(소계)이며, 계산할 때마다 다시 구합니다. 원본 파일의 누계 표지도 읽어 다시 계산합니다. -원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값을 읽어 수량산출 화면 아래에 읽기 전용 격자로 보입니다. 수식 셀은 Excel이 저장해 둔 계산값을 굵게 보이며, 셀 편집·다시 계산은 아직 하지 않습니다. +원가계산서에서 행을 고르고 `선택 행 자릿수 설정`을 누르면 원본 「선택항목 절하하기」처럼 원·십원·백원… 단위로 사사오입·절하·절상하거나 초기화합니다. 수식 행은 계산 때마다 그 자리수로 맞추고, 도급액 고정 행은 바꿀 수 없습니다. + +원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값과 수식을 함께 읽어 수량산출 화면 아래 격자로 보입니다. 셀을 누르면 수식줄에서 값이나 `=수식`을 고치고 바로 다시 계산합니다(참조·범위·사칙·SUM·ROUND 등). `우측·위·좌측·아래 합`은 원본처럼 그 방향의 숫자 셀을 더하는 SUM 을 넣고, `수량으로 지정`은 셀을 수량 항목에 연결해 항목 수량이 그 셀 값(산출서 자리수)을 따르게 합니다. 원본 파일에는 셀과 항목의 대응이 보이지 않아, 가져올 때 저장 수량과 값이 같은 합계 셀을 「추정 연결」로 둡니다. 셀을 고친 엑셀형 산출서는 ESTX 저장을 거부합니다. 산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다. diff --git a/tools/est-test/export-quantity.cjs b/tools/est-test/export-quantity.cjs index c229b653f..1b92530de 100644 --- a/tools/est-test/export-quantity.cjs +++ b/tools/est-test/export-quantity.cjs @@ -418,6 +418,8 @@ function updateQuantities(project, base, cp949, memory) { const sheets = project.quantitySheets || [], originals = base.quantitySheets || []; if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다."); + if (sheets.some((sheet) => sheet.excelEdited)) + throw Error("엑셀형 수량산출서 편집은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요."); if (sheets.some((sheet) => sheet.markerEdited)) throw Error("산출 줄의 수량 지정·해제는 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요."); if (sheets.some((sheet, i) => sheet.Code !== originals[i].Code)) diff --git a/tools/est-test/import-estx.cjs b/tools/est-test/import-estx.cjs index d2259acce..4aae2525a 100644 --- a/tools/est-test/import-estx.cjs +++ b/tools/est-test/import-estx.cjs @@ -1,5 +1,6 @@ // Read-only ESTX import. Layout: knowledge/다산소프트/ESTX_파일구조.md. const { readXls } = require("./xls-read.cjs"); +const excelSheet = require("./native-excel-sheet.cjs"); const { readDocuments } = require("./midas-read.cjs"); const zlib = require("node:zlib"); const quantity = require("./native-quantity.cjs"); @@ -373,8 +374,10 @@ function importEstx(buffer, name = "가져온 내역서") { roundMode: (props.TMulLangOption?.CalcMethod ?? 1) + 1, }); // 엑셀형 수량산출서(OLE2 .xls)는 읽기 전용 셀 값으로 붙인다. - if (bytes.length > 8 && Buffer.from(bytes).readUInt32LE(0) === 0xe011cfd0) + if (bytes.length > 8 && Buffer.from(bytes).readUInt32LE(0) === 0xe011cfd0) { sheet.excel = readXls(Buffer.from(bytes)); + sheet.excelLinks = excelSheet.inferLinks(sheet); + } quantitySheets.push(sheet); } catch (error) { warnings.add(`수량산출 ${row.Code}: ${error.message}`); diff --git a/tools/est-test/index.html b/tools/est-test/index.html index a8ece4079..090bb6a7a 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -10,6 +10,7 @@ + @@ -61,6 +62,7 @@ + diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index ebe0aa032..b06a7fe5c 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -229,6 +229,7 @@ const EstBusinessActions = (() => { throw Error("이 자료에는 자릿수 설정이 없습니다."); if (!indices.length || indices.some((index) => !rows[index])) throw Error("설정할 행을 선택하세요."); + if (state.view === "Wonga") return EstWongaTools.positionDialog(state, indices); EstActions.form( "선택 행 자릿수", [ @@ -621,7 +622,8 @@ const EstBusinessActions = (() => { (typeof EstEstTools !== "undefined" && EstEstTools.handle(name, state)) || (typeof EstPrintBatch !== "undefined" && EstPrintBatch.handle(name, state)) || (typeof EstMachFormula !== "undefined" && EstMachFormula.handle(name, state, node)) || - (typeof EstDefaults !== "undefined" && EstDefaults.handle(name, state, node)) + (typeof EstDefaults !== "undefined" && EstDefaults.handle(name, state, node)) || + (typeof EstExcelView !== "undefined" && EstExcelView.handle(name, state, node)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-excel-sheet.cjs b/tools/est-test/native-excel-sheet.cjs new file mode 100644 index 000000000..d62ba5feb --- /dev/null +++ b/tools/est-test/native-excel-sheet.cjs @@ -0,0 +1,259 @@ +// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 웹 계산·편집. 셀 = sheet.excel[n].cells["행,열"] = { value, formula? }. +// 수식: 셀 참조·범위·+−×÷^·&·비교·괄호·숫자·문자·SUM·AVERAGE·MIN·MAX·ABS·INT·ROUND·ROUNDUP·ROUNDDOWN·TRUNC·SQRT·PRODUCT·IF. +// 셀 합(btnSumRight·Top·Left·Bottom, 원본 ActionAdd* 와 같은 규칙): 고른 셀에서 그 방향으로 빈칸·숫자 셀을 지나 +// 글자 셀이나 끝에서 멈춘 범위로 =SUM(…) 을 넣는다. +const EstExcelSheet = (() => { + const letters = (n) => { + let s = ""; + for (n += 1; n > 0; n = Math.floor((n - 1) / 26)) + s = String.fromCharCode(65 + ((n - 1) % 26)) + s; + return s; + }; + const parseRef = (text) => { + const m = String(text) + .toUpperCase() + .replace(/\$/g, "") + .match(/^([A-Z]{1,3})(\d+)$/); + if (!m) return null; + return { + col: [...m[1]].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1, + row: Number(m[2]) - 1, + }; + }; + const key = (row, col) => `${row},${col}`; + const round = (v, d, mode) => { + const k = 10 ** d, + x = v * k; + const r = + mode === "up" + ? Math.sign(x) * Math.ceil(Math.abs(x) - 1e-9) + : mode === "down" + ? Math.trunc(x) + : Math.sign(x) * Math.round(Math.abs(x)); + return r / k; + }; + const fns = { + SUM: (a) => a.flat().reduce((n, v) => n + (typeof v === "number" ? v : 0), 0), + AVERAGE: (a) => { + const v = a.flat().filter((x) => typeof x === "number"); + return v.length ? v.reduce((n, x) => n + x, 0) / v.length : 0; + }, + MIN: (a) => Math.min(...a.flat().filter((x) => typeof x === "number")), + MAX: (a) => Math.max(...a.flat().filter((x) => typeof x === "number")), + PRODUCT: (a) => + a + .flat() + .filter((x) => typeof x === "number") + .reduce((n, x) => n * x, 1), + ABS: ([x]) => Math.abs(num(x)), + INT: ([x]) => Math.floor(num(x)), + SQRT: ([x]) => Math.sqrt(num(x)), + ROUND: ([x, d]) => round(num(x), num(d)), + ROUNDUP: ([x, d]) => round(num(x), num(d), "up"), + ROUNDDOWN: ([x, d]) => round(num(x), num(d), "down"), + TRUNC: ([x, d = 0]) => round(num(x), num(d), "down"), + IF: ([c, a = true, b = false]) => (c && c !== 0 ? a : b), + }; + function num(v) { + if (Array.isArray(v)) v = v.flat()[0]; + if (typeof v === "number") return v; + if (typeof v === "boolean") return v ? 1 : 0; + const n = Number(String(v ?? "").replaceAll(",", "")); + return Number.isFinite(n) ? n : 0; + } + + function tokenize(text) { + const tokens = [], + re = + /\s*("(?:[^"]|"")*"|\$?[A-Za-z]{1,3}\$?\d+(?::\$?[A-Za-z]{1,3}\$?\d+)?|[A-Za-z_][A-Za-z0-9_.]*(?=\()|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|<=|>=|<>|[-+*/^&=<>(),%:])/y; + let m; + re.lastIndex = 0; + while (re.lastIndex < text.length && (m = re.exec(text))) tokens.push(m[1]); + if (re.lastIndex < text.trimEnd().length) throw Error(`수식 해석 오류: ${text}`); + return tokens; + } + + function evaluate(text, get) { + const t = tokenize(String(text).replace(/^=/, "").replace(/×/g, "*").replace(/÷/g, "/")); + let i = 0; + const peek = () => t[i], + next = () => t[i++]; + const range = (a, b) => { + const p = parseRef(a), + q = parseRef(b), + out = []; + for (let r = Math.min(p.row, q.row); r <= Math.max(p.row, q.row); r++) + for (let c = Math.min(p.col, q.col); c <= Math.max(p.col, q.col); c++) out.push(get(r, c)); + return out; + }; + function primary() { + const tok = next(); + if (tok === undefined) throw Error("수식이 끝나지 않았습니다."); + if (tok === "(") { + const v = compare(); + if (next() !== ")") throw Error("괄호가 맞지 않습니다."); + return v; + } + if (tok === "-") return -num(power()); + if (tok === "+") return num(power()); + if (tok.startsWith('"')) return tok.slice(1, -1).replaceAll('""', '"'); + if (/^\d/.test(tok)) return Number(tok); + if (/^(TRUE|FALSE)$/i.test(tok)) return /^TRUE$/i.test(tok); + if (tok.includes(":")) { + const [a, b] = tok.split(":"); + return range(a, b); + } + if (peek() === "(" && fns[tok.toUpperCase()]) { + next(); + const args = []; + if (peek() !== ")") + do args.push(compare()); + while (peek() === "," && next()); + if (next() !== ")") throw Error("함수 괄호가 맞지 않습니다."); + return fns[tok.toUpperCase()](args); + } + const ref = parseRef(tok); + if (ref) return get(ref.row, ref.col); + throw Error(`모르는 수식 낱말: ${tok}`); + } + function percent() { + let v = primary(); + while (peek() === "%") (next(), (v = num(v) / 100)); + return v; + } + function power() { + let v = percent(); + while (peek() === "^") (next(), (v = num(v) ** num(percent()))); + return v; + } + function product() { + let v = power(); + while (peek() === "*" || peek() === "/") + v = next() === "*" ? num(v) * num(power()) : num(v) / num(power()); + return v; + } + function sum() { + let v = product(); + while (peek() === "+" || peek() === "-") + v = next() === "+" ? num(v) + num(product()) : num(v) - num(product()); + return v; + } + function concat() { + let v = sum(); + while (peek() === "&") (next(), (v = `${v ?? ""}${sum() ?? ""}`)); + return v; + } + function compare() { + let v = concat(); + while (["=", "<>", "<", ">", "<=", ">="].includes(peek())) { + const op = next(), + w = concat(); + v = { "=": v == w, "<>": v != w, "<": v < w, ">": v > w, "<=": v <= w, ">=": v >= w }[op]; + } + return v; + } + const value = compare(); + if (i < t.length) throw Error(`수식 뒤 남은 글: ${t.slice(i).join("")}`); + return Array.isArray(value) ? value.flat()[0] : value; + } + + // 시트 전체 다시 계산(수식 셀). 오류 셀은 value 를 「#오류」로 두고 errors 에 모은다. + function recalc(cells) { + const done = new Set(), + active = new Set(), + errors = []; + function get(row, col) { + const k = key(row, col), + cell = cells[k]; + if (!cell) return null; + if (typeof cell.formula !== "string" || done.has(k)) return cell.value; + if (active.has(k)) throw Error(`순환 참조: ${letters(col)}${row + 1}`); + active.add(k); + try { + const v = evaluate(cell.formula, get); + cell.value = typeof v === "number" ? Number(v.toFixed(10)) : v; + } finally { + active.delete(k); + done.add(k); + } + return cell.value; + } + for (const k of Object.keys(cells)) { + const [r, c] = k.split(",").map(Number); + try { + get(r, c); + } catch (error) { + cells[k].value = "#오류"; + errors.push(`${letters(c)}${r + 1}: ${error.message}`); + } + } + return errors; + } + + // 원본 셀 합: dir = right·left·up·down(Top=up). 고른 셀에서 반대쪽으로 빈칸·숫자 셀을 지나 글자 셀/끝에서 멈춤. + function sumFormula(cells, row, col, dir) { + const step = { left: [0, -1], right: [0, 1], up: [-1, 0], down: [1, 0] }[dir]; + if (!step) throw Error("합 방향을 고르세요."); + let r = row + step[0], + c = col + step[1], + last = null; + const limit = { + rows: 1 + Math.max(0, ...Object.keys(cells).map((k) => Number(k.split(",")[0]))), + cols: 1 + Math.max(0, ...Object.keys(cells).map((k) => Number(k.split(",")[1]))), + }; + while (r >= 0 && c >= 0 && r < limit.rows && c < limit.cols) { + const v = cells[key(r, c)]?.value; + if (v != null && v !== "" && typeof v !== "number") break; + last = [r, c]; + r += step[0]; + c += step[1]; + } + if (!last) throw Error("더할 셀이 없습니다."); + const a = `${letters(Math.min(col + step[1], last[1]))}${Math.min(row + step[0], last[0]) + 1}`, + b = `${letters(Math.max(col + step[1], last[1]))}${Math.max(row + step[0], last[0]) + 1}`; + return `SUM(${a}:${b})`; + } + + // 산출서 자리수(1 반올림·2 절사·3 올림) + function fix(value, digits = 2, mode = 2) { + if (typeof EstMath !== "undefined") return EstMath.fixed(value, digits, mode); + return round(value, digits, mode === 1 ? undefined : mode === 3 ? "up" : "down"); + } + // 연결된 셀 값 → 항목 수량 + function itemValues(sheet) { + const out = new Map(), + digits = sheet.options?.digits ?? 2, + mode = sheet.options?.roundMode ?? 2; + for (const [code, l] of Object.entries(sheet.excelLinks || {})) { + const v = sheet.excel?.[l.n]?.cells?.[key(l.row, l.col)]?.value; + if (typeof v === "number") out.set(code, fix(v, digits, mode)); + } + return out; + } + // 가져올 때 추정 연결: 다시 계산한 셀 중 값이 항목 수량과 같은 셀 하나(합계 수식 → 아래쪽 우선). + function inferLinks(sheet) { + const links = {}, + digits = sheet.options?.digits ?? 2, + mode = sheet.options?.roundMode ?? 2; + (sheet.excel || []).forEach((s) => recalc(s.cells)); + for (const item of sheet.items || []) { + const target = Number(item.GN); + if (!target) continue; + const found = []; + (sheet.excel || []).forEach((s, n) => { + for (const [k, c] of Object.entries(s.cells)) + if (typeof c.value === "number" && fix(c.value, digits, mode) === target) { + const [row, col] = k.split(",").map(Number); + found.push({ n, row, col, sum: /^SUM\(/i.test(String(c.formula ?? "")) }); + } + }); + found.sort((a, b) => Number(b.sum) - Number(a.sum) || b.row - a.row || a.col - b.col); + if (found[0]) + links[item.Code] = { n: found[0].n, row: found[0].row, col: found[0].col, inferred: true }; + } + return links; + } + + return { evaluate, recalc, sumFormula, parseRef, letters, itemValues, inferLinks }; +})(); +if (typeof module !== "undefined" && module.exports) module.exports = EstExcelSheet; diff --git a/tools/est-test/native-excel-view.js b/tools/est-test/native-excel-view.js new file mode 100644 index 000000000..5c614568c --- /dev/null +++ b/tools/est-test/native-excel-view.js @@ -0,0 +1,137 @@ +// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 웹 편집 화면 — 셀 고르기·수식줄 편집·다시 계산, 셀 합(우·상·좌·하 btnSum*), +// 수량으로 지정(popSetMullang)·지정 해제(popSetFree) = 셀 ↔ 수량 항목 연결(sheet.excelLinks). 항목 수량 = 연결 셀 값(산출서 자리수). +// 원본 파일 속 셀↔항목 대응은 찾지 못해, 가져올 때 값이 같은 셀 하나(합계 수식·아래쪽 우선)를 「추정」 연결로 둔다. +// 편집한 엑셀형 산출서는 .xls 로 다시 쓰지 못하므로 ESTX 저장을 막는다(sheet.excelEdited). +const EstExcelView = (() => { + const L = (n) => EstExcelSheet.letters(n); + + function render(sheet, state) { + if (!sheet?.excel?.length) return ""; + const { esc, number } = EstApp, + sel = state.excelCell?.sheet === sheet.Code ? state.excelCell : null, + links = sheet.excelLinks || {}; + const linked = new Map( + Object.entries(links).map(([code, l]) => [`${l.n},${l.row},${l.col}`, { code, ...l }]), + ); + return sheet.excel + .map(({ name, cells }, n) => { + const keys = Object.keys(cells).map((k) => k.split(",").map(Number)); + const rows = Math.max(20, ...keys.map(([r]) => r + 2)), + cols = Math.min(40, Math.max(8, ...keys.map(([, c]) => c + 2))); + const raw = sel && sel.n === n ? cells[`${sel.row},${sel.col}`] : null; + const bar = + sel && sel.n === n + ? `${L(sel.col)}${sel.row + 1} ` + : "셀을 누르면 수식줄에서 고칩니다."; + const items = (sheet.items || []) + .map( + (i) => + ``, + ) + .join(""); + const tools = + sel && sel.n === n + ? ` ` + : ""; + const body = Array.from({ length: rows }, (_, r) => { + const tds = Array.from({ length: cols }, (_, c) => { + const cell = cells[`${r},${c}`], + link = linked.get(`${n},${r},${c}`), + chosen = sel && sel.n === n && sel.row === r && sel.col === c; + const value = + cell == null + ? "" + : typeof cell.value === "number" + ? number(cell.value) + : esc(cell.value); + const style = [ + cell?.formula ? "font-weight:bold" : "", + link ? "background:#e6f4ea" : "", + chosen ? "outline:2px solid #316ea5" : "", + ] + .filter(Boolean) + .join(";"); + const title = [ + typeof cell?.formula === "string" ? `=${cell.formula}` : "", + link ? `수량: ${link.code}${link.inferred ? " (추정 연결)" : ""}` : "", + ] + .filter(Boolean) + .join(" · "); + return `${value}`; + }).join(""); + return `${r + 1}${tds}`; + }).join(""); + return `

엑셀형 산출서 · ${esc(name)}

${bar}${tools}
${Array.from({ length: cols }, (_, c) => ``).join("")}${body}
${L(c)}
`; + }) + .join(""); + } + + function sheetOf(state) { + return state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); + } + function setCell(sheet, n, row, col, text) { + const cells = sheet.excel[n].cells, + k = `${row},${col}`, + t = String(text ?? ""); + if (t === "") delete cells[k]; + else if (t.startsWith("=")) cells[k] = { value: 0, formula: t.slice(1) }; + else { + const v = Number(t.replaceAll(",", "")); + cells[k] = { value: t.trim() !== "" && Number.isFinite(v) ? v : t }; + } + sheet.excelEdited = true; + return EstExcelSheet.recalc(cells); + } + + function install() { + document.addEventListener("click", (event) => { + const td = event.target.closest("[data-excel-pick]"); + if (!td) return; + const [n, row, col] = td.dataset.excelPick.split(",").map(Number); + EstApp.state.excelCell = { sheet: EstApp.state.quantitySheet, n, row, col }; + EstApp.render(); + }); + document.addEventListener("change", (event) => { + if (event.target.dataset.excelBar === undefined) return; + const state = EstApp.state, + sel = state.excelCell, + value = event.target.value; + let errors = []; + EstApp.change(() => { + errors = setCell(sheetOf(state), sel.n, sel.row, sel.col, value); + }, false); + if (errors.length) state.notice = errors.slice(0, 3).join(" / "); + }); + } + + function handle(name, state, node) { + if (!["excel-sum", "excel-link", "excel-unlink"].includes(name)) return false; + const sheet = sheetOf(state), + sel = state.excelCell; + if (!sheet?.excel || !sel) throw Error("엑셀형 산출서에서 셀을 고르세요."); + if (name === "excel-sum") { + const formula = EstExcelSheet.sumFormula( + sheet.excel[sel.n].cells, + sel.row, + sel.col, + node.dataset.dir, + ); + EstApp.change(() => setCell(sheet, sel.n, sel.row, sel.col, `=${formula}`), false); + } else { + const code = document.querySelector("[data-excel-item]")?.value; + EstApp.change(() => { + sheet.excelLinks ||= {}; + for (const [k, l] of Object.entries(sheet.excelLinks)) + if (l.n === sel.n && l.row === sel.row && l.col === sel.col) delete sheet.excelLinks[k]; + if (name === "excel-link") { + if (!code) throw Error("수량 항목을 고르세요."); + sheet.excelLinks[code] = { n: sel.n, row: sel.row, col: sel.col }; + } + }, false); + } + return true; + } + + if (typeof document !== "undefined" && document.addEventListener) install(); + return { render, setCell, handle }; +})(); diff --git a/tools/est-test/native-quantity-sheets.js b/tools/est-test/native-quantity-sheets.js index d02a6c609..712be2f12 100644 --- a/tools/est-test/native-quantity-sheets.js +++ b/tools/est-test/native-quantity-sheets.js @@ -191,34 +191,9 @@ const EstQuantitySheets = (() => { return true; } - // 엑셀형 수량산출서(원본 TFRMMULEXCEL) — 가져온 셀 값을 읽기 전용 격자로 보인다. 수식 셀은 저장된 계산값(굵게). + // 엑셀형 수량산출서(원본 TFRMMULEXCEL) — 편집 화면은 native-excel-view.js. function excelView(sheet) { - if (!sheet?.excel?.length) return ""; - const { esc, number } = EstApp; - const letter = (n) => { - let s = ""; - for (n += 1; n > 0; n = Math.floor((n - 1) / 26)) - s = String.fromCharCode(65 + ((n - 1) % 26)) + s; - return s; - }; - return sheet.excel - .map(({ name, cells }) => { - const keys = Object.keys(cells).map((k) => k.split(",").map(Number)); - if (!keys.length) return ""; - const rows = Math.max(...keys.map(([r]) => r)) + 1, - cols = Math.min(40, Math.max(...keys.map(([, c]) => c)) + 1); - const body = Array.from({ length: rows }, (_, r) => { - const cellsHtml = Array.from({ length: cols }, (_, c) => { - const cell = cells[`${r},${c}`]; - if (!cell) return ""; - const value = typeof cell.value === "number" ? number(cell.value) : esc(cell.value); - return `${value}`; - }).join(""); - return `${r + 1}${cellsHtml}`; - }).join(""); - return `

엑셀형 산출서 · ${esc(name)} (읽기 전용)

${Array.from({ length: cols }, (_, c) => ``).join("")}${body}
${letter(c)}
`; - }) - .join(""); + return typeof EstExcelView === "undefined" ? "" : EstExcelView.render(sheet, EstApp.state); } return { copy, move, mark, free, excelView, matches, selector, handle }; diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index ea9bb7925..8fa90437b 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -118,9 +118,14 @@ const EstQuantity = (() => { }); if (typeof EstQuantityTotal !== "undefined") EstQuantityTotal.apply(sheet, result.rows.slice(1)); + const cellValues = + sheet.excel && typeof EstExcelSheet !== "undefined" ? EstExcelSheet.itemValues(sheet) : null; return { rows: result.rows.slice(1), - items: sheet.items.map((item) => ({ ...item, GN: values.get(item.Code) ?? item.GN })), + items: sheet.items.map((item) => ({ + ...item, + GN: values.get(item.Code) ?? cellValues?.get(item.Code) ?? item.GN, + })), checks, errors: [...result.errors, ...markerErrors], }; diff --git a/tools/est-test/native-wonga-tools.js b/tools/est-test/native-wonga-tools.js index 65eefc12d..d9196572d 100644 --- a/tools/est-test/native-wonga-tools.js +++ b/tools/est-test/native-wonga-tools.js @@ -182,6 +182,65 @@ const EstWongaTools = (() => { ); } + // 선택항목 절하하기(popupPosition 모드 0, 확인 0xd094dc): 행 Diget·DigetIndex 기록, 도급액(55) 행 거부. + // 수식 행은 재계산이 자리맞춤, 입력 행은 원금액(TC, 없으면 현재 금액)을 자리맞춤해 Cost 로. 자리수는 다음 기본값으로 기억. + // accom = 기성 원가(도급액 행 거부 검사 없음). + function position(project, indices, method, digits, accom = false) { + const rows = project.tables.Wonga || []; + if (![0, 1, 2, 3].includes(method) || !Number.isInteger(digits) || digits < 0 || digits > 6) + throw Error("자리수는 0~6이고 처리방식을 골라야 합니다."); + if (indices.some((i) => !rows[i])) throw Error("절하할 줄을 고르세요."); + if (!accom && indices.some((i) => num(rows[i].LinkIndex) === 55)) + throw Error("도급액 역산 자리수 지정한 라인은자리수를 별도로 지정할수 없습니다."); + for (const i of indices) { + const row = rows[i]; + row.Diget = method ? digits : 0; + row.DigetIndex = method; + const formula = row.VSusic && ![1, 3].includes(num(row.CalcIndex)) && !row.BaseField; + if (formula) continue; + const base = num(row.TC) || num(row.Cost); + if (!base) continue; + row.TC = base; + row.Cost = method ? EstWonga.digetRound(row, base) : base; + } + project.options.WongaLastDiget = digits; + } + function positionDialog(state, indices) { + const rows = state.project.tables.Wonga, + unit = ["원", "십원", "백원", "천원", "만원", "십만원", "백만원"]; + EstActions.form( + "선택항목 절하하기", + [ + { + name: "method", + label: "처리방식", + value: num(rows[indices[0]].DigetIndex) || 2, + choices: [ + [1, "사사오입"], + [2, "절하"], + [3, "절상"], + [0, "초기화"], + ], + }, + { + name: "digits", + label: "자리 (0 원 · 1 십원 … 6 백만원)", + type: "number", + value: num(state.project.options.WongaLastDiget), + }, + ], + (values) => { + const method = Number(values.method), + digits = Number(values.digits); + position(structuredClone(state.project), indices, method, digits); + EstApp.change((project) => position(project, indices, method, digits)); + state.notice = method + ? `${unit[digits]} 단위로 ${["", "사사오입", "절하", "절상"][method]}했습니다.` + : "자리수 지정을 풀었습니다."; + }, + ); + } + function handle(name, state) { if (name === "wonga-gita") dialog(state, "gita"); else if (name === "wonga-anjun") dialog(state, "anjun"); @@ -189,5 +248,5 @@ const EstWongaTools = (() => { return true; } - return { gita, anjun, anjunBase, handle }; + return { gita, anjun, anjunBase, position, positionDialog, handle }; })(); diff --git a/tools/est-test/native-wonga.js b/tools/est-test/native-wonga.js index 50d9f871f..874e8daa0 100644 --- a/tools/est-test/native-wonga.js +++ b/tools/est-test/native-wonga.js @@ -1,9 +1,47 @@ // REA c9f8ec / c9d278 / c9f0e4: 수식 참조, 원 미만 절사, 도급액 고정 반복. // 및 TFRMWONGABASE popupTotal: 공사비역산 자리수 맞추기 (단단위 절하 후 도급액 일치 배분) +// 행 자리맞춤 0x11293e8: Diget k>0 = 10^k 단위 · −k = 소수 k 자리, DigetIndex 0 처리 없음 · 1 사사오입 · 2 절하 · 3 절상 · 4+ 절하. const EstWonga = (() => { const { num } = EstMath; const compact = (text) => String(text || "").replace(/\s/g, ""); + const simpleRoundTo = (v, e) => { + const f = 10 ** e; + return (v < 0 ? Math.trunc(v / f - 0.5) : Math.trunc(v / f + 0.5)) * f; + }; + function roundHalfEven(v) { + const r = Math.round(v); + return Math.abs(v % 1) === 0.5 && r % 2 ? r - 1 : r; + } + function digetRound(row, v) { + const d = num(row.Diget); + let di = num(row.DigetIndex), + n = Math.abs(d), + dec = false; + if (d === 0) { + dec = true; + n = 1; + if (di === 0) di = 2; + } else if (d < 0) { + dec = true; + n += 1; + } + const method = di === 0 ? 3 : di === 1 ? 0 : di === 3 ? 2 : 1; + v = simpleRoundTo(v, -7); + if (n === 0 || method === 3) return v; + const e = dec ? -(n - 1) : n, + p = 10 ** Math.abs(e); + if (method === 0) return simpleRoundTo(v, e); + let q = simpleRoundTo(e > 0 ? v / p : v * p, -7); + q = q >= 0 === (method === 1) ? Math.floor(q) : Math.ceil(q); + return simpleRoundTo(e > 0 ? q * p : q / p, -7); + } + // 행 계산 끝 (c9d278 0xc9d607): 소수 4자리 반올림 뒤 원 미만 절사, 도급액(55) 아니고 Diget > 0 이면 자리맞춤. + function rowCost(row, raw) { + const v = Math.trunc(roundHalfEven(raw * 1e4) / 1e4); + return num(row.LinkIndex) !== 55 && num(row.Diget) > 0 ? digetRound(row, v) : v; + } + function buildContext(rows, options = {}, bases = {}) { const key = (row) => String(options.VerFormat === false ? row.NumStr || row.Gubun : row.Gubun || row.NumStr || ""); @@ -79,7 +117,8 @@ const EstWonga = (() => { !/^원금액\s*:/.test(row.VSusic) && ![1, 3].includes(num(row.CalcIndex)) ) { - row.Cost = Math.trunc( + row.Cost = rowCost( + row, EstExpression.evaluate(row.VSusic, (name) => resolve(name, index), { numericReferences: true, range: (a, b) => range(a, b, index), @@ -269,5 +308,5 @@ const EstWonga = (() => { }; } - return { calculate, scan, alignTotal, resetWonga }; + return { calculate, scan, alignTotal, resetWonga, digetRound, rowCost }; })(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 3f14f4527..3ed47cd1e 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-excel-sheet.cjs", "native-quantity-total.js", "native-quantity.cjs", "native-wonga.js", @@ -83,6 +84,7 @@ const allowed = new Set([ "native-print-batch.js", "native-mach-formula.js", "native-defaults.js", + "native-excel-view.js", "native-issues.js", "native-attachments.js", "native-standard.js", diff --git a/tools/est-test/xls-read.cjs b/tools/est-test/xls-read.cjs index 63cfcc47c..a24db4a84 100644 --- a/tools/est-test/xls-read.cjs +++ b/tools/est-test/xls-read.cjs @@ -163,6 +163,111 @@ function sst(list, index) { return strings; } +// BIFF8 수식 토큰(rgce) → A1 식. 원본 엑셀형 산출서에 쓰인 것: 참조·범위·+−×÷^·괄호·숫자·문자·SUM 등 기본 함수. +// 모르는 토큰이 있으면 null(저장된 계산값만 씀). +const functions = { + 1: "IF", + 4: "SUM", + 5: "AVERAGE", + 6: "MIN", + 7: "MAX", + 24: "ABS", + 25: "INT", + 27: "ROUND", + 197: "TRUNC", + 212: "ROUNDUP", + 213: "ROUNDDOWN", + 20: "SQRT", + 183: "PRODUCT", +}; +const fixedArgs = { 24: 1, 25: 1, 27: 2, 20: 1, 212: 2, 213: 2 }; +function columnName(index) { + let name = ""; + for (let n = index + 1; n > 0; n = Math.floor((n - 1) / 26)) + name = String.fromCharCode(65 + ((n - 1) % 26)) + name; + return name; +} +function decodeFormula(rgce) { + const stack = []; + const binary = { + 3: "+", + 4: "-", + 5: "*", + 6: "/", + 7: "^", + 8: "&", + 9: "<", + 10: "<=", + 11: "=", + 12: ">=", + 13: ">", + 14: "<>", + }; + const ref = (row, col) => `${columnName(col & 0x3fff)}${row + 1}`; + for (let i = 0; i < rgce.length;) { + const id = rgce[i]; + if (binary[id]) { + const b = stack.pop(), + a = stack.pop(); + if (a == null || b == null) return null; + stack.push(`${a}${binary[id]}${b}`); + i += 1; + } else if (id === 0x12 || id === 0x13) { + const a = stack.pop(); + if (a == null) return null; + stack.push(`${id === 0x13 ? "-" : "+"}${a}`); + i += 1; + } else if (id === 0x14) { + stack.push(`${stack.pop()}/100`); + i += 1; + } else if (id === 0x15) { + stack.push(`(${stack.pop()})`); + i += 1; + } else if (id === 0x16) { + stack.push(""); + i += 1; + } else if (id === 0x1e) { + stack.push(String(rgce.readUInt16LE(i + 1))); + i += 3; + } else if (id === 0x1f) { + stack.push(String(rgce.readDoubleLE(i + 1))); + i += 9; + } else if (id === 0x1d) { + stack.push(rgce[i + 1] ? "TRUE" : "FALSE"); + i += 2; + } else if (id === 0x17) { + const n = rgce[i + 1], + wide = rgce[i + 2] & 1, + text = wide + ? rgce.toString("utf16le", i + 3, i + 3 + n * 2) + : rgce.toString("latin1", i + 3, i + 3 + n); + stack.push(`"${text.replaceAll('"', '""')}"`); + i += 3 + (wide ? n * 2 : n); + } else if (id === 0x19) { + const opt = rgce[i + 1]; + if (opt & 0x10) stack.push(`SUM(${stack.pop()})`); + i += opt & 0x04 ? 4 + 2 * (rgce.readUInt16LE(i + 2) + 1) : 4; + } else if ([0x24, 0x44, 0x64].includes(id)) { + stack.push(ref(rgce.readUInt16LE(i + 1), rgce.readUInt16LE(i + 3))); + i += 5; + } else if ([0x25, 0x45, 0x65].includes(id)) { + stack.push( + `${ref(rgce.readUInt16LE(i + 1), rgce.readUInt16LE(i + 5))}:${ref(rgce.readUInt16LE(i + 3), rgce.readUInt16LE(i + 7))}`, + ); + i += 9; + } else if ([0x21, 0x41, 0x61, 0x22, 0x42, 0x62].includes(id)) { + const variable = (id & 0x0f) === 2, + fn = variable ? rgce.readUInt16LE(i + 2) & 0x7fff : rgce.readUInt16LE(i + 1), + argc = variable ? rgce[i + 1] & 0x7f : fixedArgs[fn]; + if (!functions[fn] || argc == null || stack.length < argc) return null; + const args = stack.splice(stack.length - argc, argc); + stack.push(`${functions[fn]}(${args.join(",")})`); + i += variable ? 4 : 3; + } else return null; + } + return stack.length === 1 ? stack[0] : null; +} + function readXls(input) { let buffer = Buffer.from(input); if (buffer[0] === 0x78) buffer = zlib.inflateSync(buffer); @@ -196,7 +301,7 @@ function readXls(input) { const { type, data } = byOffset.get(offsets[index]); if (type === 0x0a) break; const put = (row, col, value, formula = false) => - (sheet.cells[`${row},${col}`] = formula ? { value, formula: true } : { value }); + (sheet.cells[`${row},${col}`] = formula ? { value, formula } : { value }); if (type === 0xfd) put(data.readUInt16LE(0), data.readUInt16LE(2), strings[data.readUInt32LE(6)] ?? ""); else if (type === 0x203) @@ -216,15 +321,16 @@ function readXls(input) { put(data.readUInt16LE(0), data.readUInt16LE(2), data[7] ? `#ERR${v}` : Boolean(v)); } else if (type === 0x06) { const row = data.readUInt16LE(0), - col = data.readUInt16LE(2); + col = data.readUInt16LE(2), + text = decodeFormula(data.subarray(22, 22 + data.readUInt16LE(20))) || true; if (data.readUInt16LE(12) === 0xffff) { const kind = data[6]; - if (kind === 0) pendingFormula = { row, col }; + if (kind === 0) pendingFormula = { row, col, text }; else - put(row, col, kind === 1 ? Boolean(data[8]) : kind === 2 ? `#ERR${data[8]}` : "", true); - } else put(row, col, data.readDoubleLE(6), true); + put(row, col, kind === 1 ? Boolean(data[8]) : kind === 2 ? `#ERR${data[8]}` : "", text); + } else put(row, col, data.readDoubleLE(6), text); } else if (type === 0x207 && pendingFormula) { - put(pendingFormula.row, pendingFormula.col, unicode(data, 0).text, true); + put(pendingFormula.row, pendingFormula.col, unicode(data, 0).text, pendingFormula.text); pendingFormula = null; } } @@ -232,4 +338,4 @@ function readXls(input) { return sheets.map(({ name, cells }) => ({ name, cells })); } -module.exports = { readXls, cfb }; +module.exports = { readXls, cfb, decodeFormula, columnName };