diff --git a/resources/tester/test_est_code_change.py b/resources/tester/test_est_code_change.py
new file mode 100644
index 000000000..6b1a01a9e
--- /dev/null
+++ b/resources/tester/test_est_code_change.py
@@ -0,0 +1,113 @@
+"""EST 테스트 앱 조달청 표준공사코드 적용(원본 TfrmCodeChange — 코드변경·변경취소·적용·일괄변경·자동변경·다산정리·사용자정의코드)."""
+
+import json
+import subprocess
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[2]
+JODAL = Path("C:/Dasansoft/Est Plus 2/System/MainSys/JodalCodeDB.estx")
+PPS = Path("C:/Dasansoft/Est Plus 2/System/MainSys/PPS_STUFFC.dat")
+
+
+def run_js(code: str, *args: str):
+ runner = f"""
+ const fs = require('fs'), vm = require('vm');
+ const ctx = vm.createContext({{ structuredClone, console, require, process }});
+ for (const f of ['native-autolink.js', 'native-code-change.js'])
+ vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }});
+ process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx)));
+ """
+ return json.loads(
+ subprocess.check_output(["node", "-e", runner, *args], cwd=ROOT).decode("utf-8")
+ )
+
+
+def test_input_check_cancel_apply():
+ out = run_js(
+ r"""(() => {
+ const C = EstCodeChange;
+ const p = { tables: {
+ StuffcDat: [{ Index: 1, Code: 'M0000000001', Name: '휘발유', Size: '', Unite: 'ℓ' },
+ { Index: 2, Code: '1510150620282202', Name: '경유', Size: '', Unite: 'ℓ' }],
+ LaborcDat: [{ Index: 7, Code: 'L001010101000001', Name: '작업반장', Size: '' }],
+ EstMainDat: [{ LinkIndex: 5, Code: 'M0000000001', Index: 0 }, { LinkIndex: 6, Code: 'M0000000001' }],
+ CostMainDat: [{ LinkIndex: 5, Code: 'M0000000001', Index: 1 }, { LinkIndex: 5, Code: 'M0000000001', Index: 0 }],
+ MachTitleDat: [], MachMainDat: [{ TitleCode: 'E0000000001', Code: 'E0000000001' }] } };
+ const rows = C.load(p, 5);
+ const bad = C.validate(p, 5, rows);
+ const err = (code) => { try { C.input(p, 5, rows, 0, code); return ''; } catch (e) { return e.message; } };
+ const out = { bad, flags: rows.map((r) => r.Flag), dup: err('1510150620282202'),
+ other: err('L001010101000001'), short: err('123'), est: err('M0000000009') };
+ C.input(p, 5, rows, 0, '1510150620282999');
+ C.cancel(rows, 0);
+ out.cancel = rows[0].Code;
+ C.input(p, 5, rows, 0, '1510150620282999');
+ out.applied = C.apply(p, 5, rows);
+ out.codes = [p.tables.StuffcDat[0].Code, p.tables.EstMainDat.map((r) => r.Code), p.tables.CostMainDat.map((r) => r.Code)];
+ const m = [{ Code: 'E0000000001', Name: '불도저' }];
+ p.tables.MachTitleDat = m;
+ const mr = C.load(p, 105);
+ C.input(p, 105, mr, 0, '0000010100070000');
+ C.apply(p, 105, mr);
+ out.mach = [m[0].Code, p.tables.MachMainDat[0]];
+ const s = [{ Code: 'OB010.11001' }, { Code: 'OB010.11001' }, { Code: '' }];
+ C.auto(s);
+ out.auto = s.map((r) => r.Code);
+ const d = [{ Code: 'x' }, { Code: '' }];
+ C.dasan(d, 5);
+ out.dasan = d.map((r) => r.Code);
+ out.user = [C.userCode(p, 6, C.load(p, 6), 'L001010101000001'), C.userCode(p, 103, [], 'CAA16123000S'), C.userCode(p, 105, [], '00000101000700000')];
+ out.search = C.search([{ Name: '강관 지주', Size: '6m' }, { Name: '강관', Size: '' }], '강관&6 m').length;
+ return out;
+ })()"""
+ )
+ assert out["bad"] == 1 and out["flags"] == [1, 0]
+ assert out["dup"] == "같은 코드가 있습니다. 확인하세요."
+ assert out["other"].startswith('노임"7.작업반장()" ')
+ assert out["short"].startswith("코드자리수가 틀립니다")
+ assert out["est"] == "조달청 코드가 아닙니다."
+ assert out["cancel"] == "M0000000001"
+ assert out["applied"] == 3
+ assert out["codes"] == [
+ "1510150620282999",
+ ["1510150620282999", "M0000000001"],
+ ["M0000000001", "1510150620282999"],
+ ]
+ assert out["mach"] == [
+ "00000101000700000",
+ {"TitleCode": "00000101000700000", "Code": "00000101000700000"},
+ ]
+ assert out["auto"] == ["OB01011001", "COB010110010", ""]
+ assert out["dasan"] == ["M0000000000", "M0000000001"]
+ assert out["user"] == ["L0010101010Z0000", "CAA16123Z000", "00000101000Z00000"]
+ assert out["search"] == 1
+
+
+def test_bulk_from_installed_jodal_db_and_pps_list():
+ if not JODAL.exists() or not PPS.exists():
+ pytest.skip("installed EST code DB unavailable")
+ out = run_js(
+ r"""(() => {
+ const { importEstx, table } = require(process.cwd() + '/tools/est-test/import-estx.cjs');
+ const fs = require('fs');
+ const source = importEstx(fs.readFileSync(process.argv[1]));
+ const p = { tables: { LaborcDat: [
+ { Index: 1, Code: 'L0000000001', Name: '작업반장', Size: '', Unite: '인' },
+ { Index: 2, Code: 'L0000000002', Name: '없는직종', Size: '', Unite: '인' },
+ { Index: 3, Code: 'L001010101000001', Name: '작업반장', Size: '', Unite: '인' } ] } };
+ const rows = EstCodeChange.load(p, 6);
+ rows.splice(2, 1);
+ const n = EstCodeChange.bulk(p, 6, rows, source);
+ const pps = table(fs.readFileSync(process.argv[2]), null, []);
+ return { n, codes: rows.map((r) => r.Code), checked: rows.map((r) => r.Checked), pps: pps.length,
+ found: EstCodeChange.search(pps, '작업반장').some((r) => r.Code === 'L001010101000001') };
+ })()""",
+ str(JODAL),
+ str(PPS),
+ )
+ assert out["n"] == 1 and out["checked"] == [True, False]
+ assert out["codes"][0] == "L001010101000001"
+ assert out["codes"][1] == "L0000000002"
+ assert out["pps"] > 50000 and out["found"]
diff --git a/resources/tester/test_est_excel_sheet.py b/resources/tester/test_est_excel_sheet.py
index b20e39ea3..420e09b54 100644
--- a/resources/tester/test_est_excel_sheet.py
+++ b/resources/tester/test_est_excel_sheet.py
@@ -70,3 +70,65 @@ def test_real_sheets_recompute_and_link_items():
assert out["after"][:3] != out["before"][:3]
assert out["same"] is True
assert out["message"].startswith("엑셀형 수량산출서 편집은 ESTX")
+
+
+def test_edit_rows_columns_clipboard_merge_and_style():
+ out = node(r"""
+ const S = require(process.cwd() + '/tools/est-test/native-excel-sheet.cjs');
+ const E = require(process.cwd() + '/tools/est-test/native-excel-edit.cjs');
+ const s = { cells: { '0,0': { value: 1 }, '1,0': { value: 2 }, '2,0': { value: 3 },
+ '3,0': { value: 0, formula: 'SUM(A1:A3)' }, '3,1': { value: 0, formula: 'A2*$A$1+LOG10(2)' } } };
+ S.recalc(s.cells);
+ const links = { X: { n: 0, row: 3, col: 0 } };
+ const r = {};
+ E.shift(s, 'row', 1, 1, links);
+ r.insert = [s.cells['4,0'].formula, s.cells['4,0'].value, s.cells['4,1'].formula, links.X.row];
+ E.shift(s, 'row', 2, -1, links);
+ r.remove = [s.cells['3,0'].formula, s.cells['3,0'].value, s.cells['3,1'].formula, s.cells['3,1'].value];
+ E.shift(s, 'col', 0, 1, links);
+ r.col = [s.cells['3,1'].formula, links.X.col];
+ const clip = E.copyRange(s, { r1: 3, c1: 1, r2: 3, c2: 1 });
+ E.pasteRange(s, clip, 5, 3);
+ r.paste = [s.cells['5,3'].formula, E.offsetFormula('SUM(A1:B2)+$C$3+A$1', 1, 1)];
+ E.merge(s, { r1: 0, c1: 0, r2: 1, c2: 2 });
+ E.merge(s, { r1: 1, c1: 1, r2: 2, c2: 2 });
+ r.merge = s.merges;
+ E.shift(s, 'row', 0, -1, {});
+ r.mergeShift = s.merges;
+ E.split(s, { r1: 1, c1: 1, r2: 1, c2: 1 });
+ r.split = s.merges.length;
+ E.style(s, { r1: 0, c1: 0, r2: 0, c2: 1 }, 'b');
+ E.style(s, { r1: 0, c1: 0, r2: 0, c2: 0 }, 'diag', 'left');
+ r.style = [s.cells['0,0'].style, s.cells['0,1'].style];
+ E.style(s, { r1: 0, c1: 0, r2: 0, c2: 1 }, 'b');
+ r.unstyle = [s.cells['0,0'].style, s.cells['0,1'].style ?? null];
+ E.clearRange(s, { r1: 0, c1: 0, r2: 0, c2: 0 }, true);
+ r.clear = s.cells['0,0'];
+ process.stdout.write(JSON.stringify(r));
+ """)
+ assert out["insert"] == ["SUM(A1:A4)", 6, "A3*$A$1+LOG10(2)", 4]
+ assert out["remove"] == ["SUM(A1:A3)", 4, "#REF!*$A$1+LOG10(2)", "#오류"]
+ assert out["col"] == ["SUM(B1:B3)", 1]
+ assert out["paste"] == ["SUM(D3:D5)", "SUM(B2:C3)+$C$3+B$1"]
+ assert out["merge"] == [{"r1": 1, "r2": 2, "c1": 1, "c2": 2}]
+ assert out["mergeShift"] == [{"r1": 0, "r2": 1, "c1": 1, "c2": 2}]
+ assert out["split"] == 0
+ assert out["style"] == [{"b": True, "diag": "left"}, {"b": True}]
+ assert out["unstyle"] == [{"diag": "left"}, None]
+ assert out["clear"] == {"value": "", "style": {"diag": "left"}}
+
+
+def test_real_sheets_read_merged_cells():
+ if not MAIN.exists():
+ pytest.skip("installed EST main sample unavailable")
+ out = node(
+ """
+ const { importEstx } = require(process.cwd() + '/tools/est-test/import-estx.cjs');
+ const p = importEstx(require('fs').readFileSync(process.argv[1]));
+ const s = p.quantitySheets.find((x) => x.Code === 'N0000000136');
+ process.stdout.write(JSON.stringify({ count: s.excel[0].merges.length, first: s.excel[0].merges[0] }));
+ """,
+ str(MAIN),
+ )
+ assert out["count"] == 14
+ assert out["first"] == {"r1": 2, "r2": 4, "c1": 1, "c2": 1}
diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index 762c75261..b2eed5f56 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -474,7 +474,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 검증완료: `test_est_xls_read.py` 2개(설치 main.estx 엑셀형 6개·토적계산표 B2·No.1·20·수식 16개, OLE 아님 거부·격자 그리기), 수량 시험 포함 22개 통과. 엑셀형 붙인 뒤에도 ESTX 원본 그대로 저장 동일. ORCA 브라우저에서 토적계산표 격자 표시·수식 칸 16개 확인 후 샘플 복구.
- [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] 3단계 편집 동작(원본 TFRMMULEXCEL 단추·팝업) — 범위 고르기(Shift), 행·열 삽입/삭제(수식 참조 이동·지운 칸 #REF!·범위 줄임·병합·수량 연결 함께 이동), 잘라내기·복사·붙여넣기(상대 참조 이동, Ctrl+X·C·V·Delete), 내용지우기, 굵게·기울임·밑줄·정렬, 병합·분리, 왼쪽·오른쪽 대각선·삭제, 다시 계산, 품명·규격·단위로 지정(셀 글자를 항목에). 원본 .xls 병합 정보(MERGEDCELLS) 읽어 머리글 그대로 표시. 되돌리기는 앱 취소(↶).
+ - 검증완료: `test_est_excel_sheet.py` 4개(행 넣기·지우기·열 넣기 참조, 붙여넣기 상대 참조, 병합 이동·분리, 서식 켜고 끄기, 설치 main.estx 병합 14개) 등 44개 통과. ORCA 브라우저에서 설치 main.estx 토적계산표: 병합 머리글 14개 표시, 행 삽입·삭제 뒤 수량 연결 E33 유지, 범위 굵게·병합, 복사·붙여넣기 =(F33+F34)/2*E34, 임시 작업 삭제.
+ - 남음: 원본 셀↔항목 대응 근거, 셀 서식 대화(글꼴·테두리·표시형식), .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 저장 거부.
@@ -485,6 +487,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개 복귀, 샘플 복구.
+ - [x] 조달청 표준공사코드 적용(원본 mnuCodeChange → TfrmCodeChange — 디스어셈블 근거) — 작업데이타 7종 탭(일위·단산·기계·표준·견적·자재·노임), 코드변경(목록 코드·직접 입력, 기계 16자리면 끝 0), 검사 색(EST 코드 노랑·다른 표 중복 연분홍+비고·자리수 진황), 변경취소, 적용(자기 표·본문 머리 Code/TitleCode·참조 행 LinkIndex=종류·Index≤0 만, 재계산 없음), 코드일괄변경(고른 .estx 의 같은 표에서 자동연결 맞춤 규칙, EST→비EST 만·중복 금지, 표준은 옛 코드 부분일치), 자동변경(표준 행 번호 구간 C·A·M), 다산코드정리, 사용자정의코드, 목록·작업데이타 검색(& 두 조각). 조달청 목록은 원본 설치의 PPS_STUFFC.dat·PPS_HEAD.dat 를 사용자가 골라 불러옴(내장 자료 없음, `/api/pps-table`). 원본의 분류 트리(PPS_*TYPE)·다산정리 PkID 쓰기는 하지 않음.
+ - 검증완료: `test_est_code_change.py` 2개(검사·중복·자리수 거부, 취소, 적용 범위·Index>0 제외, 기계 머리 TitleCode, 자동변경·다산·사용자정의코드 꼴, 설치 JodalCodeDB 로 노임 일괄변경 1건·PPS_STUFFC 58,653행) 통과. ORCA 브라우저에서 샘플 노임 「보통인부」 L0000000000→L001010101000001 적용 → 일위·단산 참조 11행 함께 바뀜, 도급액 79,603,000 불변, 샘플 복구.
- [x] 기성 원가계산서 편집(원본 TfrmWongaAccomH/V 팝업 A3~A8 — 디스어셈블 근거) — 회차마다 원가 서식 사본(`round.wonga`)에 한줄 삽입·추가(끝줄 표지 이어받기)·삭제(확인), 내용 삭제(연결·금액·요율·수식·자리 비움), 선택항목 절하(도급액 행 허용), 선택데이타 절하(직전 자리, 구성비 기준 행 제외), 공사비역산 자리수(금회 도급액 10^n 미만 절하 → 이윤 역산), 초기화(사본 지움 = 도급 원가 서식), 되살리기(직전 조작 전), 산출근거 약어로 표시(수식칸 잠금만). 기성 행도 Diget>0 이면 자리맞춤. 도급금액 열은 순서가 아닌 기호·이름으로 짝지음. 원본의 번호·참조 재작성은 하지 않음(새 행 기호는 직접 입력). 확정 회차는 거부. 서버 허용 목록에서 빠져 있던 할증·부가세 스크립트(404)도 넣음.
- 검증완료: `test_est_progress_wonga.py`(삽입·되살리기·절하 1,000 단위·역산 4자리·내용 삭제·초기화와 되살리기·확정 거부, 도급 원가 서식 불변) 및 기성 관련 시험 40개 통과. ORCA 브라우저에서 시험 회차(50%) 한줄 삽입 31→32행·되살리기 31행·역산 4자리 42,409,505→42,400,000, 시험 회차 삭제로 복구.
- [x] 원가 행 자리수(Diget·DigetIndex) 반영(원본 행 계산 c9d278·c9d818·기성 ca6a6c 끝부분, 자리맞춤 0x11293e8, 선택항목 절하 확인 0xd094dc — 디스어셈블 근거) — 수식 결과 소수 4자리 반올림 뒤 원 미만 절사, 도급액(55) 아니고 Diget>0 이면 10^Diget 단위 자리맞춤(DigetIndex 0 처리 없음 · 1 사사오입 · 2 절하 · 3 절상 · 4+ 절하). 원가계산서 화면의 「선택 행 자릿수 설정」은 원본 「선택항목 절하하기」 대화(사사오입·절하·절상·초기화, 0 원~6 백만원, 도급액 행 거부, 입력 행은 원금액 TC 기준, 마지막 자리 기억). 기성 원가도 같은 엔진.
diff --git a/tools/est-test/README.md b/tools/est-test/README.md
index 672cb35f3..fdd27f49f 100644
--- a/tools/est-test/README.md
+++ b/tools/est-test/README.md
@@ -74,7 +74,9 @@
기성고 화면 아래 `기성 원가계산서`는 회차마다 따로 고칠 수 있습니다. 줄을 고른 뒤 한줄 삽입·추가·삭제, 내용 삭제, 선택항목 절하, 공사비역산 자리수 맞추기를 하고, `되살리기`로 바로 앞 조작을, `원가계산서 초기화`로 도급 원가 서식을 되돌립니다. 확정한 회차는 고칠 수 없습니다.
-원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값과 수식을 함께 읽어 수량산출 화면 아래 격자로 보입니다. 셀을 누르면 수식줄에서 값이나 `=수식`을 고치고 바로 다시 계산합니다(참조·범위·사칙·SUM·ROUND 등). `우측·위·좌측·아래 합`은 원본처럼 그 방향의 숫자 셀을 더하는 SUM 을 넣고, `수량으로 지정`은 셀을 수량 항목에 연결해 항목 수량이 그 셀 값(산출서 자리수)을 따르게 합니다. 원본 파일에는 셀과 항목의 대응이 보이지 않아, 가져올 때 저장 수량과 값이 같은 합계 셀을 「추정 연결」로 둡니다. 셀을 고친 엑셀형 산출서는 ESTX 저장을 거부합니다.
+편집(E) › `조달청 표준공사코드 적용`은 노임·자재·기계·일위 등의 코드를 조달청 코드로 바꿉니다. 왼쪽 목록은 원본 설치 폴더 `System\MainSys` 의 `PPS_STUFFC.dat`(노임·자재·장비) 또는 `PPS_HEAD.dat`(일위·단산·표준)를 골라 불러옵니다. 코드를 바꾼 뒤 `적용`을 누르면 그 코드를 쓰는 내역·일위·단산 줄도 함께 바뀝니다. `코드일괄변경`은 고른 .estx 파일(예: 원본 JodalCodeDB.estx)에서 품명·규격이 같은 코드를 찾아 EST 코드만 바꿉니다.
+
+원본의 엑셀형 수량산출서(본문이 옛 Excel .xls)는 가져올 때 셀 값과 수식을 함께 읽어 수량산출 화면 아래 격자로 보입니다. 셀을 누르면 수식줄에서 값이나 `=수식`을 고치고 바로 다시 계산합니다(참조·범위·사칙·SUM·ROUND 등). Shift 로 범위를 골라 행·열 넣기·지우기, 잘라내기·복사·붙여넣기, 굵게·정렬·병합·대각선을 쓸 수 있고, 수식의 참조는 원본처럼 따라 옮겨집니다. `우측·위·좌측·아래 합`은 원본처럼 그 방향의 숫자 셀을 더하는 SUM 을 넣고, `수량으로 지정`은 셀을 수량 항목에 연결해 항목 수량이 그 셀 값(산출서 자리수)을 따르게 합니다. 원본 파일에는 셀과 항목의 대응이 보이지 않아, 가져올 때 저장 수량과 값이 같은 합계 셀을 「추정 연결」로 둡니다. 셀을 고친 엑셀형 산출서는 ESTX 저장을 거부합니다.
산출 줄 옆 `수량 지정`은 줄 글에서 드래그해 고른 숫자를 결과 표지로 감싸고 수량 항목을 새로 만듭니다. 품명·규격·단위 표지가 있으면 항목 이름에 옮깁니다. `지정 해제`는 커서가 놓인 결과 표지를 풀고, 다른 줄에서 쓰지 않는 항목을 지웁니다. 내역에 연결된 항목은 먼저 연결을 비워야 합니다. 이 변경이 있는 작업은 ESTX 수정 저장을 거부합니다.
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index 2a81ed75a..6e509e17a 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -11,6 +11,7 @@
+
@@ -58,6 +59,8 @@
+
+
@@ -133,6 +136,7 @@
>
+
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js
index 8d95a4e42..0a7139027 100644
--- a/tools/est-test/native-business-actions.js
+++ b/tools/est-test/native-business-actions.js
@@ -624,7 +624,8 @@ const EstBusinessActions = (() => {
(typeof EstMachFormula !== "undefined" && EstMachFormula.handle(name, state, node)) ||
(typeof EstDefaults !== "undefined" && EstDefaults.handle(name, state, node)) ||
(typeof EstExcelView !== "undefined" && EstExcelView.handle(name, state, node)) ||
- (typeof EstProgressWonga !== "undefined" && EstProgressWonga.handle(name, state))
+ (typeof EstProgressWonga !== "undefined" && EstProgressWonga.handle(name, state)) ||
+ (typeof EstCodeChangeView !== "undefined" && EstCodeChangeView.handle(name, state, node))
) {
EstApp.render();
} else if (name === "load-contract" || name === "load-bid") {
diff --git a/tools/est-test/native-code-change-view.js b/tools/est-test/native-code-change-view.js
new file mode 100644
index 000000000..315aeef0f
--- /dev/null
+++ b/tools/est-test/native-code-change-view.js
@@ -0,0 +1,280 @@
+// 「조달청 표준공사코드 적용」 대화 화면 — 왼쪽 조달청 목록(불러온 PPS 표) · 오른쪽 작업데이타 7종 탭. 계산은 native-code-change.js.
+const EstCodeChangeView = (() => {
+ const PAGE = 100,
+ COLORS = { 1: "#fff59d", 2: "#f8d7e0", 3: "#ffcc80" };
+ const C = () => EstCodeChange;
+
+ function open(state, tag) {
+ const view = state.view,
+ guess = {
+ HeadMainDat: 103,
+ CostMainDat: 104,
+ MachMainDat: 105,
+ SilJukDat: 21,
+ EstiMateDat: 101,
+ StuffcDat: 5,
+ LaborcDat: 6,
+ }[view];
+ const cc = (state.codeChange ||= {
+ pps: {},
+ word: "",
+ rowWord: "",
+ autoMove: true,
+ nameChange: false,
+ });
+ cc.tag = tag ?? cc.tag ?? guess ?? 6;
+ cc.rows = C().load(state.project, cc.tag);
+ C().validate(state.project, cc.tag, cc.rows);
+ cc.sel = cc.rows.length ? 0 : undefined;
+ cc.page = 0;
+ cc.dirty = false;
+ cc.pick = undefined;
+ draw(state);
+ }
+ function ppsKind(tag) {
+ return [21, 103, 104].includes(tag) ? "HEAD" : "STUFFC";
+ }
+ function shownRows(cc) {
+ const w = String(cc.rowWord || "").replace(/[\s|()]/g, "");
+ return cc.rows
+ .map((r, i) => ({ r, i }))
+ .filter(
+ ({ r }) =>
+ !w ||
+ (cc.rowMode === "name"
+ ? `${r.Name}${r.Size}`.replace(/[\s|()]/g, "").includes(w)
+ : String(r.OldCode)
+ .replace(/[\s|()]/g, "")
+ .includes(w)),
+ );
+ }
+
+ function draw(state) {
+ const { esc } = EstApp,
+ cc = state.codeChange,
+ C2 = C(),
+ list = cc.pps[ppsKind(cc.tag)] || [],
+ found = C2.search(list, cc.word).slice(0, PAGE),
+ rows = shownRows(cc),
+ pages = Math.max(1, Math.ceil(rows.length / PAGE)),
+ page = Math.min(cc.page, pages - 1);
+ const tabs = C2.ORDER.map(
+ (t) =>
+ ``,
+ ).join("");
+ const left = `
`;
+ const body = rows
+ .slice(page * PAGE, page * PAGE + PAGE)
+ .map(
+ ({ r, i }) =>
+ `${[
+ ``,
+ esc(r.Index ?? ""),
+ esc(r.OldCode),
+ ``,
+ esc(r.Name),
+ esc(r.Size),
+ esc(r.Unite),
+ esc(r.Bigo),
+ ]
+ .map((v) => `| ${v} | `)
+ .join("")}
`,
+ )
+ .join("");
+ const user = cc.pick ? safeUser(state) : "";
+ const right = `${tabs}
${rows.length}행 · ${page + 1}/${pages}
| No | 당초코드 | 변경코드 | 명칭 | 규격 | 단위 | 비고 |
${body}
*조달청호환 코드로 미변경(노랑), 중복코드(연분홍 비고란 참조), 코드자리수틀림(진황색)*
`;
+ const tools = [
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ``,
+ ].join(" ");
+ EstApp.showDialog(
+ `조달청 표준공사코드 적용 — ${C2.NAMES[cc.tag]}`,
+ `${cc.message ? `${esc(cc.message)}
` : ""}${left}${right}
${tools}
`,
+ );
+ cc.message = "";
+ }
+ function safeUser(state) {
+ const cc = state.codeChange;
+ if (!cc.pick || [21, 105, 101].includes(cc.tag)) return "";
+ try {
+ return C().userCode(state.project, cc.tag, cc.rows, cc.pick.Code);
+ } catch {
+ return "";
+ }
+ }
+
+ async function run(name, state, node) {
+ const cc = state.codeChange;
+ if (name === "code-change") return open(state);
+ if (!cc) return;
+ const C2 = C();
+ if (name === "code-change-tab") {
+ if (cc.dirty && confirm(`변경된 ${C2.NAMES[cc.tag]} 의 코드를 내역서 전체에 적용할까요?`))
+ applyNow(state);
+ return open(state, Number(node.dataset.tag));
+ }
+ if (name === "code-change-search")
+ cc.word = document.querySelector("[data-cc-word]")?.value ?? "";
+ else if (name === "code-change-all") cc.word = "";
+ else if (name === "code-change-rowsearch") {
+ cc.rowWord = document.querySelector("[data-cc-rowword]")?.value ?? "";
+ cc.rowMode = document.querySelector("[data-cc-rowmode]")?.value;
+ cc.page = 0;
+ } else if (name === "code-change-page")
+ cc.page = Math.max(0, cc.page + Number(node.dataset.step));
+ else if (name === "code-change-input" || name === "code-change-user") {
+ if (!cc.pick) throw Error("조달청코드 목록에서 코드를 고르세요.");
+ const code = name === "code-change-user" ? safeUser(state) : cc.pick.Code;
+ C2.input(
+ state.project,
+ cc.tag,
+ cc.rows,
+ cc.sel,
+ code,
+ name === "code-change-input" && cc.nameChange ? { ...cc.pick, nameChange: true } : null,
+ );
+ cc.dirty = true;
+ if (cc.autoMove && cc.sel < cc.rows.length - 1) cc.sel++;
+ } else if (name === "code-change-cancel") C2.cancel(cc.rows, cc.sel);
+ else if (name === "code-change-auto") {
+ C2.auto(cc.rows);
+ cc.dirty = true;
+ } else if (name === "code-change-dasan") {
+ if (!confirm("코드를 일련번호로 새로 부여하시겠습니까?")) return;
+ C2.dasan(cc.rows, cc.tag);
+ cc.dirty = true;
+ } else if (name === "code-change-bulk") return pickEstx(state);
+ else if (name === "code-change-apply") {
+ if (!confirm(`변경된 ${C2.NAMES[cc.tag]} 의 코드를 내역서 전체에 적용할까요?`)) return;
+ applyNow(state);
+ } else if (name === "code-change-close") {
+ if (
+ cc.dirty &&
+ !confirm("내역서에 변경된 코드를 적용하지 않았습니다.코드변경작업을 종료하시겠습니까?")
+ )
+ return;
+ EstApp.byId("dialog").close();
+ state.codeChange = { ...cc, rows: [], dirty: false };
+ return;
+ }
+ draw(state);
+ }
+ function applyNow(state) {
+ const cc = state.codeChange,
+ C2 = C();
+ if (
+ C2.validate(state.project, cc.tag, cc.rows) &&
+ !confirm("조달청코드로 변경안된 데이타가 있습니다.그래도 적용하시겠습니까??")
+ )
+ return;
+ let count = 0;
+ EstApp.change((project) => (count = C2.apply(project, cc.tag, cc.rows)));
+ cc.dirty = false;
+ state.notice = `${C2.NAMES[cc.tag]}의 코드변경이 완료 되었습니다. (${count}행)`;
+ }
+ function pickEstx(state) {
+ const cc = state.codeChange,
+ picker = document.createElement("input");
+ picker.type = "file";
+ picker.accept = ".estx";
+ picker.addEventListener("change", async () => {
+ try {
+ const file = picker.files[0];
+ if (!file) return;
+ if (
+ !confirm(
+ `현재 작업데이타의 ${C().NAMES[cc.tag]} 코드를 "${file.name}"의 코드로 변경하시겠습니까?`,
+ )
+ )
+ return;
+ const source = await EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, {
+ method: "POST",
+ body: file,
+ });
+ const n = C().bulk(state.project, cc.tag, cc.rows, source);
+ if (n) cc.dirty = true;
+ cc.message = n
+ ? `${n}개 코드를 바꿨습니다. 적용을 누르세요.`
+ : "코드가 변경된 항목이 없습니다.";
+ } catch (error) {
+ cc.message = error.message;
+ }
+ draw(state);
+ });
+ picker.click();
+ }
+
+ function install() {
+ document.addEventListener("change", async (event) => {
+ const t = event.target,
+ state = EstApp.state,
+ cc = state.codeChange;
+ if (!cc?.rows) return;
+ try {
+ if (t.dataset.ccRow !== undefined) cc.sel = Number(t.dataset.ccRow);
+ else if (t.dataset.ccPick !== undefined)
+ cc.pick = EstCodeChange.search(cc.pps[ppsKind(cc.tag)] || [], cc.word)[
+ Number(t.dataset.ccPick)
+ ];
+ else if (t.dataset.ccOpt !== undefined) cc[t.dataset.ccOpt] = t.checked;
+ else if (t.dataset.ccCode !== undefined) {
+ const i = Number(t.dataset.ccCode);
+ C().input(state.project, cc.tag, cc.rows, i, t.value);
+ cc.dirty = true;
+ } else if (t.dataset.ccPps !== undefined) {
+ const file = t.files[0];
+ if (!file) return;
+ const rows = await EstApp.api("/api/pps-table", { method: "POST", body: file });
+ const kind = rows.some((r) => /공사$/.test(r.Gubun ?? "")) ? "HEAD" : "STUFFC";
+ cc.pps[kind] = rows;
+ cc.message = `조달청 ${kind === "HEAD" ? "공종" : "물품"} 코드 ${rows.length}개를 불러왔습니다.`;
+ } else return;
+ } catch (error) {
+ cc.message = error.message;
+ }
+ draw(state);
+ });
+ }
+
+ const names = new Set([
+ "code-change",
+ "code-change-tab",
+ "code-change-search",
+ "code-change-all",
+ "code-change-rowsearch",
+ "code-change-page",
+ "code-change-input",
+ "code-change-user",
+ "code-change-cancel",
+ "code-change-auto",
+ "code-change-dasan",
+ "code-change-bulk",
+ "code-change-apply",
+ "code-change-close",
+ ]);
+ function handle(name, state, node) {
+ if (!names.has(name)) return false;
+ run(name, state, node).catch((error) => {
+ if (!state.codeChange?.rows) return void (state.notice = error.message);
+ state.codeChange.message = error.message;
+ draw(state);
+ });
+ return true;
+ }
+
+ if (typeof document !== "undefined" && document.addEventListener) install();
+ return { open, handle };
+})();
diff --git a/tools/est-test/native-code-change.js b/tools/est-test/native-code-change.js
new file mode 100644
index 000000000..984295390
--- /dev/null
+++ b/tools/est-test/native-code-change.js
@@ -0,0 +1,273 @@
+// 원본 「조달청 표준공사코드 적용」(mnuCodeChange 0x10d10fc → TfrmCodeChange — 디스어셈블 근거 tmp 명세 spec_codechange).
+// 작업데이타 7종(일위 103·단산 104·기계 105·표준 21·견적 101·자재 5·노임 6)의 코드를 메모리 사본에서 바꾼 뒤 「적용」 때
+// 자기 표 Code, 본문 머리 Code·TitleCode, 참조 행(LinkIndex = 종류 · Index ≤ 0 · Code = 옛 코드) Code 만 바꾼다(재계산 없음).
+// 검사(df5e34): EST 코드 = 노랑(1) · 다른 표에 같은 코드 = 연분홍(2, 비고) · 자리수(일위·단산·표준 12, 장비 17, 견적·자재·노임 16) = 진황(3).
+// 코드일괄변경(df2b98): 고른 .estx 의 같은 종류 표에서 품명·규격·단위 맞춤(자동연결 규칙) 첫 행 — 현재 EST 코드 → 비EST 코드만,
+// 표준(21)은 「.」 뺀 옛 코드를 부분일치로. 자동변경(표준 탭): 행 번호 2~2092 C · 2095~2717 A · 2720~3361 M 앞에, 끝에 0, 「.」 제거.
+// 다산코드정리: 종류 글자 + 10자리 일련번호. 사용자정의코드(df4b64): 고른 코드 앞자리 + Z 꼴 빈 번호.
+// 원본의 조달청 목록(PPS_STUFFC·PPS_HEAD)은 설치 폴더 System\MainSys 의 .dat 를 사용자가 골라 불러온다(내장 자료 없음).
+const EstCodeChange = (() => {
+ const TABLES = {
+ 103: "HeadTitleDat",
+ 104: "CostTitleDat",
+ 105: "MachTitleDat",
+ 21: "SilJukDat",
+ 101: "EstiMateDat",
+ 5: "StuffcDat",
+ 6: "LaborcDat",
+ };
+ const MAIN = { 103: "HeadMainDat", 104: "CostMainDat", 105: "MachMainDat" };
+ const NAMES = {
+ 103: "일위대가",
+ 104: "단가산출",
+ 105: "기계경비",
+ 21: "표준시장단가",
+ 101: "견적단가",
+ 5: "자재단가",
+ 6: "노임단가",
+ };
+ const SHORT = {
+ 103: "일위",
+ 104: "단가",
+ 105: "장비",
+ 21: "표준",
+ 101: "견적",
+ 5: "자재",
+ 6: "노임",
+ };
+ const LETTER = {
+ 5: "M",
+ 6: "L",
+ 15: "N",
+ 21: "S",
+ 101: "W",
+ 103: "U",
+ 104: "D",
+ 105: "E",
+ 151: "G",
+ };
+ const ORDER = [103, 104, 105, 21, 101, 5, 6];
+ const num = (v) => Number(v) || 0;
+ const trim = (v) => String(v ?? "").trim();
+ const norm = (s) => String(s ?? "").replace(/[\s|()]/g, "");
+
+ const letter = (tag) => LETTER[tag] || "Z";
+ const isEst = (code, tag) => {
+ const c = trim(code);
+ return c.length > 0 && c.length <= 11 && c[0] === letter(tag) && c[1] === "0";
+ };
+
+ function load(project, tag) {
+ return (project.tables[TABLES[tag]] || []).map((row) => ({
+ Index: row.Index,
+ OldCode: row.Code ?? "",
+ Code: row.Code ?? "",
+ Name: row.Name ?? "",
+ Size: row.Size ?? "",
+ Unite: row.Unite ?? "",
+ Flag: 0,
+ Checked: false,
+ Bigo: "",
+ }));
+ }
+ // 다른 6 표에 같은 코드가 있으면 그 행 설명
+ function elsewhere(project, tag, code) {
+ for (const t of ORDER) {
+ if (t === tag) continue;
+ const row = (project.tables[TABLES[t]] || []).find((r) => trim(r.Code) === code);
+ if (row) return `${SHORT[t]}"${row.Index ?? ""}.${row.Name ?? ""}(${row.Size ?? ""})" `;
+ }
+ return "";
+ }
+ function check(project, tag, row, code = trim(row.Code)) {
+ row.Flag = 0;
+ if (isEst(code, tag)) return (row.Flag = 1);
+ const other = elsewhere(project, tag, code);
+ if (other) {
+ row.Bigo = other;
+ return (row.Flag = 2);
+ }
+ let size = [21, 103, 104].includes(tag) ? 12 : tag === 105 ? 17 : 16;
+ if (tag === 105 && !/^[0-9Z]/.test(code)) return (row.Flag = 1);
+ if (code.length !== size) row.Flag = 3;
+ return row.Flag;
+ }
+ function validate(project, tag, rows) {
+ for (const row of rows) {
+ row.Flag = 0;
+ row.Checked = false;
+ row.Bigo = "";
+ if (trim(row.Code)) check(project, tag, row);
+ }
+ return rows.filter((r) => r.Flag).length;
+ }
+ const MESSAGE = {
+ 1: "조달청 코드가 아닙니다.",
+ 3: "코드자리수가 틀립니다.일위,단산,표준:12자리, 장비:17자리 ,견적,노임,자재:16자리",
+ };
+
+ // 코드변경(df20f4): 목록 코드 또는 직접 입력을 현재 행에. 실패하면 문구를 던진다.
+ function input(project, tag, rows, i, code, from) {
+ let c = trim(code);
+ if (tag === 105 && c.length === 16) c += "0";
+ if (!rows[i]) throw Error("바꿀 작업데이타 행을 고르세요.");
+ const dup = rows.findIndex((r, j) => j !== i && trim(r.Code) === c);
+ if (dup >= 0) {
+ rows[dup].Flag = 2;
+ throw Error("같은 코드가 있습니다. 확인하세요.");
+ }
+ const probe = { ...rows[i] };
+ const flag = check(project, tag, probe, c);
+ if (flag === 2) throw Error(`${probe.Bigo}에 같은 코드가 있습니다.`);
+ if (flag) throw Error(MESSAGE[flag]);
+ rows[i].Code = c;
+ if (from?.nameChange && trim(from.Name)) {
+ rows[i].Name = from.Name;
+ rows[i].Size = from.Size ?? "";
+ rows[i].Unite = from.Unit ?? from.Unite ?? "";
+ }
+ }
+ function cancel(rows, i) {
+ if (rows[i]) rows[i].Code = rows[i].OldCode;
+ }
+
+ // 적용(df1c40)
+ function apply(project, tag, rows) {
+ const t = project.tables,
+ own = t[TABLES[tag]] || [];
+ let changed = 0;
+ for (const r of rows) {
+ const neu = trim(r.Code),
+ old = trim(r.OldCode);
+ if (!neu || neu === old) continue;
+ const same = (x) => (x.Name ?? "") === r.Name && (x.Size ?? "") === r.Size;
+ let hit = own.find((x) => trim(x.Code) === old);
+ if (hit && !same(hit)) hit = own.find((x) => trim(x.Code) === old && same(x));
+ if (hit) {
+ hit.Code = neu;
+ changed++;
+ }
+ if (MAIN[tag])
+ for (const x of t[MAIN[tag]] || [])
+ if (trim(x.TitleCode) === old) {
+ x.Code = neu;
+ x.TitleCode = neu;
+ }
+ const refs =
+ tag === 103
+ ? ["HeadMainDat", "CostMainDat"]
+ : tag === 104
+ ? ["CostMainDat", "HeadMainDat"]
+ : tag === 105
+ ? ["MachMainDat", "CostMainDat", "HeadMainDat"]
+ : ["MachMainDat", "CostMainDat", "HeadMainDat"];
+ for (const name of [...refs, "EstMainDat"])
+ for (const x of t[name] || [])
+ if (num(x.LinkIndex) === tag && trim(x.Code) === old && num(x.Index) <= 0) {
+ x.Code = neu;
+ changed++;
+ }
+ r.OldCode = neu;
+ }
+ return changed;
+ }
+
+ // 코드일괄변경(df2b98) — source = 고른 .estx 를 읽은 공사
+ function bulk(project, tag, rows, source) {
+ const list = source.tables?.[TABLES[tag]] || [];
+ let changed = 0;
+ rows.forEach((r) => (r.Checked = false));
+ for (const r of rows) {
+ const cur = trim(r.Code);
+ if (!cur) continue;
+ let found;
+ if (tag === 21) {
+ if (!cur.includes(".")) continue;
+ const k = cur.replaceAll(".", "");
+ found = list.find((x) => String(x.Code ?? "").includes(k));
+ if (!found) continue;
+ const c = trim(found.Code);
+ if (rows.some((x) => trim(x.Code) === c) || elsewhere(project, tag, c)) continue;
+ r.Code = c;
+ } else {
+ found = EstAutoLink.find(source, tag, EstAutoLink.key(r, 0));
+ if (!found) continue;
+ const c = trim(found.Code);
+ if (rows.some((x) => trim(x.Code) === c) || elsewhere(project, tag, c)) continue;
+ if (!isEst(cur, tag) || isEst(c, tag)) continue;
+ r.Code = c;
+ r.Flag = 0;
+ }
+ r.Checked = true;
+ changed++;
+ }
+ return changed;
+ }
+ function auto(rows) {
+ rows.forEach((r, i) => {
+ const n = i + 1,
+ c = trim(r.Code);
+ if (!c) return;
+ const head =
+ n >= 2 && n <= 2092
+ ? "C"
+ : n >= 2095 && n <= 2717
+ ? "A"
+ : n >= 2720 && n <= 3361
+ ? "M"
+ : "";
+ r.Code = (head ? `${head}${c}0` : c).replaceAll(".", "");
+ });
+ }
+ function dasan(rows, tag) {
+ rows.forEach((r, i) => (r.Code = letter(tag) + String(i).padStart(10, "0")));
+ }
+ function userCode(project, tag, rows, base) {
+ const s = `${trim(base)}00000000`;
+ const make = (n) => {
+ const nn = `000${n}`;
+ if (tag === 103 || tag === 104) return `${s.slice(0, 8)}Z${nn.slice(-3)}`;
+ if (tag === 105) return `${s.slice(0, 8)}000Z${nn.slice(-4)}0`;
+ if (tag === 101 || tag === 5) return `${s.slice(0, 6)}ZZ000Z${nn.slice(-4)}`;
+ if (tag === 6) return `${s.slice(0, 8)}010Z${nn.slice(-4)}`;
+ return "";
+ };
+ const extra = { 103: "CostTitleDat", 104: "HeadTitleDat", 105: "MachTitleDat" }[tag];
+ for (let n = 0; n <= 999; n++) {
+ const c = make(n);
+ if (!c) return "";
+ if (rows.some((r) => trim(r.Code) === c)) continue;
+ if (extra && (project.tables[extra] || []).some((r) => trim(r.Code) === c)) continue;
+ return c;
+ }
+ throw Error("사용자 정의코드 갯수범위를 초과 했습니다.");
+ }
+ // 목록 검색(df6b5c): 정규화한 품명+규격에 검색어 포함, 「&」 이면 두 조각 모두
+ function search(list, word) {
+ const w = norm(word);
+ if (!w) return list;
+ const parts = w.includes("&") ? w.split("&").slice(0, 2) : [w];
+ return list.filter((x) => {
+ const k = norm(x.Name) + norm(x.Size);
+ return parts.every((p) => k.includes(p));
+ });
+ }
+
+ return {
+ TABLES,
+ NAMES,
+ ORDER,
+ isEst,
+ load,
+ validate,
+ input,
+ cancel,
+ apply,
+ bulk,
+ auto,
+ dasan,
+ userCode,
+ search,
+ };
+})();
+if (typeof module !== "undefined" && module.exports) module.exports = EstCodeChange;
diff --git a/tools/est-test/native-excel-edit.cjs b/tools/est-test/native-excel-edit.cjs
new file mode 100644
index 000000000..05da25937
--- /dev/null
+++ b/tools/est-test/native-excel-edit.cjs
@@ -0,0 +1,163 @@
+// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 편집 동작 — 행·열 삽입/삭제(actInsertRows·actInsertCol·actCells·actDeleteCellS),
+// 잘라내기·복사·붙여넣기(popCut·popCopy·popPaste, 상대 참조는 옮긴 만큼 이동), 내용지우기(popCellClear),
+// 병합·분리(actMergeCells·actSplitCells), 글꼴·정렬(tbBold·tbItalic·tbUnderline·tb*Align), 대각선(popTilt*).
+// 시트 = { name, cells: {"행,열": { value, formula?, style? }}, merges?: [{ r1, c1, r2, c2 }] }.
+// 행·열을 지우면 그 칸을 가리키던 참조는 #REF! 가 되고(계산 오류), 범위 끝이 지워지면 범위를 줄인다.
+const EstExcelEdit = (() => {
+ const Sheet =
+ typeof EstExcelSheet !== "undefined" ? EstExcelSheet : require("./native-excel-sheet.cjs");
+ const REF =
+ /("(?:[^"]|"")*")|(\$?)([A-Za-z]{1,3})(\$?)(\d+)(?::(\$?)([A-Za-z]{1,3})(\$?)(\d+))?(?![A-Za-z0-9_(])/g;
+ const colOf = (s) => [...s.toUpperCase()].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1;
+ const write = (p) => `${p.ac}${Sheet.letters(p.col)}${p.ar}${p.row + 1}`;
+
+ // fn(ends) → 새 끝점 배열 또는 null(#REF!). ends = [{ col, row, ac, ar }] (범위면 둘)
+ function mapRefs(formula, fn) {
+ return String(formula).replace(REF, (all, str, ac, c, ar, r, ac2, c2, ar2, r2) => {
+ if (str) return str;
+ const ends = [{ ac, col: colOf(c), ar, row: Number(r) - 1 }];
+ if (c2) ends.push({ ac: ac2, col: colOf(c2), ar: ar2, row: Number(r2) - 1 });
+ const out = fn(ends);
+ if (!out) return "#REF!";
+ return out.map(write).join(":");
+ });
+ }
+ function offsetFormula(formula, dr, dc) {
+ return mapRefs(formula, (ends) => {
+ const out = ends.map((e) => ({
+ ...e,
+ row: e.ar ? e.row : e.row + dr,
+ col: e.ac ? e.col : e.col + dc,
+ }));
+ return out.some((e) => e.row < 0 || e.col < 0) ? null : out;
+ });
+ }
+
+ // axis "row" | "col", at = 첫 위치, count > 0 넣기 · < 0 지우기
+ function shift(sheet, axis, at, count, links) {
+ const k = axis === "row" ? "row" : "col",
+ gone = (i) => count < 0 && i >= at && i < at - count,
+ move = (i) => (i >= at ? i + count : i);
+ const cells = {};
+ for (const [key, cell] of Object.entries(sheet.cells)) {
+ const [row, col] = key.split(",").map(Number),
+ p = { row, col };
+ if (gone(p[k])) continue;
+ p[k] = move(p[k]);
+ cells[`${p.row},${p.col}`] = cell;
+ }
+ for (const cell of Object.values(cells))
+ if (typeof cell.formula === "string")
+ cell.formula = mapRefs(cell.formula, (ends) => {
+ if (ends.length === 1) {
+ if (gone(ends[0][k])) return null;
+ return [{ ...ends[0], [k]: move(ends[0][k]) }];
+ }
+ let [a, b] = ends.map((e) => ({ ...e }));
+ if (a[k] > b[k]) [a, b] = [b, a];
+ if (count < 0) {
+ if (gone(a[k]) && gone(b[k])) return null;
+ if (gone(a[k])) a[k] = at - count;
+ if (gone(b[k])) b[k] = at - 1;
+ }
+ a[k] = move(a[k]);
+ b[k] = move(b[k]);
+ return [a, b];
+ });
+ sheet.cells = cells;
+ const k1 = k === "row" ? "r1" : "c1",
+ k2 = k === "row" ? "r2" : "c2";
+ sheet.merges = (sheet.merges || [])
+ .filter((m) => !(gone(m[k1]) && gone(m[k2])))
+ .map((m) => {
+ const x = { ...m };
+ if (gone(x[k1])) x[k1] = at - count;
+ if (gone(x[k2])) x[k2] = at - 1;
+ x[k1] = move(x[k1]);
+ x[k2] = move(x[k2]);
+ return x;
+ })
+ .filter((m) => m.r2 > m.r1 || m.c2 > m.c1);
+ for (const [code, l] of Object.entries(links || {})) {
+ if (gone(l[k])) delete links[code];
+ else l[k] = move(l[k]);
+ }
+ return Sheet.recalc(sheet.cells);
+ }
+
+ const norm = (r) => ({
+ r1: Math.min(r.r1, r.r2),
+ r2: Math.max(r.r1, r.r2),
+ c1: Math.min(r.c1, r.c2),
+ c2: Math.max(r.c1, r.c2),
+ });
+ function copyRange(sheet, range) {
+ const { r1, r2, c1, c2 } = norm(range),
+ cells = {};
+ for (let r = r1; r <= r2; r++)
+ for (let c = c1; c <= c2; c++) {
+ const cell = sheet.cells[`${r},${c}`];
+ if (cell) cells[`${r - r1},${c - c1}`] = structuredClone(cell);
+ }
+ return { rows: r2 - r1 + 1, cols: c2 - c1 + 1, from: { row: r1, col: c1 }, cells };
+ }
+ function clearRange(sheet, range, keepStyle = false) {
+ const { r1, r2, c1, c2 } = norm(range);
+ for (let r = r1; r <= r2; r++)
+ for (let c = c1; c <= c2; c++) {
+ const k = `${r},${c}`,
+ style = sheet.cells[k]?.style;
+ if (keepStyle && style) sheet.cells[k] = { value: "", style };
+ else delete sheet.cells[k];
+ }
+ return Sheet.recalc(sheet.cells);
+ }
+ function pasteRange(sheet, clip, row, col) {
+ const dr = row - clip.from.row,
+ dc = col - clip.from.col;
+ for (let r = 0; r < clip.rows; r++)
+ for (let c = 0; c < clip.cols; c++) delete sheet.cells[`${row + r},${col + c}`];
+ for (const [k, cell] of Object.entries(clip.cells)) {
+ const [r, c] = k.split(",").map(Number),
+ copy = structuredClone(cell);
+ if (typeof copy.formula === "string") copy.formula = offsetFormula(copy.formula, dr, dc);
+ sheet.cells[`${row + r},${col + c}`] = copy;
+ }
+ return Sheet.recalc(sheet.cells);
+ }
+
+ function merge(sheet, range) {
+ const m = norm(range);
+ if (m.r1 === m.r2 && m.c1 === m.c2) throw Error("병합할 범위를 두 칸 이상 고르세요.");
+ split(sheet, m);
+ sheet.merges.push(m);
+ }
+ function split(sheet, range) {
+ const m = norm(range);
+ sheet.merges = (sheet.merges || []).filter(
+ (x) => x.r2 < m.r1 || x.r1 > m.r2 || x.c2 < m.c1 || x.c1 > m.c2,
+ );
+ }
+ // name: b·i·u (켜고 끄기), align: left·center·right, diag: left·right·"" (대각선)
+ function style(sheet, range, name, value) {
+ const { r1, r2, c1, c2 } = norm(range);
+ let on = null;
+ for (let r = r1; r <= r2; r++)
+ for (let c = c1; c <= c2; c++) {
+ const k = `${r},${c}`,
+ cell = (sheet.cells[k] ||= { value: "" }),
+ s = { ...(cell.style || {}) };
+ if (["b", "i", "u"].includes(name)) {
+ on ??= !s[name];
+ if (on) s[name] = true;
+ else delete s[name];
+ } else if (value) s[name] = value;
+ else delete s[name];
+ if (Object.keys(s).length) cell.style = s;
+ else delete cell.style;
+ }
+ }
+
+ return { mapRefs, offsetFormula, shift, copyRange, clearRange, pasteRange, merge, split, style };
+})();
+if (typeof module !== "undefined" && module.exports) module.exports = EstExcelEdit;
diff --git a/tools/est-test/native-excel-view.js b/tools/est-test/native-excel-view.js
index 5c614568c..a9d1ed104 100644
--- a/tools/est-test/native-excel-view.js
+++ b/tools/est-test/native-excel-view.js
@@ -1,9 +1,65 @@
-// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 웹 편집 화면 — 셀 고르기·수식줄 편집·다시 계산, 셀 합(우·상·좌·하 btnSum*),
-// 수량으로 지정(popSetMullang)·지정 해제(popSetFree) = 셀 ↔ 수량 항목 연결(sheet.excelLinks). 항목 수량 = 연결 셀 값(산출서 자리수).
-// 원본 파일 속 셀↔항목 대응은 찾지 못해, 가져올 때 값이 같은 셀 하나(합계 수식·아래쪽 우선)를 「추정」 연결로 둔다.
-// 편집한 엑셀형 산출서는 .xls 로 다시 쓰지 못하므로 ESTX 저장을 막는다(sheet.excelEdited).
+// 원본 엑셀형 수량산출서(TFRMMULEXCEL) 웹 편집 화면 — 셀 고르기(Shift 로 범위)·수식줄 편집·다시 계산(btnReCalc),
+// 셀 합(우·상·좌·하 btnSum*), 수량·품명·규격·단위로 지정(popSetMullang·popSetName·popSetSize·popSetUnite)·지정 해제(popSetFree),
+// 잘라내기·복사·붙여넣기·내용지우기, 행·열 삽입/삭제, 굵게·기울임·밑줄·정렬, 병합·분리, 대각선(native-excel-edit.cjs).
+// 수량 지정 = 셀 ↔ 항목 연결(sheet.excelLinks) — 항목 수량이 그 셀 값(산출서 자리수)을 따른다.
+// 품명·규격·단위 지정은 그때 셀 글자를 항목에 적는다. 원본 파일 속 셀↔항목 대응은 찾지 못해, 가져올 때 값이 같은 셀
+// 하나(합계 수식·아래쪽 우선)를 「추정」 연결로 둔다. 편집한 엑셀형 산출서는 .xls 로 다시 쓰지 못하므로 ESTX 저장을 막는다.
+// 되돌리기(actUndo)는 앱 전체 취소(↶)를 쓴다.
const EstExcelView = (() => {
const L = (n) => EstExcelSheet.letters(n);
+ const DIAG = {
+ left: "linear-gradient(to bottom right,transparent 49%,#555 50%,transparent 51%)",
+ right: "linear-gradient(to top right,transparent 49%,#555 50%,transparent 51%)",
+ };
+ const range = (sel) => ({
+ r1: Math.min(sel.row, sel.row2 ?? sel.row),
+ r2: Math.max(sel.row, sel.row2 ?? sel.row),
+ c1: Math.min(sel.col, sel.col2 ?? sel.col),
+ c2: Math.max(sel.col, sel.col2 ?? sel.col),
+ });
+ const btn = (action, label, extra = "") =>
+ ``;
+
+ function toolbar(sheet) {
+ const items = (sheet.items || [])
+ .map(
+ (i) =>
+ ``,
+ )
+ .join("");
+ return [
+ btn("excel-recalc", "다시 계산"),
+ btn("excel-cut", "잘라내기"),
+ btn("excel-copy", "복사"),
+ btn("excel-paste", "붙여넣기"),
+ btn("excel-clear", "내용지우기"),
+ btn("excel-insert", "행 삽입", 'data-axis="row"'),
+ btn("excel-insert", "열 삽입", 'data-axis="col"'),
+ btn("excel-delete", "행 삭제", 'data-axis="row"'),
+ btn("excel-delete", "열 삭제", 'data-axis="col"'),
+ btn("excel-style", "B", 'data-name="b" title="굵게"'),
+ btn("excel-style", "I", 'data-name="i" title="기울임"'),
+ btn("excel-style", "U", 'data-name="u" title="밑줄"'),
+ btn("excel-style", "왼쪽", 'data-name="align" data-value="left"'),
+ btn("excel-style", "가운데", 'data-name="align" data-value="center"'),
+ btn("excel-style", "오른쪽", 'data-name="align" data-value="right"'),
+ btn("excel-merge", "병합"),
+ btn("excel-split", "분리"),
+ btn("excel-style", "왼쪽대각선", 'data-name="diag" data-value="left"'),
+ btn("excel-style", "오른쪽대각선", 'data-name="diag" data-value="right"'),
+ btn("excel-style", "대각선 삭제", 'data-name="diag" data-value=""'),
+ btn("excel-sum", "우측 합", 'data-dir="right"'),
+ btn("excel-sum", "위 합", 'data-dir="up"'),
+ btn("excel-sum", "좌측 합", 'data-dir="left"'),
+ btn("excel-sum", "아래 합", 'data-dir="down"'),
+ ``,
+ btn("excel-link", "수량으로 지정"),
+ btn("excel-field", "품명으로 지정", 'data-field="Name"'),
+ btn("excel-field", "규격으로 지정", 'data-field="Size"'),
+ btn("excel-field", "단위로 지정", 'data-field="Unite"'),
+ btn("excel-unlink", "지정 해제"),
+ ].join("");
+ }
function render(sheet, state) {
if (!sheet?.excel?.length) return "";
@@ -14,30 +70,36 @@ const EstExcelView = (() => {
Object.entries(links).map(([code, l]) => [`${l.n},${l.row},${l.col}`, { code, ...l }]),
);
return sheet.excel
- .map(({ name, cells }, n) => {
+ .map(({ name, cells, merges = [] }, 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 rows = Math.max(20, ...keys.map(([r]) => r + 2), ...merges.map((m) => m.r2 + 2)),
+ cols = Math.min(
+ 40,
+ Math.max(8, ...keys.map(([, c]) => c + 2), ...merges.map((m) => m.c2 + 2)),
+ );
+ const here = sel && sel.n === n,
+ box = here ? range(sel) : null;
+ const raw = here ? cells[`${sel.row},${sel.col}`] : null;
+ const bar = here
+ ? `${L(sel.col)}${sel.row + 1}${box.r2 > box.r1 || box.c2 > box.c1 ? `:${L(box.c2)}${box.r2 + 1}` : ""}
${toolbar(sheet)}`
+ : "셀을 누르면 수식줄에서 고칩니다. Shift 로 범위를 고릅니다.";
+ const covered = new Set(),
+ spans = new Map();
+ for (const m of merges) {
+ spans.set(`${m.r1},${m.c1}`, m);
+ for (let r = m.r1; r <= m.r2; r++)
+ for (let c = m.c1; c <= m.c2; c++)
+ if (r !== m.r1 || c !== m.c1) covered.add(`${r},${c}`);
+ }
const body = Array.from({ length: rows }, (_, r) => {
const tds = Array.from({ length: cols }, (_, c) => {
- const cell = cells[`${r},${c}`],
+ const k = `${r},${c}`;
+ if (covered.has(k)) return "";
+ const cell = cells[k],
link = linked.get(`${n},${r},${c}`),
- chosen = sel && sel.n === n && sel.row === r && sel.col === c;
+ m = spans.get(k),
+ chosen = box && r >= box.r1 && r <= box.r2 && c >= box.c1 && c <= box.c2,
+ s = cell?.style || {};
const value =
cell == null
? ""
@@ -45,9 +107,13 @@ const EstExcelView = (() => {
? number(cell.value)
: esc(cell.value);
const style = [
- cell?.formula ? "font-weight:bold" : "",
- link ? "background:#e6f4ea" : "",
- chosen ? "outline:2px solid #316ea5" : "",
+ cell?.formula || s.b ? "font-weight:bold" : "",
+ s.i ? "font-style:italic" : "",
+ s.u ? "text-decoration:underline" : "",
+ s.align ? `text-align:${s.align}` : "",
+ s.diag ? `background-image:${DIAG[s.diag]}` : "",
+ link ? "background-color:#e6f4ea" : chosen ? "background-color:#e8f0fb" : "",
+ here && sel.row === r && sel.col === c ? "outline:2px solid #316ea5" : "",
]
.filter(Boolean)
.join(";");
@@ -57,11 +123,14 @@ const EstExcelView = (() => {
]
.filter(Boolean)
.join(" · ");
- return `${value} | `;
+ const span = m
+ ? `${m.r2 > m.r1 ? ` rowspan="${m.r2 - m.r1 + 1}"` : ""}${m.c2 > m.c1 ? ` colspan="${m.c2 - m.c1 + 1}"` : ""}`
+ : "";
+ return `${value} | `;
}).join("");
return `| ${r + 1} | ${tds}
`;
}).join("");
- return `엑셀형 산출서 · ${esc(name)}
${bar}${tools}
| ${Array.from({ length: cols }, (_, c) => `${L(c)} | `).join("")}
${body}
`;
+ return `엑셀형 산출서 · ${esc(name)}
${bar}
| ${Array.from({ length: cols }, (_, c) => `${L(c)} | `).join("")}
${body}
`;
})
.join("");
}
@@ -72,23 +141,35 @@ const EstExcelView = (() => {
function setCell(sheet, n, row, col, text) {
const cells = sheet.excel[n].cells,
k = `${row},${col}`,
- t = String(text ?? "");
- if (t === "") delete cells[k];
+ t = String(text ?? ""),
+ style = cells[k]?.style;
+ if (t === "" && !style) 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 };
}
+ if (style && cells[k]) cells[k].style = style;
sheet.excelEdited = true;
return EstExcelSheet.recalc(cells);
}
+ function linksOf(sheet, n) {
+ const out = {};
+ for (const [code, l] of Object.entries(sheet.excelLinks || {})) if (l.n === n) out[code] = l;
+ return out;
+ }
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 };
+ const [n, row, col] = td.dataset.excelPick.split(",").map(Number),
+ state = EstApp.state,
+ old = state.excelCell;
+ state.excelCell =
+ event.shiftKey && old && old.sheet === state.quantitySheet && old.n === n
+ ? { ...old, row2: row, col2: col }
+ : { sheet: state.quantitySheet, n, row, col };
EstApp.render();
});
document.addEventListener("change", (event) => {
@@ -102,23 +183,101 @@ const EstExcelView = (() => {
}, false);
if (errors.length) state.notice = errors.slice(0, 3).join(" / ");
});
+ document.addEventListener("keydown", (event) => {
+ const state = EstApp.state;
+ if (!state.excelCell || event.target.closest?.("input,textarea,select")) return;
+ const keyAction = event.ctrlKey
+ ? { c: "excel-copy", x: "excel-cut", v: "excel-paste" }[event.key.toLowerCase()]
+ : event.key === "Delete"
+ ? "excel-clear"
+ : null;
+ if (!keyAction || !sheetOf(state)?.excel) return;
+ event.preventDefault();
+ try {
+ handle(keyAction, state, { dataset: {} });
+ } catch (error) {
+ state.notice = error.message;
+ EstApp.render();
+ }
+ });
}
+ const names = new Set([
+ "excel-sum",
+ "excel-link",
+ "excel-unlink",
+ "excel-field",
+ "excel-recalc",
+ "excel-cut",
+ "excel-copy",
+ "excel-paste",
+ "excel-clear",
+ "excel-insert",
+ "excel-delete",
+ "excel-style",
+ "excel-merge",
+ "excel-split",
+ ]);
function handle(name, state, node) {
- if (!["excel-sum", "excel-link", "excel-unlink"].includes(name)) return false;
+ if (!names.has(name)) return false;
const sheet = sheetOf(state),
sel = state.excelCell;
if (!sheet?.excel || !sel) throw Error("엑셀형 산출서에서 셀을 고르세요.");
+ const page = sheet.excel[sel.n],
+ box = range(sel),
+ edit = (fn) =>
+ EstApp.change(() => {
+ const errors = fn() || [];
+ sheet.excelEdited = true;
+ if (errors.length) state.notice = errors.slice(0, 3).join(" / ");
+ }, false);
if (name === "excel-sum") {
- const formula = EstExcelSheet.sumFormula(
- sheet.excel[sel.n].cells,
- sel.row,
- sel.col,
- node.dataset.dir,
- );
+ const formula = EstExcelSheet.sumFormula(page.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;
+ } else if (name === "excel-recalc") edit(() => EstExcelSheet.recalc(page.cells));
+ else if (name === "excel-copy" || name === "excel-cut") {
+ state.excelClip = EstExcelEdit.copyRange(page, box);
+ if (name === "excel-cut") edit(() => EstExcelEdit.clearRange(page, box));
+ else state.notice = `${state.excelClip.rows}×${state.excelClip.cols} 칸을 복사했습니다.`;
+ } else if (name === "excel-paste") {
+ if (!state.excelClip) throw Error("복사한 셀이 없습니다.");
+ edit(() => EstExcelEdit.pasteRange(page, state.excelClip, box.r1, box.c1));
+ } else if (name === "excel-clear") edit(() => EstExcelEdit.clearRange(page, box, true));
+ else if (name === "excel-insert" || name === "excel-delete") {
+ const axis = node.dataset.axis,
+ at = axis === "row" ? box.r1 : box.c1,
+ count = axis === "row" ? box.r2 - box.r1 + 1 : box.c2 - box.c1 + 1;
+ if (
+ name === "excel-delete" &&
+ !confirm(`${axis === "row" ? "행" : "열"} ${count}개를 삭제하시겠습니까?`)
+ )
+ return true;
+ edit(() =>
+ EstExcelEdit.shift(
+ page,
+ axis,
+ at,
+ name === "excel-insert" ? count : -count,
+ linksOf(sheet, sel.n),
+ ),
+ );
+ state.excelCell = { sheet: sel.sheet, n: sel.n, row: box.r1, col: box.c1 };
+ } else if (name === "excel-style")
+ edit(() => EstExcelEdit.style(page, box, node.dataset.name, node.dataset.value));
+ else if (name === "excel-merge") edit(() => EstExcelEdit.merge(page, box));
+ else if (name === "excel-split") edit(() => EstExcelEdit.split(page, box));
+ else {
+ const code = document.querySelector("[data-excel-item]")?.value,
+ item = sheet.items?.find((i) => i.Code === code);
+ if (name === "excel-field") {
+ if (!item) throw Error("항목을 고르세요.");
+ const v = page.cells[`${sel.row},${sel.col}`]?.value;
+ if (v == null || v === "") throw Error("빈 셀입니다.");
+ edit(() => {
+ item[node.dataset.field] = String(v);
+ });
+ return true;
+ }
EstApp.change(() => {
sheet.excelLinks ||= {};
for (const [k, l] of Object.entries(sheet.excelLinks))
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index 3814b3fad..60b6307f3 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -4,7 +4,7 @@ const http = require("node:http"),
path = require("node:path"),
vm = require("node:vm"),
crypto = require("node:crypto");
-const { importEstx } = require("./import-estx.cjs");
+const { importEstx, table: dbisamTable } = require("./import-estx.cjs");
const exchange = require("./exchange.cjs");
const { exportEstx } = require("./export-estx.cjs");
const jdl = require("./jdl.cjs");
@@ -85,6 +85,9 @@ const allowed = new Set([
"native-mach-formula.js",
"native-defaults.js",
"native-excel-view.js",
+ "native-excel-edit.cjs",
+ "native-code-change.js",
+ "native-code-change-view.js",
"native-progress-wonga.js",
"native-haljung.js",
"native-vat.js",
@@ -233,6 +236,18 @@ const server = http.createServer(async (req, res) => {
reply(res, 200, importEstx(bytes, name));
return;
}
+ if (req.method === "POST" && url.pathname === "/api/pps-table") {
+ // 조달청 코드 목록(원본 System\MainSys\PPS_STUFFC.dat · PPS_HEAD.dat, DBISAM) — 코드·명칭·규격·단위만
+ const rows = dbisamTable(await body(req), null, []);
+ if (!rows.length || !("Code" in rows[0]) || !("Name" in rows[0]))
+ throw Error("조달청 코드 목록(PPS_STUFFC.dat · PPS_HEAD.dat)이 아닙니다.");
+ reply(
+ res,
+ 200,
+ rows.map(({ Gubun, Code, Name, Size, Unit }) => ({ Gubun, Code, Name, Size, Unit })),
+ );
+ return;
+ }
if (req.method === "POST" && url.pathname === "/api/inspect-accom") {
reply(res, 200, inspectAccom(await body(req)));
return;
diff --git a/tools/est-test/xls-read.cjs b/tools/est-test/xls-read.cjs
index a24db4a84..749019a17 100644
--- a/tools/est-test/xls-read.cjs
+++ b/tools/est-test/xls-read.cjs
@@ -329,13 +329,26 @@ function readXls(input) {
else
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 === 0xe5) {
+ // MERGEDCELLS: 개수, (첫 행·끝 행·첫 열·끝 열) 반복
+ for (let i = 0, count = data.readUInt16LE(0); i < count; i++) {
+ const at = 2 + i * 8;
+ (sheet.merges ||= []).push({
+ r1: data.readUInt16LE(at),
+ r2: data.readUInt16LE(at + 2),
+ c1: data.readUInt16LE(at + 4),
+ c2: data.readUInt16LE(at + 6),
+ });
+ }
} else if (type === 0x207 && pendingFormula) {
put(pendingFormula.row, pendingFormula.col, unicode(data, 0).text, pendingFormula.text);
pendingFormula = null;
}
}
}
- return sheets.map(({ name, cells }) => ({ name, cells }));
+ return sheets.map(({ name, cells, merges }) =>
+ merges ? { name, cells, merges } : { name, cells },
+ );
}
module.exports = { readXls, cfb, decodeFormula, columnName };