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 = `
조달청코드 목록 (${ppsKind(cc.tag) === "HEAD" ? "단산·일위·표준 = PPS_HEAD" : "노임·자재·장비·견적 = PPS_STUFFC"})
${list.length ? `${found.length}/${list.length}` : "목록 없음 — 원본 System\\MainSys 의 PPS_*.dat 를 고르세요"}
${found + .map( + (x, i) => + ``, + ) + .join("")}
코드명칭규격단위
${esc(x.Code)}${esc(x.Name)}${esc(x.Size)}${esc(x.Unit)}
`; + 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}
${body}
No당초코드변경코드명칭규격단위비고

*조달청호환 코드로 미변경(노랑), 중복코드(연분홍 비고란 참조), 코드자리수틀림(진황색)*

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

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

${bar}
${Array.from({ length: cols }, (_, c) => ``).join("")}${body}
${L(c)}
`; }) .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 };