feat(b09-print): 원가계산 영역 이어 붙임 — 서버 몸(pdfBody · xlsxBody)을 표 파일로 · 원가 18장 → 표 PDF · 엑셀 이음매 시험 (302-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-13 07:55:19 +09:00
co-authored by Claude Opus 5.5
parent 952ce4142a
commit deab5a348f
4 changed files with 100 additions and 17 deletions
+3 -14
View File
@@ -20,7 +20,7 @@ import type { MountedPage } from "../../A00_Common/router";
import { navigateTo } from "../../A00_Common/router";
import { workflowSteps } from "../../A00_Common/b_page_scaffold";
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../../A00_Common/b_workflow_nav";
import { inlineImages, sheetGrid, sheetHtml } from "./B09_Draft_Print_Table";
import { inlineImages, pdfBody, xlsxBody } from "./B09_Draft_Print_Table";
import type {
CollectSheets,
DrawingItem,
@@ -72,20 +72,9 @@ function tableArea(projectId: string, title: string, sheets: PrintSheet[]): Area
items: sheets.map(({ id, name }) => ({ id, name })),
pdf: async (ids) => {
const picked = await Promise.all(pick(ids).map(inlineImages));
return post(projectId, "table-pdf", {
title,
sheets: picked.map((s) => ({
name: s.name,
orientation: s.orientation ?? "portrait",
html: sheetHtml(s),
})),
});
return post(projectId, "table-pdf", pdfBody(title, picked));
},
xlsx: (ids) =>
post(projectId, "table-xlsx", {
title,
sheets: pick(ids).map((s) => ({ name: s.name, grid: sheetGrid(s.table) })),
}),
xlsx: (ids) => post(projectId, "table-xlsx", xlsxBody(title, pick(ids))),
};
}
@@ -122,6 +122,21 @@ export function sheetGrid(table: DataTableOptions): SheetGrid {
};
}
/** 서버 몸 — 함수 칸(rowClass · level.of …)은 여기서 펼쳐 글 · 값으로만 보냄 */
export const pdfBody = (title: string, sheets: PrintSheet[]) => ({
title,
sheets: sheets.map((s) => ({
name: s.name,
orientation: s.orientation ?? "portrait",
html: sheetHtml(s),
})),
});
export const xlsxBody = (title: string, sheets: PrintSheet[]) => ({
title,
sheets: sheets.map((s) => ({ name: s.name, grid: sheetGrid(s.table) })),
});
/** 그림 주소 → data: 주소(서버 헤드리스는 바깥 요청을 막음 · 로그인 쿠키로 브라우저가 받아 둠) */
export async function inlineImages(s: PrintSheet): Promise<PrintSheet> {
if (!s.images?.some((im) => !im.src.startsWith("data:"))) return s;
@@ -0,0 +1,15 @@
/* =============================================================================
* print_frame_entry.ts (PLAN 302-1 시험 진입)
* 원가계산 영역(`collectCostSheets` 몸통 `costSheetsOf`) → 인쇄 페이지와 같은 서버 몸(`pdfBody` · `xlsxBody`).
* vite `--ssr` 로 번들해 node 로 실행 — 인자 = payload JSON 경로 · 출력 = {pdf, xlsx} JSON.
* ========================================================================== */
import { readFileSync } from "node:fs";
import { costSheetsOf } from "../../../B09_Estimation/draft/B09_Draft_Print_Cost";
import { pdfBody, xlsxBody } from "../../../B09_Estimation/draft/B09_Draft_Print_Table";
const payload = JSON.parse(readFileSync(process.argv[2], "utf-8"));
const sheets = costSheetsOf(payload, payload.user ?? {});
console.log(
JSON.stringify({ pdf: pdfBody("원가계산", sheets), xlsx: xlsxBody("원가계산", sheets) }),
);
+67 -3
View File
@@ -3,6 +3,7 @@
import io
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
@@ -76,10 +77,10 @@ TABLE = """{
def _sheets():
return _ts(
f"(() => {{ const t = {TABLE}; return ["
"{name: '갑 / 수량[1]', orientation: 'portrait', html: sheetHtml({id:'a', name:'갑', table:t}),"
"{name: '갑 / 수량[1]', orientation: 'portrait', html: sheetHtml({id:'a', name:'갑', table:t})," # noqa: E501
" grid: sheetGrid(t)},"
"{name: '을: 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만', orientation: 'landscape',"
" html: sheetHtml({id:'b', name:'을', table:t, images:[{src:'data:image/png;base64,iVBORw0KGgo=',"
"{name: '을: 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만', orientation: 'landscape'," # noqa: E501
" html: sheetHtml({id:'b', name:'을', table:t, images:[{src:'data:image/png;base64,iVBORw0KGgo='," # noqa: E501
" caption:'그림 <1>'}]}), grid: sheetGrid(t)}]; })()"
)
@@ -249,3 +250,66 @@ def test_시트_이름_다듬기():
assert pr.sheet_title("a/b\\c?d*e[f]g:h", used) == "a_b_c_d_e_f_g_h"
assert pr.sheet_title("", used) == "시트"
assert pr.sheet_title("A_B_C_D_E_F_G_H", used) == "A_B_C_D_E_F_G_H (2)" # 대소문자 같은 이름
# ── 영역 이음매 — 원가계산(302-4) 쪽 → 페이지와 같은 서버 몸 → 표 PDF · 엑셀 ────────────
ENTRY = ROOT / "resources/tester/print_frame/print_frame_entry.ts"
VITE = ROOT / "config/node_modules/vite/bin/vite.js"
@pytest.fixture(scope="module")
def cost_bodies(tmp_path_factory) -> dict:
sys.path.insert(0, str(ROOT / "resources/tester"))
import test_302_4_print_cost as t302_4
if shutil.which("node") is None:
pytest.skip("node 가 없음")
work = tmp_path_factory.mktemp("p302_1")
root = t302_4.t145._project(work / "proj", [(100, "SS000901"), (400, "SS000902")])
data = work / "payload.json"
data.write_text(json.dumps(t302_4._payload(root), ensure_ascii=False, default=str), "utf-8")
out = work / "build"
entry_rel = "../" + str(ENTRY.relative_to(ROOT)).replace("\\", "/")
build = subprocess.run(
["node", str(VITE), "build", "--configLoader", "runner", "--ssr", entry_rel,
"--outDir", str(out)],
cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=180,
) # fmt: skip
assert build.returncode == 0, f"vite ssr 번들 실패:\n{build.stdout}\n{build.stderr}"
run = subprocess.run(
["node", str(out / "print_frame_entry.js"), str(data)],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60,
) # fmt: skip
assert run.stdout.strip(), run.stderr
return json.loads(run.stdout)
def test_원가_영역_엑셀_18장_차례_그대로(cost_bodies):
from openpyxl import load_workbook
body = cost_bodies["xlsx"]
assert len(body["sheets"]) == 18
res = _client().post(f"/api/projects/{PID}/print/table-xlsx", json=body)
assert res.status_code == 200, res.text
wb = load_workbook(io.BytesIO(res.content))
assert wb.sheetnames == [s["name"] for s in body["sheets"]] # 이름 31자 안 · 차례 그대로
first = wb[wb.sheetnames[0]]
assert first.max_row > body["sheets"][0]["grid"]["head_rows"] # 값 줄이 들어감
assert any(c.data_type == "n" for row in first.iter_rows() for c in row if c.value is not None)
def test_원가_영역_PDF_시트_이름_가로(cost_bodies):
pymupdf = pytest.importorskip("pymupdf")
body = cost_bodies["pdf"]
assert all(s["orientation"] == "landscape" and "<table" in s["html"] for s in body["sheets"])
res = _client().post(f"/api/projects/{PID}/print/table-pdf", json=body)
if res.status_code == 503:
pytest.skip(res.json()["detail"])
assert res.status_code == 200, res.text
doc = pymupdf.open(stream=res.content)
assert doc.page_count >= 18
text = "".join(p.get_text() for p in doc)
assert all(s["name"] in text for s in body["sheets"])
assert all(p.rect.width > p.rect.height for p in doc)