diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 4113dbad9..5297e0b8a 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -36,6 +36,7 @@ for (const file of [ "native-extra.js", "native-business-actions.js", "native-forms.js", + "native-excel-to-est.js", ]) { vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), context, { filename: file }); } diff --git a/resources/tester/test_est_excel_to_est.py b/resources/tester/test_est_excel_to_est.py new file mode 100644 index 000000000..bf4874a95 --- /dev/null +++ b/resources/tester/test_est_excel_to_est.py @@ -0,0 +1,36 @@ +"""EST 테스트 앱 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst — 셀 규칙·머리 줄 꼴·호표 만들기).""" + +from test_est_rea_parity import run + + +def test_convert_tabs_and_heads(): + result = run( + "math", + r"""(()=>{ + const X=EstExcelToEst; + const sheets={노임단가:[['L1',' 보통인부 ','','M2',172698,'']], + 견적단가:[['','.펌프','','1','EA','1,500','500','1,000','0','']], + 표준시장단가:[['C1','포장','t=5','m2',1000,300,600,100,0.3,'포함',''],], + 단가산출:[['','산근 1 호표 : 거푸집 합판 (1 ㎡ 당)',100,60,40,0],['','합판 1*2',40,0,40,0],['','#.산근2|철근가공|D10|ton',9,9,0,0]], + 일위대가:[['','No 5 호표 터파기','','','',0,10,0,10,0,0,0,0,''],['','인부','','0.1','인',100,10,100,10,0,0,0,0,''],['','[합계]','','','',0,10,0,0,0,0,0,0,'']]}; + const cols=(n)=>Array.from({length:n},(_,i)=>String.fromCharCode(65+i)); + const tabs={LaborcDat:{sheet:'노임단가',start:1,end:1,columns:cols(6)},EstiMateDat:{sheet:'견적단가',start:1,end:1,columns:cols(10)}, + SilJukDat:{sheet:'표준시장단가',start:1,end:1,columns:cols(11)},CostMainDat:{sheet:'단가산출',start:1,end:3,columns:cols(6)}, + HeadMainDat:{sheet:'일위대가',start:1,end:3,columns:cols(14)},StuffcDat:{sheet:'없는시트',start:1,end:1,columns:cols(18)}}; + const r=X.convert(sheets,tabs,{name:'시험',wonga:[]}), t=r.project.tables; + return {failed:r.failed, lab:[t.LaborcDat[0].Name,t.LaborcDat[0].Unite,t.LaborcDat[0].TC], + est:[t.EstiMateDat[0].Name,t.EstiMateDat[0].TC,t.EstiMateDat[0].OTC,t.EstiMateDat[0].Code.slice(0,1)], + sil:[t.SilJukDat[0].NCRatio,t.SilJukDat[0].Cost1,t.SilJukDat[0].ONC], + cost:t.CostTitleDat.map(x=>[x.Name,x.Size,x.Unite]), line:t.CostMainDat[1].LineData, + head:[t.HeadTitleDat.map(x=>x.Name),t.HeadMainDat.length], rows:t.EstMainDat.length, auto:r.project.options.AutoCalc}; + })()""", + ) + assert result["failed"] == ["없는시트 시트가 없습니다."] + assert result["lab"] == ["보통인부", "㎡", 172698] + assert result["est"] == ["펌프", 1500, 1500, "N"] + assert result["sil"] == [30, 1000, 300] + assert result["cost"] == [["거푸집", "합판", "㎡"], ["철근가공", "D10", "ton"]] + assert result["line"] == "합판 1×2" + assert result["head"] == [["터파기"], 2] + assert result["rows"] == 36 + assert result["auto"] is False diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 3355420c9..3520cc57c 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -489,6 +489,8 @@ 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] 엑셀데이타를 EST로 변환 7개 탭(원본 TfrmExcelToEst 0x109c3bc — spec_pc §1) — 노임·자재·견적·표준시장·단가산출·일위대가·내역서 탭마다 시트명·시작/끝행·필드별 열 글자(.xlsx·.xls), 노임 → 자재 → 견적 → 표준 → 단산 → 일위 → 내역 차례로 새 공사(현재 원가 서식, 자동계산 끔 — Excel 값 그대로). 셀 규칙(Trim·줄바꿈·앞 「.」·㎡/㎥·「*」→「×」·쉼표·노무비율 ×100·「B:D」 이어붙임), 노임 단가 → Cost1·TC, 견적·표준 → O 칸 함께, 빈 코드 N+10자리, 내역 36행 채움. 단가산출·일위대가 머리 줄 꼴로 호표 만들기(코드·TitleCode·Index·LineData 품명/규격/단위 표지, [합계] 줄 지움), 못 읽은 시트 안내. 하지 않음: 글자 굵기 판정·고정폭 머리말·단가산출 목록 시트형(굵기·폭 정보 없음). + - 검증완료: `test_est_excel_to_est.py`(없는 시트 안내, 노임 ㎡·TC, 견적 앞점·쉼표·O칸·N코드, 표준 노무비율 30, 단산 호표 2개·×, 일위 호표·[합계] 지움, 36행, 자동계산 끔) 통과. ORCA 브라우저에서 탭 7개·단가산출 열 6칸 표시(파일 고르기는 수동). - [x] 전회기성 불러오기·조달수수료·원가에 내역 금액·원가 구성비(spec_pb1 1·9·10 · spec_pb2 8) — 전회기성: 완료 회차 아무것이나 골라 그 누계를 전회로(현재 회차 거부, `round.junFrom`). 조달수수료 입력(Ctrl+J): 현재 줄을 Code AJJ01·LinkIndex 57·GN %·자리방법 Index·자리수 Gangub·계산 비목 OCheck·저장 칸 SCheck 로(57 줄이면 수정, 허용 검사·원본 문구). 원가계산서에 내역 금액 넣기: 원본은 끌어 놓기 — 웹은 줄 고르기 + 합계/노무/재료/경비 고르기 → 원가 줄 Cost 고정(LinkIndex 1, 덮어쓰기 확인). 구성비 표시: 원가 요율 칸을 「비율」로 바꿔 기준 행(CalcBase 또는 도급액) 대비 대상 비목 Cost 를 0.01% 절사·최대 나머지로 100% 맞춤(계산 불변). - 검증완료: 기성·바인딩 시험 14개 통과. ORCA 브라우저에서 구성비 20줄 합 100.00%·머리 「비율」, 내역 금액 넣기 노무비 384,804, Ctrl+J 조달수수료 AJJ01·57·0.54%·절하, 샘플 복구. - [x] 단가 도구(spec_pb2) — 중복 항목 정리(자재단가: 품명·규격·단위 빈칸·| 무시 같으면 TC 가장 큰 행만, 같으면 뒤 행, 묻지 않음), 단가정의 엑셀변환(표준시장단가 설명문 있는 항목만 「코드 / 번호. 품명 (규격) [단위]」+설명 줄, .xlsx), 할증·낙찰(고른 2행 이상 또는 전 행 Ratio·NachalRatio, 원래 단가 OTC 보관, 100 이면 자리 해제)·복원(두 율 0), 바꾸기(Main Data 로 현재 표 하나만 갱신, 못 찾음 표시), SUM(현재 줄을 항목선택계산 62 줄로: Code 'A'+두 자리 색인·적용율·비목, 원본 거부 문구, 줄 자신 SKind = 묶음 최대+1). 엔진: 62 줄 Code 를 두 자리 색인 번호로 읽음(설치 main.estx 의 「A01」 꼴, 예전 글자 해석은 A0103 의 C 를 놓쳤음). diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 91b146e97..e03a04309 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -19,6 +19,7 @@ + @@ -164,7 +165,8 @@ > + >
diff --git a/tools/est-test/native-actions-more.js b/tools/est-test/native-actions-more.js index 2c1a4608d..1f2c9e305 100644 --- a/tools/est-test/native-actions-more.js +++ b/tools/est-test/native-actions-more.js @@ -5,6 +5,7 @@ const EstActionsMore = (() => { typeof EstQuantityList !== "undefined" && EstQuantityList, typeof EstPriceTools !== "undefined" && EstPriceTools, typeof EstMiscTools !== "undefined" && EstMiscTools, + typeof EstExcelToEst !== "undefined" && EstExcelToEst, ].filter(Boolean); function handle(name, state, node) { for (const module of modules()) if (module.handle(name, state, node)) return true; diff --git a/tools/est-test/native-excel-to-est.js b/tools/est-test/native-excel-to-est.js new file mode 100644 index 000000000..17cecb3af --- /dev/null +++ b/tools/est-test/native-excel-to-est.js @@ -0,0 +1,370 @@ +// 원본 「엑셀데이타를 EST로 변환」(mnuExcelToEst → TfrmExcelToEst btnOkClick 0x109c3bc — spec_pc §1) 7개 탭: +// 노임 → 자재 → 견적 → 표준시장 → 단가산출 → 일위대가 → 내역서 차례로 한 Excel 파일의 시트들을 새 공사로 옮긴다(금액은 Excel 값 그대로, +// 다시 셈 끔). 셀 규칙: 문자 Trim·줄바꿈 제거·품명/규격 앞 「.」 제거·단위 M2→㎡ M3→㎥·산출근거 「*」→「×」, 수치는 쉼표 빼고, +// 노무비율 ≤ 1 이면 ×100, 열 「B:D」 는 이어 붙임. 단가산출·일위대가는 머리 줄 꼴(#.산근 · #| · 제n호표 · 산근 n 호표 : … (… 당) · +// (산근 · No n 호표 · No.n)을 찾아 호표를 만든다(원본의 글자 굵기 판정은 웹 Excel 읽기가 굵기를 몰라 하지 않음). +const EstExcelToEst = (() => { + const TABS = [ + ["LaborcDat", "노임단가", ["Code", "Name", "Size", "Unite", "Cost1", "Bigo"]], + [ + "StuffcDat", + "자재단가", + [ + "Code", + "Name", + "Size", + "Unite", + "Cost1", + "Page1", + "Cost2", + "Page2", + "Cost3", + "Page3", + "Cost4", + "Page4", + "Cost5", + "Page5", + "Cost6", + "Page6", + "TC", + "Bigo", + ], + ], + [ + "EstiMateDat", + "견적단가", + ["Code", "Name", "Size", "GN", "Unite", "TC", "NC", "GC", "JC", "Bigo"], + ], + [ + "SilJukDat", + "표준시장단가", + ["Code", "Name", "Size", "Unite", "TC", "NC", "GC", "JC", "NCRatio", "EXPLEAN", "Bigo"], + ], + ["CostMainDat", "단가산출", ["Code", "LineData", "TC", "NC", "GC", "JC"]], + [ + "HeadMainDat", + "일위대가", + [ + "Code", + "Name", + "Size", + "GN", + "Unite", + "TU", + "TC", + "NU", + "NC", + "GU", + "GC", + "JU", + "JC", + "Bigo", + ], + ], + [ + "EstMainDat", + "내역서", + [ + "Code", + "Pr", + "Name", + "Size", + "GN", + "Unite", + "TU", + "TC", + "NU", + "NC", + "GU", + "GC", + "JU", + "JC", + "Bigo", + ], + ], + ]; + const TEXT = new Set([ + "Code", + "Pr", + "Name", + "Size", + "Unite", + "Bigo", + "LineData", + "EXPLEAN", + "Page1", + "Page2", + "Page3", + "Page4", + "Page5", + "Page6", + ]); + const num = (v) => Number(v) || 0; + const letter = (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 col = (s) => + [...String(s).toUpperCase()].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) - 1; + const unit = (s) => + String(s) + .replace(/^[Mm]2$/, "㎡") + .replace(/^[Mm]3$/, "㎥"); + + function cell(table, r, spec) { + const [a, b] = String(spec).toUpperCase().split(":"); + if (!b) return table[r]?.[col(a)]; + let out = ""; + for (let c = col(a); c <= col(b); c++) { + const v = table[r]?.[c]; + if (v != null && String(v) !== "") out += String(v); + else if (out.length <= 1) out += " "; + } + return out; + } + function value(field, raw) { + if (TEXT.has(field)) { + let t = String(raw ?? "").replace(/\r?\n/g, ""); + t = field === "LineData" ? t.replaceAll("*", "×") : t.trim(); + if (field === "Unite") t = unit(t); + if (field === "Name" || field === "Size") t = t.replace(/^\.+/, ""); + return t; + } + let v = typeof raw === "number" ? raw : Number(String(raw ?? "").replaceAll(",", "")) || 0; + if (field === "NCRatio" && v <= 1) v *= 100; + return v; + } + function readRows(table, tab, fields, columns) { + const rows = []; + for (let r = tab.start - 1; r <= tab.end - 1; r++) { + const row = { Index: 0 }; + fields.forEach((f, i) => { + if (columns[i]) row[f] = value(f, cell(table, r, columns[i])); + }); + rows.push(row); + } + return rows; + } + + // 머리 줄 꼴 → { Name, Size, Unite, skipNext } + function costHead(text, next) { + const t = String(text ?? "").trim(), + split = (s) => { + const parts = s.trim().split(/\s+/); + return { + Name: parts[0] || "", + Size: parts.slice(1, -1).join(" "), + Unite: parts.length > 1 ? parts.at(-1) : "", + }; + }; + if (/^#\./.test(t) && t.includes("산근")) { + if (t.includes("|")) { + const [, n = "", s = "", u = ""] = t.split("|"); + return { Name: n.trim(), Size: s.trim(), Unite: u.trim() }; + } + return split(t.replace(/^.*?산근\s*\d*/, "").replace(/\//g, " ")); + } + if (/^#/.test(t) && t.includes("|")) { + const [, n = "", s = "", u = ""] = t.split("|"); + return { Name: n.trim(), Size: s.trim(), Unite: u.trim() }; + } + if (/산근.*호표/.test(t) && t.includes("당")) { + const body = t.split(/[::]/).slice(1).join(":"), + u = /\(\s*[\d.]*\s*([^()]*?)\s*당\s*\)/.exec(body)?.[1] || "", + rest = body + .replace(/\([^()]*당\s*\)/, "") + .trim() + .split(/\s+/); + return { Name: rest[0] || "", Size: rest.slice(1).join(" "), Unite: u }; + } + if (/제\s*\d+\s*호표/.test(t) && next != null) + return { ...split(String(next)), skipNext: true }; + if (t.replace(/\s/g, "").includes("(산근")) return split(t.replace(/\(산근[^)]*\)/, "")); + return null; + } + function headHead(name) { + const t = String(name ?? "").trim(); + if (/No/.test(t) && t.includes("호표")) + return { Name: t.split("호표").slice(1).join("호표").trim(), Size: "", Unite: "" }; + if (/^No\.\s*[1-9]/.test(t)) + return { Name: t.replace(/^No\.\s*\d+\s*/, ""), Size: "", Unite: "" }; + if (/\(호표/.test(t)) { + const parts = t + .replace(/\(호표[^)]*\)/, "") + .trim() + .split(/\s+/); + return { Name: parts[0] || "", Size: parts.slice(1).join(" "), Unite: "" }; + } + return null; + } + function groups(rows, titles, kind, headOf) { + let pk = 0; + const out = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (kind === 103 && String(row.Name ?? "").replace(/\s/g, "") === "[합계]") continue; + const h = headOf(row, rows[i + 1]); + if (!h) { + out.push(row); + continue; + } + const index = titles.length + 1, + code = `N${String(++pk).padStart(10, "0")}`; + titles.push({ + Index: index, + Code: code, + Name: h.Name, + Size: h.Size, + Unite: unit(h.Unite), + TC: num(row.TC), + NC: num(row.NC), + GC: num(row.GC), + JC: num(row.JC), + }); + Object.assign(row, { Code: code, TitleCode: code, Index: index }); + if (kind === 104) + row.LineData = `\x0e${h.Name}\x0e${h.Size ? `\x13${h.Size}\x13` : ""}${h.Unite ? `\x15${unit(h.Unite)}\x15` : ""}`; + out.push(row); + if (h.skipNext) i++; + } + return out; + } + + function convert(sheets, tabs, template) { + const project = EstNative.empty(); + project.name = template.name; + project.options = { ...project.options, AutoCalc: false, SourceRefresh: false }; + project.tables.Wonga = structuredClone(template.wonga || []); + const failed = []; + for (const [table, label, fields] of TABS) { + const tab = tabs[table]; + if (!tab?.sheet || !(tab.start > 0) || !(tab.end > 0)) continue; + const data = sheets[tab.sheet]; + if (!data) { + failed.push(`${tab.sheet || label} 시트가 없습니다.`); + continue; + } + let rows = readRows(data, tab, fields, tab.columns); + if (table === "LaborcDat") rows.forEach((r) => (r.TC = r.Cost1)); + if (table === "SilJukDat" || table === "EstiMateDat") + rows.forEach((r) => + Object.assign(r, { + OTC: r.TC, + ONC: r.NC, + OGC: r.GC, + OJC: r.JC, + ...(table === "SilJukDat" && { Cost1: r.TC }), + }), + ); + if (table === "CostMainDat") { + project.tables.CostTitleDat = []; + rows = groups(rows, project.tables.CostTitleDat, 104, (r, n) => + costHead(r.LineData, n?.LineData), + ); + } else if (table === "HeadMainDat") { + project.tables.HeadTitleDat = []; + rows = groups(rows, project.tables.HeadTitleDat, 103, (r) => headHead(r.Name)); + } + project.tables[table] = rows; + } + while (project.tables.EstMainDat.length < 36) project.tables.EstMainDat.push({ Index: 0 }); + let pk = 0; + for (const t of Object.keys(project.tables)) + for (const r of project.tables[t]) + if (r && typeof r === "object" && r.PkID == null) r.PkID = ++pk; + for (const t of ["LaborcDat", "StuffcDat", "SilJukDat", "EstiMateDat"]) + for (const r of project.tables[t] || []) + if (!String(r.Code ?? "").trim()) r.Code = `N${String(r.PkID).padStart(10, "0")}`; + return { project, failed }; + } + + // 화면 + function form(state) { + const x = (state.e2e ||= { + tab: "LaborcDat", + tabs: Object.fromEntries( + TABS.map(([t, label, fields]) => [ + t, + { sheet: label, start: 0, end: 0, columns: fields.map((_, i) => letter(i)) }, + ]), + ), + }); + const { esc } = EstApp, + [, , fields] = TABS.find(([t]) => t === x.tab), + tab = x.tabs[x.tab]; + EstApp.showDialog( + "엑셀데이타를 EST로 변환", + `
${TABS.map(([t, l]) => ``).join("")}

${esc(x.file || "")} ${x.sheets ? `(시트 ${Object.keys(x.sheets).length}개)` : ""}

해당 칼럼명칭 입력(엑셀 열 글자, 비우면 건너뜀, 「B:D」 는 이어 붙임)

${fields.map((f, i) => ``).join("")}
`, + ); + } + function read(state) { + const x = state.e2e, + tab = x.tabs[x.tab], + q = (n) => document.querySelector(`#dialog-content [name="${n}"]`); + if (!q("e2e-sheet")) return; + tab.sheet = q("e2e-sheet").value.trim(); + tab.start = Number(q("e2e-start").value) || 0; + tab.end = Number(q("e2e-end").value) || 0; + document + .querySelectorAll('#dialog-content [name="e2e-col"]') + .forEach((n) => (tab.columns[Number(n.dataset.i)] = n.value.trim().toUpperCase())); + } + async function run(name, state, node) { + if (name === "excel-to-est") return form(state); + read(state); + const x = state.e2e; + if (name === "e2e-tab") x.tab = node.dataset.tab; + else if (name === "e2e-open") { + const file = await new Promise((resolve) => { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = ".xlsx,.xls"; + picker.addEventListener("change", () => resolve(picker.files[0])); + picker.click(); + }); + if (!file) return; + const body = await file.arrayBuffer(); + x.sheets = /\.xls$/i.test(file.name) + ? EstJdlCompare.xlsTables(await EstApp.api("/api/read-xls", { method: "POST", body })) + : EstExcelMap.sheetTables(await EstApp.api("/api/import-xlsx", { method: "POST", body })); + x.file = file.name; + } else if (name === "e2e-run") { + const tabs = Object.values(x.tabs); + if ( + tabs.every((t) => !t.start) || + tabs.every((t) => !t.end) || + tabs.some((t) => t.end < t.start) + ) + throw Error("변환할 엑셀자료의 범위설정이 잘못되었습니다."); + if (!confirm("현재 작업을 엑셀 자료로 만든 새 공사로 바꾸시겠습니까?")) return; + const out = convert(x.sheets, x.tabs, { + name: x.file.replace(/\.xlsx?$/i, ""), + wonga: (state.project.tables.Wonga || []).map(({ Cost, ...rest }) => rest), + }); + if ( + out.failed.length && + !confirm(`${out.failed.join("\n")}\n변환을 못한 시트가 있습니다.\n\n종료하시겠습니까?`) + ) + return; + EstApp.byId("dialog").close(); + EstApp.setProject(out.project); + state.notice = + "엑셀 역변환 작업이 완료되었습니다. 금액은 엑셀 값 그대로이며 자동계산은 꺼 두었습니다."; + EstApp.render(); + return; + } + form(state); + } + const names = new Set(["excel-to-est", "e2e-tab", "e2e-open", "e2e-run"]); + function handle(name, state, node) { + if (!names.has(name)) return false; + run(name, state, node).catch((error) => { + state.notice = error.message; + EstApp.render(); + }); + return true; + } + return { convert, costHead, headHead, handle }; +})(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 5573ec358..e4620e34f 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -99,6 +99,7 @@ const allowed = new Set([ "native-quantity-list.js", "native-price-tools.js", "native-misc-tools.js", + "native-excel-to-est.js", "native-actions-more.js", "native-wonga-tools.js", "native-estimate-print.js",