diff --git a/resources/tester/test_est_exchange.py b/resources/tester/test_est_exchange.py index 329f1e3c3..fcdd1158a 100644 --- a/resources/tester/test_est_exchange.py +++ b/resources/tester/test_est_exchange.py @@ -10,6 +10,33 @@ from test_est_rea_parity import run ROOT = Path(__file__).resolve().parents[2] +def test_report_excel_contains_numeric_values_and_separate_errors(): + import base64 + import openpyxl + + script = """const {server}=require('./tools/est-test/server.cjs'); + server.listen(0,'127.0.0.1',async()=>{try{ + const url=`http://127.0.0.1:${server.address().port}/api/export-report-xlsx`; + const post=body=>fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); + const ok=await post({name:'test',columns:['Name','Value'],rows:[['=1+1',7]],detail:'summary',errors:['bad input']}); + const bad=await post({columns:['Name'],rows:[[null]],errors:[]}); + process.stdout.write(JSON.stringify({data:Buffer.from(await ok.arrayBuffer()).toString('base64'),status:bad.status})); + }finally{server.close()}});""" + result = json.loads( + subprocess.run( + ["node", "-e", script], cwd=ROOT, capture_output=True, check=True, timeout=15 + ).stdout + ) + workbook = openpyxl.load_workbook(io.BytesIO(base64.b64decode(result["data"]))) + assert workbook["Report"]["A2"].value == "=1+1" + assert workbook["Report"]["A2"].data_type == "s" + assert workbook["Report"]["B2"].value == 7 + assert workbook["Summary"]["A2"].value == "summary" + assert workbook["Errors"]["A2"].value == "bad input" + assert result["status"] == 400 + workbook.close() + + def export_workbook(project): result = subprocess.run( [ diff --git a/tools/est-test/README.md b/tools/est-test/README.md index cc4ec9d2f..4de48b4de 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -8,6 +8,8 @@ 별도 인쇄 서식으로 기성·물가변동의 내역서와 원가계산서, 연차 배분표와 연차별 내역서, 설계변경 원가 증감표를 선택할 수 있습니다. 기성은 선택한 회차를 출력하고 확정된 회차는 확정 당시 결과를 사용합니다. +인쇄 미리보기에서 `서식 Excel 저장`을 누르면 선택한 표의 계산 결과를 별도 Excel 파일로 내려받습니다. 결과 요약과 계산 오류가 있으면 각각 별도 시트에 표시합니다. 이 파일은 출력용이며 전체 작업을 다시 가져오려면 전체 자료 Excel 또는 JSON 저장을 사용합니다. + 저장은 이 폴더의 `data/`에 작업을 보관하고 `.estweb.json` 파일도 내려받습니다. 브라우저 임시 저장은 같은 탭에서 복원하는 용도입니다. JSON은 모든 웹 업무와 원본 압축 파일·바이너리를 보관하는 전체 저장 형식입니다. ESTX 수정 저장은 아래 지원 조건을 따릅니다. ## 추가 입출력·문서 기능 (2026-11-06) diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index dc6eed120..abceb7289 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -214,6 +214,7 @@ const EstActions = (() => { const level = Number(node.dataset.level); EstApp.change(() => selected().forEach((i) => (activeRows()[i].Level = level))); } else if (name === "export-xlsx") await EstExchange.exportExcel(); + else if (name === "export-report-xlsx") await EstReport.exportExcel(); else if (name === "export-estx") await EstExchange.exportNative(); else if (name === "export-jdl") await EstExchange.exportJdl(); else if (name === "import-jdl") EstApp.byId("jdl-file").click(); diff --git a/tools/est-test/native-forms.js b/tools/est-test/native-forms.js index 8e2b78397..c0bbe412b 100644 --- a/tools/est-test/native-forms.js +++ b/tools/est-test/native-forms.js @@ -309,10 +309,11 @@ const EstForms = (() => { JC: "경비", SC: "산출경비", }; + const label = (key) => labels[key] || EstModel.labels[key] || key; function grid(output) { const { esc } = EstApp, number = (v) => new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 12 }).format(v); return `${output.columns.map((key) => ``).join("")}${output.rows.map((row) => `${output.columns.map((key) => ``).join("")}`).join("")}
${esc(labels[key] || EstModel.labels[key] || key)}
${esc(typeof row[key] === "number" ? number(row[key]) : key === "LineData" ? EstText.clean(row[key] || "") : (row[key] ?? ""))}
`; } - return { data, grid }; + return { data, grid, label }; })(); diff --git a/tools/est-test/native-report.js b/tools/est-test/native-report.js index 1bbb01f20..2a6633057 100644 --- a/tools/est-test/native-report.js +++ b/tools/est-test/native-report.js @@ -172,7 +172,7 @@ const EstReport = (() => { .join("")} ${table ? '' : ""} - + `, ); @@ -252,5 +252,51 @@ const EstReport = (() => { frame.contentWindow.focus(); frame.contentWindow.print(); } - return { open, preview, print }; + async function exportExcelReport() { + const { state } = EstApp; + const form = catalog.find((row) => String(row.Index) === setting().form); + const output = form && EstForms.data(form.Index, state.project, state); + if (!output) throw Error("표 형식의 인쇄 서식을 선택하세요."); + if (!output.rows.length) throw Error("출력할 행이 없습니다."); + const used = new Set(); + const columns = output.columns.map((key) => { + const base = EstForms.label(key); + let name = base, + suffix = 2; + while (used.has(name)) name = `${base} (${suffix++})`; + used.add(name); + return name; + }); + const rows = output.rows.map((row) => + output.columns.map((key) => + key === "LineData" ? EstText.clean(row[key] || "") : (row[key] ?? ""), + ), + ); + const response = await fetch("/api/export-report-xlsx", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: state.project.name, + columns, + rows, + detail: output.detail || "", + errors: output.errors || [], + }), + }); + if (!response.ok) throw Error((await response.json()).error); + EstApp.download( + `${state.project.name} - ${form.Name}.xlsx`, + await response.blob(), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + } + async function exportExcel() { + try { + await exportExcelReport(); + EstApp.byId("print-error").textContent = ""; + } catch (error) { + EstApp.byId("print-error").textContent = error.message; + } + } + return { open, preview, print, exportExcel }; })(); diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs index 622afa543..e09bff232 100644 --- a/tools/est-test/server.cjs +++ b/tools/est-test/server.cjs @@ -165,6 +165,40 @@ const server = http.createServer(async (req, res) => { reply(res, 200, { data: out.bytes.toString("base64"), warnings: out.warnings }); return; } + if (req.method === "POST" && url.pathname === "/api/export-report-xlsx") { + const input = JSON.parse((await body(req)).toString("utf8")); + if ( + !Array.isArray(input.columns) || + !input.columns.length || + input.columns.some((x) => typeof x !== "string") || + new Set(input.columns).size !== input.columns.length || + !Array.isArray(input.rows) || + input.rows.some( + (row) => + !Array.isArray(row) || + row.length !== input.columns.length || + row.some((value) => !["string", "number", "boolean"].includes(typeof value)), + ) || + (input.detail != null && typeof input.detail !== "string") || + !Array.isArray(input.errors) || + input.errors.some((x) => typeof x !== "string") + ) + throw Error("Excel 출력 데이터 형식 오류"); + const rows = input.rows.map((row) => + Object.fromEntries(input.columns.map((key, index) => [key, row[index]])), + ); + const tables = { Report: rows }; + if (input.detail) tables.Summary = [{ "결과 요약": input.detail }]; + if (input.errors.length) + tables.Errors = input.errors.map((message) => ({ "계산 오류": message })); + reply( + res, + 200, + exchange.workbook({ name: String(input.name || ""), tables }), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + return; + } if (req.method === "POST" && url.pathname === "/api/export-xlsx") { const project = calculate(JSON.parse((await body(req)).toString("utf8"))); reply(