diff --git a/resources/tester/helper_est_native.cjs b/resources/tester/helper_est_native.cjs index 587ebb92f..a389e3db4 100644 --- a/resources/tester/helper_est_native.cjs +++ b/resources/tester/helper_est_native.cjs @@ -40,6 +40,7 @@ for (const file of [ "native-print-range.js", "native-work-mode.js", "native-dongwon-check.js", + "native-excel-files.js", "native-business-actions.js", "native-forms.js", "native-excel-to-est.js", diff --git a/resources/tester/test_est_excel_files.py b/resources/tester/test_est_excel_files.py new file mode 100644 index 000000000..50fb85ba6 --- /dev/null +++ b/resources/tester/test_est_excel_files.py @@ -0,0 +1,26 @@ +"""엑셀물량 파일목록: 추가(같은 이름 거부·번호)·삭제.""" + +from test_est_rea_parity import run + + +def test_add_remove_and_duplicate_name(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty(),F=EstExcelFiles; + F.add(p,'a.xlsx','QUJD');F.add(p,'b.xls','REVG'); + let dup=null;try{F.add(p,'a.xlsx','')}catch(e){dup=e.message} + const idx=p.excelFiles.map(f=>f.Index); + F.remove(p,'a.xlsx');const after=p.excelFiles.map(f=>f.FileName); + F.add(p,'c.xlsx','');const next=p.excelFiles.at(-1).Index; + F.remove(p,'b.xls');F.remove(p,'c.xlsx'); + return {dup,idx,after,next,gone:'excelFiles' in p}; + })()""", + ) + assert result == { + "dup": "같은 이름의 파일이 내역서에 포함되어 있습니다.", + "idx": [1, 2], + "after": ["b.xls"], + "next": 3, + "gone": False, + } diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index ef41f72bb..974633e6a 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -508,6 +508,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD - 검증완료: 작업모드 시험 2개·ESTX 저장 시험 34개 통과. ORCA 브라우저에서 표준내역서로 바꿈 → 표준 양식 30종 → 열기, 내역서로 돌아오면 원래 원가계산서 31행 복원, 계약심사목록 창 확인, 원복. - [x] 동원 확인·내역서 탭(TfrmDongWon Button1~4 · spec_part §4) — 동원 화면 단추 확인 장비·단산·일위: 호표마다 1 단위를 펼친 항목 금액 합과 호표 금액을 대조해 10원 넘게 어긋나면 표시(어긋난 것만 보기), 행을 고르면 당초값·계산값(노무·재료·경비·실적)·항목 목록. 내역서: 내역 줄마다 펼친 항목. 웹 동원 계산은 기계경비를 더 쪼개지 않아 장비 확인은 늘 맞음. - 검증완료: 확인 시험 1개·동원 관련 시험 35개 통과. ORCA 브라우저에서 일위 8건 모두 일치·당초값=계산값, 항목 3개 표시. + - [x] 엑셀물량 파일목록(TfrmExcelMullang btnNewProject·btnDelete · spec_pa §7) — 공사에 딸린 엑셀 물량 파일을 작업에 담음(Index·파일명). 추가 = 파일 골라 목록에(같은 이름 거부 — 원본은 엑셀에 띄우기만, 웹은 엑셀이 없어 여기서 등록) · 삭제 = 확인 뒤 목록·담은 파일 지움(셀 연결은 남음) · 열기 = 내려받기. 셀 수량 연결 창에서 목록 파일을 골라 쓰고, 새 파일로 연결하면 목록에 들어감. ESTX 저장은 JSON 안내. + - 검증완료: 파일목록 시험 1개·수량산출 시험 6개 통과. ORCA 브라우저에서 목록 등록 → 목록 파일로 B2 셀 연결(수량 12.5)·연결 수 1 → 삭제 확인, 원복. - [x] 다른 공사 불러오기 창(원본 barbtnSelfLoad → TfrmOtherLoad, 찾기 popSerch) — 저장된 다른 작업을 골라 같은 표를 띄우고 찾기(품명·규격, A&B 둘 다)·고른 줄을 하위 자료와 함께 가져오기(내역서는 고른 줄 아래에 줄, 나머지는 자료·호표 합침, 같은 코드는 이 공사 것). 검증완료: 바인딩 통과, ORCA 브라우저에서 산불진화임도 작업의 일위대가 3개 → 「취수장」 1개 → 가져오기 호표 8→9·하위 41건, 샘플 복구. - [x] 사용위치 검색 창(원본 TfrmUsesDataSerch btnSerch·btnAllDisplay) — 현재 표 항목을 품명·규격(빈칸·|·괄호 무시)으로 거르고 골라 사용위치 보기. 검증완료: 바인딩 통과, ORCA 브라우저에서 노임 9개 → 「보 통」 1개 → 보통인부 사용위치 11곳. - [x] 인쇄양식 불러오기·저장하기(원본 mnuPrintFormatLoad·Save 0x10ed318·0x10ed000 — spec_pc §3) — .pfot(인쇄정보표 MIDAS 이진) 읽기·쓰기 `print-format.cjs`(설명 BLOB·빈 칸 표지까지 보존, 원본 파일 그대로 다시 쓰면 바이트 같음). 불러오면 공사 인쇄표를 그 값으로, 저장은 지금 인쇄표를 원본 설치 main.pfot 필드 설명자 위에(`EST_PFOT_TEMPLATE`). .gfot(cxGrid 열 배치)는 웹 격자와 호환되지 않아 하지 않음. diff --git a/tools/est-test/export-estx.cjs b/tools/est-test/export-estx.cjs index 3631e4b29..e586037fe 100644 --- a/tools/est-test/export-estx.cjs +++ b/tools/est-test/export-estx.cjs @@ -164,6 +164,8 @@ function exportEstx(project) { ); if (project.attachments?.length) throw Error("웹 첨부파일은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요."); + if (project.excelFiles?.length) + throw Error("웹 엑셀물량 파일목록은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요."); if (project.excelQuantityLinks?.length) throw Error("웹 Excel 수량 연결은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요."); if (project.schedule?.length) diff --git a/tools/est-test/index.html b/tools/est-test/index.html index 28897203e..40cd07137 100644 --- a/tools/est-test/index.html +++ b/tools/est-test/index.html @@ -28,6 +28,7 @@ + @@ -126,6 +127,7 @@ > + diff --git a/tools/est-test/native-actions-more.js b/tools/est-test/native-actions-more.js index 973a9b54d..30ca6eaf2 100644 --- a/tools/est-test/native-actions-more.js +++ b/tools/est-test/native-actions-more.js @@ -13,6 +13,7 @@ const EstActionsMore = (() => { typeof EstPrintRange !== "undefined" && EstPrintRange, typeof EstWorkMode !== "undefined" && EstWorkMode, typeof EstDongwonCheck !== "undefined" && EstDongwonCheck, + typeof EstExcelFiles !== "undefined" && EstExcelFiles, ].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-files.js b/tools/est-test/native-excel-files.js new file mode 100644 index 000000000..cc364f84e --- /dev/null +++ b/tools/est-test/native-excel-files.js @@ -0,0 +1,101 @@ +// 원본 「엑셀물량 파일목록」(TfrmExcelMullang — spec_pa §7 · spec_small §8.3): 공사에 딸린 엑셀 물량 파일 목록(Index·FileName). +// 원본은 파일을 공사 폴더에 두고, 웹은 작업(project.excelFiles)에 파일 내용을 담는다. 추가(btnNewProject) = 파일을 골라 목록에 +// 넣음(원본은 엑셀에 띄우기만 하고 셀 연결 때 등록 — 웹은 엑셀이 없어 여기서 등록) · 같은 이름은 거부. 삭제(btnDelete) = 목록과 +// 담은 파일을 지움(원본도 공사 폴더의 파일을 지움) — 그 파일 셀 연결은 남아 다음 갱신 때 못 찾음으로 드러남. 열기 = 내려받기. +// 셀 수량 연결 창은 목록의 파일을 골라 쓸 수 있고, 새 파일로 연결하면 목록에 들어간다(0xc7328c). +const EstExcelFiles = (() => { + const list = (project) => project.excelFiles || []; + function add(project, name, data) { + const files = (project.excelFiles ||= []); + if (files.some((f) => f.FileName === name)) + throw Error("같은 이름의 파일이 내역서에 포함되어 있습니다."); + const entry = { + Index: files.reduce((m, f) => Math.max(m, Number(f.Index) || 0), 0) + 1, + FileName: name, + data, + }; + files.push(entry); + return entry; + } + function remove(project, name) { + const files = list(project), + at = files.findIndex((f) => f.FileName === name); + if (at < 0) throw Error("삭제할 엑셀파일을 선택하지 않았습니다."); + files.splice(at, 1); + if (!files.length) delete project.excelFiles; + } + const toBase64 = (buffer) => { + const bytes = new Uint8Array(buffer); + let out = ""; + for (let i = 0; i < bytes.length; i += 0x8000) + out += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(out); + }; + const toBuffer = (data) => Uint8Array.from(atob(data), (c) => c.charCodeAt(0)).buffer; + + let picked = ""; + function dialog(state) { + const { esc } = EstApp, + files = list(state.project), + links = state.project.excelQuantityLinks || []; + EstApp.showDialog( + "엑셀물량 파일목록", + ` ${ + files + .map( + (f) => + ``, + ) + .join("") || '' + }
파 일 명셀 연결
${esc(f.FileName)}${links.filter((l) => l.file === f.FileName).length}
등록된 엑셀 파일이 없습니다.
`, + ); + } + const chosen = () => + document.querySelector('#dialog-content [name="excel-file-pick"]:checked')?.value || ""; + + async function run(name, state) { + if (name === "excel-files") return dialog(state); + if (name === "excel-files-add") { + const file = await new Promise((resolve) => { + const picker = document.createElement("input"); + picker.type = "file"; + picker.accept = ".xls,.xlsx"; + picker.addEventListener("change", () => resolve(picker.files[0])); + picker.click(); + }); + if (!file) return; + const data = toBase64(await file.arrayBuffer()); + EstApp.change((project) => add(project, file.name, data), false); + picked = file.name; + return dialog(state); + } + const target = chosen(); + if (name === "excel-files-delete") { + if (!target.trim()) throw Error("삭제할 엑셀파일을 선택하지 않았습니다."); + if (!confirm(`${target}를현재작업목록에서 삭제하시겠습니까?`)) return; + EstApp.change((project) => remove(project, target), false); + return dialog(state); + } + if (name === "excel-files-open") { + const file = list(state.project).find((f) => f.FileName === target); + if (!file) throw Error("열 엑셀파일을 선택하세요."); + EstApp.download(file.FileName, new Blob([toBuffer(file.data)]), "application/octet-stream"); + } + } + function handle(name, state) { + if ( + !["excel-files", "excel-files-add", "excel-files-delete", "excel-files-open"].includes(name) + ) + return false; + run(name, state).catch((error) => { + const note = EstApp.byId("excel-files-error"); + if (note) note.textContent = error.message; + else { + state.notice = error.message; + EstApp.render(); + } + }); + return true; + } + return { add, remove, list, toBase64, toBuffer, handle }; +})(); diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index 16bb77624..4134648a6 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -255,7 +255,7 @@ const EstQuantity = (() => { const rows = state.project.tables.EstMainDat; showDialog( "Excel 셀 수량 반영", - `

파일명·시트·셀 연결은 작업 JSON에 저장합니다. 저장된 숫자 값을 사용하므로 Excel 파일이 바뀌면 연결 관리에서 다시 선택하세요.

`, + `

파일명·시트·셀 연결은 작업 JSON에 저장합니다. 저장된 숫자 값을 사용하므로 Excel 파일이 바뀌면 연결 관리에서 다시 선택하세요.

`, ); } function excelUpdates(project, filename, files) { @@ -486,7 +486,16 @@ const EstQuantity = (() => { const project = state.project, revision = state.revision, fileInput = document.getElementById("quantity-excel-file"), - file = fileInput.files[0], + uploaded = fileInput.files[0] || null, + stored = (project.excelFiles || []).find( + (f) => f.FileName === document.getElementById("quantity-excel-stored")?.value, + ), + file = + fileInput.files[0] || + (stored && { + name: stored.FileName, + arrayBuffer: async () => EstExcelFiles.toBuffer(stored.data), + }), name = document.getElementById("quantity-excel-sheet").value.trim(), address = document.getElementById("quantity-excel-cell").value, selected = document.getElementById("quantity-excel-target").value, @@ -498,16 +507,14 @@ const EstQuantity = (() => { project.tables.EstMainDat.filter((item) => item.PkID === row.PkID).length !== 1 ) throw Error("내역 행 식별자가 없거나 중복되어 연결할 수 없습니다."); - const files = await EstApp.api("/api/import-xlsx", { - method: "POST", - body: await file.arrayBuffer(), - }), + const bytes = await file.arrayBuffer(), + files = await EstApp.api("/api/import-xlsx", { method: "POST", body: bytes }), value = excelCell(files, name, address); if ( state.project !== project || state.revision !== revision || document.getElementById("quantity-excel-file") !== fileInput || - fileInput.files[0] !== file || + (fileInput.files[0] || null) !== uploaded || document.getElementById("quantity-excel-sheet")?.value.trim() !== name || document.getElementById("quantity-excel-cell")?.value !== address || document.getElementById("quantity-excel-target")?.value !== selected || @@ -526,6 +533,12 @@ const EstQuantity = (() => { sheet: name, cell: address.trim().toUpperCase(), }); + // 셀 연결 때 엑셀물량 파일목록에 없으면 등록(0xc7328c) + if ( + typeof EstExcelFiles !== "undefined" && + !(project.excelFiles || []).some((f) => f.FileName === file.name) + ) + EstExcelFiles.add(project, file.name, EstExcelFiles.toBase64(bytes)); }); document.getElementById("dialog").close(); })().catch((error) => { diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 6a77b94a2..80503723f 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -112,6 +112,7 @@ const allowed = new Set([ "native-print-range.js", "native-work-mode.js", "native-dongwon-check.js", + "native-excel-files.js", "native-actions-more.js", "native-wonga-tools.js", "native-estimate-print.js",