diff --git a/resources/tester/test_est_excel_map.py b/resources/tester/test_est_excel_map.py new file mode 100644 index 000000000..59cb0f7d9 --- /dev/null +++ b/resources/tester/test_est_excel_map.py @@ -0,0 +1,134 @@ +"""EST 테스트 앱 Excel 내역 열 맞춰 가져오기(원본 TFRMEXCELTOEST 내역서 탭).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +FILES = [ + "native-math.js", + "native-formula.js", + "native-machine.js", + "native-rows.js", + "native-text.js", + "native-quantity.cjs", + "native-wonga.js", + "native-engine.js", + "native-row-match.js", + "native-escalation.js", + "native-progress.js", + "native-business.js", + "native-review.js", + "native-resources.js", + "native-schedule.js", + "native-model.js", + "native-verify.js", + "native-excel-map.js", +] +TABLE = """const table = [ + ['공 내 역 서'], + ['번호','품명','규격','단위','수량','합계단가','재료단가','노무단가','경비단가','비고'], + ['1','토공사'], + ['1.1','터파기','백호 0.7㎥','㎥','1,200',null,'0','3500','1200','현장'], + [], + ['1.2','되메우기','','㎥',800], + ['2','포장공'], + ['2.1','아스콘포장','t=5cm','㎡',350.5,31000,21000,'7000','3000'], +]; +const columns = {Pr:'a',Name:'B',Size:'C',Unite:'D',GN:'E',TU:'F',GU:'G',NU:'H',JU:'I',Bigo:'J'};""" + + +def run_js(code: str): + runner = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{ structuredClone, console }}); + for (const f of {json.dumps(FILES)}) + vm.runInContext(fs.readFileSync('tools/est-test/' + f, 'utf8'), ctx, {{ filename: f }}); + ctx.project = JSON.parse(fs.readFileSync('tools/est-test/sample.json', 'utf8')); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + out = subprocess.check_output(["node", "-e", runner], cwd=ROOT) + return json.loads(out.decode("utf-8")) + + +def test_records_map_columns_parse_numbers_and_skip_blank_rows(): + result = run_js( + "(() => {" + + TABLE + + """ + const list = EstExcelMap.records(table, 3, 20, columns); + const errors = []; + const t = (fn) => { try { fn(); errors.push('ok'); } catch (e) { errors.push(e.message.slice(0, 8)); } }; + t(() => EstExcelMap.records(table, 3, 8, {GN:'E'})); + t(() => EstExcelMap.records(table, 0, 8, columns)); + t(() => EstExcelMap.records(table, 3, 8, {...columns, Name:'B1'})); + t(() => EstExcelMap.records([[null,'x','', '', '1개']], 1, 1, columns)); + return { lines: list.map((r) => r.line), first: list[1], errors, + col: [EstExcelMap.columnIndex('AA'), EstExcelMap.columnName(26)] }; + })()""" + ) + assert result["lines"] == [3, 4, 6, 7, 8] + assert result["first"]["GN"] == 1200 and result["first"]["NU"] == 3500 + assert result["first"]["TU"] is None and result["first"]["Pr"] == "1.1" + assert result["col"] == [26, "AA"] + assert [e[:4] for e in result["errors"]] == ["품명 열", "시작행을", "열 문자", "E1: "] + + +def test_rows_make_headings_details_and_insert_above_net_cost(): + result = run_js( + "(() => {" + + TABLE + + """ + const list = EstExcelMap.records(table, 3, 20, columns); + const { rows, warnings } = EstExcelMap.rows(project, list); + const est = project.tables.EstMainDat, boundary = est.findIndex((r) => Number(r.LinkIndex) === 54); + const ids = new Set([...est.map((r) => r.PkID), ...rows.map((r) => r.PkID)]); + EstExcelMap.insert(project, rows, 'append'); + const done = EstNative.calculate(EstModel.validate(project)); + const at = done.tables.EstMainDat.findIndex((r) => r.PkID === rows[1].PkID); + const pave = done.tables.EstMainDat.find((r) => r.PkID === rows[4].PkID); + return { + kinds: rows.map((r) => [r.Name, Number(r.Level) || 0]), + warnings, unique: ids.size === est.length, + placed: at === boundary + 1, net: done.tables.EstMainDat[boundary + rows.length].LinkIndex, + dig: [done.tables.EstMainDat[at].NC, done.tables.EstMainDat[at].JC], + pave: [pave.TC, pave.Bigo ?? null], + }; + })()""" + ) + assert result["kinds"] == [ + ["토공사", 1], + ["터파기", 0], + ["되메우기", 0], + ["포장공", 1], + ["아스콘포장", 0], + ] + assert result["warnings"] == [] + assert result["unique"] is True + assert result["placed"] is True + assert result["net"] == 54 + assert result["dig"] == [4200000, 1440000] + assert result["pave"][0] == 350.5 * 31000 + + +def test_total_price_without_split_is_rejected_and_replace_mode(): + result = run_js( + "(() => {" + + TABLE + + """ + table[3][6] = null; table[3][7] = null; table[3][8] = null; table[3][5] = 4700; + let message = ''; + try { EstExcelMap.rows(project, EstExcelMap.records(table, 3, 20, columns)); } catch (e) { message = e.message; } + table[3][5] = null; + const { rows } = EstExcelMap.rows(project, EstExcelMap.records(table, 3, 4, columns)); + const before = project.tables.EstMainDat.length, + boundary = project.tables.EstMainDat.findIndex((r) => Number(r.LinkIndex) === 54); + EstExcelMap.insert(project, rows, 'replace'); + return { message, length: project.tables.EstMainDat.length === before - boundary + rows.length, + first: project.tables.EstMainDat[0].Name, net: project.tables.EstMainDat[rows.length].LinkIndex }; + })()""" + ) + assert result["message"].startswith("4행: 합계단가만 있습니다") + assert result["length"] is True + assert result["first"] == "토공사" + assert result["net"] == 54 diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index ca63f2e61..b2c65f256 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -441,11 +441,13 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 검증완료: `test_est_quantity_sheets.py` 3개(복사 위치·코드·연결 비움·원본 불변, 끝에서 멈춤, 설치 main.estx 순서 변경 저장 거부), 수량 관련 23개 통과. ORCA 브라우저에서 토공 복사 → 「토공 복사본」 선택·연결 빈칸, 아래로 → 토공·포장·복사본 순서 확인 후 샘플 복구. - [x] 산출 줄 수량 지정·해제(원본 TFRMMULWORD popSetMulLang·popSetFree) — 드래그한 숫자를 결과 표지로 감싸 새 항목(품명·규격·단위 표지 옮김), 커서 위치 결과 해제·미사용 항목 삭제, 내역 연결 항목 해제 거부. 이 편집은 ESTX 저장을 명확한 문구로 거부(이전에는 「행 삭제」로 잘못 안내). - 검증완료: `test_est_quantity_sheets.py` 5개(지정 위치·항목·계산 6, 중복·숫자 아님 거부, 해제 원상, 연결 항목 거부, 설치 main.estx 해제 후 저장 거부 문구), 수량 관련 25개 통과. ORCA 브라우저에서 「= 3」 드래그 → 수량 지정 → r000001 잔토처리 ㎥ 3, 커서 두고 지정 해제 → 원래 줄·항목 복귀, 샘플 복구. + - [x] Excel 내역 열 맞춰 가져오기(원본 TFRMEXCELTOEST 내역서 탭 — tvViewEst 열 Code·Pr·Name·Size·GN·Unite·TU·TC·NU·NC·GU·GC·JU·JC·Bigo) — 시트·시작행·끝행·항목별 열 문자, 미리보기, 순공사비 위 추가·바꾸기. 단위·수량 없는 줄 = 공종 제목(Level 1), 금액은 다시 계산, 수식 셀은 저장된 계산값. 합계단가만 있는 줄은 비목 임의 배분 없이 거부. + - 검증완료: `test_est_excel_map.py` 3개(열 문자·쉼표 숫자·빈 줄·잘못된 입력, 제목·상세 구분·PkID 중복 없음·순공사비 위 배치·금액 계산, 합계단가만 거부·바꾸기 모드), 관련 9개 통과. ORCA 브라우저에서 openpyxl로 만든 수식 포함 xlsx를 서버로 읽어 시트 2개 표시·미리보기, 5행(제목 2) 가져오기 → 순공사비 45,293,066 → 61,798,566 확인 후 샘플 복구. + - 남음: 일위대가·단가산출·표준·견적·자재·노임 탭, 원본 「셀부호 밀기·당기기」. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. 3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄. - 4. Excel → EST 열 맞춤 가져오기(남의 공내역 Excel). 5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기). 6. 낙찰률 전체·범위·공종별 적용. 7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 1553fc2d5..03cc8e034 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +`파일 → Excel 내역 열 맞춰 가져오기`는 원본 엑셀→EST 변환처럼 다른 프로그램이나 발주처의 Excel 내역을 읽습니다. 시트·시작행·끝행과 번호·품명·규격·단위·수량·단가 등 항목별 Excel 열 문자를 적고 미리보기로 확인한 뒤, 순공사비 줄 위에 추가하거나 그 위 행을 바꿉니다. 단위와 수량이 없는 줄은 공종 제목이 되고, 금액은 단가×수량으로 다시 계산합니다. 수식 셀은 Excel이 저장해 둔 값을 씁니다. 합계단가만 있고 노무·재료·경비로 나눈 단가가 없으면 비목을 정할 수 없어 가져오지 않습니다. + `파일 → 공내역 파일로 저장`은 원본처럼 내역·일위대가의 금액을 비운 사본을 내려받습니다. 남길 값은 수량만(단가 비움)·단가만(수량 비움)·수량·단가(금액만 비움) 중에서 고르고, 원본 ESTX를 가져온 작업은 ESTX로도 받을 수 있습니다. 사본은 자동계산을 끈 상태로 저장해 열어도 빈칸이 유지되며, 원가계산서 금액도 비웁니다. 현재 작업은 바뀌지 않습니다. `편집 → 찾기·다음찾기`(Ctrl+F)는 원본 찾기 창처럼 검색어를 처음부터 또는 현재 위치부터 찾아 해당 행으로 이동합니다. 일위대가·단가산출·기계경비는 찾은 행의 호표로 넘어갑니다. 단가산출에서는 찾은 행의 문구를 한 건씩 바꾸고 다음 위치로 이동하며, 모두 바꾸기는 `산출근거 문구 바꾸기`를 씁니다. 표 위 검색창은 걸러 보기로 그대로 남습니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 23b895460..f0a555e59 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -41,6 +41,7 @@ + @@ -67,6 +68,7 @@ > + diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index ec2cf9019..675b54910 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -602,7 +602,8 @@ const EstBusinessActions = (() => { (typeof EstPick !== "undefined" && EstPick.handle(name, state)) || (typeof EstFind !== "undefined" && EstFind.handle(name, state)) || (typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) || - (typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) + (typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) || + (typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-excel-map.js b/tools/est-test/native-excel-map.js new file mode 100644 index 000000000..335e9ce00 --- /dev/null +++ b/tools/est-test/native-excel-map.js @@ -0,0 +1,287 @@ +// 원본 엑셀데이타를 EST로 변환(TFRMEXCELTOEST) 내역서 탭 — 시트·시작행·끝행과 필드별 열 문자로 남의 Excel 내역을 가져온다. +// 필드는 원본 tvViewEst 열(Code·Pr·Name·Size·GN·Unite·TU·TC·NU·NC·GU·GC·JU·JC·Bigo). 금액은 다시 계산하고 단가만 받는다. +// 단위·수량이 없는 줄은 공종 제목(Level 1). 합계단가만 있고 노무·재료·경비 단가가 없으면 비목을 정할 수 없어 거부한다. +const EstExcelMap = (() => { + const fields = [ + ["Code", "코드"], + ["Pr", "번호"], + ["Name", "품명"], + ["Size", "규격"], + ["GN", "수량"], + ["Unite", "단위"], + ["TU", "합계단가"], + ["TC", "합계금액"], + ["NU", "노무단가"], + ["NC", "노무금액"], + ["GU", "재료단가"], + ["GC", "재료금액"], + ["JU", "경비단가"], + ["JC", "경비금액"], + ["Bigo", "비고"], + ]; + const numeric = new Set(["GN", "TU", "TC", "NU", "NC", "GU", "GC", "JU", "JC"]); + const letters = (value) => + String(value ?? "") + .trim() + .toUpperCase(); + + function columnIndex(value) { + const text = letters(value); + if (!text) return -1; + if (!/^[A-Z]{1,3}$/.test(text)) throw Error(`열 문자가 올바르지 않습니다: ${value}`); + const index = [...text].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1; + if (index >= 16384) throw Error(`열 범위를 넘었습니다: ${value}`); + return index; + } + function columnName(index) { + let name = ""; + for (let n = index + 1; n > 0; n = Math.floor((n - 1) / 26)) + name = String.fromCharCode(65 + ((n - 1) % 26)) + name; + return name; + } + function number(value, where) { + if (value == null || value === "") return null; + if (typeof value === "number") return value; + const text = String(value).replace(/,/g, "").trim(); + if (!text) return null; + const parsed = Number(text); + if (!Number.isFinite(parsed)) throw Error(`${where}: 숫자가 아닙니다 (${value})`); + return parsed; + } + + function records(table, start, end, columns) { + if (!Number.isInteger(start) || start < 1) throw Error("시작행을 1 이상으로 입력하세요."); + if (!Number.isInteger(end) || end < start) + throw Error("끝행은 시작행보다 크거나 같아야 합니다."); + const map = Object.entries(columns) + .map(([key, value]) => [key, columnIndex(value)]) + .filter(([, index]) => index >= 0); + if (!map.some(([key]) => key === "Name")) throw Error("품명 열을 지정하세요."); + const out = []; + for (let line = start; line <= Math.min(end, table.length); line++) { + const cells = table[line - 1] || [], + record = { line }; + for (const [key, index] of map) { + const value = cells[index]; + record[key] = numeric.has(key) + ? number(value, `${columnName(index)}${line}`) + : value == null + ? "" + : String(value).trim(); + } + if (map.some(([key]) => record[key] !== "" && record[key] != null)) out.push(record); + } + return out; + } + + function rows(project, list) { + const built = [], + rejected = [], + warnings = []; + const table = [...(project.tables.EstMainDat || [])]; + for (const record of list) { + if (!record.Name && !record.Size && record.GN == null) continue; + const row = EstModel.newRow("EstMainDat", table, project); + table.push(row); + Object.assign(row, { Name: record.Name || "", Size: record.Size || "", Pr: record.Pr || "" }); + if (record.Code) row.Code = record.Code; + if (record.Bigo) row.Bigo = record.Bigo; + if (!record.Unite && record.GN == null) { + Object.assign(row, { Level: 1, GN: null, Unite: "" }); + built.push(row); + continue; + } + const split = ["NU", "GU", "JU"].some((key) => record[key] != null); + if (!split && record.TU) { + rejected.push(record.line); + continue; + } + Object.assign(row, { + Unite: record.Unite || "", + GN: record.GN ?? 0, + NU: record.NU ?? 0, + GU: record.GU ?? 0, + JU: record.JU ?? 0, + }); + if (record.TU != null && Math.abs(row.NU + row.GU + row.JU - record.TU) > 0.5) + warnings.push(record.line); + built.push(row); + } + if (rejected.length) + throw Error( + `${rejected.slice(0, 5).join(", ")}행${rejected.length > 5 ? ` 외 ${rejected.length - 5}개` : ""}: 합계단가만 있습니다. 노무·재료·경비 단가 열을 지정하거나 합계단가 열을 비우세요.`, + ); + return { rows: built, warnings }; + } + + function insert(project, built, mode) { + const list = project.tables.EstMainDat; + let boundary = list.findIndex((row) => Number(row.LinkIndex) === 54); + if (boundary < 0) boundary = list.length; + if (mode === "replace") { + list.splice(0, boundary, ...built); + return built.length; + } + list.splice(boundary, 0, ...built); + return built.length; + } + + // 화면: Excel 시트 XML을 읽어 행 배열로 바꾼다. 수식 셀은 Excel이 저장한 계산값을 쓴다. + function sheetTables(files) { + const parse = (source) => { + if (/ + [...si.getElementsByTagNameNS("*", "t")].map((t) => t.textContent).join(""), + ) + : []; + const rels = [ + ...parse(files["xl/_rels/workbook.xml.rels"]).getElementsByTagNameNS("*", "Relationship"), + ]; + const tables = {}; + for (const sheet of parse(files["xl/workbook.xml"]).getElementsByTagNameNS("*", "sheet")) { + const id = sheet.getAttributeNS( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "id", + ), + target = rels.find((r) => r.getAttribute("Id") === id)?.getAttribute("Target") || "", + path = target.startsWith("/") ? target.slice(1) : "xl/" + target; + if (!files[path]) continue; + const table = []; + for (const row of parse(files[path]).getElementsByTagNameNS("*", "row")) { + const line = Number(row.getAttribute("r")) || table.length + 1, + values = []; + for (const cell of row.getElementsByTagNameNS("*", "c")) { + const at = columnIndex(cell.getAttribute("r")?.match(/^[A-Z]+/)?.[0] || ""); + if (at < 0) continue; + const type = cell.getAttribute("t"), + v = cell.getElementsByTagNameNS("*", "v")[0]?.textContent; + if (type === "s") values[at] = shared[Number(v)]; + else if (type === "inlineStr") + values[at] = [...cell.getElementsByTagNameNS("*", "t")] + .map((t) => t.textContent) + .join(""); + else if (type === "e") values[at] = null; + else if (type === "str" || type === "b") values[at] = v ?? ""; + else values[at] = v == null ? null : Number(v); + } + table[line - 1] = values; + } + tables[sheet.getAttribute("name")] = table; + } + return tables; + } + + function preview(state) { + const map = state.excelMap, + table = map.tables[map.sheet] || [], + shown = table.slice(map.start - 1, map.start + 7), + { esc } = EstApp; + const width = Math.min(16, Math.max(1, ...shown.map((r) => (r || []).length))); + const head = Array.from({ length: width }, (_, i) => `${columnName(i)}`).join(""); + const body = shown + .map( + (cells, i) => + `${map.start + i}${Array.from({ length: width }, (_, c) => `${esc(cells?.[c] ?? "")}`).join("")}`, + ) + .join(""); + return `
${head}${body}
`; + } + + function form(state) { + const map = state.excelMap, + { esc } = EstApp; + const sheets = Object.keys(map.tables) + .map( + (name) => + ``, + ) + .join(""); + const mode = (value, label) => + ``; + EstApp.showDialog( + "Excel 내역 열 맞춰 가져오기", + `

각 항목이 있는 Excel 열 문자(A, B …)를 적으세요. 비우면 가져오지 않습니다. 금액은 단가×수량으로 다시 계산합니다.

${fields.map(([key, label]) => ``).join("")}
${preview(state)}
`, + ); + } + + function read(state) { + const value = (name) => document.querySelector(`#dialog-content [name="${name}"]`)?.value; + const map = state.excelMap; + map.sheet = value("xm-sheet") ?? map.sheet; + map.start = Number(value("xm-start")) || 1; + map.end = Number(value("xm-end")) || map.start; + map.mode = value("xm-mode") || "append"; + for (const [key] of fields) map.columns[key] = letters(value(`xm-${key}`)); + } + + function load(state, files) { + const tables = sheetTables(files), + sheet = Object.keys(tables)[0]; + if (!sheet) throw Error("읽을 시트가 없습니다."); + state.excelMap = { + start: 1, + mode: "append", + columns: {}, + ...state.excelMap, + tables, + sheet, + end: tables[sheet].length, + documentKey: state.documentKey, + }; + form(state); + } + + function open(state) { + if (state.view !== "EstMainDat") throw Error("내역서 화면에서 Excel 내역을 가져오세요."); + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".xlsx"; + input.addEventListener("change", async () => { + try { + if (!input.files[0]) return; + const body = await input.files[0].arrayBuffer(); + load(state, await EstApp.api("/api/import-xlsx", { method: "POST", body })); + } catch (error) { + state.notice = error.message; + EstApp.render(); + } + }); + input.click(); + } + + function apply(state) { + read(state); + const map = state.excelMap; + if (map.documentKey !== state.documentKey) throw Error("작업이 바뀌었습니다. 다시 여세요."); + const list = records(map.tables[map.sheet] || [], map.start, map.end, map.columns), + { rows: built, warnings } = rows(state.project, list); + if (!built.length) throw Error("가져올 행이 없습니다. 시작행·끝행과 품명 열을 확인하세요."); + const headings = built.filter((row) => Number(row.Level) > 0).length, + where = map.mode === "replace" ? "순공사비 위 행을 바꿔" : "순공사비 위에", + differ = warnings.length + ? `\n합계단가와 노무·재료·경비 단가 합이 다른 행: ${warnings.slice(0, 5).join(", ")}` + : ""; + if (!confirm(`${built.length}행(공종 제목 ${headings}행)을 ${where} 넣습니다.${differ}`)) + return; + EstApp.byId("dialog").close(); + EstApp.change((project) => insert(project, built, map.mode)); + state.notice = `Excel에서 ${built.length}행을 가져왔습니다. 단가 고르기로 기초자료에 연결할 수 있습니다.`; + } + + function handle(name, state) { + if (name === "excel-map-open") open(state); + else if (name === "excel-map-preview") { + read(state); + form(state); + } else if (name === "excel-map-apply") apply(state); + else return false; + return true; + } + + return { fields, columnIndex, columnName, records, rows, insert, load, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 9458cdb04..f8113e4c7 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -61,6 +61,7 @@ const allowed = new Set([ "native-find.js", "native-blank.js", "native-quantity-sheets.js", + "native-excel-map.js", "native-issues.js", "native-attachments.js", "native-standard.js",