diff --git a/resources/tester/test_est_transfer.py b/resources/tester/test_est_transfer.py new file mode 100644 index 000000000..1d5fc9f7d --- /dev/null +++ b/resources/tester/test_est_transfer.py @@ -0,0 +1,102 @@ +"""EST 테스트 앱 연결 자료와 함께 복사·붙여넣기(원본 TFRMOTHERLOAD).""" + +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-transfer.js", +] + + +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.source = 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")) + + +PICK = """ + const calc = (p) => EstNative.calculate(EstModel.validate(p)); + const src = calc(source); + const linked = src.tables.EstMainDat.filter((r) => r.Code && !Number(r.Level)); + const picked = [5, 103, 104].flatMap((k) => linked.filter((r) => Number(r.LinkIndex) === k).slice(0, 2)); + const deps = EstTransfer.collect(src, picked); +""" + + +def test_copy_into_empty_project_keeps_prices_and_links(): + result = run_js( + "(() => {" + + PICK + + """ + const target = EstNative.empty(); + target.options = structuredClone(src.options); + const merged = EstTransfer.merge(target, structuredClone(deps)); + EstTransfer.paste(target, picked, 0); + const done = calc(target); + const pasted = done.tables.EstMainDat.slice(0, picked.length); + return { + kinds: [...new Set(picked.map((r) => Number(r.LinkIndex)))].sort((a, b) => a - b), + groups: Object.keys(deps.groups).sort(), + nested: (deps.flat.LaborcDat || []).length > 0, + missing: done.calculation.errors.filter((e) => e.includes('연결 자료 없음') || e.includes('하위 자료 없음')), + same: pasted.every((r, i) => Math.abs(Number(r.TC) - Number(picked[i].TC)) < 1), + added: merged.added.length > 0, kept: merged.kept.length, criteria: merged.criteria, + unique: new Set(done.tables.HeadMainDat.map((r) => r.PkID)).size === done.tables.HeadMainDat.length, + }; + })()""" + ) + assert result["kinds"] == [5, 103, 104] + assert result["groups"] == ["CostMainDat", "HeadMainDat", "MachMainDat"] + assert result["nested"] is True + assert result["missing"] == [] + assert result["same"] is True + assert result["added"] is True + assert result["kept"] == 0 + assert result["criteria"] is True + assert result["unique"] is True + + +def test_same_code_in_target_is_kept_not_overwritten(): + result = run_js( + "(() => {" + + PICK + + """ + const target = structuredClone(source); + const labor = target.tables.LaborcDat[0]; + labor.Cost1 = Number(labor.Cost1) * 2; labor.TC = Number(labor.TC) * 2; + const before = target.tables.HeadMainDat.length; + target.tables.MachGijunDat[0].TC = 12345; + const merged = EstTransfer.merge(target, structuredClone(deps)); + return { added: merged.added.length, kept: merged.kept.length > 0, + criteria: merged.criteria === false && target.tables.MachGijunDat[0].TC === 12345, + heads: target.tables.HeadMainDat.length === before, + labor: target.tables.LaborcDat[0].TC === labor.TC }; + })()""" + ) + assert result == {"added": 0, "kept": True, "criteria": True, "heads": True, "labor": True} diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index b2c65f256..0a284af62 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -444,11 +444,14 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [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 확인 후 샘플 복구. - 남음: 일위대가·단가산출·표준·견적·자재·노임 탭, 원본 「셀부호 밀기·당기기」. + - [x] 연결 자료와 함께 복사·붙여넣기(원본 TFRMOTHERLOAD) — 내역 행의 자재·노임·실적·견적 행과 일위대가·단가산출·기계경비 호표를 하위까지 모아 다른 공사에 붙여넣기. 같은 코드는 붙여넣을 공사 자료 유지(덮어쓰지 않음), 기계경비 적용기준은 붙여넣을 공사에 없을 때만 옮김. + - 검증완료: `test_est_transfer.py` 2개(빈 공사로 옮겨 연결 오류 0·금액 동일·하위 기계경비 호표 포함·PkID 중복 없음, 같은 코드·적용기준 덮어쓰지 않음), 관련 시험 통과. ORCA 브라우저에서 샘플 내역 3행 복사 → 새 내역 붙여넣기 → 연결 자료 12건 추가·금액 1,285,046·417,024·680,414 원본과 동일·계산 오류 0, 샘플 복구. + - 남음: 프로젝트 추가(다른 파일의 내역 전체 합치기), 두 공사 나란히 보기. - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 1. 통합검색 — 저장된 다른 작업에서 단가·호표 찾기. 3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄. - 5. 다른 공사 불러오기 시 딸린 일위·단가산출 함께 복사, 프로젝트 추가(합치기). + 5. 프로젝트 추가(다른 파일의 내역 합치기). 6. 낙찰률 전체·범위·공종별 적용. 7. 무관자료 삭제, 데이타 정리(정렬·번호), 품명정리. 8. 원가계산서 — 정산원가계산서, 기타경비·요율 대화, 서식 저장·불러오기, 기성 원가 편집. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 03cc8e034..67e697f16 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +`편집 → 내역 행 연결 자료와 함께 복사`는 고른 내역 행이 쓰는 자재·노임·실적·견적 자료와 일위대가·단가산출·기계경비 호표를 하위까지 모아 복사합니다. 다른 공사를 연 뒤 `연결 자료와 함께 붙여넣기`를 누르면 없는 자료만 추가하고, 같은 코드가 이미 있으면 그 공사의 자료를 그대로 씁니다. 붙여넣을 공사에 기계경비 적용기준이 비어 있을 때만 적용기준도 함께 옮깁니다. + `파일 → Excel 내역 열 맞춰 가져오기`는 원본 엑셀→EST 변환처럼 다른 프로그램이나 발주처의 Excel 내역을 읽습니다. 시트·시작행·끝행과 번호·품명·규격·단위·수량·단가 등 항목별 Excel 열 문자를 적고 미리보기로 확인한 뒤, 순공사비 줄 위에 추가하거나 그 위 행을 바꿉니다. 단위와 수량이 없는 줄은 공종 제목이 되고, 금액은 단가×수량으로 다시 계산합니다. 수식 셀은 Excel이 저장해 둔 값을 씁니다. 합계단가만 있고 노무·재료·경비로 나눈 단가가 없으면 비목을 정할 수 없어 가져오지 않습니다. `파일 → 공내역 파일로 저장`은 원본처럼 내역·일위대가의 금액을 비운 사본을 내려받습니다. 남길 값은 수량만(단가 비움)·단가만(수량 비움)·수량·단가(금액만 비움) 중에서 고르고, 원본 ESTX를 가져온 작업은 ESTX로도 받을 수 있습니다. 사본은 자동계산을 끈 상태로 저장해 열어도 빈칸이 유지되며, 원가계산서 금액도 비웁니다. 현재 작업은 바뀌지 않습니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index f0a555e59..d00944f8e 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -42,6 +42,7 @@ + @@ -87,6 +88,8 @@ > +
{ (typeof EstFind !== "undefined" && EstFind.handle(name, state)) || (typeof EstBlank !== "undefined" && EstBlank.handle(name, state)) || (typeof EstQuantitySheets !== "undefined" && EstQuantitySheets.handle(name, state, node)) || - (typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) + (typeof EstExcelMap !== "undefined" && EstExcelMap.handle(name, state)) || + (typeof EstTransfer !== "undefined" && EstTransfer.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/native-transfer.js b/tools/est-test/native-transfer.js new file mode 100644 index 000000000..962002e9d --- /dev/null +++ b/tools/est-test/native-transfer.js @@ -0,0 +1,154 @@ +// 원본 다른 공사 불러오기·복사(TFRMOTHERLOAD)와 프로젝트 추가(mnuFileLoadProjectAs)의 하위 자료 보존. +// 내역 행이 쓰는 자재·노임·실적·견적 행과 일위대가·단가산출·기계경비 호표를 하위까지 모아 함께 옮긴다. +// 붙여넣을 공사에 같은 코드가 있으면 그 공사의 자료를 그대로 쓰고(덮어쓰지 않음) 개수만 알린다. +// 기계경비 적용기준은 붙여넣을 공사에 하나도 없을 때만 함께 옮긴다. +const EstTransfer = (() => { + const flat = { 5: "StuffcDat", 6: "LaborcDat", 21: "SilJukDat", 101: "EstiMateDat" }; + const grouped = { + 103: ["HeadTitleDat", "HeadMainDat"], + 104: ["CostTitleDat", "CostMainDat"], + 105: ["MachTitleDat", "MachMainDat"], + }; + const num = (value) => Number(value) || 0; + + function collect(source, rows) { + const deps = { flat: {}, groups: {} }, + seen = new Set(), + queue = [...rows]; + while (queue.length) { + const row = queue.shift(), + kind = num(row.LinkIndex), + code = row.Code; + if (!code || seen.has(`${kind}:${code}`)) continue; + if (flat[kind]) { + const found = (source.tables[flat[kind]] || []).find((r) => r.Code === code); + if (!found) continue; + seen.add(`${kind}:${code}`); + (deps.flat[flat[kind]] ||= []).push(structuredClone(found)); + } else if (grouped[kind]) { + const [titleTable, mainTable] = grouped[kind], + group = EstNative.groups(source.tables[mainTable] || []).get(code); + if (!group) continue; + seen.add(`${kind}:${code}`); + const title = (source.tables[titleTable] || []).find((r) => r.Code === code); + (deps.groups[mainTable] ||= []).push({ + code, + titleTable, + title: title ? structuredClone(title) : null, + rows: structuredClone(group.rows), + }); + queue.push(...group.rows.slice(1)); + } + } + // 기계경비 적용기준은 공사 전체 설정 — 붙여넣을 공사에 없을 때만 함께 옮긴다. + if (deps.groups.MachMainDat?.length && source.tables.MachGijunDat?.length) + deps.criteria = structuredClone(source.tables.MachGijunDat); + return deps; + } + + function nextPkID(project, table, extra = []) { + return ( + Math.max( + 0, + ...(project.tables[table] || []).map((r) => num(r.PkID)), + ...extra.map((r) => num(r.PkID)), + ) + 1 + ); + } + + function merge(target, deps) { + const added = [], + kept = []; + for (const [table, list] of Object.entries(deps.flat)) { + const rows = (target.tables[table] ||= []); + for (const row of list) { + if (rows.some((r) => r.Code === row.Code)) { + kept.push(row.Code); + continue; + } + row.PkID = nextPkID(target, table); + row.Index = Math.max(0, ...rows.map((r) => num(r.Index))) + 1; + rows.push(row); + added.push(row.Code); + } + } + for (const [table, list] of Object.entries(deps.groups)) { + const rows = (target.tables[table] ||= []); + for (const group of list) { + if (EstNative.groups(rows).has(group.code)) { + kept.push(group.code); + continue; + } + let pk = nextPkID(target, table); + const index = Math.max(0, ...rows.map((r) => num(r.Index))) + 1; + group.rows.forEach((row, i) => { + row.PkID = pk++; + if (i === 0) row.Index = index; + }); + rows.push(...group.rows); + if (group.title) { + const titles = (target.tables[group.titleTable] ||= []); + if (!titles.some((r) => r.Code === group.code)) { + group.title.PkID = nextPkID(target, group.titleTable); + group.title.Index = index; + titles.push(group.title); + } + } + added.push(group.code); + } + } + let criteria = false; + if (deps.criteria && !target.tables.MachGijunDat?.length) { + target.tables.MachGijunDat = deps.criteria; + criteria = true; + } + return { added, kept, criteria }; + } + + function paste(target, rows, at) { + const list = target.tables.EstMainDat; + let pk = nextPkID(target, "EstMainDat"); + const copies = structuredClone(rows).map((row) => { + row.PkID = pk++; + row.NewItem = true; + delete row.PreviousCodes; + return row; + }); + list.splice(at, 0, ...copies); + return copies.length; + } + + // 화면 + let clipboard = null; + function handle(name, state) { + if (name === "copy-with-links") { + if (state.view !== "EstMainDat") throw Error("내역서 화면에서 행을 고르세요."); + const rows = state.project.tables.EstMainDat, + picked = [...state.selection].sort((a, b) => a - b).map((i) => rows[i]); + if (!picked.length) throw Error("복사할 내역 행을 선택하세요."); + clipboard = { + rows: structuredClone(picked), + deps: collect(state.project, picked), + from: state.project.name, + }; + const count = + Object.values(clipboard.deps.flat).flat().length + + Object.values(clipboard.deps.groups).flat().length; + state.notice = `내역 ${picked.length}행과 연결 자료 ${count}건을 복사했습니다. 다른 공사를 열고 붙여넣으세요.`; + } else if (name === "paste-with-links") { + if (!clipboard) throw Error("먼저 「연결 자료와 함께 복사」를 하세요."); + if (state.view !== "EstMainDat") throw Error("내역서 화면에서 붙여넣으세요."); + const rows = state.project.tables.EstMainDat, + at = ([...state.selection].sort((a, b) => a - b).at(-1) ?? rows.length - 1) + 1; + let result; + EstApp.change((project) => { + result = merge(project, structuredClone(clipboard.deps)); + paste(project, clipboard.rows, at); + }); + state.notice = `내역 ${clipboard.rows.length}행을 붙여넣었습니다. 연결 자료 ${result.added.length}건 추가${result.kept.length ? `, 같은 코드 ${result.kept.length}건은 이 공사 자료를 그대로 씀` : ""}${result.criteria ? ", 비어 있던 기계경비 적용기준도 함께 옮김" : ""}.`; + } else return false; + return true; + } + + return { collect, merge, paste, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index f8113e4c7..571301a67 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -62,6 +62,7 @@ const allowed = new Set([ "native-blank.js", "native-quantity-sheets.js", "native-excel-map.js", + "native-transfer.js", "native-issues.js", "native-attachments.js", "native-standard.js",