From aac94d6508849c5f57c3de56e6075e0b422d7e4f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 15:49:57 +0900 Subject: [PATCH] feat(est): apply quantity from an Excel cell --- .../tester/test_est_quantity_workflow.py | 21 +++++ tools/est-test/PLAN.md | 2 + tools/est-test/README.md | 2 + tools/est-test/native-quantity.cjs | 90 ++++++++++++++++++- 4 files changed, 114 insertions(+), 1 deletion(-) diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py index e7f9ec595..f14da7907 100644 --- a/resources/tester/test_est_quantity_workflow.py +++ b/resources/tester/test_est_quantity_workflow.py @@ -1,6 +1,27 @@ from test_est_rea_parity import run +def test_excel_quantity_cell_rejects_invalid_address_or_non_numeric_value(): + result = run( + "math", + """(()=>{ + let type='n',value='6.25'; + const sheet={getAttribute:k=>k==='name'?'Data':null,getAttributeNS:()=> 'r1'}; + const rel={getAttribute:k=>({Id:'r1',Target:'/xl/worksheets/sheet1.xml'})[k]}; + const cell={getAttribute:k=>({r:'B2',t:type})[k],getElementsByTagNameNS:()=>[{textContent:value}]}; + const doc=items=>({querySelector:()=>null,getElementsByTagNameNS:()=>items}); + globalThis.DOMParser=class{parseFromString(x){return x==='workbook'?doc([sheet]):x==='rels'?doc([rel]):doc([cell])}}; + const files={'xl/workbook.xml':'workbook','xl/_rels/workbook.xml.rels':'rels','xl/worksheets/sheet1.xml':'sheet'}; + const valid=EstQuantity.excelCell(files,'Data','b2');let rejected=0; + for(const [name,address,t,v] of [['Other','B2','n','1'],['Data','XFE1','n','1'],['Data','B3','n','1'],['Data','B2','s','1'],['Data','B2','n',''],['Data','B2','n','1e100']]){ + type=t;value=v;try{EstQuantity.excelCell(files,name,address)}catch{rejected++} + } + return {valid,rejected}; + })()""", + ) + assert result == {"valid": 6.25, "rejected": 6} + + def test_apply_all_quantities_sums_sheets_and_rejects_errors_atomically(): result = run( "math", diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index ddfe2464b..0334c5d3e 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -1,4 +1,6 @@ ## Codex · EST 독립 테스트 앱 (2026-11-04) +- [x] Excel 숫자 셀을 선택한 내역 행의 수량으로 반영 → 파일·시트·셀 오류 차단, 계산·저장·취소 확인. + - 자체 검증: Tailscale HTTP Edge에서 실제 xlsx B2=6.25 반영·금액 625·취소·JSON 저장, 문자·미계산 수식·누락·범위 오류 차단 확인. 수량·Excel 시험 23개 통과, openpyxl 경고 7건. 자동 파일 감시는 없음. - [x] Tailscale HTTP에서 수량산출서와 산출 행 생성 → 생성·JSON 저장·복원 확인. - 자체 검증: Tailscale HTTP Edge에서 산출서·행 생성, JSON 저장·재열기 확인. 수량·독립 서버 시험 19개 통과. - [x] 기존 계산 불일치 수량산출서도 품명·규격만 바꿀 때 원본 수량 유지 → 실자료 재열기 확인. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index a3a897528..f0ecf19ce 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -54,6 +54,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 수량산출서와 수식표에서 내역 행을 직접 선택해 연결할 수 있습니다. 내역 행 선택을 사용하면 코드가 없거나 중복되어도 해당 행에만 수량을 반영합니다. 코드를 직접 입력할 때는 해당 상세 내역이 정확히 하나여야 하며, 대상이 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다. +수량 화면의 `Excel 셀 수량 반영`은 `.xlsx` 파일과 시트·셀·내역 행을 선택해 저장된 숫자 값을 한 번 반영합니다. 수식 셀은 파일에 저장된 계산값을 사용하며, 저장된 값이 없거나 숫자가 아니면 반영하지 않습니다. Excel 파일이 바뀌면 다시 반영해야 합니다. + 수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다. 원본 ESTX의 기존 수량산출 항목은 품명·규격을 수정해 ESTX에 저장할 수 있습니다. 연결 변경과 새 항목 추가·삭제는 계속 JSON으로 보관합니다. diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index 806d289b2..0d77b0002 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -210,7 +210,59 @@ const EstQuantity = (() => { } function selector(state) { const { esc } = EstApp; - return `
`; + return `
`; + } + function excelCell(files, name, address) { + const parse = (text) => { + if (!text || / n * 26 + c.charCodeAt(0) - 64, 0) > 16384 || + Number(parts[2]) > 1048576 + ) + throw Error("Excel 셀 주소를 확인하세요. 예: B12"); + const workbook = parse(files["xl/workbook.xml"]), + sheet = [...workbook.getElementsByTagNameNS("*", "sheet")].find( + (s) => s.getAttribute("name") === name, + ); + if (!sheet) throw Error(`Excel 시트가 없습니다: ${name}`); + const id = sheet.getAttributeNS( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "id", + ), + rels = parse(files["xl/_rels/workbook.xml.rels"]), + rel = [...rels.getElementsByTagNameNS("*", "Relationship")].find( + (r) => r.getAttribute("Id") === id, + ), + target = rel?.getAttribute("Target") || "", + path = target.startsWith("/") ? target.slice(1) : `xl/${target}`; + const doc = parse(files[path]), + cell = [...doc.getElementsByTagNameNS("*", "c")].find((c) => c.getAttribute("r") === address); + if (!cell || (cell.getAttribute("t") && cell.getAttribute("t") !== "n")) + throw Error(`${name} ${address}: 숫자 셀이 아닙니다.`); + const value = cell.getElementsByTagNameNS("*", "v")[0]?.textContent; + if ( + value == null || + !value.trim() || + !Number.isFinite(Number(value)) || + Math.abs(Number(value)) > Number.MAX_SAFE_INTEGER + ) + throw Error(`${name} ${address}: 저장된 숫자 값이 없습니다.`); + return Number(value); + } + function excelDialog() { + const { state, esc, showDialog } = EstApp; + const rows = state.project.tables.EstMainDat; + showDialog( + "Excel 셀 수량 반영", + `

저장된 숫자 값을 한 번 반영합니다. Excel에서 값이 바뀌면 다시 반영하세요.

`, + ); } function render(state) { state.quantitySheet ||= state.project.quantitySheets[0]?.Code; @@ -274,6 +326,41 @@ const EstQuantity = (() => { }); document.addEventListener("click", (event) => { const { state } = EstApp; + if (event.target.closest("[data-quantity-excel]")) { + excelDialog(); + return; + } + if (event.target.closest("[data-quantity-excel-apply]")) { + const project = state.project, + revision = state.revision, + fileInput = document.getElementById("quantity-excel-file"), + file = fileInput.files[0], + name = document.getElementById("quantity-excel-sheet").value.trim(), + address = document.getElementById("quantity-excel-cell").value, + selected = document.getElementById("quantity-excel-target").value, + row = selected === "" ? null : project.tables.EstMainDat[Number(selected)]; + (async () => { + if (!file || !name || !row) throw Error("파일·시트·셀·내역 행을 선택하세요."); + const files = await EstApp.api("/api/import-xlsx", { + method: "POST", + body: await file.arrayBuffer(), + }), + value = excelCell(files, name, address); + if ( + state.project !== project || + state.revision !== revision || + document.getElementById("quantity-excel-file") !== fileInput || + project.tables.EstMainDat[Number(selected)] !== row + ) + throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요."); + EstApp.change(() => (row.GN = value)); + document.getElementById("dialog").close(); + })().catch((error) => { + const output = document.getElementById("quantity-excel-error"); + if (output) output.textContent = error.message; + }); + return; + } if (event.target.closest("[data-quantity-new]")) { EstApp.change(() => { const code = `Q${EstApp.uid()}`; @@ -353,6 +440,7 @@ const EstQuantity = (() => { totals, allTotals, applyTotals, + excelCell, targetSelect, newCode, selector,