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:
@@ -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) }),
|
||||
);
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user