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) => `| ${esc(labels[key] || EstModel.labels[key] || key)} | `).join("")}
${output.rows.map((row) => `${output.columns.map((key) => `| ${esc(typeof row[key] === "number" ? number(row[key]) : key === "LineData" ? EstText.clean(row[key] || "") : (row[key] ?? ""))} | `).join("")}
`).join("")}
`;
}
- 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(