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) => `${L(c)} | `).join("")}
${body}
`;
+ })
+ .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) => `${letter(c)} | `).join("")}
${body}
`;
- })
- .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 };