diff --git a/resources/tester/test_est_autolink.py b/resources/tester/test_est_autolink.py new file mode 100644 index 000000000..971f0531f --- /dev/null +++ b/resources/tester/test_est_autolink.py @@ -0,0 +1,63 @@ +"""EST 테스트 앱 데이타 자동연결(원본 TfrmDataAutoLink, 맞춤 10e0e58·행 처리 10d2a48).""" + +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def run(code: str): + script = f""" + const fs = require('fs'), vm = require('vm'); + const ctx = vm.createContext({{}}); + vm.runInContext(fs.readFileSync('tools/est-test/native-autolink.js', 'utf8'), ctx); + process.stdout.write(JSON.stringify(vm.runInContext({json.dumps(code)}, ctx))); + """ + return json.loads(subprocess.check_output(["node", "-e", script], cwd=ROOT).decode("utf-8")) + + +def test_matching_rules_order_and_overwrite(): + result = run( + """(() => { + const base = () => ({ tables: { + LaborcDat: [{ Code: 'L1', Name: '보통 인부', TC: 170000, NC: 170000 }], + StuffcDat: [{ Code: 'M1', Name: '레미콘', Size: '25-24-(150)', Unite: 'm3', TC: 90000, GC: 90000 }, + { Code: 'M2', Name: '보통인부', TC: 1, GC: 1 }], + HeadTitleDat: [{ Code: 'U1', Name: '거푸집', Size: '합판', Unite: '㎡당', TC: 30000, NC: 20000, GC: 10000, SCheck: 3 }], + EstMainDat: [ + { Name: '공종', Level: 2 }, + { Name: '보통인부', Size: '일반', Unite: '인' }, + { Name: '레미콘', Size: '25-24-150', Unite: '[m3]' }, + { Name: '레미콘', Size: '25-24-150', Unite: 'ton' }, + { Name: '거 푸 집', Size: '(합판)', Unite: '㎡' }, + { Name: '일반', Size: '보통인부' }, + { Name: '순공사비', LinkIndex: 54 }, + { Name: '이미', LinkIndex: 5, Code: 'OLD' } ] } }); + const p = base(); + const n = EstAutoLink.run(p, 'EstMainDat', 'all', false); + const r = p.tables.EstMainDat; + const q = base(); q.tables.EstMainDat[7].Name = '레미콘'; q.tables.EstMainDat[7].Size = '25-24-150'; q.tables.EstMainDat[7].Unite = 'm3'; + EstAutoLink.run(q, 'EstMainDat', '5', true); + const u = base(); const cut = EstAutoLink.run(u, 'EstMainDat', 'unlink', false); + return { n, rows: r.map((x) => [x.Code ?? null, x.LinkIndex ?? null]), unit: [r[2].GU, r[2].TU], + swap: [r[5].Name, r[5].Size], head: [r[4].NU, r[4].GU, r[4].SCheck], over: q.tables.EstMainDat[7].Code, + cut, allZero: u.tables.EstMainDat.every((x) => x.LinkIndex === 0) }; + })()""" + ) + assert result["n"] == 4 + assert result["rows"] == [ + [None, None], + ["L1", 6], + ["M1", 5], + [None, None], + ["U1", 103], + ["L1", 6], + [None, 54], + ["OLD", 5], + ] + assert result["unit"] == [90000, 90000] + assert result["swap"] == ["보통인부", "일반"] + assert result["head"] == [20000, 10000, 3] + assert result["over"] == "M1" + assert result["cut"] == 8 and result["allZero"] is True diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 227eba1b4..297a57e60 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -492,6 +492,9 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - [x] 계약심사 자동입력(원본 TfrmInvest 심사평가결과·주요심사내용 0x10bfc98 — 디스어셈블 근거) — 총공사비 행 Cost_/Cost, 절감율 Trunc(×10000)×0.01, cp949 16바이트 맞춤·증액 「+」, 7개 표 NewItem 「○○ 조정 : 이름(규격) 외N 종」(N 원본 셈). 심사 사본에 설계 원가 Cost_ 보존. - 검증완료: `test_est_review_auto.py`(네 줄 글·증액 부호·총공사비 없음·주요심사 두 줄) 통과, 계약심사 관련 시험 44개 통과. ORCA 브라우저에서 수량 0.9배 심사 → 사본 → 자동입력 「설계 97,199,324 · 심사 96,952,167 · 절감 −247,157 · −0.25 %」, 샘플 복구. - 남음: 위치·사업개요·사업기간·기타사항 칸(Invest.Dat 10개 RTF). + - [x] 데이타 자동연결(원본 ActionAutoLink 0x10d54f4·TfrmDataAutoLink 0x10d3008·10d2a48·맞춤 10e0e58 — 디스어셈블 근거) — 9개 선택·Over Write, 정규화·맞춤 단계·노임 맞바꿈·기계 주연료·건너뛰기 규칙·쓰는 값 원본대로, 전체 순서 노임→자재→견적→표준→기계(내역·일위만)→단가산출→일위대가. + - 검증완료: `test_est_autolink.py`(노임 품명만·규격 재시도 맞바꿈·단위 정규화·일위 비목 단가·SCheck 3·Over Write·이미 연결 건너뜀·전체 끊기) 통과. ORCA 브라우저에서 샘플 전체자동(Over Write) → 7행 연결·순공사비 45,293,066 그대로, 되돌리기 원상. + - 남음: 전역변수(`` 표지 — 설치 자료 사용 0건, 단가산출 엔진 깊은 변경이 필요해 보류). - 대조 합계(CAD 제외 882개): 구현 283 · 부분 193 · 없음 273 · 제외 133. - [ ] 남은 핵심(실무 빈도순): 3. 수량산출서 — 누계·4방향 합계, 제출용 전체 인쇄. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index b3547b757..d85c28752 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -22,6 +22,8 @@ 내역서·일위대가 화면의 `편집 → 단가 고르기`는 자재·노임·기계경비·일위대가·단가산출·견적·실적 자료를 검색어로 찾아 선택 행 아래에 넣거나, 선택 행 하나의 연결을 바꿉니다. 여러 개를 골라 한 번에 넣을 수 있고, 바꿀 때는 변경 전·후를 확인한 뒤 같은 코드를 쓰는 이 표의 다른 행도 함께 바꿀지 고릅니다. 넣거나 바꾼 행만 원천 단가를 다시 읽습니다. 단가산출 산출근거 행은 대상이 아닙니다. +`편집 → 데이타 자동연결`은 원본처럼 내역서·일위대가·기계경비 행을 현재 공사의 노임·자재·견적·표준·기계경비·단가산출·일위대가 표와 품명(·규격·단위)으로 맞춰 코드·단가·연결종류를 적습니다. 빈칸·|·괄호를 무시하고 단위의 「당」·[ ]를 지우며, 노임은 품명만 맞추고 못 찾으면 규격으로 찾아 품명·규격을 맞바꿉니다. `Over Write`를 끄면 이미 연결된 행은 건너뜁니다. `전체연결 끊기`는 원본처럼 모든 행의 연결종류를 0으로 합니다. + `편집 → 선택 행 자재단가 등록`·`견적단가 등록`은 원본처럼 내역·일위대가 행을 새 자재(M)·견적(W) 단가로 등록하고 그 행을 새 코드에 연결합니다. 같은 품명·규격이 있으면 거부하고, 일위대가 호표 제목 줄은 등록하지 않습니다. `품명정리`는 고른 일위대가 호표의 품명을 커서 자리에서 나눠 뒤쪽을 규격 앞에 붙이고, 단위가 비어 있으면 채웁니다. 계약심사 화면의 `심사평가결과 자동입력`은 원본처럼 원가계산서의 총공사비 행에서 설계(심사 전)·심사 금액, 절감액, 절감율(소수 둘째 자리 버림)을 적고, `주요심사내용 자동입력`은 노임·자재·표준시장·견적·기계경비·단가산출·일위대가 중 새로 바뀐 자료를 「○○ 조정 : 이름(규격) 외N 종」으로 적습니다. 심사 내역 사본은 설계 원가 금액을 함께 남깁니다. diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 0c09853d8..86ecc3e89 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -56,6 +56,7 @@ + @@ -123,6 +124,7 @@ + diff --git a/tools/est-test/native-autolink.js b/tools/est-test/native-autolink.js new file mode 100644 index 000000000..d395ea8e8 --- /dev/null +++ b/tools/est-test/native-autolink.js @@ -0,0 +1,140 @@ +// 원본 데이타 자동연결(ActionAutoLink Ctrl+Alt+L 0x10d54f4 → TfrmDataAutoLink 적용 0x10d3008, 행 처리 10d2a48, 맞춤 10e0e58). +// 내역서·일위대가·기계경비 화면에서 현재 공사의 노임·자재·견적·표준·기계경비·단가산출·일위대가 표로 품명(·규격·단위)을 맞춰 연결. +// 정규화: 빈칸·|·(·) 제거, 단위는 「당」·[·] 를 한 번씩 더 지움. 노임은 품명만(못 찾으면 규격으로 찾고 품명·규격을 맞바꿈), +// 규격·단위 비면 품명+규격, 단위 있으면 품명+규격+단위. 노임·기계는 단위 무시. 처음 맞는 행. Over Write 끄면 이미 연결된 행은 건너뜀. +// 쓰는 값: Code, TU(=원본 TC), 내역·일위대가는 NU·GU·JU·SU(=원본 NC·GC·JC·SC), LinkIndex, 원본 SCheck 3 이면 SCheck 3. +// 「전체연결 끊기」는 원본처럼 모든 행 LinkIndex 를 0 으로 한다(조건 없음). +const EstAutoLink = (() => { + const num = (value) => Number(value) || 0; + const sources = { + 6: "LaborcDat", + 5: "StuffcDat", + 101: "EstiMateDat", + 21: "SilJukDat", + 105: "MachTitleDat", + 104: "CostTitleDat", + 103: "HeadTitleDat", + }; + const order = [6, 5, 101, 21, 105, 104, 103]; + const tags = { EstMainDat: 1, HeadMainDat: 2, MachMainDat: 4 }; + const norm = (s) => String(s ?? "").replace(/[\s|()]/g, ""); + const unit = (s) => norm(s).replace("당", "").replace("[", "").replace("]", ""); + + function key(row, tag) { + let name = row.Name ?? "", + size = row.Size ?? ""; + const unite = row.Unite ?? ""; + if (tag === 4) { + name = String(name).replace(/[\s|]/g, ""); + size = String(size).replace(/[\s|]/g, ""); + if ([5, 212].includes(num(row.LinkIndex)) && name.includes("주연료")) { + name = size; + size = ""; + } + } + return { name: String(name).trim(), size: String(size).trim(), unite: String(unite).trim() }; + } + + function find(project, kind, k) { + const rows = project.tables[sources[kind]] || [], + useUnit = ![6, 105].includes(kind) && k.unite; + const mode = !k.size && !useUnit ? (kind === 6 ? "name" : "size") : useUnit ? "unit" : "size"; + return rows.find((src) => { + if (!src.Code) return false; + if (kind !== 101 && src.LinkIndex != null && num(src.LinkIndex) !== kind) return false; + if (src.DataKind != null && num(src.DataKind) !== 1) return false; + if (norm(src.Name) !== norm(k.name)) return false; + if (mode === "name" || kind === 6) return true; + if (norm(src.Size) !== norm(k.size)) return false; + if (mode === "size" || kind === 105) return true; + return unit(src.Unite) === unit(k.unite); + }); + } + + function linkKind(project, view, kind, overwrite) { + const tag = tags[view], + rows = project.tables[view] || []; + let linked = 0; + for (const row of rows) { + if ((tag === 2 || tag === 4) && num(row.Index) > 0) continue; + if (tag === 1 && (num(row.Level) > 0 || [54, 55, 58].includes(num(row.LinkIndex)))) continue; + const k = key(row, tag); + if (!k.name && !k.size && !k.unite) continue; + if (!overwrite && num(row.LinkIndex)) continue; + let src = find(project, kind, kind === 6 ? { name: k.name, size: "", unite: "" } : k); + if (!src && kind === 6 && k.size) { + src = find(project, kind, { name: k.size, size: "", unite: "" }); + if (src) [row.Name, row.Size] = [row.Size, row.Name]; + } + if (!src) continue; + row.Code = src.Code; + row.TU = num(src.TC); + if (tag === 1 || tag === 2) { + row.NU = num(src.NC); + row.GU = num(src.GC); + row.JU = num(src.JC); + row.SU = num(src.SC); + } + row.LinkIndex = kind; + if (num(src.SCheck) === 3) row.SCheck = 3; + linked++; + } + return linked; + } + + function run(project, view, choice, overwrite) { + if (!tags[view]) throw Error("내역서·일위대가·기계경비 화면에서 자동연결하세요."); + if (choice === "unlink") { + for (const row of project.tables[view] || []) row.LinkIndex = 0; + return (project.tables[view] || []).length; + } + const kinds = + choice === "all" + ? order.filter((k) => k !== 105 || [1, 2].includes(tags[view])) + : [Number(choice)]; + return kinds.reduce((n, kind) => n + linkKind(project, view, kind, overwrite), 0); + } + + const choices = [ + ["unlink", "전체연결 끊기"], + ["all", "전체자동 연결"], + ["6", "노임단가 연결"], + ["5", "자재단가 연결"], + ["101", "견적단가 연결"], + ["21", "표준단가 연결"], + ["105", "기계경비 연결"], + ["104", "단가산출 연결"], + ["103", "일위대가 연결"], + ]; + function open(state) { + if (!tags[state.view]) throw Error("내역서·일위대가·기계경비 화면에서 자동연결하세요."); + EstApp.showDialog( + "데이타 자동연결", + `
`, + ); + } + function apply(state) { + const choice = document.querySelector('#dialog-content [name="al-choice"]').value, + overwrite = document.querySelector('#dialog-content [name="al-over"]').checked; + const ask = + choice === "unlink" + ? "데이타를 연결을 끊을까요?" + : choice === "all" + ? "전체 데이타를 연결 시킬까요?" + : null; + if (ask && !confirm(ask)) return; + let count; + EstApp.change((project) => (count = run(project, state.view, choice, overwrite))); + EstApp.byId("autolink-result").textContent = + choice === "unlink" ? `${count}행의 연결을 끊었습니다.` : `${count}행을 연결했습니다.`; + } + + function handle(name, state) { + if (name === "autolink") open(state); + else if (name === "autolink-apply") apply(state); + else return false; + return true; + } + + return { run, find, key, handle }; +})(); diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js index 363418f0a..c9eba8674 100644 --- a/tools/est-test/native-business-actions.js +++ b/tools/est-test/native-business-actions.js @@ -616,7 +616,8 @@ const EstBusinessActions = (() => { (typeof EstWongaTools !== "undefined" && EstWongaTools.handle(name, state)) || (typeof EstEstimatePrint !== "undefined" && EstEstimatePrint.handle(name, state)) || (typeof EstXmlCheck !== "undefined" && EstXmlCheck.handle(name, state)) || - (typeof EstReviewAuto !== "undefined" && EstReviewAuto.handle(name, state)) + (typeof EstReviewAuto !== "undefined" && EstReviewAuto.handle(name, state)) || + (typeof EstAutoLink !== "undefined" && EstAutoLink.handle(name, state)) ) { EstApp.render(); } else if (name === "load-contract" || name === "load-bid") { diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index cf5580b4f..a98e7e22d 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -78,6 +78,7 @@ const allowed = new Set([ "native-estimate-print.js", "native-xml-check.js", "native-review-auto.js", + "native-autolink.js", "native-issues.js", "native-attachments.js", "native-standard.js",