feat(est-test): 엑셀형 수량산출서 계산·편집과 원가 행 자리수

- 엑셀형 산출서 BIFF 수식 해석·다시 계산·수식줄 편집·4방향 합·수량으로 지정(가져올 때 값이 같은 합계 셀 추정 연결), 셀 편집 시 ESTX 저장 거부
- 원가 행 계산을 원본 c9d278 끝부분대로: 소수 4자리 반올림 뒤 원 미만 절사, Diget>0 행 자리맞춤(0x11293e8)
- 원가계산서 「선택 행 자릿수 설정」을 원본 선택항목 절하하기 대화로(도급액 행 거부, 입력 행 원금액 기준)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 12:24:06 +09:00
co-authored by Claude Opus 5.5
parent 39c147a8a4
commit d38d2a2893
17 changed files with 775 additions and 45 deletions
+72
View File
@@ -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")
+61
View File
@@ -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
+3 -2
View File
@@ -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('<th>B</th>'), html.includes('font-weight:bold'), vm.runInContext('EstQuantitySheets.excelView({})', ctx)] }));
+5 -2
View File
@@ -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 변환, 글꼴·크기·여백 세부값.
+3 -1
View File
@@ -70,7 +70,9 @@
산출 줄 옆 `누계`는 원본 누계(Ctrl+U)처럼 커서 자리에 누계 값을 넣습니다. 값은 가장 가까운 위쪽 품명 줄(포함) 또는 앞 누계 줄(제외)부터 바로 위 줄까지 줄마다 첫 결과 수량의 합(소계)이며, 계산할 때마다 다시 구합니다. 원본 파일의 누계 표지도 읽어 다시 계산합니다.
원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값을 읽어 수량산출 화면 아래에 읽기 전용 격자로 보입니다. 수식 셀은 Excel이 저장해 둔 계산값을 굵게 보이며, 셀 편집·다시 계산은 아직 하지 않습니다.
원가계산서에서 행을 고르고 `선택 행 자릿수 설정`을 누르면 원본 「선택항목 절하하기」처럼 원·십원·백원… 단위로 사사오입·절하·절상하거나 초기화합니다. 수식 행은 계산 때마다 그 자리수로 맞추고, 도급액 고정 행은 바꿀 수 없습니다.
원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값과 수식을 함께 읽어 수량산출 화면 아래 격자로 보입니다. 셀을 누르면 수식줄에서 값이나 `=수식`을 고치고 바로 다시 계산합니다(참조·범위·사칙·SUM·ROUND 등). `우측·위·좌측·아래 합`은 원본처럼 그 방향의 숫자 셀을 더하는 SUM 을 넣고, `수량으로 지정`은 셀을 수량 항목에 연결해 항목 수량이 그 셀 값(산출서 자리수)을 따르게 합니다. 원본 파일에는 셀과 항목의 대응이 보이지 않아, 가져올 때 저장 수량과 값이 같은 합계 셀을 「추정 연결」로 둡니다. 셀을 고친 엑셀형 산출서는 ESTX 저장을 거부합니다.
산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다.
+2
View File
@@ -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))
+4 -1
View File
@@ -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}`);
+2
View File
@@ -10,6 +10,7 @@
<script defer src="native-machine.js"></script>
<script defer src="native-rows.js"></script>
<script defer src="native-text.js"></script>
<script defer src="native-excel-sheet.cjs"></script>
<script defer src="native-quantity-total.js"></script>
<script defer src="native-quantity.cjs"></script>
<script defer src="native-wonga.js"></script>
@@ -61,6 +62,7 @@
<script defer src="native-print-batch.js"></script>
<script defer src="native-mach-formula.js"></script>
<script defer src="native-defaults.js"></script>
<script defer src="native-excel-view.js"></script>
<script defer src="native-actions.js"></script>
<script defer src="native-business-actions.js"></script>
<script defer src="native-app.js"></script>
+3 -1
View File
@@ -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") {
+259
View File
@@ -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;
+137
View File
@@ -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} <input data-excel-bar style="width:50%" value="${esc(raw ? (typeof raw.formula === "string" ? `=${raw.formula}` : (raw.value ?? "")) : "")}" aria-label="수식줄">`
: "셀을 누르면 수식줄에서 고칩니다.";
const items = (sheet.items || [])
.map(
(i) =>
`<option value="${esc(i.Code)}">${esc(i.Code)} ${esc(i.Name ?? "")} ${esc(i.Size ?? "")}</option>`,
)
.join("");
const tools =
sel && sel.n === n
? ` <button data-action="excel-sum" data-dir="right">우측 합</button><button data-action="excel-sum" data-dir="up">위 합</button><button data-action="excel-sum" data-dir="left">좌측 합</button><button data-action="excel-sum" data-dir="down">아래 합</button> <select data-excel-item aria-label="수량 항목">${items}</select><button data-action="excel-link">수량으로 지정</button><button data-action="excel-unlink">지정 해제</button>`
: "";
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 `<td data-excel-pick="${n},${r},${c}"${style ? ` style="${style}"` : ""}${title ? ` title="${esc(title)}"` : ""}>${value}</td>`;
}).join("");
return `<tr><th>${r + 1}</th>${tds}</tr>`;
}).join("");
return `<h4>엑셀형 산출서 · ${esc(name)}</h4><div class="business-tools">${bar}${tools}</div><div style="overflow:auto;max-height:420px"><table class="sheet" style="font-size:11px"><thead><tr><th></th>${Array.from({ length: cols }, (_, c) => `<th>${L(c)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div>`;
})
.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 };
})();
+2 -27
View File
@@ -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 "<td></td>";
const value = typeof cell.value === "number" ? number(cell.value) : esc(cell.value);
return `<td${cell.formula ? ' style="font-weight:bold" title="수식(저장된 계산값)"' : ""}>${value}</td>`;
}).join("");
return `<tr><th>${r + 1}</th>${cellsHtml}</tr>`;
}).join("");
return `<h4>엑셀형 산출서 · ${esc(name)} (읽기 전용)</h4><div style="overflow:auto;max-height:420px"><table class="sheet" style="font-size:11px"><thead><tr><th></th>${Array.from({ length: cols }, (_, c) => `<th>${letter(c)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div>`;
})
.join("");
return typeof EstExcelView === "undefined" ? "" : EstExcelView.render(sheet, EstApp.state);
}
return { copy, move, mark, free, excelView, matches, selector, handle };
+6 -1
View File
@@ -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],
};
+60 -1
View File
@@ -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 };
})();
+41 -2
View File
@@ -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 };
})();
+2
View File
@@ -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",
+113 -7
View File
@@ -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 };