diff --git a/resources/tester/test_est_attachments.py b/resources/tester/test_est_attachments.py index 90ee60318..43363504a 100644 --- a/resources/tester/test_est_attachments.py +++ b/resources/tester/test_est_attachments.py @@ -47,3 +47,15 @@ def test_estx_export_rejects_web_attachments_instead_of_dropping_them(): timeout=10, ) assert "JSON" in result.stdout.decode("utf-8") + + +def test_estx_export_rejects_web_excel_links_instead_of_dropping_them(): + script = """try{require('./tools/est-test/export-estx.cjs').exportEstx({originalArchive:'AA==',excelQuantityLinks:[{}]});process.exit(1)}catch(e){process.stdout.write(e.message)}""" + result = subprocess.run( + ["node", "-e", script], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + check=True, + timeout=10, + ) + assert "JSON" in result.stdout.decode("utf-8") diff --git a/resources/tester/test_est_quantity_workflow.py b/resources/tester/test_est_quantity_workflow.py index f14da7907..78cb2c685 100644 --- a/resources/tester/test_est_quantity_workflow.py +++ b/resources/tester/test_est_quantity_workflow.py @@ -16,10 +16,31 @@ def test_excel_quantity_cell_rejects_invalid_address_or_non_numeric_value(): 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++} } + type='n';value='8';const p={tables:{EstMainDat:[{PkID:1,GN:2},{PkID:2,GN:3}]},excelQuantityLinks:[{target:1,file:'a.xlsx',sheet:'Data',cell:'B2'},{target:2,file:'a.xlsx',sheet:'Data',cell:'B2'}]}; + const updates=EstQuantity.excelUpdates(p,'a.xlsx',files).map(x=>x.value),before=p.tables.EstMainDat.map(x=>x.GN);let bad=0; + p.excelQuantityLinks[1].cell='B3';try{EstQuantity.excelUpdates(p,'a.xlsx',files)}catch{bad++} + p.tables.EstMainDat.push({PkID:1});try{EstQuantity.excelUpdates(p,'a.xlsx',files)}catch{bad++} + try{EstQuantity.excelUpdates(p,'missing.xlsx',files)}catch{bad++} + return {valid,rejected,updates,before,bad}; + })()""", + ) + assert result == {"valid": 6.25, "rejected": 6, "updates": [8, 8], "before": [2, 3], "bad": 3} + + +def test_excel_quantity_links_validate_and_survive_project_validation(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.excelQuantityLinks=[{target:1,file:'a.xlsx',sheet:'Data',cell:'B2'}]; + const valid=EstModel.validate(p).excelQuantityLinks;let rejected=0; + for(const bad of [[valid[0],valid[0]],[{...valid[0],target:'1'}],[{...valid[0],cell:'wrong'}],[{...valid[0],cell:'XFE1'}],[{...valid[0],cell:'A1048577'}]]){ + const q=EstNative.empty();q.excelQuantityLinks=bad;try{EstModel.validate(q)}catch{rejected++} + } return {valid,rejected}; })()""", ) - assert result == {"valid": 6.25, "rejected": 6} + assert result["valid"] == [{"target": 1, "file": "a.xlsx", "sheet": "Data", "cell": "B2"}] + assert result["rejected"] == 5 def test_apply_all_quantities_sums_sheets_and_rejects_errors_atomically(): diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md index 0334c5d3e..739296c6f 100644 --- a/tools/est-test/PLAN.md +++ b/tools/est-test/PLAN.md @@ -1,4 +1,6 @@ ## Codex · EST 독립 테스트 앱 (2026-11-04) +- [x] Excel 셀과 내역 행 연결을 JSON에 보관하고 다시 선택한 파일로 일괄 갱신 → 중복·누락·오류 시 전체 변경 차단. + - 자체 검증: Tailscale HTTP Edge에서 2행 연결·JSON 재열기·일괄 갱신·실행 취소·연결 해제 확인. 한 셀 오류 시 두 행 모두 유지, ESTX 저장에서 연결 누락 차단. 수량·첨부·독립 서버 시험 25개 통과. - [x] Excel 숫자 셀을 선택한 내역 행의 수량으로 반영 → 파일·시트·셀 오류 차단, 계산·저장·취소 확인. - 자체 검증: Tailscale HTTP Edge에서 실제 xlsx B2=6.25 반영·금액 625·취소·JSON 저장, 문자·미계산 수식·누락·범위 오류 차단 확인. 수량·Excel 시험 23개 통과, openpyxl 경고 7건. 자동 파일 감시는 없음. - [x] Tailscale HTTP에서 수량산출서와 산출 행 생성 → 생성·JSON 저장·복원 확인. diff --git a/tools/est-test/README.md b/tools/est-test/README.md index f0ecf19ce..2252c4717 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -54,7 +54,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 수량산출서와 수식표에서 내역 행을 직접 선택해 연결할 수 있습니다. 내역 행 선택을 사용하면 코드가 없거나 중복되어도 해당 행에만 수량을 반영합니다. 코드를 직접 입력할 때는 해당 상세 내역이 정확히 하나여야 하며, 대상이 없거나 중복되면 다른 항목을 포함해 반영을 중단하고 오류를 표시합니다. -수량 화면의 `Excel 셀 수량 반영`은 `.xlsx` 파일과 시트·셀·내역 행을 선택해 저장된 숫자 값을 한 번 반영합니다. 수식 셀은 파일에 저장된 계산값을 사용하며, 저장된 값이 없거나 숫자가 아니면 반영하지 않습니다. Excel 파일이 바뀌면 다시 반영해야 합니다. +수량 화면의 `Excel 셀 수량 연결`은 `.xlsx` 파일과 시트·셀·내역 행을 연결하고 저장된 숫자 값을 반영합니다. 연결 정보는 작업 JSON에 저장되며, `Excel 연결 관리`에서 같은 이름의 파일을 다시 선택해 해당 파일의 연결을 일괄 갱신하거나 연결을 해제할 수 있습니다. 어느 연결에 오류가 있어도 갱신 전체를 중단합니다. 수식 셀은 파일에 저장된 계산값을 사용하며, 저장된 값이 없거나 숫자가 아니면 반영하지 않습니다. Excel 파일은 자동 감시하지 않습니다. 웹 Excel 연결은 원본 ESTX에 저장할 수 없어 JSON으로 보관합니다. 수량 화면에서 새 산출서를 만들고 이름과 산출 항목의 품명·규격을 편집할 수 있습니다. `산출 행 추가`로 결과 표지가 연결된 행과 항목을 함께 만든 뒤 수식을 입력하고, 연결 내역코드를 지정해 계산 수량을 내역에 반영합니다. 행 아래 삽입, 위·아래 이동과 산출 행·산출서 삭제도 가능하며, 이 구조 변경은 JSON 저장과 다시 열기를 지원합니다. 기존 연결은 내역코드를 비워 해제할 수 있습니다. diff --git a/tools/est-test/export-estx.cjs b/tools/est-test/export-estx.cjs index 26f49c2dc..56bd65cb8 100644 --- a/tools/est-test/export-estx.cjs +++ b/tools/est-test/export-estx.cjs @@ -137,6 +137,8 @@ function exportEstx(project) { throw Error("원본 ESTX를 가져온 작업에서만 ESTX 수정 저장이 가능합니다."); if (project.attachments?.length) throw Error("웹 첨부파일은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요."); + if (project.excelQuantityLinks?.length) + throw Error("웹 Excel 수량 연결은 ESTX에 저장할 수 없습니다. JSON 저장을 사용하세요."); const original = Buffer.from(project.originalArchive, "base64"), base = importEstx(original), files = unzip(original); diff --git a/tools/est-test/native-model.js b/tools/est-test/native-model.js index 4eb28fdf1..5d7423456 100644 --- a/tools/est-test/native-model.js +++ b/tools/est-test/native-model.js @@ -345,6 +345,26 @@ const EstModel = (() => { if (s[key] != null && (typeof s[key] !== "string" || s[key].length > 100000)) throw Error("인쇄 문구 형식 오류"); } + if (project.excelQuantityLinks != null) { + if (!Array.isArray(project.excelQuantityLinks)) throw Error("Excel 수량 연결 형식 오류"); + const targets = new Set(); + for (const link of project.excelQuantityLinks) { + if ( + !link || + !Number.isSafeInteger(link.target) || + targets.has(link.target) || + typeof link.file !== "string" || + !link.file || + link.file.length > 255 || + typeof link.sheet !== "string" || + !link.sheet || + link.sheet.length > 255 || + !EstQuantity.validExcelAddress(link.cell) + ) + throw Error("Excel 수량 연결 오류"); + targets.add(link.target); + } + } if (project.documents != null) { if (typeof project.documents !== "object" || Array.isArray(project.documents)) throw Error("문서 본문 형식 오류"); diff --git a/tools/est-test/native-quantity.cjs b/tools/est-test/native-quantity.cjs index 0d77b0002..e4e2f65d3 100644 --- a/tools/est-test/native-quantity.cjs +++ b/tools/est-test/native-quantity.cjs @@ -210,7 +210,15 @@ const EstQuantity = (() => { } function selector(state) { const { esc } = EstApp; - return `
`; + return `
`; + } + function validExcelAddress(address) { + const parts = typeof address === "string" && address.match(/^([A-Z]{1,3})([1-9]\d*)$/); + return Boolean( + parts && + [...parts[1]].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) <= 16384 && + Number(parts[2]) <= 1048576, + ); } function excelCell(files, name, address) { const parse = (text) => { @@ -220,13 +228,7 @@ const EstQuantity = (() => { return doc; }; address = address.trim().toUpperCase(); - const parts = address.match(/^([A-Z]{1,3})([1-9]\d*)$/); - if ( - !parts || - [...parts[1]].reduce((n, c) => n * 26 + c.charCodeAt(0) - 64, 0) > 16384 || - Number(parts[2]) > 1048576 - ) - throw Error("Excel 셀 주소를 확인하세요. 예: B12"); + if (!validExcelAddress(address)) throw Error("Excel 셀 주소를 확인하세요. 예: B12"); const workbook = parse(files["xl/workbook.xml"]), sheet = [...workbook.getElementsByTagNameNS("*", "sheet")].find( (s) => s.getAttribute("name") === name, @@ -256,12 +258,43 @@ const EstQuantity = (() => { throw Error(`${name} ${address}: 저장된 숫자 값이 없습니다.`); return Number(value); } + function excelTarget(row) { + return ( + row && + !Number(row.Level) && + ![51, 52, 54, 55, 57, 58, 59, 62, 67, 68].includes(Number(row.LinkIndex)) && + !["%", "%"].includes(row.Unite) + ); + } function excelDialog() { const { state, esc, showDialog } = EstApp; const rows = state.project.tables.EstMainDat; showDialog( "Excel 셀 수량 반영", - `

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

`, + `

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

`, + ); + } + function excelUpdates(project, filename, files) { + const links = (project.excelQuantityLinks || []).filter((link) => link.file === filename); + if (!links.length) throw Error("선택한 파일에 연결된 내역이 없습니다."); + return links.map((link) => { + const rows = project.tables.EstMainDat.filter((row) => row.PkID === link.target); + if (rows.length !== 1 || !excelTarget(rows[0])) + throw Error(`연결 내역이 없거나 중복되었거나 상세 행이 아닙니다: ${link.target}`); + return { row: rows[0], value: excelCell(files, link.sheet, link.cell) }; + }); + } + function excelLinksDialog() { + const { state, esc, showDialog } = EstApp, + links = state.project.excelQuantityLinks || []; + showDialog( + "Excel 수량 연결 관리", + `

연결된 파일을 다시 선택하면 해당 파일의 모든 연결을 갱신합니다.

${links + .map((link, index) => { + const row = state.project.tables.EstMainDat.find((r) => r.PkID === link.target); + return ``; + }) + .join("")}
파일시트셀내역작업
${esc(link.file)}${esc(link.sheet)}${esc(link.cell)}${esc(row?.Name || link.target)}
`, ); } function render(state) { @@ -330,6 +363,50 @@ const EstQuantity = (() => { excelDialog(); return; } + if (event.target.closest("[data-quantity-excel-links]")) { + excelLinksDialog(); + return; + } + const removeLink = event.target.closest("[data-quantity-excel-remove]"); + if (removeLink) { + EstApp.change( + () => + state.project.excelQuantityLinks.splice( + Number(removeLink.dataset.quantityExcelRemove), + 1, + ), + false, + ); + document.getElementById("dialog").close(); + excelLinksDialog(); + return; + } + if (event.target.closest("[data-quantity-excel-refresh]")) { + const project = state.project, + revision = state.revision, + input = document.getElementById("quantity-excel-refresh-file"), + file = input.files[0]; + (async () => { + if (!file) throw Error("Excel 파일을 선택하세요."); + const files = await EstApp.api("/api/import-xlsx", { + method: "POST", + body: await file.arrayBuffer(), + }); + if ( + state.project !== project || + state.revision !== revision || + document.getElementById("quantity-excel-refresh-file") !== input + ) + throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요."); + const updates = excelUpdates(project, file.name, files); + EstApp.change(() => updates.forEach(({ row, value }) => (row.GN = value))); + document.getElementById("dialog").close(); + })().catch((error) => { + const output = document.getElementById("quantity-excel-links-error"); + if (output) output.textContent = error.message; + }); + return; + } if (event.target.closest("[data-quantity-excel-apply]")) { const project = state.project, revision = state.revision, @@ -341,6 +418,11 @@ const EstQuantity = (() => { row = selected === "" ? null : project.tables.EstMainDat[Number(selected)]; (async () => { if (!file || !name || !row) throw Error("파일·시트·셀·내역 행을 선택하세요."); + if ( + !Number.isSafeInteger(row.PkID) || + 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(), @@ -353,7 +435,19 @@ const EstQuantity = (() => { project.tables.EstMainDat[Number(selected)] !== row ) throw Error("파일을 읽는 동안 작업이 바뀌었습니다. 다시 선택하세요."); - EstApp.change(() => (row.GN = value)); + EstApp.change(() => { + row.GN = value; + project.excelQuantityLinks ||= []; + project.excelQuantityLinks = project.excelQuantityLinks.filter( + (link) => link.target !== row.PkID, + ); + project.excelQuantityLinks.push({ + target: row.PkID, + file: file.name, + sheet: name, + cell: address.trim().toUpperCase(), + }); + }); document.getElementById("dialog").close(); })().catch((error) => { const output = document.getElementById("quantity-excel-error"); @@ -441,6 +535,8 @@ const EstQuantity = (() => { allTotals, applyTotals, excelCell, + validExcelAddress, + excelUpdates, targetSelect, newCode, selector,