From deab5a348f4ae98cb77a05871426410e8f5d0754 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 13 Oct 2026 07:55:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09-print):=20=EC=9B=90=EA=B0=80=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=98=81=EC=97=AD=20=EC=9D=B4=EC=96=B4=20=EB=B6=99?= =?UTF-8?q?=EC=9E=84=20=E2=80=94=20=EC=84=9C=EB=B2=84=20=EB=AA=B8(pdfBody?= =?UTF-8?q?=20=C2=B7=20xlsxBody)=EC=9D=84=20=ED=91=9C=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EC=9B=90=EA=B0=80=2018=EC=9E=A5=20?= =?UTF-8?q?=E2=86=92=20=ED=91=9C=20PDF=20=C2=B7=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EC=9D=B4=EC=9D=8C=EB=A7=A4=20=EC=8B=9C=ED=97=98=20(302-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B09_Estimation/draft/B09_Draft_Print_Page.ts | 17 +---- B09_Estimation/draft/B09_Draft_Print_Table.ts | 15 ++++ .../tester/print_frame/print_frame_entry.ts | 15 ++++ resources/tester/test_302_1_print_frame.py | 70 ++++++++++++++++++- 4 files changed, 100 insertions(+), 17 deletions(-) create mode 100644 resources/tester/print_frame/print_frame_entry.ts diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.ts b/B09_Estimation/draft/B09_Draft_Print_Page.ts index 247cfcf8a..c22a9b6dd 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Page.ts +++ b/B09_Estimation/draft/B09_Draft_Print_Page.ts @@ -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))), }; } diff --git a/B09_Estimation/draft/B09_Draft_Print_Table.ts b/B09_Estimation/draft/B09_Draft_Print_Table.ts index 8f5255cc3..43c3db7c3 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Table.ts +++ b/B09_Estimation/draft/B09_Draft_Print_Table.ts @@ -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 { if (!s.images?.some((im) => !im.src.startsWith("data:"))) return s; diff --git a/resources/tester/print_frame/print_frame_entry.ts b/resources/tester/print_frame/print_frame_entry.ts new file mode 100644 index 000000000..f56bfb438 --- /dev/null +++ b/resources/tester/print_frame/print_frame_entry.ts @@ -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) }), +); diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py index 654e48399..e64df7bb9 100644 --- a/resources/tester/test_302_1_print_frame.py +++ b/resources/tester/test_302_1_print_frame.py @@ -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 "= 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)