From 892285843d4051c786098b0167d397e2e2c3a234 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:35:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(est-test):=20=EC=9B=8C=EB=93=9C=ED=98=95?= =?UTF-8?q?=20=EC=88=98=EB=9F=89=EC=82=B0=EC=B6=9C=EC=84=9C=20=ED=91=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4=EA=B3=BC=20=ED=8E=B8=EC=A7=91=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가져오기가 WPT 표를 버리던 결함 고침: 표 줄(칸·수량 표지·수식·세로병합) 보존, 문서 읽기 native-quantity-wpt.cjs 로 분리 - 표 셀 합·수량 지정·병합/분리·표 만들기·표 모양, 문단·그림·쪽 삽입/삭제·품명/규격 지정·공종명칭 입력·테두리선·산출근거 양식 - 수량 불러오기·수량목록보기·선택수량복사·내역서 수량붙여넣기(연결) - ESTX 저장은 표 줄을 건너 문단만 맞춤, 표·서식 편집은 거부 Co-Authored-By: Claude Opus 5.5 --- resources/tester/helper_est_native.cjs | 1 + resources/tester/test_est_bid.py | 1 + resources/tester/test_est_cleanup.py | 1 + resources/tester/test_est_docs.py | 1 + resources/tester/test_est_est_tools.py | 1 + resources/tester/test_est_estimate_print.py | 1 + resources/tester/test_est_excel_map.py | 1 + resources/tester/test_est_find.py | 1 + resources/tester/test_est_jungsang.py | 1 + resources/tester/test_est_name_reg.py | 1 + resources/tester/test_est_pick.py | 1 + resources/tester/test_est_print_batch.py | 1 + resources/tester/test_est_progress_wonga.py | 1 + resources/tester/test_est_quantity_table.py | 71 +++++ resources/tester/test_est_quantity_total.py | 1 + resources/tester/test_est_sources.py | 1 + resources/tester/test_est_tidy.py | 1 + resources/tester/test_est_transfer.py | 1 + resources/tester/test_est_wonga_tools.py | 1 + tools/est-test/PLAN.md | 3 + tools/est-test/export-quantity.cjs | 53 +++- tools/est-test/index.html | 5 + tools/est-test/native-actions-more.js | 9 + tools/est-test/native-business-actions.js | 4 +- tools/est-test/native-quantity-list.js | 157 ++++++++++++ tools/est-test/native-quantity-sheets.js | 4 +- tools/est-test/native-quantity-table.js | 270 ++++++++++++++++++++ tools/est-test/native-quantity-wpt.cjs | 190 ++++++++++++++ tools/est-test/native-quantity.cjs | 64 ++--- tools/est-test/server.cjs | 14 +- 30 files changed, 799 insertions(+), 63 deletions(-) create mode 100644 resources/tester/test_est_quantity_table.py create mode 100644 tools/est-test/native-actions-more.js create mode 100644 tools/est-test/native-quantity-list.js create mode 100644 tools/est-test/native-quantity-table.js create mode 100644 tools/est-test/native-quantity-wpt.cjs diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 9b5f65fc9..4113dbad9 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -9,6 +9,7 @@ for (const file of [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_bid.py b/resources/tester/test_est_bid.py index 2893b3ce2..65c83adee 100644 --- a/resources/tester/test_est_bid.py +++ b/resources/tester/test_est_bid.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_cleanup.py b/resources/tester/test_est_cleanup.py index 11b77cfc0..582d22668 100644 --- a/resources/tester/test_est_cleanup.py +++ b/resources/tester/test_est_cleanup.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_docs.py b/resources/tester/test_est_docs.py index e31f03f4a..8021b753e 100644 --- a/resources/tester/test_est_docs.py +++ b/resources/tester/test_est_docs.py @@ -14,6 +14,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_est_tools.py b/resources/tester/test_est_est_tools.py index b4b68eac9..883144df4 100644 --- a/resources/tester/test_est_est_tools.py +++ b/resources/tester/test_est_est_tools.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_estimate_print.py b/resources/tester/test_est_estimate_print.py index 3062c54a1..ddca91de7 100644 --- a/resources/tester/test_est_estimate_print.py +++ b/resources/tester/test_est_estimate_print.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_excel_map.py b/resources/tester/test_est_excel_map.py index 1c389cdfd..c8d8ef7ba 100644 --- a/resources/tester/test_est_excel_map.py +++ b/resources/tester/test_est_excel_map.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_find.py b/resources/tester/test_est_find.py index 5c2cc80d3..377e6fe6e 100644 --- a/resources/tester/test_est_find.py +++ b/resources/tester/test_est_find.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_jungsang.py b/resources/tester/test_est_jungsang.py index 71650c642..d380e02a2 100644 --- a/resources/tester/test_est_jungsang.py +++ b/resources/tester/test_est_jungsang.py @@ -12,6 +12,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-jungsang.js", diff --git a/resources/tester/test_est_name_reg.py b/resources/tester/test_est_name_reg.py index ba807b62b..d5323eb07 100644 --- a/resources/tester/test_est_name_reg.py +++ b/resources/tester/test_est_name_reg.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_pick.py b/resources/tester/test_est_pick.py index 282bbc673..2361a5fb3 100644 --- a/resources/tester/test_est_pick.py +++ b/resources/tester/test_est_pick.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_print_batch.py b/resources/tester/test_est_print_batch.py index b49dddc08..62a916746 100644 --- a/resources/tester/test_est_print_batch.py +++ b/resources/tester/test_est_print_batch.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_progress_wonga.py b/resources/tester/test_est_progress_wonga.py index be4c1f60f..e1715f56e 100644 --- a/resources/tester/test_est_progress_wonga.py +++ b/resources/tester/test_est_progress_wonga.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_quantity_table.py b/resources/tester/test_est_quantity_table.py new file mode 100644 index 000000000..a907ddaf4 --- /dev/null +++ b/resources/tester/test_est_quantity_table.py @@ -0,0 +1,71 @@ +"""EST 테스트 앱 워드형 수량산출서 표(원본 WPT 보존 · 방향 셀 합 · 수량 표지 · 세로 병합).""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MAIN = Path("C:/Dasansoft/Est Plus 2/Data/ProjectData/main.estx") + + +def node(code: str, *args: str): + return json.loads( + subprocess.check_output(["node", "-e", code, *args], cwd=ROOT).decode("utf-8") + ) + + +def test_table_parse_sum_values_merge(): + out = node(r""" + const W = require(process.cwd() + '/tools/est-test/native-quantity-wpt.cjs'); + const wpt = '' + + '
단가 9
' + + '
' + + '' + + '
공종수량
절단1,000.5
깨기2
계
'; + const s = W.fromWpt('N1', '시험', wpt, [], {}); + const t = s.rows[1].table; + const div = s.rows[0].LineData; + t[3][1].formula = W.sumFormula(t, 3, 1, 'down'); + const values = Object.fromEntries(W.values(s)); + const right = W.sumFormula(t, 3, 1, 'right'); + W.vmerge(t, 1, 2, 0); + const merged = [t[1][0].text, t[2][0].vmerge, t[1][0].vtop]; + W.vsplit(t, 1, 0); + process.stdout.write(JSON.stringify({ rows: s.rows.length, div, cell: t[1][1], name: t[1][0].name, + formula: t[3][1].formula, sum: t[3][1].text, values, right, merged, split: t[2][0].vmerge ?? null, + created: W.create(2, 3).map((r) => r.length) })); + """) + assert out["rows"] == 2 + assert out["div"] == "단가 9" # 문단의 G 표지는 표지로 보지 않음 + assert out["cell"] == {"text": "1,000.5", "mark": "G00000203"} + assert out["name"] is True + assert out["formula"] == "SUM(B2:B3)" and out["sum"] == "1,002.5" + assert out["values"] == {"G00000203": 1000.5} + assert out["right"] == "SUM(A4:A4)" + assert out["merged"] == ["절단 깨기", True, 2] + assert out["split"] is None + assert out["created"] == [3, 3] + + +def test_installed_main_tables_survive_import(): + if not MAIN.exists(): + pytest.skip("installed EST main sample unavailable") + out = node( + r""" + const { importEstx } = require(process.cwd() + '/tools/est-test/import-estx.cjs'); + const p = importEstx(require('fs').readFileSync(process.argv[1])); + const tables = p.quantitySheets.filter((s) => s.rows.some((r) => r.table)) + .map((s) => { const t = s.rows.find((r) => r.table).table; return [s.Code, t.length, t[0].length]; }); + process.stdout.write(JSON.stringify(tables)); + """, + str(MAIN), + ) + assert out == [ + ["N0000000097", 9, 13], + ["N0000000108", 7, 5], + ["N0000000109", 5, 5], + ["N0000000110", 7, 5], + ["N0000000111", 8, 5], + ] diff --git a/resources/tester/test_est_quantity_total.py b/resources/tester/test_est_quantity_total.py index c9873a046..4b7e24b23 100644 --- a/resources/tester/test_est_quantity_total.py +++ b/resources/tester/test_est_quantity_total.py @@ -10,6 +10,7 @@ FILES = [ "native-formula.js", "native-text.js", "native-quantity-total.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", ] SCRIPT = r"""(() => { diff --git a/resources/tester/test_est_sources.py b/resources/tester/test_est_sources.py index 0d8627572..c1065e40d 100644 --- a/resources/tester/test_est_sources.py +++ b/resources/tester/test_est_sources.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-sources.js", diff --git a/resources/tester/test_est_tidy.py b/resources/tester/test_est_tidy.py index 38cdd15ca..bf7d8981f 100644 --- a/resources/tester/test_est_tidy.py +++ b/resources/tester/test_est_tidy.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_transfer.py b/resources/tester/test_est_transfer.py index d581fc95d..70c1a8055 100644 --- a/resources/tester/test_est_transfer.py +++ b/resources/tester/test_est_transfer.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/resources/tester/test_est_wonga_tools.py b/resources/tester/test_est_wonga_tools.py index 4175df2f8..f64eb1396 100644 --- a/resources/tester/test_est_wonga_tools.py +++ b/resources/tester/test_est_wonga_tools.py @@ -11,6 +11,7 @@ FILES = [ "native-machine.js", "native-rows.js", "native-text.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-engine.js", diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 37ed2916b..673b9f8c7 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,9 @@ 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] 워드형 수량산출서 표와 편집 도구(원본 TFRMMULWORD·TFRMMULLANG — spec_word · spec_pa) — 가져오기가 WPT 을 통째로 버리던 결함 고침: 표를 한 줄 { table: [[칸]] }(글·수량 표지 r/L/G·품명/규격 표지·수식·세로병합)로 보존(설치 main.estx 표 5개 9×13~5×5). 문서 읽기는 `native-quantity-wpt.cjs` 로 분리. 표 화면: 칸 고르기(Shift 범위)·글/수식 고치기·방향 셀 합(우·좌·위·아래, 원본 범위 규칙·1,234.5 꼴)·수량으로 지정/해제·셀수직병합/분리·표 모양(테두리)·아래 표 만들기. 줄 도구: 문단(정렬·들여쓰기)·그림 넣기·한페이지 삽입/삭제(웹 한 쪽 45줄)·품명지정/규격지정(한 줄 하나·40자·제목 위 금지·원본 문구)·테두리선·산출근거 양식(화면 표시)·공종명칭 입력(머리 제목 줄 굵게 큰 글자). 수량 불러오기(Main Data 산출서 75개에서 새 산출서로), 수량목록보기(모든 산출서·수량 항목·원문 미리보기·검색·선택수량복사), 내역서 수량붙여넣기(연결)(고른 줄부터 아래로 수량·연결, 품명,규격입력 체크 시 이름도). 표·서식을 고친 산출서는 ESTX 저장 거부(JSON), 고치지 않은 표 산출서는 ESTX 저장이 표 줄을 건너 문단만 맞춤. + - 검증완료: `test_est_quantity_table.py` 2개(표 읽기·문단 G 표지 무시·아래 합 SUM(B2:B3)=1,002.5·수량 값·병합/분리·설치 표 5개) 및 수량·ESTX 내보내기 회귀 49개 통과. ORCA 브라우저에서 보차도경계석 보수 표 35칸, 수량 1.0→2.5 항목 수량 반영, 아래 합 SUM(D2:D6)=3.923, 수량 불러오기 75개·문단 가운데·산출근거 양식·쪽 삽입 45줄·삭제, 공종명칭 「보차도경계석 보수(L=1m)」, 품명지정 ⟦품명⟧, 수량목록 복사 → 내역 붙여넣기 1·0.19·연결 id:36, 샘플 복구. + - 남음: 글자모양(글꼴·색), 수량산출서 원본 모양 인쇄(쪽·간지), 엑셀형 시트 수량붙여넣기(연결). - [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 값으로 덮음). diff --git a/tools/est-test/export-quantity.cjs b/tools/est-test/export-quantity.cjs index 1b92530de..0ca3e0e0e 100644 --- a/tools/est-test/export-quantity.cjs +++ b/tools/est-test/export-quantity.cjs @@ -211,7 +211,12 @@ function editDocument(text, oldSheet, sheet) { ); }); if (rowIndex !== oldSheet.rows.length) throw Error("수량산출서 행 수 불일치"); - if (!same(quantity.fromWpt(sheet.Code, sheet.Name, result, sheet.items, sheet.options), sheet)) + if ( + !same( + withoutTables(quantity.fromWpt(sheet.Code, sheet.Name, result, sheet.items, sheet.options)), + sheet, + ) + ) throw Error("수량산출서 문서 재읽기 검산 실패"); return result; } @@ -414,10 +419,34 @@ function reorderRows(text, sheet, rows) { return { text: document, rows: order.map((source) => sheet.rows[source]) }; } +// 워드형 산출서의 표 줄(row.table)은 문단 줄과 따로 다룸: 표는 고칠 수 없으므로(tableEdited 거부) 같은 자리에 그대로 있어야 하고, +// 문단 줄 처리 동안은 빼 두었다가 저장 결과에 다시 넣는다. +const tableRows = (sheet) => sheet.rows.flatMap((row, k) => (row.table ? [[k, row]] : [])); +const withoutTables = (sheet) => + sheet.rows.some((row) => row.table) + ? { ...sheet, rows: sheet.rows.filter((row) => !row.table) } + : sheet; +function withTables(sheet, tables) { + if (!tables.length) return sheet; + const rows = sheet.rows.slice(); + for (const [k, row] of tables) rows.splice(k, 0, row); + return { ...sheet, rows }; +} + function updateQuantities(project, base, cp949, memory) { - const sheets = project.quantitySheets || [], - originals = base.quantitySheets || []; + const fullSheets = project.quantitySheets || [], + fullOriginals = base.quantitySheets || []; + fullSheets.forEach((sheet, i) => { + if (fullOriginals[i] && !same(tableRows(sheet), tableRows(fullOriginals[i]))) + throw Error( + "표가 있는 산출서의 줄 추가·삭제·이동은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요.", + ); + }); + const sheets = fullSheets.map(withoutTables), + originals = fullOriginals.map(withoutTables); if (sheets.length !== originals.length) throw Error("수량산출서 추가·삭제는 지원하지 않습니다."); + if (sheets.some((sheet) => sheet.tableEdited)) + throw Error("워드형 산출서의 표 편집은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요."); if (sheets.some((sheet) => sheet.excelEdited)) throw Error("엑셀형 수량산출서 편집은 ESTX 저장을 지원하지 않습니다. JSON으로 저장하세요."); if (sheets.some((sheet) => sheet.markerEdited)) @@ -570,12 +599,14 @@ function updateQuantities(project, base, cp949, memory) { document = insertRows(document, sheets[i], inserts); if ( !same( - quantity.fromWpt( - sheets[i].Code, - sheets[i].Name, - document, - sheets[i].items, - sheets[i].options, + withoutTables( + quantity.fromWpt( + sheets[i].Code, + sheets[i].Name, + document, + sheets[i].items, + sheets[i].options, + ), ), sheets[i], ) @@ -667,7 +698,9 @@ function updateQuantities(project, base, cp949, memory) { } return { ...project, - quantitySheets: savedSheets, + quantitySheets: savedSheets.map((sheet, i) => + sheet === sheets[i] ? fullSheets[i] : withTables(sheet, tableRows(fullSheets[i])), + ), tables: { ...project.tables, MulMainDat: rows, MulTitleDat: titles }, }; } diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 29f6f57cd..0d0212ecb 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -13,7 +13,11 @@ + + + + @@ -304,6 +308,7 @@ > { + const modules = () => [typeof EstQuantityList !== "undefined" && EstQuantityList].filter(Boolean); + function handle(name, state, node) { + for (const module of modules()) if (module.handle(name, state, node)) return true; + return false; + } + return { handle }; +})(); diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index a2c660209..677b2cb56 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -639,7 +639,9 @@ const EstBusinessActions = (() => { (typeof EstNewEstimate !== "undefined" && EstNewEstimate.handle(name, state)) || (typeof EstBook !== "undefined" && EstBook.handle(name, state, node)) || (typeof EstDataUpdate !== "undefined" && EstDataUpdate.handle(name, state)) || - (typeof EstAnnualLoad !== "undefined" && EstAnnualLoad.handle(name, state, node)) + (typeof EstAnnualLoad !== "undefined" && EstAnnualLoad.handle(name, state, node)) || + (typeof EstQuantityTable !== "undefined" && EstQuantityTable.handle(name, state, node)) || + (typeof EstActionsMore !== "undefined" && EstActionsMore.handle(name, state, node)) ) { EstApp.render(); } else if (name === "insert-annual") { diff --git a/tools/est-test/native-quantity-list.js b/tools/est-test/native-quantity-list.js new file mode 100644 index 000000000..0b776eeb4 --- /dev/null +++ b/tools/est-test/native-quantity-list.js @@ -0,0 +1,157 @@ +// 원본 수량 목록(TFRMMULLANGLIST — spec_pa §2·3)·수량붙여넣기(연결)(TFRMEST ActionMullangInput §8.2)· +// 품명·규격 지정(TFRMMULNAMEINPUT btnSetName·btnSetSize §4)·공종명칭 입력(TFRMMULTITLENAMEINPUT §5). +// 수량 목록: 왼쪽 모든 산출서(검색 = 명칭·규격에서 빈칸·|·괄호 뺀 부분일치, 전체보기) · 가운데 고른 산출서 수량 항목 · 오른쪽 원문. +// 선택수량복사: 고른 수량 항목을 앱 안에 담아 둠(클립보드 아님). 내역서 「수량붙여넣기(연결)」: 고른 줄부터 아래로 차례로 +// 수량을 넣고 그 항목을 내역 줄에 연결(「품명,규격입력」 체크면 품명·규격·단위도). 행은 더하지 않음. +const EstQuantityList = (() => { + const norm = (s) => String(s ?? "").replace(/[\s|()]/g, ""); + const RESULT_NAMES = { name: ["\x0e", "품명"], size: ["\x13", "규격"] }; + + function open(state) { + const l = (state.qList ||= { word: "", code: null, nameSize: false, picked: new Set() }); + const { esc, number } = EstApp, + sheets = (state.project.quantitySheets || []).filter( + (s) => !l.word || norm(s.Name).includes(l.word) || norm(s.Size).includes(l.word), + ), + sheet = state.project.quantitySheets?.find((s) => s.Code === l.code) || sheets[0]; + l.code = sheet?.Code; + const items = sheet ? EstQuantity.calculate(sheet).items : []; + const preview = sheet + ? sheet.rows + .map((r) => + r.table + ? `
${r.table.map((tr) => `${tr.map((c) => ``).join("")}`).join("")}
${esc(c.text)}
` + : `
${esc(EstText.clean(r.LineData || ""))}
`, + ) + .join("") + : ""; + EstApp.showDialog( + "수량 목록", + `
${sheets.map((s) => `
${esc(s.Name)} ${esc(s.Size || "")}
`).join("")}
${items.map((i, k) => ``).join("")}
명칭규격단위수량
${esc(i.Name)}${esc(i.Size)}${esc(i.Unite)}${number(i.GN)}
${preview}
`, + ); + } + + function paste(state) { + const clip = state.mulClip; + if (!clip?.items.length) throw Error("수량 목록에서 선택수량복사를 먼저 하세요."); + if (state.view !== "EstMainDat") throw Error("내역서에서 붙여넣을 줄을 고르세요."); + const rows = state.project.tables.EstMainDat, + start = [...state.selection].sort((a, b) => a - b)[0]; + if (!Number.isInteger(start)) throw Error("내역서에서 붙여넣을 줄을 고르세요."); + EstApp.change((project) => { + const sheet = project.quantitySheets.find((s) => s.Code === clip.code); + clip.items.forEach((src, k) => { + const row = project.tables.EstMainDat[Math.min(start + k, rows.length - 1)], + item = sheet?.items.find((i) => i.Code === src.Code); + if (clip.nameSize) Object.assign(row, { Name: src.Name, Size: src.Size, Unite: src.Unite }); + row.GN = Number(src.GN) || 0; + row.MullangIndex = 9999; + if (item && row.PkID != null) + Object.assign(item, { TargetKey: `id:${row.PkID}`, TargetCode: row.Code || "" }); + }); + }); + state.notice = `수량 ${clip.items.length}개를 붙여넣고 연결했습니다.`; + } + + // 품명·규격 지정: 산출 줄 글에서 고른 글자를 ⟦품명⟧·⟦규격⟧ 표지로(한 줄에 하나, 40자 이하, 제목 표지 위 금지) + function nameSize(state, kind, index) { + const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet), + editor = document.querySelector(`[data-quantity-line="${index}"]`); + if (!sheet?.rows[index] || !editor) throw Error("산출 줄을 고르세요."); + const [mark, label] = RESULT_NAMES[kind], + text = editor.value; + let a = editor.selectionStart ?? 0, + b = editor.selectionEnd ?? a; + if (a === b) { + while (a > 0 && /[^\s⟦⟧]/.test(text[a - 1])) a--; + while (b < text.length && /[^\s⟦⟧]/.test(text[b])) b++; + } + const picked = text.slice(a, b); + if (EstText.encode(text).includes(mark)) + throw Error(`한라인에 ${label}을두번이상 지정할수 없습니다.`); + if (sheet.rows[index].title) throw Error("제목표시에는 지정할수 없습니다."); + if (picked.length > 40) throw Error("선택한 범위가 너무 큽니다."); + if (!picked.trim()) + throw Error( + `${label}으로 지정할 문자를 드래그하여 선택하던지커서를 선택할위치로 옮겨 놓으세요.`, + ); + const token = `⟦${label}⟧`; + EstApp.change(() => { + sheet.rows[index].LineData = EstText.encode( + text.slice(0, a) + token + picked + token + text.slice(b), + ); + sheet.markerEdited = true; + }, false); + } + + // 공종명칭 입력: 목록 명칭·규격을 바꾸고 문서 머리에 「공백5 + 명칭(규격) + 공백5」 제목 줄(굵게 큰 글자)을 넣거나 바꿈 + function title(state) { + const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); + if (!sheet) throw Error("수량산출서를 고르세요."); + const head = sheet.rows[0]?.title ? sheet.rows[0] : null; + const name = prompt("명칭", sheet.Name ?? ""); + if (name == null) return; + const size = prompt("규격", sheet.Size ?? "") ?? ""; + EstApp.change(() => { + sheet.Name = name.trim(); + sheet.Size = size.trim(); + const text = ` ${name.trim()}${size.trim() ? `(${size.trim()})` : ""} `; + if (head) head.LineData = text; + else + sheet.rows.unshift({ LineData: text, markers: [], title: true, para: { align: "center" } }); + sheet.tableEdited = true; + }, false); + } + + function install() { + document.addEventListener("click", (event) => { + const a = event.target.closest("[data-ql-sheet]"); + if (!a) return; + event.preventDefault(); + const l = EstApp.state.qList; + l.code = a.dataset.qlSheet; + l.picked = new Set(); + open(EstApp.state); + }); + document.addEventListener("change", (event) => { + const t = event.target, + l = EstApp.state.qList; + if (!l) return; + if (t.dataset.qlItem !== undefined) { + const k = Number(t.dataset.qlItem); + if (t.checked) l.picked.add(k); + else l.picked.delete(k); + } else if (t.dataset.qlNamesize !== undefined) l.nameSize = t.checked; + else if (t.dataset.qlWord !== undefined) { + l.word = norm(t.value); + open(EstApp.state); + } + }); + } + if (typeof document !== "undefined" && document.addEventListener) install(); + + function handle(name, state, node) { + if (name === "quantity-list") open(state); + else if (name === "ql-search") { + state.qList.word = norm(document.querySelector("[data-ql-word]")?.value); + open(state); + } else if (name === "ql-all") { + state.qList.word = ""; + open(state); + } else if (name === "ql-copy") { + const l = state.qList, + sheet = state.project.quantitySheets.find((s) => s.Code === l.code), + items = EstQuantity.calculate(sheet).items.filter((_, k) => l.picked.has(k)); + if (!items.length) throw Error("복사할 수량 항목을 고르세요."); + state.mulClip = { code: sheet.Code, items, nameSize: l.nameSize }; + state.notice = `수량 ${items.length}개를 담았습니다. 내역서에서 수량붙여넣기(연결)를 누르세요.`; + EstApp.render(); + } else if (name === "mul-paste") paste(state); + else if (name === "quantity-name" || name === "quantity-size") + nameSize(state, name === "quantity-name" ? "name" : "size", Number(node.dataset.index)); + else if (name === "quantity-title") title(state); + else return false; + return true; + } + return { handle }; +})(); diff --git a/tools/est-test/native-quantity-sheets.js b/tools/est-test/native-quantity-sheets.js index 0e07d6441..ca8bc1b96 100644 --- a/tools/est-test/native-quantity-sheets.js +++ b/tools/est-test/native-quantity-sheets.js @@ -129,7 +129,7 @@ const EstQuantitySheets = (() => { ``, ) .join(""); - return `
`; + return `
`; } function handle(name, state, node) { @@ -274,7 +274,7 @@ const EstQuantitySheets = (() => { function excelView(sheet) { if (typeof EstExcelView === "undefined") return ""; const on = sheet.excel?.length && !sheet.excelOff; - return `

${on ? EstExcelView.render(sheet, EstApp.state) : ""}`; + return `${typeof EstQuantityTable !== "undefined" ? EstQuantityTable.banner(sheet) : ""}

${on ? EstExcelView.render(sheet, EstApp.state) : ""}`; } function excelMode(state) { const sheet = state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); diff --git a/tools/est-test/native-quantity-table.js b/tools/est-test/native-quantity-table.js new file mode 100644 index 000000000..a04548625 --- /dev/null +++ b/tools/est-test/native-quantity-table.js @@ -0,0 +1,270 @@ +// 워드형 수량산출서 표 화면·편집 — 칸 고르기(Shift 로 범위), 칸 글·수식(=…) 고치기, 방향 셀 합(ActionAddRight·Left·Up·Down), +// 수량으로 지정·지정 해제(칸 수량 표지 → 수량 항목), 셀수직병합·셀분리(btnCellMergeVer·btnCellSplitV), 표 만들기(mnuTableCreate). +// 계산·표 자료는 native-quantity-wpt.cjs. 표를 고친 산출서는 원본 WPT 로 다시 쓰지 못하므로 ESTX 저장을 막는다(tableEdited). +const EstQuantityTable = (() => { + const W = () => EstQuantityWpt; + function sheetOf(state) { + return state.project.quantitySheets?.find((s) => s.Code === state.quantitySheet); + } + const PAGE = 45; // 웹 산출서 한 쪽 줄 수(원본은 편집기 쪽 배치) + function paraStyle(row) { + const p = row.para || {}; + return [ + p.align && `text-align:${p.align}`, + p.indent && `padding-left:${p.indent * 8}px`, + row.title && "font-weight:bold;font-size:20px;color:#800000", + ] + .filter(Boolean) + .join(";"); + } + // 테두리선(popFormatOutLine)·산출근거 양식(popFormatGungu): 화면에만 그림(원본도 글로 넣지 않음) + function banner(sheet) { + return sheet.gunguForm + ? `
공 종산 출 내 역수 량
` + : sheet.outline + ? '

테두리선 켬(인쇄 쪽 둘레)

' + : ""; + } + function html(sheet, index, state) { + const { esc } = EstApp; + if (sheet.rows[index].image) + return `산출서 그림`; + const table = sheet.rows[index].table, + sel = state.qtCell?.sheet === sheet.Code && state.qtCell.row === index ? state.qtCell : null; + W().recalc(table); + const inBox = (r, c) => + sel && + r >= Math.min(sel.r, sel.r2 ?? sel.r) && + r <= Math.max(sel.r, sel.r2 ?? sel.r) && + c >= Math.min(sel.c, sel.c2 ?? sel.c) && + c <= Math.max(sel.c, sel.c2 ?? sel.c); + const cur = sel ? table[sel.r]?.[sel.c] : null; + const tools = sel + ? `
${W().letters(sel.c)}${sel.r + 1} ${[ + ["right", "우측 합"], + ["left", "좌측 합"], + ["up", "위 합"], + ["down", "아래 합"], + ] + .map(([d, l]) => ``) + .join( + "", + )}
` + : ""; + const body = table + .map( + (row, r) => + `${row + .map((cell, c) => + cell.vmerge + ? "" + : `${esc(cell.text) || " "}`, + ) + .join("")}`, + ) + .join(""); + const border = sheet.rows[index].tableBorder ?? 1; + return `${tools}${body}
`; + } + + function edit(state, fn) { + const sheet = sheetOf(state), + sel = state.qtCell; + if (!sheet || !sel || sel.sheet !== sheet.Code) throw Error("표에서 칸을 고르세요."); + const table = sheet.rows[sel.row]?.table; + if (!table) throw Error("표에서 칸을 고르세요."); + EstApp.change(() => { + fn(table, sel, sheet); + W().recalc(table); + sheet.tableEdited = true; + }, false); + } + + function handle(name, state, node) { + if (name === "qt-sum") + edit(state, (t, s) => { + t[s.r][s.c].formula = W().sumFormula(t, s.r, s.c, node.dataset.dir); + }); + else if (name === "qt-mark") + edit(state, (t, s, sheet) => { + const cell = t[s.r][s.c]; + if (cell.mark) throw Error("이미 수량으로 지정된 칸입니다."); + const code = EstQuantity.nextItemCode(sheet); + cell.mark = code; + const name = t[s.r].find((c) => c.name)?.text ?? ""; + sheet.items.push({ + Code: code, + Name: name, + Size: t[s.r].find((c) => c.size)?.text ?? "", + GN: 0, + }); + }); + else if (name === "qt-free") + edit(state, (t, s, sheet) => { + const cell = t[s.r][s.c]; + if (!cell.mark) throw Error("수량으로 지정된 칸이 아닙니다."); + const item = sheet.items.find((i) => i.Code === cell.mark); + if (item?.TargetCode || item?.TargetKey) + throw Error("내역에 연결된 수량 항목입니다. 연결 내역코드를 먼저 비우세요."); + sheet.items = sheet.items.filter((i) => i.Code !== cell.mark); + delete cell.mark; + }); + else if (name === "qt-vmerge") + edit(state, (t, s) => + W().vmerge(t, Math.min(s.r, s.r2 ?? s.r), Math.max(s.r, s.r2 ?? s.r), s.c), + ); + else if (name === "qt-vsplit") edit(state, (t, s) => W().vsplit(t, s.r, s.c)); + else if (name === "qt-create") { + const sheet = sheetOf(state); + if (!sheet) throw Error("수량산출서를 고르세요."); + const rows = Number(prompt("줄 수", "3")), + cols = Number(prompt("칸 수", "5")); + if (!rows || !cols) return true; + const at = Number(node.dataset.index); + EstApp.change(() => { + sheet.rows.splice(Number.isInteger(at) ? at + 1 : sheet.rows.length, 0, { + LineData: "", + markers: [], + table: W().create(rows, cols), + }); + sheet.tableEdited = true; + }, false); + } else if (name === "qt-style") + edit(state, (t, s, sheet) => { + const w = Number(prompt("표 테두리 굵기(0~5)", String(sheet.rows[s.row].tableBorder ?? 1))); + if (!(w >= 0 && w <= 5)) throw Error("테두리 굵기는 0~5 입니다."); + sheet.rows[s.row].tableBorder = w; + }); + else if (name.startsWith("qt-page") || name === "qt-para" || name === "qt-image") + lineTool(name, state, Number(node.dataset.index)); + else if (name === "qt-outline" || name === "qt-gungu-form") { + const sheet = sheetOf(state); + if (!sheet) throw Error("수량산출서를 고르세요."); + const key = name === "qt-outline" ? "outline" : "gunguForm"; + EstApp.change(() => { + sheet[key] = !sheet[key]; + sheet.tableEdited = true; + }, false); + } else if (name === "qt-mul-load" || name === "qt-mul-take") mulLoad(name, state, node); + else return false; + return true; + } + + // 문단(btnsetPara·mnuFormatPara)·그림 넣기(btnJPGInput)·한페이지 삽입/삭제(popPageInsert·popPageDel) + function lineTool(name, state, index) { + const sheet = sheetOf(state); + if (!sheet?.rows?.[index]) throw Error("산출 줄을 고르세요."); + const change = (fn) => + EstApp.change(() => { + fn(); + sheet.tableEdited = true; + }, false); + if (name === "qt-para") { + const align = prompt("정렬(left·center·right)", sheet.rows[index].para?.align || "left"); + if (align == null) return; + if (!["left", "center", "right"].includes(align)) + throw Error("정렬은 left·center·right 입니다."); + const indent = Number(prompt("들여쓰기(칸)", String(sheet.rows[index].para?.indent ?? 0))); + if (!(indent >= 0 && indent <= 40)) throw Error("들여쓰기는 0~40 칸입니다."); + change(() => (sheet.rows[index].para = { align, indent })); + } else if (name === "qt-image") { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = "image/*"; + picker.addEventListener("change", () => { + const file = picker.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => + change(() => + sheet.rows.splice(index + 1, 0, { LineData: "", markers: [], image: reader.result }), + ); + reader.readAsDataURL(file); + }); + picker.click(); + } else if (name === "qt-page-insert") + change(() => + sheet.rows.splice( + index + 1, + 0, + ...Array.from({ length: PAGE }, () => ({ LineData: " ", markers: [] })), + ), + ); + else if (name === "qt-page-delete") { + const start = Math.floor(index / PAGE) * PAGE, + rows = sheet.rows.slice(start, start + PAGE); + if (rows.some((r) => r.markers?.length || r.table?.some((x) => x.some((c) => c.mark)))) + throw Error("수량으로 지정된 줄이 있는 쪽은 지울 수 없습니다. 먼저 지정 해제하세요."); + if (rows.some((r) => r.image) && !confirm("그림이 있는 쪽입니다. 지우시겠습니까?")) return; + if (!confirm(`${start + 1}~${start + rows.length}줄(한 쪽)을 지우시겠습니까?`)) return; + change(() => sheet.rows.splice(start, PAGE)); + } + } + + // 수량 불러오기(btnMulLoad): Main Data(원본 main.estx) 수량산출서 목록에서 골라 새 산출서로(새 코드, 본문 그대로) + async function mulLoad(name, state, node) { + const { esc } = EstApp; + if (name === "qt-mul-load") { + state.mulLoad ||= (await EstApp.api("/api/main-data?part=quantity")).quantitySheets; + EstApp.showDialog( + "수량 불러오기", + `
${state.mulLoad.map((s, i) => `
${esc(s.Code)} ${esc(s.Name)}${s.excel ? " (엑셀형)" : ""}
`).join("")}
`, + ); + return; + } + const src = state.mulLoad[Number(node.dataset.i)]; + EstApp.change((project) => { + const copy = structuredClone(src); + copy.Code = `Q${EstApp.uid()}`; + for (const item of copy.items || []) delete item.TargetCode; + (project.quantitySheets ||= []).push(copy); + state.quantitySheet = copy.Code; + }, false); + EstApp.byId("dialog").close(); + state.notice = `「${src.Name}」 산출서를 불러왔습니다.`; + } + + function install() { + document.addEventListener("click", (event) => { + const td = event.target.closest("[data-qt]"); + if (!td) return; + const [row, r, c] = td.dataset.qt.split(",").map(Number), + state = EstApp.state, + old = state.qtCell; + state.qtCell = + event.shiftKey && old && old.row === row && old.sheet === state.quantitySheet + ? { ...old, r2: r, c2: c } + : { sheet: state.quantitySheet, row, r, c }; + EstApp.render(); + }); + document.addEventListener("change", (event) => { + if (event.target.dataset.qtBar === undefined) return; + const v = event.target.value, + state = EstApp.state; + try { + edit(state, (t, s) => { + const cell = t[s.r][s.c]; + if (v.trim().startsWith("=")) cell.formula = v.trim().slice(1); + else { + delete cell.formula; + cell.text = v; + } + }); + } catch (error) { + state.notice = error.message; + EstApp.render(); + } + }); + } + if (typeof document !== "undefined" && document.addEventListener) install(); + return { html, banner, paraStyle, handle }; +})(); diff --git a/tools/est-test/native-quantity-wpt.cjs b/tools/est-test/native-quantity-wpt.cjs new file mode 100644 index 000000000..7272783c6 --- /dev/null +++ b/tools/est-test/native-quantity-wpt.cjs @@ -0,0 +1,190 @@ +// 워드형 수량산출서(TFRMMULWORD, WPTools 문서) 읽기와 표 — spec_word. +// 문단(
) 은 산출 줄, 표() 는 한 줄 { table: [[칸]] } 로 둔다. 칸 = { text, mark(수량 표지 이름 r·L·G), name·size(품명·규격 표지), +// formula(=SUM(B3:B5) — 원본 PAR_COMMAND), vmerge(세로 병합으로 가려짐) }. +// 방향 셀 합(ActionAddRight·Left·Up·Down 0xd3c5f0): 옆 칸부터 밖으로 — 빈칸·숫자 칸은 범위를 늘리고 글자 칸은 건너뛰며, +// 품명·규격 표지 칸이나 표 끝에서 멈춤. 칸에 SUM 수식을 두고 바로 계산(값 글자 = 1,234.5 꼴, 소수 9자리까지, 끝수 처리 없음). +// 계산 때마다 수식 칸을 왼쪽 위부터 한 번씩 다시 셈. 수량 표지 칸의 수는 그 항목 수량. +const EstQuantityWpt = (() => { + function entities(text) { + return String(text) + .replace(/&#(x[0-9a-f]+|\d+);/gi, (_, n) => { + const code = n[0].toLowerCase() === "x" ? parseInt(n.slice(1), 16) : Number(n); + return code <= 0x10ffff ? String.fromCodePoint(code) : ""; + }) + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/ /g, " ") + .replace(/&/g, "&"); + } + const tag = (name) => + /^[rLG]\d+$/.test(name) + ? "\x12" + : /^[Fa]\d+$/.test(name) + ? "\x16" + : /^N\d+$/.test(name) + ? "\x0e" + : /^S\d+$/.test(name) + ? "\x13" + : ""; + + function fromWpt(code, name, text, items, options) { + const tags = new Map(), + rows = []; + const superscripts = new Set( + [...text.matchAll(/]*\bnr=(\d+)[^>]*>/gi)] + .filter((m) => (Number(m[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0) + .map((m) => m[1]), + ); + for (const match of text.matchAll(/]*\bTag=(\d+)[^>]*>/gi)) { + const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1]; + if (name) tags.set(match[1], name); + } + // 문단·칸 글 → 표지 글자(\x12 결과·\x16 누계·\x0e 품명·\x13 규격) + function convert(html, cell = false) { + // 문단의 G 표지는 원본처럼 표지로 보지 않음(표 칸에서만 수량) + const tagOf = (n) => (!cell && /^G/.test(n) ? "" : tag(n)); + const markers = [], + totals = [], + names = []; + let raised = false; + let body = html.replace(//gi, (_, id) => { + const next = superscripts.has(id), + value = next && !raised ? "^(" : !next && raised ? ")" : ""; + raised = next; + return value; + }); + if (raised) body += ")"; + body = body + .replace(/]*\bTag=(\d+)[^>]*>/gi, (_, id) => { + const name = tags.get(id) || ""; + names.push(name); + if (tagOf(name) === "\x12" && !/^G/.test(name)) markers.push(name); + if (tagOf(name) === "\x16") totals.push(name); + return tagOf(name); + }) + .replace(/]*\bTag=(\d+)[^>]*>/gi, (_, id) => tagOf(tags.get(id) || "")); + return { + body: entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, "")), + markers, + totals, + names, + }; + } + for (const match of text.matchAll( + /|]*>([\s\S]*?)<\/div>/gi, + )) { + if (/^
]*>([\s\S]*?)<\/tr>/gi)].map((tr) => + [...tr[1].matchAll(/]*>([\s\S]*?)<\/td>/gi)].map((td) => { + const c = convert(td[1], true), + mark = c.names.find((n) => tag(n) === "\x12"); + return { + text: c.body.replace(/[\x0e\x12\x13\x16]/g, "").trim(), + ...(mark && { mark }), + ...(c.names.some((n) => /^N\d+$/.test(n)) && { name: true }), + ...(c.names.some((n) => /^S\d+$/.test(n)) && { size: true }), + }; + }), + ); + if (table.length) rows.push({ LineData: "", markers: [], table }); + continue; + } + const c = convert(match[1]); + if (c.body.trim()) + rows.push({ + LineData: c.body, + markers: c.markers, + ...(c.totals.length && { totals: c.totals }), + }); + } + return { Code: code, Name: name, rows, items, options }; + } + + // ── 표 계산 ── + const L = (n) => { + let s = ""; + for (n += 1; n > 0; n = Math.floor((n - 1) / 26)) + s = String.fromCharCode(65 + ((n - 1) % 26)) + s; + return s; + }; + const numeric = (t) => /^[+-]?[\d,]*\.?\d+$/.test(String(t).trim()); + const value = (t) => (numeric(t) ? Number(String(t).replaceAll(",", "")) : 0); + const show = (v) => v.toLocaleString("en-US", { maximumFractionDigits: 9 }); + function recalc(table) { + const errors = []; + table.forEach((row) => + row.forEach((cell) => { + if (!cell.formula) return; + try { + const X = + typeof EstExcelSheet !== "undefined" + ? EstExcelSheet + : require("./native-excel-sheet.cjs"); + const v = X.evaluate(cell.formula, (r, c) => value(table[r]?.[c]?.text ?? "")); + cell.text = show(typeof v === "number" ? v : Number(v) || 0); + } catch (error) { + errors.push(error.message); + } + }), + ); + return errors; + } + // 항목 수량: 수량 표지 칸의 수 + function values(sheet) { + const out = new Map(); + for (const row of sheet.rows || []) { + if (!row.table) continue; + recalc(row.table); + for (const r of row.table) + for (const c of r) if (c.mark && !c.vmerge) out.set(c.mark, value(c.text)); + } + return out; + } + function sumFormula(table, r, c, dir) { + const [dr, dc] = { right: [0, -1], left: [0, 1], up: [1, 0], down: [-1, 0] }[dir] || []; + if (dr == null) throw Error("방향을 고르세요."); + // 방향 = 결과 칸이 있는 쪽(원본 Ctrl+← 는 오른쪽 칸들을 더함) + let rr = r + dr, + cc = c + dc, + last = null; + const first = [rr, cc]; + while (table[rr]?.[cc]) { + const cell = table[rr][cc]; + if (cell.name || cell.size) break; + if (!cell.text.trim() || numeric(cell.text)) last = [rr, cc]; + rr += dr; + cc += dc; + } + if (!table[first[0]]?.[first[1]]) throw Error("더할 셀이 없습니다."); + if (!last) last = dir === "right" ? [r, 0] : first; + const a = [Math.min(first[0], last[0]), Math.min(first[1], last[1])], + b = [Math.max(first[0], last[0]), Math.max(first[1], last[1])]; + return `SUM(${L(a[1])}${a[0] + 1}:${L(b[1])}${b[0] + 1})`; + } + function vmerge(table, r1, r2, c) { + if (r2 <= r1) throw Error("세로로 여러 칸을 고르세요."); + const top = table[r1][c]; + for (let r = r1 + 1; r <= r2; r++) { + const cell = table[r][c]; + if (cell.text.trim()) top.text = `${top.text} ${cell.text}`.trim(); + cell.text = ""; + cell.vmerge = true; + } + top.vtop = r2 - r1 + 1; + } + function vsplit(table, r, c) { + const top = table[r][c]; + for (let i = r + 1; i < r + (top.vtop || 1); i++) if (table[i]?.[c]) delete table[i][c].vmerge; + delete top.vtop; + } + function create(rows, cols) { + if (!(rows >= 1 && rows <= 100 && cols >= 1 && cols <= 30)) + throw Error("줄 1~100, 칸 1~30 입니다."); + return Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ text: "" }))); + } + + return { entities, fromWpt, recalc, values, sumFormula, vmerge, vsplit, create, letters: L }; +})(); +if (typeof module !== "undefined" && module.exports) module.exports = EstQuantityWpt; diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index f0ffd7ccf..16bb77624 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -13,52 +13,10 @@ const EstQuantity = (() => { .replace(/ /g, " ") .replace(/&/g, "&"); } - function fromWpt(code, name, text, items, options) { - const tags = new Map(), - rows = []; - const superscripts = new Set( - [...text.matchAll(/]*\bnr=(\d+)[^>]*>/gi)] - .filter((m) => (Number(m[0].match(/CharStyleON:(\d+)/)?.[1] || 0) & 16) !== 0) - .map((m) => m[1]), - ); - for (const match of text.matchAll(/]*\bTag=(\d+)[^>]*>/gi)) { - const name = entities(match[0]).match(/Name:["']([^"']+)["']/)?.[1]; - if (name) tags.set(match[1], name); - } - for (const match of text.matchAll(/]*>([\s\S]*?)<\/div>/gi)) { - const markers = [], - totals = []; - let raised = false; - const tag = (name) => - /^r\d+$/.test(name) || /^L\d+$/.test(name) - ? "\x12" - : /^[Fa]\d+$/.test(name) - ? "\x16" - : /^N\d+$/.test(name) - ? "\x0e" - : /^S\d+$/.test(name) - ? "\x13" - : ""; - let body = match[1].replace(//gi, (_, id) => { - const next = superscripts.has(id), - value = next && !raised ? "^(" : !next && raised ? ")" : ""; - raised = next; - return value; - }); - if (raised) body += ")"; - body = body - .replace(/]*\bTag=(\d+)[^>]*>/gi, (_, id) => { - const name = tags.get(id) || ""; - if (tag(name) === "\x12") markers.push(name); - if (tag(name) === "\x16") totals.push(name); - return tag(name); - }) - .replace(/]*\bTag=(\d+)[^>]*>/gi, (_, id) => tag(tags.get(id) || "")); - body = entities(body.replace(/<[^>]*>/g, "").replace(/\r?\n/g, "")); - if (body.trim()) rows.push({ LineData: body, markers, ...(totals.length && { totals }) }); - } - return { Code: code, Name: name, rows, items, options }; - } + // 워드형 문서 읽기·표는 native-quantity-wpt.cjs + const wpt = () => + typeof EstQuantityWpt !== "undefined" ? EstQuantityWpt : require("./native-quantity-wpt.cjs"); + const fromWpt = (...args) => wpt().fromWpt(...args); function calculate(sheet) { const markerErrors = sheet.rows.flatMap((row, index) => { const count = [...String(row.LineData || "").matchAll(/\x12([-\d.,]+)\x12/g)].length; @@ -82,7 +40,10 @@ const EstQuantity = (() => { }; return { ...row, - LineData: row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴|\x16[^\x16]*\x16/g, ""), + LineData: + row.table || row.image + ? "" + : row.LineData.replace(/\*개소당|\bPIPE\b|소\s*계|∴|\x16[^\x16]*\x16/g, ""), Index: 0, SCheck: 0, LinkIndex: 0, @@ -118,13 +79,18 @@ const EstQuantity = (() => { }); if (typeof EstQuantityTotal !== "undefined") EstQuantityTotal.apply(sheet, result.rows.slice(1)); + const tableValues = sheet.rows.some((row) => row.table) ? wpt().values(sheet) : null; const cellValues = sheet.excel && typeof EstExcelSheet !== "undefined" ? EstExcelSheet.itemValues(sheet) : null; return { rows: result.rows.slice(1), items: sheet.items.map((item) => ({ ...item, - GN: values.get(item.Code) ?? cellValues?.get(item.Code) ?? item.GN, + GN: + values.get(item.Code) ?? + tableValues?.get(item.Code) ?? + cellValues?.get(item.Code) ?? + item.GN, })), checks, errors: [...result.errors, ...markerErrors], @@ -325,7 +291,7 @@ const EstQuantity = (() => { { esc, number, byId } = EstApp; byId("work-content").innerHTML = selector(state) + - `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}
${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}
`; + `
${output.items.map((item, index) => ``).join("")}
산출코드품명규격단위저장 수량재계산 수량연결 내역코드끝수 처리작업
${esc(item.Code)}${number(sheet.items[index]?.GN ?? 0)}${number(item.GN)}${targetSelect(state.project, item, "sheet", index)}${formatSelect(sheet.items[index], index)}
${typeof EstQuantitySheets !== "undefined" ? EstQuantitySheets.excelView(sheet) : ""}

${esc(output.errors.join(" / "))} 산출 행의 결과 표지로 수량 항목을 연결합니다. 신규 산출서와 지원되지 않는 원본 구조 변경은 JSON으로 저장합니다.

${sheet.rows.map((row, index) => ``).join("")}
줄산출근거 편집작업
${index + 1}${(sheet.rows[index].table || sheet.rows[index].image) && typeof EstQuantityTable !== "undefined" ? EstQuantityTable.html(sheet, index, state) : ``}
`; } // 원본 수량목록 자리수 메뉴(mnuAuto·mnuTrunc·mnuUp·mnuRound): 결과 코드 뒤 방식(1 반올림·2 절사·3 올림)+자리수. function resultFormat(code) { diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 248e3fb1a..e5be16fe4 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -28,6 +28,7 @@ const scripts = [ "native-text.js", "native-excel-sheet.cjs", "native-quantity-total.js", + "native-quantity-wpt.cjs", "native-quantity.cjs", "native-wonga.js", "native-jungsang.js", @@ -94,6 +95,9 @@ const allowed = new Set([ "native-new-estimate.js", "native-book.js", "native-data-update.js", + "native-quantity-table.js", + "native-quantity-list.js", + "native-actions-more.js", "native-wonga-tools.js", "native-estimate-print.js", "native-xml-check.js", @@ -271,9 +275,17 @@ const server = http.createServer(async (req, res) => { mainData = { name: "Main", tables: Object.fromEntries(keys.map((k) => [k, project.tables[k] || []])), + quantitySheets: project.quantitySheets || [], }; } - reply(res, 200, mainData); + // 수량 불러오기(TFRMMULLANG btnMulLoad)는 수량산출서만 + reply( + res, + 200, + url.searchParams.get("part") === "quantity" + ? { quantitySheets: mainData.quantitySheets } + : { name: mainData.name, tables: mainData.tables }, + ); return; } if (req.method === "GET" && url.pathname.startsWith("/api/book")) {