diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index df39ccbcc..7acb55966 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts * 마스터 템플릿 좌측 패널 차례(716-3 · 717-2a) — 「단가산출」(층 단추 따로 · 기본 개인 · `UI_Side_Sangeun`) › 「일위대가」(층 단추 따로 · 2단 폴더 나무 · `UI_Side_Precombo` · - * 50-3 · 134-3) › 「내 기계」 · 「내 품목」(821-3 · 개인 층 · 펼치면 가운데 화면) › 「구조물 조합」(층 단추 · 나무) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 · + * 50-3 · 134-3) › 「구조물 조합」(층 단추 · 나무) › 「개인관리」(824-1 · 단추 「기계」 · 「자재」 · 가운데 화면) › 수평 구분선 › 「일위대가 불러오기」(구조물 조합 층 · * `Hopyo_Side` · 324-4) › 「도면 양식」(새로 · 본떠 · 지우기). * 구조물 옵션 정의는 M01 로 옮김(716-1). * 도면 양식 = 시스템 관리자면 늘(마스터 층 것 · 구조물 조합 층과 무관 · 720-4). @@ -157,17 +157,16 @@ export function buildSide(opt: SideOptions): SideHandle { getOpen: opt.getOpen, }); const sangeunSec = section(L("M02_SangeunTitle"), sangeun.body); - // 내 기계 · 내 품목(821-3) — 자기 개인 층 · 펼치면 가운데 화면(목록 · 양식) - const hint = (key: "Side_Hint" | "Items_Side_Hint"): HTMLElement => - createEmpty({ text: mt(key), small: true }); - const myMachSec = openOnExpand(section(mt("Title"), hint("Side_Hint")), async () => { + // 개인관리(824-1) — 단추 「기계」 · 「자재」(821-3 · 자기 개인 층) · 누르면 가운데 화면(목록 · 양식) + const myMachBtn = createButton({ label: mt("Title"), variant: "ghost", size: "sm" }); + myMachBtn.addEventListener("click", async () => { if (!(await opt.confirmLeave())) return; let mark = (_d: boolean): void => {}; const node = buildMyMachineView({ owner: null, setDirty: (d) => mark(d) }); - mark = opt.showView(mt("Title"), node); + mark = opt.showView(mt("Crumb_Machine"), node); }); - myMachSec.dataset.myMachines = ""; - const myItemsSec = openOnExpand(section(mt("Items_Title"), hint("Items_Side_Hint")), async () => { + const myItemsBtn = createButton({ label: mt("Items_Title"), variant: "ghost", size: "sm" }); + myItemsBtn.addEventListener("click", async () => { if (!(await opt.confirmLeave())) return; const node = buildMyItemsView({ owner: null, @@ -184,8 +183,13 @@ export function buildSide(opt: SideOptions): SideHandle { }); }, }); - opt.showView(mt("Items_Title"), node); + opt.showView(mt("Crumb_Items"), node); }); + const personalSec = section( + mt("Personal_Title"), + el("div", { className: "m02-side__row", children: [myMachBtn, myItemsBtn] }), + ); + personalSec.dataset.myMachines = ""; // 실무자료로 일위대가를 만들었음(확인 창 · 엑셀 보기 창) → 목록 다시 · 이 패널이 내려갔으면 듣기 끝 const onBuilt = (): void => precombo.body.isConnected @@ -205,9 +209,8 @@ export function buildSide(opt: SideOptions): SideHandle { children: [ sangeunSec, precomboSec, - myMachSec, - myItemsSec, summary, + personalSec, createDivider(), section(hopyo.title, hopyo.body), drawing, diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 674704b72..4716f793b 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -16,6 +16,11 @@ gap: var(--spacing-4); } +/* 개인관리(824-1) — 제목 아래 단추 줄 띄움 */ +[data-my-machines] > .m02-side__row { + margin-top: var(--spacing-12); +} + /* 일위대가 조합 서고 목록표(51-4) — 표 꼴은 M01 목록(`m01-master__grid`) · 첫 칸 = 고름 */ .m01-master__grid .m02-lib__pick { width: 2.5rem; diff --git a/M02_MasterTemplete/M02_MyItems_View.ts b/M02_MasterTemplete/M02_MyItems_View.ts index 823a63507..3cf894d95 100644 --- a/M02_MasterTemplete/M02_MyItems_View.ts +++ b/M02_MasterTemplete/M02_MyItems_View.ts @@ -28,7 +28,7 @@ export function buildMyItemsView(opt: MyItemsViewOptions): HTMLElement { const uses = createFindList({ placeholder: mt("Open_Sk") }); uses.root.hidden = true; const table = createDataTable({ - caption: mt("Items_Title"), + caption: mt("Crumb_Items"), columns: [ { key: "이름", label: mt("Name"), width: 200 }, { key: "규격", label: mt("Spec"), width: 140 }, diff --git a/M02_MasterTemplete/M02_MyMachine_Text.ts b/M02_MasterTemplete/M02_MyMachine_Text.ts index 19b1e1732..d06f4c1cc 100644 --- a/M02_MasterTemplete/M02_MyMachine_Text.ts +++ b/M02_MasterTemplete/M02_MyMachine_Text.ts @@ -4,8 +4,11 @@ * ========================================================================== */ const TEXT = { - Title: "내 기계", - Items_Title: "내 품목", + Personal_Title: "개인관리", + Title: "기계", + Items_Title: "자재", + Crumb_Machine: "개인관리 › 기계", + Crumb_Items: "개인관리 › 자재", New: "새 기계", Copy_Title: "복사해 내 기계로", Find: "이름 · 규격 · 키로 찾기", @@ -38,8 +41,6 @@ const TEXT = { No_Uses: "쓰는 단가산출 없음", Leave_Ask: "저장 안 한 고침을 버리고 닫을까?", Mine: "내 기계", - Side_Hint: "펼치면 가운데에 목록 · 양식", - Items_Side_Hint: "펼치면 가운데에 모아 보기(읽기)", Won: "원", Labor: "노", Material: "재", diff --git a/M02_MasterTemplete/M02_MyMachine_View.ts b/M02_MasterTemplete/M02_MyMachine_View.ts index ffebeaa77..88b6aea9a 100644 --- a/M02_MasterTemplete/M02_MyMachine_View.ts +++ b/M02_MasterTemplete/M02_MyMachine_View.ts @@ -1,6 +1,6 @@ /* ============================================================================= * M02_MyMachine_View.ts - * M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「내 기계」 칸을 펼치면 가운데에 뜸(개인 층). + * M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「개인관리」 › [기계] 단추로 가운데에 뜸(개인 층). * 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 · [새 기계] * 줄 누름 = 양식 한 장(`M02_MyMachine_Form`) · [목록] = 저장 안 한 고침이면 물음 · 저장 · 지우기 뒤 목록 다시 * ========================================================================== */ @@ -33,7 +33,7 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement { const hint = createText(""); const fail = el("div"); const table = createDataTable({ - caption: mt("Title"), + caption: mt("Crumb_Machine"), columns: [ { key: "이름", label: mt("Name"), width: 180 }, { key: "규격", label: mt("Spec"), width: 120 }, diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index c1b0fb606..9b5f65fc9 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -15,6 +15,7 @@ for (const file of [ "native-row-match.js", "native-escalation.js", "native-progress.js", + "native-annual-load.js", "native-business.js", "native-review.js", "native-resources.js", diff --git a/resources/tester/test_est_book.py b/resources/tester/test_est_book.py new file mode 100644 index 000000000..eca7e7fe0 --- /dev/null +++ b/resources/tester/test_est_book.py @@ -0,0 +1,43 @@ +"""EST 테스트 앱 품셈 보기(원본 BookData 암호 표 해독 · 목차 · 본문 · 검색).""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +BOOK = Path("C:/Dasansoft/Est Plus 2/System/BookData/forest.dat") + + +def node(code: str): + return json.loads(subprocess.check_output(["node", "-e", code], cwd=ROOT).decode("utf-8")) + + +def test_blowfish_constants_and_rtf_text(): + out = node(r""" + const b = require('./tools/est-test/book-store.cjs'); + const w = b.piWords(1042); + const rtf = Buffer.from(String.raw`{\rtf1{\fonttbl{\f0 x;}}\'b0\'a1\cell 1.2\row \u44032?\par}`, 'latin1'); + process.stdout.write(JSON.stringify({ p0: w[0].toString(16), last: w[1041].toString(16), text: b.rtfText(rtf) })); + """) + assert out["p0"] == "243f6a88" + assert out["last"] == "3ac372e6" # Blowfish S4[255] + assert out["text"] == "가\t1.2\n가\n" + + +def test_installed_forest_book(): + if not BOOK.exists(): + pytest.skip("installed EST book data unavailable") + out = node(r""" + const b = require('./tools/est-test/book-store.cjs'); + const list = b.list(), tree = b.tree('forest'), page = b.text('forest', 902); + process.stdout.write(JSON.stringify({ books: list.map((x) => x.name), forest: list.find((x) => x.name === 'forest'), + nodes: tree.length, page: page.text.split('\n').filter((l) => l.startsWith('1.0 이하'))[0], + title: b.search('forest', '교목 굴취').map((h) => h.pkid), body: b.search('forest', '근원직경을', 'body').length })); + """) + assert out["books"] == ["civil", "archit", "machine", "electric", "culture", "forest", "woods"] + assert out["forest"]["desc"].startswith("산림품셈(2026)") and out["forest"]["nodes"] == 563 + assert out["page"] == "1.0 이하\t0.072\t0.012\t" + assert 902 in out["title"] + assert out["body"] >= 1 diff --git a/resources/tester/test_est_data_update.py b/resources/tester/test_est_data_update.py new file mode 100644 index 000000000..323ec0486 --- /dev/null +++ b/resources/tester/test_est_data_update.py @@ -0,0 +1,61 @@ +"""EST 테스트 앱 현재데이타 갱신(원본 TFRMDATAUPDATE → 0x10e12c8 — 노임·자재·유류·표준·기계·환율).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone }}); + vm.runInContext(fs.readFileSync('tools/est-test/native-data-update.js', 'utf8'), ctx); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + return json.loads(subprocess.check_output(["node", "-e", runner], cwd=ROOT).decode("utf-8")) + + +def test_update_rules(): + out = run_js( + r"""(() => { + const U = EstDataUpdate; + const src = { tables: { + LaborcDat: [{ Code: 'X1', Name: '보 통 인 부', Size: '', TC: 200, Cost1: 200, Bimog: 2 }], + StuffcDat: [{ Code: 'S1', Name: '시멘트', Size: '40kg', Unite: '포당', TC: 6000, Cost1: 6000, Page1: '12', Ratio: 5 }, + { Code: 'S2', Name: '경유', TC: 1700, Cost1: 1700 }], + SilJukDat: [{ Code: 'C1', Name: '새 이름', Size: '', Unite: 'm', TC: 99, NC: 50 }], + MachGijunDat: [{ Index: 36, Name: '달러($)', TC: 1400, Bigo: '새' }, { Index: 1, Name: '계수', TC: 0.3 }], + MachTitleDat: [], MachMainDat: [] } }; + const mk = () => ({ tables: { + LaborcDat: [{ Code: 'L1', Name: '보통인부', TC: 100, Cost1: 100, Cost1_: 7 }, { Code: 'L2', Name: '없는직종', TC: 1 }], + StuffcDat: [{ Code: 'M1', Name: '시멘트', Size: '40 kg', Unite: '[포]', TC: 5000, Cost1: 5000, Ratio: 1 }, + { Code: 'M2', Name: '경 유', TC: 1600, Cost1: 1600 }], + SilJukDat: [{ Code: 'C1', Name: '옛 이름', TC: 1 }], + MachGijunDat: [{ Index: 36, Name: '달러($)', TC: 1300, Bigo: '옛', TC_: 9 }], + MachTitleDat: [], MachMainDat: [] } }); + const a = mk(), ra = U.apply(a, src, { labor: 1, stuff: 1, siljuk: 1, mach: 1, fuel: 1, rate: 1 }); + const b = mk(); U.apply(b, src, { stuff: 1 }); + const c = mk(); U.apply(c, src, { mach: 1 }); + const l = a.tables.LaborcDat; + return { + labor: [l[0].TC, l[0].Code, l[0].Cost1_, l[0].Checked, l[0].Bimog, l[1].Checked], + stuff: [a.tables.StuffcDat[0].TC, a.tables.StuffcDat[0].Page1, a.tables.StuffcDat[0].Code, a.tables.StuffcDat[1].TC], + stuffOnly: [b.tables.StuffcDat[0].TC, b.tables.StuffcDat[0].Ratio, b.tables.StuffcDat[1].TC], + siljuk: [a.tables.SilJukDat[0].Code, a.tables.SilJukDat[0].Name, a.tables.SilJukDat[0].NC], + gijun: a.tables.MachGijunDat.map((r) => [r.Index, r.TC, r.TC_ ?? null]), + rateKept: c.tables.MachGijunDat.find((r) => r.Index === 36).TC, + notices: ra.notices, + fuel: [U.isFuel({ Name: '휘 발 유' }), U.isFuel({ Name: '선박용경유' })], + }; + })()""" + ) + assert out["labor"] == [200, "L1", 7, False, 2, True] + assert out["stuff"] == [6000, "12", "M1", 1700] + assert out["stuffOnly"] == [6000, 1, 1600] + assert out["siljuk"] == ["C1", "새 이름", 50] + assert out["gijun"] == [[36, 1400, 9], [1, 0.3, None]] + assert out["rateKept"] == 1300 + assert out["notices"][0].startswith("노임단가에서 1 개의 찾지못한 데이타가 있습니다.") + assert out["fuel"] == [True, False] diff --git a/resources/tester/test_est_progress_workflow.py b/resources/tester/test_est_progress_workflow.py index ae94c9051..431afafe7 100644 --- a/resources/tester/test_est_progress_workflow.py +++ b/resources/tester/test_est_progress_workflow.py @@ -202,3 +202,21 @@ def test_progress_preserves_prior_costs_for_multiple_blank_codes(): assert result["errors"] == [] assert [row["JunCost"] for row in result["costs"]] == [200, 100] assert [row["TotalCost"] for row in result["costs"]] == [500, 250] + + +def test_annual_loaded_rows_replace_formula(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.tables.EstMainDat=[{PkID:1,Code:'R',GN:10,GU:100},{PkID:2,Code:'S',GN:4,NU:50},{LinkIndex:54}]; + const loaded=[{PkID:1,Code:'R',GN:3,GU:100,GC:300,TC:300},{PkID:2,Code:'S',GN:2,NU:50,NC:100,TC:100}]; + p.annual=[{name:'1차',formula:'다른공사',rows:loaded}]; + const out=EstBusiness.annual(EstNative.calculate(p)); + const rows=out.results[0].project.tables.EstMainDat; + return {gn:rows.slice(0,2).map(r=>r.GN), tc:out.results[0].project.calculation.direct.TC, errors:out.errors}; + })()""", + ) + assert result["gn"] == [3, 2] + assert result["tc"] == 400 + assert result["errors"] == [] diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 02631c567..37ed2916b 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,14 @@ 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] 연차 내역서·상단·하단데이타 불러오기(원본 TFRMESTMULTIEDIT popLoadEst·popUpDataLoad·popDownLoad 0xedb784 → 0xf11a60 — spec_part §7) — 차수 줄의 세 단추: 고른 .estx 내역서 줄을 차수에 짝지어 옮기고(행 수는 현재 내역), 수식칸에 파일 이름. 상단·하단은 설계변경 내역만(당초 「_」 칸 있는 것), 상단 = 당초값·하단 = 변경값. 그 차수의 상세 줄은 수식 대신 불러온 값(금액 고정, 다시 셈 없음). 서버 계산도 같은 코드(`native-annual-load.js`). + - 검증완료: 기성·연차 시험 13개(불러온 줄 수량 3·2·직접공사비 400, 수식 무시) 통과. 당초/변경 고르기·설계변경 아닌 파일 거부 확인. ORCA 브라우저에서 차수 줄에 불러오기 단추 3개 표시(파일 선택은 수동 조작 필요). + - [x] 현재데이타 갱신(원본 TFRMDATAUPDATE btnApply 0x10bc3d4 → 엔진 0x10e12c8 — spec_update) — 원천: Main Data(원본 설치 main.estx = BufDat\Main\Main_1, 서버 `/api/main-data`, `EST_MAIN_DATA` 로 경로 변경) 또는 고른 .estx. 항목: 노임·자재(유류 제외)·유류(이름 8종)·표준(코드 일치)·기계(손료계수·환율 표 교체, 기계 호표 이름·규격 일치, 산출근거 줄 교체 뒤 자재·노임 다시 잇기)·환율(36·89·69·77·70·76). 맞춤 규칙·복사 칸·단가미갱신 표시·안내 문구 원본대로. 행 추가·삭제 없음. 갱신 뒤 재계산. 하지 않음: 최신(웹접속 — 원본 회사 서버·유지보수 인증), 자재 단가열 출처 재배치(OtherTables — 같은 열 순서 가정), 원본 결함(환율 끔 때 파운드를 76 값으로 덮음). + - 검증완료: `test_est_data_update.py`(노임 갱신·당초 칸 유지·못 찾음 표시와 문구, 자재 규격·단위 정리 맞춤·쪽수, 자재만 갱신 때 유류·비율 제외, 표준 코드 맞춤·이름 교체, 손료 계수 표 교체·당초 칸 유지, 환율 끔 유지) 통과. 샘플을 원본 main.estx 로 갱신하면 보통인부 172,068→172,698(2026 하반기)·못 찾음 0(145ms). ORCA 브라우저에서 같은 결과, 샘플 복구. + - [x] 견적서 엑셀변환(원본 TFRMEXCELCONVERTESTIPRN btnConvert) — 견적서 창 「엑셀변환」: 머리(제목·공사명·귀하·견적일자·합계금액 한글·공급자)와 레벨에 맞춘 내역 줄(품명·규격·단위·수량·재료/노무/경비/합계 단가·금액·비고)을 .xlsx 로 내려받기. 원본의 인쇄비율·셀 수식은 하지 않음(값만). + - 검증완료: 견적서·바인딩 시험 통과. ORCA 브라우저에서 「EST REA 보관 샘플_견적서.xlsx」(7,490 바이트) 내려받기, 샘플 복구. + - [x] 품셈 보기·품셈자료 붙여넣기(원본 barbtnBook → ProductBook.exe/TfrmBookViewer, popupPasteBook 10d6e28 — spec_book) — 서버 `book-store.cjs` 가 원본 설치 BookData(7권, DBISAM 암호 표: MD5 키 Blowfish-CBC, BLOB zlib RTF)를 읽음(Node 에 Blowfish 가 없어 π 자리로 상수 만들어 직접 구현, `EST_BOOK_DIR` 로 경로 변경). 화면: 품셈 고르기·목차 트리(펼치기·접기)·본문(표 칸 나눔)·검색(제목 / 제목+본문 / 키워드, 검색어 빈칸·'|' 지움)·본문 검색 강조·복사하기. 붙여넣기(복사한 뒤만): 단가산출은 첫 줄을 마지막 산출근거 칸 커서 자리, 둘째 줄부터 아래 새 줄, 내역·일위·기계는 마지막 칸에 마지막 줄(수치 칸 검사). 사용자 결정: 원본처럼 잠긴 품셈 파일을 읽음. + - 검증완료: `test_est_book.py`(Blowfish 상수 첫·끝 값, RTF 글자, 설치 7권·산림 563마디·교목굴취 표 줄·제목·본문 검색) 및 바인딩 시험 통과. ORCA 브라우저에서 산림품셈 목차 펼치기 → 5-1-2 교목굴취 표 34칸, 「보통인부」 강조, 복사 → 단가산출 붙여넣기 363→385줄, 샘플 복구. - [x] 수량산출서 삽입·선택공종 삭제(원본 TFRMMULLANG btnInsert·btnSelectDel) — 「삽입」: 현재 산출서 앞에 공종명칭·규격을 물어 새 산출서, 「선택공종 삭제」: 고른 여러 산출서를 한 번에 지움. - 검증완료: 산출서·바인딩 시험 7개 통과. ORCA 브라우저에서 둘째 위치에 「끼운 공종/1m」 삽입, 1·3번째 고른 삭제 → 「끼운 공종」만 남음, 샘플 복구. - [x] 새내역서 파일로저장(원본 mnuSaveNewEst 0x113384c) — 현재 화면은 그대로, 표 12개의 「_」 당초값 칸 비움·신규 표시 끔·원가 안전관리비 특례 줄(CalcIndex 4)→1·당초 사본 뺀 새 작업을 이름 붙여 서버에 따로 저장하고 내려받기. 재계산 없음. diff --git a/tools/est-test/book-store.cjs b/tools/est-test/book-store.cjs new file mode 100644 index 000000000..28e3fd1bf --- /dev/null +++ b/tools/est-test/book-store.cjs @@ -0,0 +1,271 @@ +// 원본 품셈 보기 자료(System\BookData — civil·archit·machine·electric·culture·forest·woods) 읽기. +// DBISAM 4 암호 표: 키 = MD5(원본 세션 암호), Blowfish-CBC(IV 0) — .dat 머리 0x200 은 평문, 필드 설명은 한 사슬, +// 레코드는 레코드마다·.blb 512 바이트 블록은 블록마다 사슬을 새로 시작. 필드 pkid·upperid·orderno·chapter·title·keywords·content(BLOB). +// 본문 BLOB = zlib 압축 RTF(WPTools) → 글자(문단 줄바꿈, 표 칸 탭). Node 가 Blowfish 를 빼서 직접 구현(상수는 π 16진 자리). +// 설치 폴더는 EST_BOOK_DIR 로 바꿀 수 있다. 읽기 전용. +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); +const crypto = require("crypto"); + +const DIR = process.env.EST_BOOK_DIR || "C:/Dasansoft/Est Plus 2/System/BookData"; +const PASSWORD = 'LW`bdGD"4['; + +// π 소수부 16진 자리 → Blowfish P(18)·S(4×256) 초기값 +function piWords(count) { + const digits = count * 8 + 8, + scale = 1n << BigInt(digits * 4 + 64); + const atan = (x) => { + const xx = x * x; + let term = scale / x, + sum = term, + k = 1n; + for (let sign = -1n; term; sign = -sign, k += 2n) { + term /= xx; + sum += (sign * term) / (k + 2n); + } + return sum; + }; + const pi = 16n * atan(5n) - 4n * atan(239n); + const frac = (pi - 3n * scale) >> 64n; + const hex = frac.toString(16).padStart(digits, "0"); + return Array.from({ length: count }, (_, i) => parseInt(hex.slice(i * 8, i * 8 + 8), 16) >>> 0); +} + +function blowfish(key) { + const init = piWords(18 + 1024), + P = init.slice(0, 18), + S = [0, 1, 2, 3].map((i) => init.slice(18 + i * 256, 18 + (i + 1) * 256)); + const F = (x) => + ((((S[0][x >>> 24] + S[1][(x >>> 16) & 255]) >>> 0) ^ S[2][(x >>> 8) & 255]) + + S[3][x & 255]) >>> + 0; + function encrypt(l, r) { + for (let i = 0; i < 16; i++) { + l = (l ^ P[i]) >>> 0; + r = (r ^ F(l)) >>> 0; + [l, r] = [r, l]; + } + [l, r] = [r, l]; + r = (r ^ P[16]) >>> 0; + l = (l ^ P[17]) >>> 0; + return [l, r]; + } + function decrypt(l, r) { + for (let i = 17; i > 1; i--) { + l = (l ^ P[i]) >>> 0; + r = (r ^ F(l)) >>> 0; + [l, r] = [r, l]; + } + [l, r] = [r, l]; + r = (r ^ P[1]) >>> 0; + l = (l ^ P[0]) >>> 0; + return [l, r]; + } + for (let i = 0, k = 0; i < 18; i++) { + let w = 0; + for (let j = 0; j < 4; j++, k++) w = ((w << 8) | key[k % key.length]) >>> 0; + P[i] = (P[i] ^ w) >>> 0; + } + let l = 0, + r = 0; + for (let i = 0; i < 18; i += 2) { + [l, r] = encrypt(l, r); + P[i] = l; + P[i + 1] = r; + } + for (const box of S) + for (let i = 0; i < 256; i += 2) { + [l, r] = encrypt(l, r); + box[i] = l; + box[i + 1] = r; + } + // CBC 풀기(IV 0), 8 바이트 미만 꼬리는 그대로 + return function cbc(buf) { + const out = Buffer.from(buf), + n = buf.length - (buf.length % 8); + let pl = 0, + pr = 0; + for (let o = 0; o < n; o += 8) { + const cl = buf.readUInt32BE(o), + cr = buf.readUInt32BE(o + 4), + [dl, dr] = decrypt(cl, cr); + out.writeUInt32BE((dl ^ pl) >>> 0, o); + out.writeUInt32BE((dr ^ pr) >>> 0, o + 4); + pl = cl; + pr = cr; + } + return out; + }; +} + +let cbc = null; +const dec = (buf) => + (cbc ||= blowfish(crypto.createHash("md5").update(PASSWORD, "latin1").digest()))(buf); +const cp949 = new TextDecoder("euc-kr"); +const books = new Map(); + +function load(name) { + if (books.has(name)) return books.get(name); + if (!/^[a-z]+$/.test(name)) throw Error("품셈 이름이 올바르지 않습니다."); + const d = fs.readFileSync(path.join(DIR, `${name}.dat`)); + const size = d.readUInt16LE(0x2d), + nf = d[0x2f], + descEnd = d.indexOf(0, 0x48), + desc = cp949.decode(d.subarray(0x48, Math.min(descEnd < 0 ? 0x68 : descEnd, 0x68))); + const fd = dec(d.subarray(0x200, 0x200 + nf * 0x300)); + const fields = []; + for (let i = 0; i < nf; i++) { + const b = fd.subarray(i * 0x300, (i + 1) * 0x300); + fields.push([b.subarray(3, 3 + b[2]).toString("latin1"), b[0xa4], b.readUInt16LE(0xac)]); + } + const rows = []; + for (let o = 0x200 + nf * 0x300; o + size <= d.length; o += size) { + const r = dec(d.subarray(o, o + size)), + row = {}; + for (const [fn, t, off] of fields) { + if (!r[off]) row[fn] = null; + else if (t === 6) row[fn] = r.readInt32LE(off + 1); + else if (t === 1) { + const end = r.indexOf(0, off + 1); + row[fn] = cp949.decode(r.subarray(off + 1, end < 0 ? off + 302 : Math.min(end, off + 302))); + } else row[fn] = r.readUInt32LE(off + 1); + } + if (row.pkid != null) rows.push(row); + } + const book = { name, desc, rows, byId: new Map(rows.map((r) => [r.pkid, r])), texts: new Map() }; + books.set(name, book); + return book; +} + +function blob(name, first) { + const b = fs.readFileSync(path.join(DIR, `${name}.blb`)); + const parts = []; + let block = first, + total = null, + got = 0; + const seen = new Set(); + while (block && !seen.has(block)) { + seen.add(block); + const x = dec(b.subarray(block * 512, (block + 1) * 512)), + next = x.readUInt32LE(4), + length = x.readUInt16LE(8); + if (total == null) total = x.readUInt32LE(14); + parts.push(x.subarray(18, 18 + length)); + got += length; + block = next; + if (got >= total) break; + } + return zlib.inflateSync(Buffer.concat(parts)); +} + +const SKIP = /^\{\\(\*|fonttbl|colortbl|stylesheet|info|pict|listtable|listoverridetable|object)/; +function rtfText(raw) { + const s = raw.toString("latin1"), + out = [], + pend = []; + let i = 0, + depth = 0, + skip = null, + uc = 1; + const flush = () => { + if (pend.length) out.push(cp949.decode(Buffer.from(pend.splice(0)))); + }; + while (i < s.length) { + const c = s[i]; + if (c === "{") { + depth++; + if (skip == null && SKIP.test(s.slice(i, i + 24))) skip = depth; + i++; + } else if (c === "}") { + if (skip === depth) skip = null; + depth--; + i++; + } else if (c === "\\") { + const hx = /^\\'([0-9a-fA-F]{2})/.exec(s.slice(i, i + 4)); + if (hx) { + if (skip == null) pend.push(parseInt(hx[1], 16)); + i += 4; + continue; + } + const cw = /^\\([a-zA-Z]+)(-?\d+)? ?/.exec(s.slice(i, i + 40)); + if (cw) { + const [whole, w, a] = cw; + i += whole.length; + if (skip != null) continue; + if (w === "u" && a) { + flush(); + out.push(String.fromCharCode(Number(a) & 0xffff)); + i += uc; + } else if (w === "uc" && a) uc = Number(a); + else if (["par", "line", "row"].includes(w)) { + flush(); + out.push("\n"); + } else if (["cell", "tab"].includes(w)) { + flush(); + out.push("\t"); + } + continue; + } + if (skip == null && "\\{}".includes(s[i + 1] ?? "")) pend.push(s.charCodeAt(i + 1)); + i += 2; + } else if (c === "\r" || c === "\n") i++; + else { + if (skip == null) pend.push(s.charCodeAt(i) & 0xff); + i++; + } + } + flush(); + return out.join(""); +} + +function list() { + const names = fs + .readFileSync(path.join(DIR, "filelist.ini"), "latin1") + .split(/\s+/) + .filter(Boolean); + return names.map((name) => { + const b = load(name); + return { name, desc: b.desc, nodes: b.rows.length }; + }); +} +function tree(name) { + const b = load(name); + return b.rows.map(({ pkid, upperid, orderno, chapter, title, content }) => ({ + pkid, + parent: b.byId.has(upperid) ? upperid : null, + order: orderno, + chapter: chapter ?? "", + title: title ?? "", + content: Boolean(content), + })); +} +function text(name, pkid) { + const b = load(name), + r = b.byId.get(Number(pkid)); + if (!r) throw Error("품셈 항목이 없습니다."); + if (!r.content) return { pkid: r.pkid, chapter: r.chapter, title: r.title, text: "" }; + if (!b.texts.has(r.pkid)) b.texts.set(r.pkid, rtfText(blob(name, r.content))); + return { + pkid: r.pkid, + chapter: r.chapter ?? "", + title: r.title ?? "", + text: b.texts.get(r.pkid), + }; +} +// 원본 전체검색(actFullSearch 0xdf8514): 검색어의 빈칸·'|' 를 지우고 제목(또는 제목+본문)에 들어 있으면. +// 키워드검색(actKeywordSearch): keywords 칸에 들어 있으면. +function search(name, word, mode = "title") { + const b = load(name), + kw = String(word ?? "").replace(/[\s|]/g, ""); + if (!kw) throw Error("검색어를 입력하세요."); + return b.rows + .filter((r) => { + if (mode === "keyword") return String(r.keywords ?? "").includes(kw); + if (String(r.title ?? "").includes(kw)) return true; + return mode === "body" && r.content && text(name, r.pkid).text.includes(kw); + }) + .map((r) => ({ pkid: r.pkid, chapter: r.chapter ?? "", title: r.title ?? "" })); +} + +module.exports = { list, tree, text, search, piWords, blowfish, rtfText }; diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 64716fc48..29f6f57cd 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -22,6 +22,7 @@ + @@ -65,6 +66,8 @@ + + @@ -150,7 +153,8 @@ > + >
| ${hl(c)} | `).join("")}
검색 결과 ${b.hits.length}건
${b.hits.map((h) => ``).join("")}` + : treeHtml(b, b.tree); + EstApp.showDialog( + "품셈", + `왼쪽에서 항목을 고르세요.
"}갱신시 적용할 항목 : ${opts.map(([k, l]) => ``).join(" ")}
※주의사항 — 현재데이타 갱신후 이전 상태로 복원할 수 없습니다(저장 전에는 ↶ 취소로 되돌림).
`, + ); + } + async function source(kind) { + if (kind === "main") return EstApp.api("/api/main-data"); + const file = await new Promise((resolve) => { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = ".estx"; + picker.addEventListener("change", () => resolve(picker.files[0])); + picker.click(); + }); + if (!file) throw Error("갱신할 파일을 선택하지 않았습니다."); + return EstApp.api(`/api/import?name=${encodeURIComponent(file.name)}`, { + method: "POST", + body: await file.arrayBuffer(), + }); + } + async function run(state) { + const kind = document.querySelector('#dialog-content [name="du-src"]:checked')?.value, + o = Object.fromEntries( + [...document.querySelectorAll('#dialog-content [name="du-opt"]')].map((n) => [ + n.value, + n.checked, + ]), + ); + if (!Object.values(o).some(Boolean)) throw Error("갱신할 항목을 고르세요."); + const src = await source(kind); + if ( + !confirm( + `현재 작업데이타를 "${kind === "main" ? "Main" : src.name}"의 단가로 갱신하시겠습니까?`, + ) + ) + return; + let result; + EstApp.byId("dialog").close(); + EstApp.change((project) => (result = apply(project, src, o))); + state.notice = result.notices.join(" / ") || "현재데이타를 갱신했습니다."; + EstApp.render(); + } + function handle(name, state) { + if (name === "data-update") dialog(state); + else if (name === "data-update-run") + run(state).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + else return false; + return true; + } + return { apply, isFuel, handle }; +})(); diff --git a/tools/est-test/native-estimate-print.js b/tools/est-test/native-estimate-print.js index f7012f84f..8cdf77218 100644 --- a/tools/est-test/native-estimate-print.js +++ b/tools/est-test/native-estimate-print.js @@ -171,7 +171,7 @@ const EstEstimatePrint = (() => { ``; EstApp.showDialog( "견적서 작성", - `