feat(est): export report results to Excel
This commit is contained in:
@@ -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(
|
||||
[
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
별도 인쇄 서식으로 기성·물가변동의 내역서와 원가계산서, 연차 배분표와 연차별 내역서, 설계변경 원가 증감표를 선택할 수 있습니다. 기성은 선택한 회차를 출력하고 확정된 회차는 확정 당시 결과를 사용합니다.
|
||||
|
||||
인쇄 미리보기에서 `서식 Excel 저장`을 누르면 선택한 표의 계산 결과를 별도 Excel 파일로 내려받습니다. 결과 요약과 계산 오류가 있으면 각각 별도 시트에 표시합니다. 이 파일은 출력용이며 전체 작업을 다시 가져오려면 전체 자료 Excel 또는 JSON 저장을 사용합니다.
|
||||
|
||||
저장은 이 폴더의 `data/`에 작업을 보관하고 `.estweb.json` 파일도 내려받습니다. 브라우저 임시 저장은 같은 탭에서 복원하는 용도입니다. JSON은 모든 웹 업무와 원본 압축 파일·바이너리를 보관하는 전체 저장 형식입니다. ESTX 수정 저장은 아래 지원 조건을 따릅니다.
|
||||
|
||||
## 추가 입출력·문서 기능 (2026-11-06)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 `<table><thead><tr>${output.columns.map((key) => `<th>${esc(labels[key] || EstModel.labels[key] || key)}</th>`).join("")}</tr></thead><tbody>${output.rows.map((row) => `<tr>${output.columns.map((key) => `<td class="${typeof row[key] === "number" ? "numeric" : ""}">${esc(typeof row[key] === "number" ? number(row[key]) : key === "LineData" ? EstText.clean(row[key] || "") : (row[key] ?? ""))}</td>`).join("")}</tr>`).join("")}</tbody></table>`;
|
||||
}
|
||||
return { data, grid };
|
||||
return { data, grid, label };
|
||||
})();
|
||||
|
||||
@@ -172,7 +172,7 @@ const EstReport = (() => {
|
||||
.join("")}
|
||||
<label>업체명<input data-print="company" value="${esc(s.company)}"></label>
|
||||
${table ? '<label>출력 범위 <select id="print-scope"><option value="current">현재 호표·검색 결과 전체 행</option><option value="all">현재 자료의 모든 호표·행</option></select></label>' : ""}
|
||||
<button data-action="print-document">인쇄 / PDF 저장</button><span id="print-error" role="status"></span></div>
|
||||
<button data-action="print-document">인쇄 / PDF 저장</button><button data-action="export-report-xlsx">서식 Excel 저장</button><span id="print-error" role="status"></span></div>
|
||||
<label>문서 본문(표지·갑지·설명서·시방서·심사·공정 문서)<textarea data-print="notes" rows="3" style="width:100%">${esc(s.notes)}</textarea></label>
|
||||
<iframe id="print-frame" title="인쇄할 문서" style="width:100%;height:65vh;border:1px solid #aaa"></iframe>`,
|
||||
);
|
||||
@@ -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 };
|
||||
})();
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user