feat(b09-print): 인쇄 미리보기 틀 — draft 길 선언 glob 등록 · 페이지(영역 셋 · 쪽 체크 · PDF 보기 · 내려받기) · 서버 표 PDF(Playwright) · 엑셀(openpyxl) · B09 Router 초안 길 끼우기 (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:
+3
-1
@@ -20,6 +20,7 @@ import {
|
||||
renderCurrentRoute,
|
||||
ensureInitialHash,
|
||||
setLeaveGuard,
|
||||
draftRoute,
|
||||
showWithinPage,
|
||||
takeLeaveGuard,
|
||||
} from "./router";
|
||||
@@ -70,7 +71,8 @@ function bootstrap(): void {
|
||||
shown = currentRoute();
|
||||
shownHash = window.location.hash;
|
||||
if (within) return;
|
||||
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute());
|
||||
const showFooter =
|
||||
!FOOTERLESS_ROUTES.includes(currentRoute()) && !draftRoute(currentRoute())?.footerless;
|
||||
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
|
||||
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
|
||||
};
|
||||
|
||||
+29
-4
@@ -74,6 +74,29 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
|
||||
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
|
||||
};
|
||||
|
||||
/** 초안 길(302-1) — 페이지 폴더 `draft/*_Route.ts` 의 `ROUTE` 선언 · 폴더째 지우면 길도 없음.
|
||||
* 정적 표에 이름을 박지 않음 — 보호 · 단계 화면(로그인 · 회사) · 프로젝트 · 꼬리말 없음도 선언에서. */
|
||||
export interface DraftRoute {
|
||||
path: string;
|
||||
load: () => Promise<PageRenderer>;
|
||||
/** 로그인 · 회사 필요(B 단계 화면) — 로그인만이면 protected */
|
||||
workflow?: boolean;
|
||||
protected?: boolean;
|
||||
/** 현재 프로젝트로 여는 화면(지워진 프로젝트 안내) */
|
||||
project?: boolean;
|
||||
footerless?: boolean;
|
||||
}
|
||||
|
||||
const DRAFT_ROUTES = new Map(
|
||||
Object.values(import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true }))
|
||||
.map((m) => m.ROUTE)
|
||||
.filter(Boolean)
|
||||
.map((r) => [r.path, r] as const),
|
||||
);
|
||||
|
||||
/** 초안 길 선언(없으면 undefined) */
|
||||
export const draftRoute = (route: string): DraftRoute | undefined => DRAFT_ROUTES.get(route);
|
||||
|
||||
/** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */
|
||||
let leaveGuard: (() => Promise<boolean>) | null = null;
|
||||
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
|
||||
@@ -159,7 +182,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
||||
mounted?.dispose();
|
||||
mounted = null;
|
||||
|
||||
if (PROTECTED_ROUTES.includes(route) && !(await isAuthenticated())) {
|
||||
const draft = draftRoute(route);
|
||||
const guarded = PROTECTED_ROUTES.includes(route) || !!(draft?.protected || draft?.workflow);
|
||||
if (guarded && !(await isAuthenticated())) {
|
||||
navigateTo(ROUTES.A06_LOGIN);
|
||||
return;
|
||||
}
|
||||
@@ -178,7 +203,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
||||
ROUTES.B11_LOADING,
|
||||
];
|
||||
|
||||
if (workflowRoutes.includes(route)) {
|
||||
if (workflowRoutes.includes(route) || draft?.workflow) {
|
||||
const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch");
|
||||
try {
|
||||
const user = await fetchSessionUser();
|
||||
@@ -197,7 +222,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
// 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다.
|
||||
if (PROJECT_ROUTES.includes(route)) {
|
||||
if (PROJECT_ROUTES.includes(route) || draft?.project) {
|
||||
const { dropGoneProject } = await import("./b_workflow_nav");
|
||||
if (await dropGoneProject()) {
|
||||
if (seq !== renderSeq) return;
|
||||
@@ -208,7 +233,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const loader = routeTable[route];
|
||||
const loader = routeTable[route] ?? draft?.load;
|
||||
if (!loader) {
|
||||
renderPlaceholder(outlet, route);
|
||||
return;
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -341,3 +343,19 @@ async def library_inputs(
|
||||
|
||||
# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한
|
||||
router.include_router(quotes_router)
|
||||
|
||||
|
||||
# 302 초안 길(draft/*_Router.py 의 router · 폴더째 지우면 길도 없음) — 같은 머리 · 같은 권한
|
||||
def include_drafts(target: APIRouter, folder: Path, package: str) -> list[str]:
|
||||
"""folder 의 *_Router.py 마다 router 를 끼움 → 끼운 파일 이름(못 읽은 초안은 건너뜀)."""
|
||||
done = []
|
||||
for path in sorted(folder.glob("*_Router.py")):
|
||||
try:
|
||||
target.include_router(importlib.import_module(f"{package}.{path.stem}").router)
|
||||
done.append(path.stem)
|
||||
except (ImportError, AttributeError) as error: # 한 초안이 막혀도 B09 는 뜸
|
||||
logging.getLogger(__name__).warning("초안 길 %s 못 읽음 — %s", path.name, error)
|
||||
return done
|
||||
|
||||
|
||||
include_drafts(router, Path(__file__).parent / "draft", "B09_Estimation.draft")
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* 인쇄 미리보기(PLAN 302-1 · 초안) — 상태 한 줄 + PDF 보기 칸(창 아래 끝까지). 색은 테마 변수만. */
|
||||
|
||||
.b09-print {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
height: calc(100vh - var(--spacing-64));
|
||||
padding: var(--spacing-16) var(--spacing-24);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.b09-print__status {
|
||||
margin: 0;
|
||||
min-height: 1.4em;
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b09-print__status.is-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.b09-print__frame {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface, var(--color-canvas));
|
||||
}
|
||||
|
||||
.b09-print__areas {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b09-print__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.b09-print__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-body-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/* =============================================================================
|
||||
* B09_Draft_Print_Page.ts
|
||||
* 인쇄 미리보기 페이지(PLAN 302-1 · 요구 192-3 · 초안) — 주소 #/b09-print(`…_Print_Route`).
|
||||
* 좌측 = 영역 셋(도면 · 수량산출 · 원가계산) 고르기 + 그 영역 쪽 목록(체크로 빼고 넣기 · 차례 그대로) +
|
||||
* [PDF 내려받기] [엑셀 내려받기](도면 = PDF 만) + [B09 로 돌아가기]
|
||||
* 본문 = 고른 쪽들의 PDF 를 브라우저 PDF 보기로(보는 것 = 받는 것) · 만드는 동안 작은 표시(잠금 없음) ·
|
||||
* 실패 = 까닭 한 줄 · 체크를 바꾸면 다시 만듦(최신 요청만 채택)
|
||||
* 영역 = 다른 창 파일(`…_Print_Quantity` collectQuantitySheets · `…_Print_Cost` collectCostSheets ·
|
||||
* `…_Print_Drawings` fetchDrawingList · fetchDrawingsPdf) — 없으면 「준비 중」
|
||||
* 표 영역 PDF · 엑셀 = 서버(`B09_Draft_Print_Router` table-pdf · table-xlsx) · 서버는 꼴만 바꿈
|
||||
* ========================================================================== */
|
||||
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
import "./B09_Draft_Print_Page.css";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
|
||||
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
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 type {
|
||||
CollectSheets,
|
||||
DrawingItem,
|
||||
FetchDrawingList,
|
||||
FetchDrawingsPdf,
|
||||
PrintSheet,
|
||||
} from "./B09_Draft_Print_Types";
|
||||
|
||||
type AreaId = "drawings" | "quantity" | "cost";
|
||||
|
||||
const AREAS: { id: AreaId; label: string; file: string }[] = [
|
||||
{ id: "drawings", label: "도면", file: "./B09_Draft_Print_Drawings.ts" },
|
||||
{ id: "quantity", label: "수량산출", file: "./B09_Draft_Print_Quantity.ts" },
|
||||
{ id: "cost", label: "원가계산", file: "./B09_Draft_Print_Cost.ts" },
|
||||
];
|
||||
|
||||
/** 영역 파일(다른 창 몫) — 없는 파일은 목록에 없음 = 「준비 중」 */
|
||||
const MODS = import.meta.glob([
|
||||
"./B09_Draft_Print_Drawings.ts",
|
||||
"./B09_Draft_Print_Quantity.ts",
|
||||
"./B09_Draft_Print_Cost.ts",
|
||||
]);
|
||||
|
||||
/** 영역 한 벌 — 쪽 목록 · 고른 쪽 PDF · 엑셀(도면 없음) */
|
||||
interface Area {
|
||||
items: DrawingItem[];
|
||||
pdf: (ids: string[]) => Promise<Blob>;
|
||||
xlsx?: (ids: string[]) => Promise<Blob>;
|
||||
}
|
||||
|
||||
async function post(projectId: string, path: string, body: unknown): Promise<Blob> {
|
||||
const res = await fetch(`${API_BASE_URL}/projects/${projectId}/print/${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = ((await res.json().catch(() => null)) as { detail?: unknown } | null)?.detail;
|
||||
throw new Error(typeof detail === "string" ? detail : `HTTP ${res.status}`);
|
||||
}
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
/** 표 영역 — 쪽 = PrintSheet · PDF · 엑셀 = 서버 꼴 바꿈 */
|
||||
function tableArea(projectId: string, title: string, sheets: PrintSheet[]): Area {
|
||||
const pick = (ids: string[]) => ids.map((id) => sheets.find((s) => s.id === id)!);
|
||||
return {
|
||||
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),
|
||||
})),
|
||||
});
|
||||
},
|
||||
xlsx: (ids) =>
|
||||
post(projectId, "table-xlsx", {
|
||||
title,
|
||||
sheets: pick(ids).map((s) => ({ name: s.name, grid: sheetGrid(s.table) })),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 영역 파일 읽기 — 없음 = null(준비 중) */
|
||||
async function loadArea(projectId: string, id: AreaId): Promise<Area | null> {
|
||||
const def = AREAS.find((a) => a.id === id)!;
|
||||
const load = MODS[def.file];
|
||||
if (!load) return null;
|
||||
const mod = (await load()) as Record<string, unknown>;
|
||||
if (id === "drawings") {
|
||||
const list = mod.fetchDrawingList as FetchDrawingList | undefined;
|
||||
const pdf = mod.fetchDrawingsPdf as FetchDrawingsPdf | undefined;
|
||||
if (!list || !pdf) return null;
|
||||
return { items: await list(projectId), pdf: (ids) => pdf(projectId, ids) };
|
||||
}
|
||||
const collect = mod[id === "quantity" ? "collectQuantitySheets" : "collectCostSheets"] as
|
||||
CollectSheets | undefined;
|
||||
return collect ? tableArea(projectId, def.label, await collect(projectId)) : null;
|
||||
}
|
||||
|
||||
function save(blob: Blob, name: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
export function renderB09Print(root: HTMLElement): MountedPage {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
|
||||
const status = document.createElement("p");
|
||||
status.className = "b09-print__status";
|
||||
status.dataset.b09PrintStatus = "";
|
||||
const frame = document.createElement("iframe");
|
||||
frame.className = "b09-print__frame";
|
||||
frame.title = "인쇄 미리보기";
|
||||
const body = document.createElement("div");
|
||||
body.className = "b09-print";
|
||||
body.append(status, frame);
|
||||
|
||||
const areaCard = createPanelCard("영역");
|
||||
const areaRow = document.createElement("div");
|
||||
areaRow.className = "b09-print__areas";
|
||||
areaCard.append(areaRow);
|
||||
const sheetCard = createPanelCard("쪽 목록");
|
||||
const list = document.createElement("div");
|
||||
list.className = "b09-print__list";
|
||||
sheetCard.append(list);
|
||||
|
||||
let area: Area | null = null;
|
||||
let areaId: AreaId = AREAS[0].id;
|
||||
let off = new Set<string>(); // 체크 뺀 쪽
|
||||
let seq = 0;
|
||||
let shown: { url: string; blob: Promise<Blob> } | null = null;
|
||||
let timer = 0;
|
||||
|
||||
const label = () => AREAS.find((a) => a.id === areaId)!.label;
|
||||
const say = (text: string, error = false) => {
|
||||
status.textContent = text;
|
||||
status.classList.toggle("is-error", error);
|
||||
};
|
||||
const picked = () => (area?.items ?? []).map((it) => it.id).filter((id) => !off.has(id));
|
||||
const clearFrame = () => {
|
||||
if (shown?.url) URL.revokeObjectURL(shown.url);
|
||||
shown = null;
|
||||
frame.removeAttribute("src");
|
||||
};
|
||||
|
||||
/** 고른 쪽 PDF 다시 만듦 — 최신 요청만 보임 · 내려받기는 같은 PDF */
|
||||
const rebuild = () => {
|
||||
const my = ++seq;
|
||||
const ids = picked();
|
||||
clearFrame();
|
||||
if (!area) return;
|
||||
if (!ids.length) return say("고른 쪽이 없습니다.");
|
||||
say(`${label()} PDF 만드는 중…`);
|
||||
const blob = area.pdf(ids);
|
||||
shown = { url: "", blob };
|
||||
blob.then(
|
||||
(b) => {
|
||||
if (my !== seq) return;
|
||||
shown!.url = URL.createObjectURL(b);
|
||||
frame.src = shown!.url;
|
||||
say(`${label()} · ${ids.length}쪽 묶음`);
|
||||
},
|
||||
(error: unknown) => my === seq && say(`PDF 를 못 만들었습니다 — ${errText(error)}`, true),
|
||||
);
|
||||
};
|
||||
const later = () => {
|
||||
clearTimeout(timer);
|
||||
timer = window.setTimeout(rebuild, 300);
|
||||
};
|
||||
|
||||
const pdfBtn = createButton({
|
||||
label: "PDF 내려받기",
|
||||
onClick: () => {
|
||||
if (!shown) return;
|
||||
const name = `${label()}.pdf`;
|
||||
shown.blob.then(
|
||||
(b) => save(b, name),
|
||||
() => undefined,
|
||||
);
|
||||
},
|
||||
});
|
||||
pdfBtn.dataset.b09PrintPdf = "";
|
||||
const xlsxBtn = createButton({
|
||||
label: "엑셀 내려받기",
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
const ids = picked();
|
||||
if (!area?.xlsx || !ids.length) return;
|
||||
const name = `${label()}.xlsx`;
|
||||
say(`${label()} 엑셀 만드는 중…`);
|
||||
area.xlsx(ids).then(
|
||||
(b) => (save(b, name), say(`${label()} 엑셀을 내려받았습니다.`)),
|
||||
(error: unknown) => say(`엑셀을 못 만들었습니다 — ${errText(error)}`, true),
|
||||
);
|
||||
},
|
||||
});
|
||||
xlsxBtn.dataset.b09PrintXlsx = "";
|
||||
const back = createButton({
|
||||
label: "B09 로 돌아가기",
|
||||
variant: "ghost",
|
||||
onClick: () => navigateTo(ROUTES.B09_ESTIMATION),
|
||||
});
|
||||
|
||||
const drawList = () => {
|
||||
list.replaceChildren(
|
||||
...(area?.items ?? []).map((it) => {
|
||||
const row = document.createElement("label");
|
||||
row.className = "b09-print__item";
|
||||
const box = document.createElement("input");
|
||||
box.type = "checkbox";
|
||||
box.checked = !off.has(it.id);
|
||||
box.dataset.b09PrintItem = it.id;
|
||||
box.addEventListener("change", () => {
|
||||
if (box.checked) off.delete(it.id);
|
||||
else off.add(it.id);
|
||||
later();
|
||||
});
|
||||
const name = document.createElement("span");
|
||||
name.textContent = it.name;
|
||||
row.append(box, name);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const pickArea = (id: AreaId) => {
|
||||
areaId = id;
|
||||
area = null;
|
||||
off = new Set();
|
||||
const my = ++seq;
|
||||
clearFrame();
|
||||
areaRow.replaceChildren(
|
||||
...AREAS.map((a) => {
|
||||
const b = createButton({
|
||||
label: a.label,
|
||||
size: "sm",
|
||||
variant: a.id === id ? "filled" : "ghost",
|
||||
onClick: () => a.id !== areaId && pickArea(a.id),
|
||||
});
|
||||
b.dataset.b09PrintArea = a.id;
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
xlsxBtn.hidden = id === "drawings";
|
||||
drawList();
|
||||
if (!projectId) return say("현재 프로젝트가 없습니다.", true);
|
||||
say(`${label()} 쪽 목록 읽는 중…`);
|
||||
loadArea(projectId, id).then(
|
||||
(got) => {
|
||||
if (my !== seq) return;
|
||||
if (!got) return say(`${label()} — 준비 중`);
|
||||
area = got;
|
||||
drawList();
|
||||
if (!got.items.length) return say(`${label()} — 쪽이 없습니다.`);
|
||||
rebuild();
|
||||
},
|
||||
(error: unknown) =>
|
||||
my === seq && say(`${label()} 목록을 못 읽었습니다 — ${errText(error)}`, true),
|
||||
);
|
||||
};
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "ui-sidebar-actions";
|
||||
actions.append(pdfBtn, xlsxBtn, back);
|
||||
const left = document.createElement("div");
|
||||
left.className = "ui-sidebar-panel b09-print__panel";
|
||||
left.append(areaCard, sheetCard, actions);
|
||||
attachCollapsible(left);
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
title: "인쇄 미리보기",
|
||||
steps: workflowSteps(),
|
||||
activeStep: 6,
|
||||
mainContent: body,
|
||||
leftPanel: left,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex: number) => {
|
||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
},
|
||||
});
|
||||
root.append(layout.root);
|
||||
pickArea(areaId);
|
||||
|
||||
return {
|
||||
covers: () => false,
|
||||
show: () => undefined,
|
||||
dispose: () => {
|
||||
seq++;
|
||||
clearTimeout(timer);
|
||||
clearFrame();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const errText = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
@@ -0,0 +1,15 @@
|
||||
/* =============================================================================
|
||||
* B09_Draft_Print_Route.ts
|
||||
* 인쇄 미리보기 길 선언(PLAN 302-1 · 초안) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록.
|
||||
* 주소 = #/b09-print · 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 — 폴더째 지우면 길도 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DraftRoute } from "../../A00_Common/router";
|
||||
|
||||
export const ROUTE: DraftRoute = {
|
||||
path: "b09-print",
|
||||
workflow: true,
|
||||
project: true,
|
||||
footerless: true,
|
||||
load: async () => (await import("./B09_Draft_Print_Page")).renderB09Print,
|
||||
};
|
||||
@@ -0,0 +1,334 @@
|
||||
"""인쇄 미리보기 표 출력 길(PLAN 302-1 · 초안) — 서버는 꼴만 바꿈(값 셈 · 저장 없음 · 정본 아님).
|
||||
|
||||
POST /api/projects/{id}/print/table-pdf {title, sheets:[{name, orientation, html}]}
|
||||
html = 브라우저가 `renderDataTableHtml` 로 만든 표(화면과 같은 꼴) → 인쇄 CSS 를 입혀
|
||||
헤드리스 Edge(없으면 Chrome)로 A4 PDF 한 벌 · 시트 = 차례대로 새 쪽 · 방향 = 시트마다 ·
|
||||
쪽 머리 = 시트 이름(표 머리와 함께 쪽마다 되풀이) · 쪽 바닥 = 쪽 번호 ·
|
||||
넓은 표 = 쪽 폭에 맞춰 줄임
|
||||
POST /api/projects/{id}/print/table-xlsx {title, sheets:[{name, grid}]}
|
||||
grid(`B09_Draft_Print_Table.sheetGrid`) → 엑셀 — 시트 = 차례대로 · 이름 31자 ·
|
||||
금지 글자 바꿈 · 머리 굵게 · 병합 · 테두리 · 숫자 = 숫자 칸(열 소수 서식) ·
|
||||
레벨 = 들여쓰기 · 열 폭
|
||||
|
||||
`B09_Estimation_Router` 가 draft/*_Router.py 의 router 를 끼워 넣음
|
||||
(같은 머리 · 같은 권한 = 회사 · 프로젝트).
|
||||
PDF 는 자식 파이썬 프로세스(이 파일을 스크립트로 · 입력 JSON stdin · 결과 JSON 한 줄) —
|
||||
서버 안 스레드에선 Playwright 가 드라이버를 못 띄움(`B08_Quantity_Milestone_Capture` 와 같은 까닭).
|
||||
헤드리스 쪽은 스크립트 끔 · 바깥 요청 모두 막음(그림 = data: 주소만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as htmlmod
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
TIMEOUT_S = 90
|
||||
CHANNELS = ("msedge", "chrome") # 시스템 브라우저 — 번들 chromium 은 안 받아 둠
|
||||
NO_BROWSER = "서버에서 브라우저(Edge · Chrome)를 못 띄움"
|
||||
#: A4 인쇄 칸 폭(px · 96dpi) — 210 · 297mm − 좌우 여백 10mm 둘
|
||||
PRINT_W = {"portrait": 190 / 25.4 * 96, "landscape": 277 / 25.4 * 96}
|
||||
MIN_ZOOM = 0.3
|
||||
|
||||
PRINT_CSS = """
|
||||
@page { size: A4 portrait; margin: 10mm 10mm 14mm; }
|
||||
@page wide { size: A4 landscape; margin: 10mm 10mm 14mm; }
|
||||
html, body { margin: 0; }
|
||||
body { font-family: "Malgun Gothic", "맑은 고딕", sans-serif; font-size: 11px; color: #000; }
|
||||
.sheet { break-after: page; }
|
||||
.sheet:last-child { break-after: auto; }
|
||||
.sheet.is-landscape { page: wide; }
|
||||
.ui-data-table__table {
|
||||
width: var(--dt-w); table-layout: fixed; border-collapse: collapse; line-height: 1.3;
|
||||
}
|
||||
.ui-data-table__table caption { display: none; }
|
||||
.ui-data-table__table th, .ui-data-table__table td {
|
||||
height: 20px; padding: 0 4px; overflow: hidden; border: 0.5pt solid #666;
|
||||
white-space: nowrap; white-space-collapse: preserve-spaces; text-overflow: clip;
|
||||
}
|
||||
.ui-data-table__table [data-num] { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.ui-data-table__table td.is-empty { text-align: center; color: #555; }
|
||||
.ui-data-table__table thead { display: table-header-group; }
|
||||
.ui-data-table__table thead th { background: #eee; font-weight: 600; text-align: center; }
|
||||
.ui-data-table__table thead th.sheet-title {
|
||||
background: #fff; border: 0; text-align: left; font-size: 13px; font-weight: 700; height: 26px;
|
||||
}
|
||||
.ui-data-table__table tfoot { display: table-row-group; }
|
||||
.ui-data-table__table tfoot td { background: #f3f3f3; font-weight: 700; }
|
||||
.ui-data-table__table tbody tr.is-title td { font-weight: 700; }
|
||||
.ui-data-table__table td.is-merged { vertical-align: middle; }
|
||||
.ui-data-table__table tr { break-inside: avoid; }
|
||||
.ui-data-table__table td.ui-data-table__lv {
|
||||
text-align: left; padding-left: calc(4px + var(--dt-lv, 0) * 1em);
|
||||
}
|
||||
.ui-data-table__table td.ui-data-table__lv.is-node { font-weight: 700; }
|
||||
.ui-data-table__guide, .ui-data-table__icon { display: none; }
|
||||
.sheet-figure { margin: 8px 0 0; break-inside: avoid; }
|
||||
.sheet-figure img { max-width: 100%; max-height: 240mm; }
|
||||
.sheet-figure figcaption { font-size: 11px; margin-top: 2px; }
|
||||
"""
|
||||
FOOTER = (
|
||||
'<div style="width:100%;font-size:9px;text-align:center;font-family:sans-serif">'
|
||||
'<span class="pageNumber"></span> / <span class="totalPages"></span></div>'
|
||||
)
|
||||
|
||||
_lock = threading.Lock() # ponytail: 서버 전체 한 줄 — 브라우저 한 번에 하나
|
||||
|
||||
|
||||
class PrintError(Exception):
|
||||
"""까닭 한 줄(예외 원문 없음)."""
|
||||
|
||||
|
||||
def _titled(table: str, name: str) -> str:
|
||||
"""표 머리 맨 위에 시트 이름 줄 — thead 가 쪽마다 되풀이되므로 쪽 머리가 됨."""
|
||||
span = max(1, table.count("<col "))
|
||||
row = f'<tr><th class="sheet-title" colspan="{span}">{htmlmod.escape(name)}</th></tr>'
|
||||
return table.replace("<thead>", f"<thead>{row}", 1) if "<thead>" in table else table
|
||||
|
||||
|
||||
def document_html(title: str, sheets: list[dict[str, Any]]) -> str:
|
||||
"""시트마다 한 덩이(새 쪽 · 방향) — 표 머리에 시트 이름."""
|
||||
parts = [
|
||||
f'<section class="sheet is-{s.get("orientation") or "portrait"}">'
|
||||
f"{_titled(s['html'], s['name'])}</section>"
|
||||
for s in sheets
|
||||
]
|
||||
return (
|
||||
f'<!doctype html><html><head><meta charset="utf-8"><title>{htmlmod.escape(title)}</title>'
|
||||
f"<style>{PRINT_CSS}</style></head><body>{''.join(parts)}</body></html>"
|
||||
)
|
||||
|
||||
|
||||
#: 시트 덩이마다 쪽 폭에 맞춰 줄임(zoom · 넓은 표만)
|
||||
FIT_JS = (
|
||||
"""(w) => { for (const s of document.querySelectorAll('.sheet')) {
|
||||
const width = s.scrollWidth, max = w[s.classList.contains('is-landscape') ? 1 : 0];
|
||||
if (width > max) s.style.zoom = String(Math.max(%s, max / width)); } }"""
|
||||
% MIN_ZOOM
|
||||
)
|
||||
|
||||
|
||||
def render_pdf(doc: str) -> bytes:
|
||||
"""헤드리스 브라우저 → PDF 바이트. 실패 = PrintError."""
|
||||
try:
|
||||
from playwright.sync_api import Error as PwError
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError as error:
|
||||
raise PrintError("서버에 Playwright 가 없습니다(pip install playwright)") from error
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = None
|
||||
for channel in CHANNELS:
|
||||
try:
|
||||
browser = p.chromium.launch(channel=channel, headless=True)
|
||||
break
|
||||
except PwError:
|
||||
continue
|
||||
if browser is None:
|
||||
raise PrintError(NO_BROWSER)
|
||||
try:
|
||||
context = browser.new_context(java_script_enabled=False)
|
||||
context.route("**/*", lambda route: route.abort()) # 바깥 요청 없음(data: 만)
|
||||
page = context.new_page()
|
||||
page.set_content(doc, wait_until="load")
|
||||
page.evaluate(FIT_JS, [PRINT_W["portrait"], PRINT_W["landscape"]])
|
||||
return page.pdf(
|
||||
prefer_css_page_size=True,
|
||||
print_background=True,
|
||||
display_header_footer=True,
|
||||
header_template="<span></span>",
|
||||
footer_template=FOOTER,
|
||||
)
|
||||
finally:
|
||||
browser.close()
|
||||
except PrintError:
|
||||
raise
|
||||
except Exception as error: # 드라이버 못 뜸 등 — 예외 글은 내지 않음
|
||||
raise PrintError(NO_BROWSER) from error
|
||||
|
||||
|
||||
def spawn_pdf(doc: str) -> bytes:
|
||||
"""자식 파이썬 프로세스로 render_pdf — 결과 PDF 는 임시 파일로 받음."""
|
||||
with _lock, tempfile.TemporaryDirectory() as tmp:
|
||||
out = Path(tmp) / "out.pdf"
|
||||
try:
|
||||
done = subprocess.run(
|
||||
[sys.executable, __file__],
|
||||
input=json.dumps({"doc": doc, "out": str(out)}).encode(),
|
||||
capture_output=True,
|
||||
timeout=TIMEOUT_S,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise PrintError(f"PDF 만들기가 {TIMEOUT_S}초 안에 안 끝남") from error
|
||||
except OSError as error:
|
||||
raise PrintError(NO_BROWSER) from error
|
||||
try:
|
||||
result = json.loads(done.stdout.decode().strip().splitlines()[-1])
|
||||
except (ValueError, IndexError): # 자식이 죽음 — stderr(예외 글)는 내지 않음
|
||||
result = {"reason": NO_BROWSER}
|
||||
if result.get("reason"):
|
||||
raise PrintError(result["reason"])
|
||||
return out.read_bytes()
|
||||
|
||||
|
||||
# ── 엑셀 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
BAD_TITLE = re.compile(r"[\[\]:*?/\\]")
|
||||
|
||||
|
||||
def sheet_title(name: str, used: set[str]) -> str:
|
||||
"""엑셀 시트 이름 — 금지 글자 = 「_」 · 31자 · 같은 이름 = 뒤에 (2)…"""
|
||||
base = BAD_TITLE.sub("_", name).strip().strip("'") or "시트"
|
||||
title, n = base[:31], 1
|
||||
while title.lower() in used:
|
||||
n += 1
|
||||
tail = f" ({n})"
|
||||
title = base[: 31 - len(tail)] + tail
|
||||
used.add(title.lower())
|
||||
return title
|
||||
|
||||
|
||||
def number_format(decimals: int | None) -> str:
|
||||
return "#,##0" + ("." + "0" * decimals if decimals else "")
|
||||
|
||||
|
||||
def build_xlsx(sheets: list[dict[str, Any]]) -> bytes:
|
||||
"""grid 시트들 → 엑셀 바이트(차례 그대로)."""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
thin = Side(style="thin", color="666666")
|
||||
box = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
head_fill = PatternFill("solid", fgColor="EEEEEE")
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
used: set[str] = set()
|
||||
for sheet in sheets:
|
||||
ws = wb.create_sheet(sheet_title(sheet["name"], used))
|
||||
g = sheet["grid"]
|
||||
cols, top = g["cols"], g["head_rows"]
|
||||
for j, col in enumerate(cols, 1):
|
||||
ws.column_dimensions[get_column_letter(j)].width = max(4, round(col["w"] / 7, 1))
|
||||
for r in range(1, top + 1):
|
||||
for j in range(1, len(cols) + 1):
|
||||
cell = ws.cell(r, j)
|
||||
cell.font, cell.fill, cell.border = Font(bold=True), head_fill, box
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
for h in g["head"]:
|
||||
ws.cell(h["r"] + 1, h["c"] + 1, h["text"])
|
||||
if h["rs"] > 1 or h["cs"] > 1:
|
||||
ws.merge_cells(
|
||||
start_row=h["r"] + 1,
|
||||
start_column=h["c"] + 1,
|
||||
end_row=h["r"] + h["rs"],
|
||||
end_column=h["c"] + h["cs"],
|
||||
)
|
||||
lv_col = g.get("level_col")
|
||||
for i, row in enumerate(g["rows"]):
|
||||
r = top + 1 + i
|
||||
bold = bool(g["bold"][i]) if i < len(g["bold"]) else False
|
||||
level = g["levels"][i] if i < len(g["levels"]) else None
|
||||
for j, value in enumerate(row):
|
||||
cell = ws.cell(r, j + 1, value)
|
||||
cell.border = box
|
||||
if bold:
|
||||
cell.font = Font(bold=True)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
cell.number_format = number_format(cols[j].get("dec"))
|
||||
if j == lv_col and level:
|
||||
cell.alignment = Alignment(indent=max(0, int(level) - 1))
|
||||
for m in g.get("merges", []):
|
||||
ws.merge_cells(
|
||||
start_row=top + 1 + m["r"],
|
||||
start_column=m["c"] + 1,
|
||||
end_row=top + m["r"] + m["rs"],
|
||||
end_column=m["c"] + 1,
|
||||
)
|
||||
ws.cell(top + 1 + m["r"], m["c"] + 1).alignment = Alignment(vertical="center")
|
||||
if top:
|
||||
ws.freeze_panes = ws.cell(top + 1, (g.get("frozen") or 0) + 1)
|
||||
out = io.BytesIO()
|
||||
wb.save(out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
if __name__ == "__main__": # 자식 프로세스 몸통 — fastapi 를 읽지 않음
|
||||
job = json.loads(sys.stdin.buffer.read())
|
||||
try:
|
||||
Path(job["out"]).write_bytes(render_pdf(job["doc"]))
|
||||
print(json.dumps({"reason": None}))
|
||||
except PrintError as error:
|
||||
print(json.dumps({"reason": str(error)}, ensure_ascii=False))
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
# ── 길 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
import asyncio # noqa: E402
|
||||
from urllib.parse import quote # noqa: E402
|
||||
from uuid import UUID # noqa: E402
|
||||
|
||||
from fastapi import APIRouter, HTTPException # noqa: E402
|
||||
from fastapi.responses import Response # noqa: E402
|
||||
from pydantic import BaseModel, Field # noqa: E402
|
||||
|
||||
router = APIRouter()
|
||||
MAX_SHEETS = 200
|
||||
|
||||
|
||||
class PdfSheet(BaseModel):
|
||||
name: str = Field(max_length=200)
|
||||
orientation: Literal["portrait", "landscape"] = "portrait"
|
||||
html: str
|
||||
|
||||
|
||||
class XlsxSheet(BaseModel):
|
||||
name: str = Field(max_length=200)
|
||||
grid: dict[str, Any]
|
||||
|
||||
|
||||
class PdfBody(BaseModel):
|
||||
title: str = Field(default="인쇄", max_length=200)
|
||||
sheets: list[PdfSheet] = Field(min_length=1, max_length=MAX_SHEETS)
|
||||
|
||||
|
||||
class XlsxBody(BaseModel):
|
||||
title: str = Field(default="인쇄", max_length=200)
|
||||
sheets: list[XlsxSheet] = Field(min_length=1, max_length=MAX_SHEETS)
|
||||
|
||||
|
||||
def _file(data: bytes, media: str, title: str, ext: str) -> Response:
|
||||
name = quote(f"{title}.{ext}")
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=media,
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{name}"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/print/table-pdf")
|
||||
async def table_pdf(project_id: UUID, body: PdfBody) -> Response:
|
||||
doc = document_html(body.title, [s.model_dump() for s in body.sheets])
|
||||
try:
|
||||
data = await asyncio.to_thread(spawn_pdf, doc)
|
||||
except PrintError as error:
|
||||
raise HTTPException(status_code=503, detail=str(error)) from error
|
||||
return _file(data, "application/pdf", body.title, "pdf")
|
||||
|
||||
|
||||
@router.post("/{project_id}/print/table-xlsx")
|
||||
async def table_xlsx(project_id: UUID, body: XlsxBody) -> Response:
|
||||
try:
|
||||
data = await asyncio.to_thread(build_xlsx, [s.model_dump() for s in body.sheets])
|
||||
except (KeyError, TypeError, ValueError, IndexError) as error:
|
||||
raise HTTPException(status_code=400, detail=f"표 꼴이 맞지 않음 — {error}") from error
|
||||
media = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
return _file(data, media, body.title, "xlsx")
|
||||
@@ -0,0 +1,146 @@
|
||||
/* =============================================================================
|
||||
* B09_Draft_Print_Table.ts
|
||||
* 인쇄 시트 → 서버 몸(PLAN 302-1 · 초안) — 화면과 같은 표 짜기(DataTableOptions)를 그대로 씀.
|
||||
* sheetHtml = `renderDataTableHtml`(누름 · 입력 뺀 보기 전용) + 그림(data: 주소) → 서버 표 PDF
|
||||
* sheetGrid = 머리 줄(묶음 병합) · 값 줄(숫자 = 숫자 · 합계 = 끝 줄) · 열 폭 · 소수 · 레벨 · 세로 병합 → 서버 엑셀
|
||||
* 그림 = 엑셀엔 안 넣음(PDF 만)
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
mergeSpans,
|
||||
renderDataTableHtml,
|
||||
SCALE,
|
||||
type DataTableOptions,
|
||||
} from "@ui/ui_template_data_table";
|
||||
import type { PrintSheet } from "./B09_Draft_Print_Types";
|
||||
|
||||
export interface GridHead {
|
||||
r: number;
|
||||
c: number;
|
||||
rs: number;
|
||||
cs: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SheetGrid {
|
||||
head_rows: number;
|
||||
head: GridHead[];
|
||||
/** 열 폭 px · 소수 자리(글 열 = null) */
|
||||
cols: { w: number; dec: number | null }[];
|
||||
rows: (string | number | null)[][];
|
||||
bold: boolean[];
|
||||
levels: (number | null)[];
|
||||
level_col: number | null;
|
||||
/** 본문 세로 병합(mergeSame) — r = 값 줄 번호 */
|
||||
merges: { r: number; c: number; rs: number }[];
|
||||
frozen: number;
|
||||
}
|
||||
|
||||
const ESC_HTML: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """ };
|
||||
const escHtml = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC_HTML[c]);
|
||||
|
||||
/** 보기 전용 — 누름 · 입력 · 강조를 뺀 같은 표 */
|
||||
const still = (t: DataTableOptions): DataTableOptions => ({
|
||||
...t,
|
||||
onRowClick: undefined,
|
||||
onCellClick: undefined,
|
||||
editable: undefined,
|
||||
onEdit: undefined,
|
||||
editOnDouble: false,
|
||||
editing: null,
|
||||
focus: null,
|
||||
fillViewport: false,
|
||||
});
|
||||
|
||||
export function sheetHtml(s: PrintSheet): string {
|
||||
const figs = (s.images ?? [])
|
||||
.map(
|
||||
(im) =>
|
||||
`<figure class="sheet-figure"><img src="${escHtml(im.src)}" alt="">` +
|
||||
(im.caption ? `<figcaption>${escHtml(im.caption)}</figcaption>` : "") +
|
||||
`</figure>`,
|
||||
)
|
||||
.join("");
|
||||
return renderDataTableHtml(still(s.table)) + figs;
|
||||
}
|
||||
|
||||
function cellValue(v: unknown): string | number | null {
|
||||
if (v === null || v === undefined || v === "") return null;
|
||||
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
||||
return String(v);
|
||||
}
|
||||
|
||||
export function sheetGrid(table: DataTableOptions): SheetGrid {
|
||||
const t = still(table);
|
||||
const cols = t.columns;
|
||||
const depth = Math.max(0, ...cols.map((c) => c.group?.length ?? 0));
|
||||
const head: GridHead[] = [];
|
||||
const same = (a: string[] | undefined, b: string[] | undefined, n: number) =>
|
||||
(b?.length ?? 0) > n && a!.slice(0, n + 1).join("\u0000") === b!.slice(0, n + 1).join("\u0000");
|
||||
// 화면 머리(`headHtml`)와 같은 칸 나눔 — 묶음 = 옆으로 합침 · 맨 아래 머리 = 아래로 합침
|
||||
for (let lv = 0; lv <= depth; lv++) {
|
||||
for (let j = 0; j < cols.length; j++) {
|
||||
const g = cols[j].group?.length ?? 0;
|
||||
if (g > lv) {
|
||||
let k = j;
|
||||
while (k + 1 < cols.length && same(cols[j].group, cols[k + 1].group, lv)) k++;
|
||||
head.push({ r: lv, c: j, rs: 1, cs: k - j + 1, text: cols[j].group![lv] });
|
||||
j = k;
|
||||
} else if (g === lv) {
|
||||
head.push({ r: lv, c: j, rs: depth - lv + 1, cs: 1, text: cols[j].label });
|
||||
}
|
||||
}
|
||||
}
|
||||
const n = t.rows.length;
|
||||
const rows = t.rows.map((r) => cols.map((c) => cellValue(r[c.key])));
|
||||
const bold = t.rows.map((_r, i) => /\bis-title\b/.test(t.rowClass?.(i) ?? ""));
|
||||
const levels = t.rows.map((_r, i) => (t.level ? (t.level.of(i) ?? 1) : null));
|
||||
if (t.total) {
|
||||
rows.push(cols.map((c) => cellValue(t.total![c.key])));
|
||||
bold.push(true);
|
||||
levels.push(null);
|
||||
}
|
||||
const merges: SheetGrid["merges"] = [];
|
||||
if (t.mergeSame?.length)
|
||||
mergeSpans(t, 0, n).forEach((spans, i) =>
|
||||
spans.forEach((rs, c) => rs > 1 && merges.push({ r: i, c, rs })),
|
||||
);
|
||||
const lvCol = t.level ? cols.findIndex((c) => c.key === t.level!.key) : -1;
|
||||
return {
|
||||
head_rows: depth + 1,
|
||||
head,
|
||||
cols: cols.map((c) => ({
|
||||
w: Math.round((c.width ?? (c.kind === "number" ? 84 : 96)) * SCALE),
|
||||
dec: c.kind === "number" ? (c.decimals ?? 0) : null,
|
||||
})),
|
||||
rows,
|
||||
bold,
|
||||
levels,
|
||||
level_col: lvCol >= 0 ? lvCol : null,
|
||||
merges,
|
||||
frozen: Math.min(t.frozen ?? 0, cols.length),
|
||||
};
|
||||
}
|
||||
|
||||
/** 그림 주소 → data: 주소(서버 헤드리스는 바깥 요청을 막음 · 로그인 쿠키로 브라우저가 받아 둠) */
|
||||
export async function inlineImages(s: PrintSheet): Promise<PrintSheet> {
|
||||
if (!s.images?.some((im) => !im.src.startsWith("data:"))) return s;
|
||||
const images = await Promise.all(
|
||||
s.images.map(async (im) => {
|
||||
if (im.src.startsWith("data:")) return im;
|
||||
try {
|
||||
const blob = await (await fetch(im.src, { credentials: "include" })).blob();
|
||||
const src = await new Promise<string>((done, fail) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => done(String(reader.result));
|
||||
reader.onerror = () => fail(reader.error);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
return { ...im, src };
|
||||
} catch {
|
||||
return { ...im, src: "" };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { ...s, images: images.filter((im) => im.src) };
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"""PLAN 302-1 — 인쇄 미리보기 틀(초안 길 등록 · 서버 표 PDF · 엑셀 · 표 → 서버 이음매)."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation import B09_Estimation_Router as b09 # noqa: E402
|
||||
from B09_Estimation.draft import B09_Draft_Print_Router as pr # noqa: E402
|
||||
|
||||
DRAFT = ROOT / "B09_Estimation/draft"
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
PID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text("utf-8")
|
||||
|
||||
|
||||
def _client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(pr.router, prefix="/api/projects")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _ts(expr: str) -> object:
|
||||
"""표 짜기(공용 표 + 인쇄 표) TS 를 노드로 — expr 결과 JSON."""
|
||||
js = "".join(
|
||||
IMPORTS.sub("", _text(p))
|
||||
for p in (
|
||||
ROOT / "ui_template/ui_template_data_table.ts",
|
||||
DRAFT / "B09_Draft_Print_Table.ts",
|
||||
)
|
||||
)
|
||||
run = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
|
||||
input=js + f"\nconsole.log(JSON.stringify({expr}));",
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
#: 머리 두 줄(묶음) · 숫자 열 · 레벨 · 세로 병합 · 합계 · 제목 줄
|
||||
TABLE = """{
|
||||
columns: [
|
||||
{ key: "kind", label: "공종" },
|
||||
{ key: "name", label: "이름", width: 120 },
|
||||
{ key: "q", label: "수량", group: ["물량"], kind: "number", decimals: 2 },
|
||||
{ key: "a", label: "금액", group: ["물량"], kind: "number" },
|
||||
],
|
||||
rows: [
|
||||
{ kind: "토공", name: "토공", q: null, a: null },
|
||||
{ kind: "토공", name: "터파기", q: 12.5, a: 1500 },
|
||||
{ kind: "토공", name: "되메우기 <A&B>", q: 3, a: 300 },
|
||||
{ kind: "포장", name: "포장", q: 1234.567, a: 2000000 },
|
||||
],
|
||||
total: { name: "합계", a: 2001800 },
|
||||
frozen: 1,
|
||||
mergeSame: ["kind"],
|
||||
level: { key: "name", of: (i) => (i === 0 ? 1 : 2) },
|
||||
rowClass: (i) => (i === 0 ? "is-title" : null),
|
||||
onRowClick: () => {},
|
||||
}"""
|
||||
|
||||
|
||||
def _sheets():
|
||||
return _ts(
|
||||
f"(() => {{ const t = {TABLE}; return ["
|
||||
"{name: '갑 / 수량[1]', orientation: 'portrait', html: sheetHtml({id:'a', name:'갑', table:t}),"
|
||||
" grid: sheetGrid(t)},"
|
||||
"{name: '을: 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만', orientation: 'landscape',"
|
||||
" html: sheetHtml({id:'b', name:'을', table:t, images:[{src:'data:image/png;base64,iVBORw0KGgo=',"
|
||||
" caption:'그림 <1>'}]}), grid: sheetGrid(t)}]; })()"
|
||||
)
|
||||
|
||||
|
||||
# ── 길 등록 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_초안_길은_선언으로_등록_정적_표엔_이름_없음():
|
||||
router = _text(ROOT / "A00_Common/router.ts")
|
||||
assert (
|
||||
'import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true })'
|
||||
in router
|
||||
)
|
||||
assert "routeTable[route] ?? draft?.load" in router
|
||||
assert "draft?.workflow" in router and "draft?.project" in router
|
||||
assert "draftRoute(currentRoute())?.footerless" in _text(ROOT / "A00_Common/main.ts")
|
||||
route = _text(DRAFT / "B09_Draft_Print_Route.ts")
|
||||
assert 'path: "b09-print"' in route and "workflow: true" in route and "project: true" in route
|
||||
# 가짜 대조 — 정적 표 · 셸 · 문구에 초안 이름이 없음(폴더째 지우면 길이 함께 사라짐)
|
||||
for rel in (
|
||||
"config/config_frontend.ts", "A00_Common/router.ts", "A00_Common/main.ts",
|
||||
"ui_template/ui_template_loupe.ts", "B09_Estimation/B09_Estimation_UI_Shell.ts",
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs.ts",
|
||||
): # fmt: skip
|
||||
body = _text(ROOT / rel)
|
||||
assert "b09-print" not in body and "Draft_Print" not in body, rel
|
||||
# 돋보기 · 기본 메뉴 막음 = 주소 꼴(b0x-)로 함께 걸림
|
||||
assert re.search(r"/\^\(b0\[1-9\]", _text(ROOT / "ui_template/ui_template_loupe.ts"))
|
||||
|
||||
|
||||
def test_서버_초안_길_끼우기_폴더_없으면_길_없음(tmp_path, monkeypatch):
|
||||
paths = {r.path for r in b09.router.routes}
|
||||
assert "/api/projects/{project_id}/print/table-pdf" in paths
|
||||
assert "/api/projects/{project_id}/print/table-xlsx" in paths
|
||||
# 가짜 폴더 — 비면 아무것도 안 끼움 · *_Router.py 가 있으면 그 길만 · 깨진 초안은 건너뜀
|
||||
pkg = tmp_path / "fakepkg"
|
||||
(pkg / "draft").mkdir(parents=True)
|
||||
(pkg / "__init__.py").write_text("")
|
||||
(pkg / "draft/__init__.py").write_text("")
|
||||
monkeypatch.syspath_prepend(str(tmp_path))
|
||||
empty = APIRouter()
|
||||
assert b09.include_drafts(empty, pkg / "draft", "fakepkg.draft") == []
|
||||
assert empty.routes == []
|
||||
(pkg / "draft/X_Router.py").write_text(
|
||||
"from fastapi import APIRouter\nrouter = APIRouter()\n"
|
||||
"@router.get('/x')\ndef x():\n return 1\n"
|
||||
)
|
||||
(pkg / "draft/Y_Router.py").write_text("import 없는_모듈\n")
|
||||
got = APIRouter()
|
||||
assert b09.include_drafts(got, pkg / "draft", "fakepkg.draft") == ["X_Router"]
|
||||
assert [r.path for r in got.routes] == ["/x"]
|
||||
|
||||
|
||||
def test_로그인_회사_권한은_B09_길과_같음():
|
||||
main = _text(ROOT / "main.py")
|
||||
assert "app.include_router(b09_estimation_router, dependencies=protected_with_company)" in main
|
||||
assert "Depends(require_project_access)" in main
|
||||
# 초안 router 는 머리 · 의존성 없이 B09 router 안으로만 들어감
|
||||
assert "APIRouter()" in _text(DRAFT / "B09_Draft_Print_Router.py")
|
||||
|
||||
|
||||
# ── 서버 표 출력 ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_table_pdf_쪽_방향_시트_이름_쪽_번호():
|
||||
pymupdf = pytest.importorskip("pymupdf")
|
||||
sheets = _sheets()
|
||||
res = _client().post(
|
||||
f"/api/projects/{PID}/print/table-pdf",
|
||||
json={"title": "수량산출", "sheets": [{k: s[k] for k in ("name", "orientation", "html")}
|
||||
for s in sheets]},
|
||||
) # fmt: skip
|
||||
if res.status_code == 503:
|
||||
pytest.skip(res.json()["detail"]) # 서버에 Edge · Chrome 없음
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.content[:5] == b"%PDF-" and res.headers["content-type"] == "application/pdf"
|
||||
doc = pymupdf.open(stream=res.content)
|
||||
assert doc.page_count == 2
|
||||
p1, p2 = doc[0], doc[1]
|
||||
assert p1.rect.width < p1.rect.height and p2.rect.width > p2.rect.height # 시트마다 방향
|
||||
t1, t2 = p1.get_text(), p2.get_text()
|
||||
assert "갑 / 수량[1]" in t1 and "을: 원가표" in t2 # 쪽 머리 = 시트 이름
|
||||
assert "1 / 2" in t1 and "2 / 2" in t2 # 쪽 번호
|
||||
assert "되메우기 <A&B>" in t1 and "1,234.57" in t1 and "2,001,800" in t1 # 화면과 같은 글
|
||||
assert "그림 <1>" in t2
|
||||
|
||||
|
||||
def test_table_pdf_넓은_표는_쪽_폭에_맞춤_긴_표는_머리_되풀이():
|
||||
pymupdf = pytest.importorskip("pymupdf")
|
||||
rows = "".join(f"<tr><td>줄{i}</td><td data-num>{i}</td></tr>" for i in range(120))
|
||||
html = (
|
||||
'<table class="ui-data-table__table" style="--dt-w:3000px"><colgroup>'
|
||||
'<col style="width:1500px"><col style="width:1500px"></colgroup>'
|
||||
f"<thead><tr><th>이름</th><th>값</th></tr></thead><tbody>{rows}</tbody></table>"
|
||||
)
|
||||
doc = pr.document_html("t", [{"name": "넓은 표", "orientation": "portrait", "html": html}])
|
||||
try:
|
||||
data = pr.spawn_pdf(doc)
|
||||
except pr.PrintError as error:
|
||||
pytest.skip(str(error))
|
||||
pdf = pymupdf.open(stream=data)
|
||||
words = [w for p in pdf for w in p.get_text("words")]
|
||||
assert max(w[2] for w in words) <= pdf[0].rect.width # 오른쪽이 쪽 밖으로 안 나감
|
||||
texts = [p.get_text() for p in pdf]
|
||||
assert "줄119" in texts[-1]
|
||||
assert all("넓은 표" in t and "이름" in t for t in texts) # 쪽마다 머리
|
||||
|
||||
|
||||
def test_table_pdf_바깥_요청_막음():
|
||||
html = '<table><thead></thead></table><img src="http://127.0.0.1:9/x.png">'
|
||||
doc = pr.document_html("t", [{"name": "s", "orientation": "portrait", "html": html}])
|
||||
assert "<script" not in pr.PRINT_CSS
|
||||
try:
|
||||
assert pr.spawn_pdf(doc)[:5] == b"%PDF-" # 막힌 그림 = 빈 칸 · PDF 는 나옴
|
||||
except pr.PrintError as error:
|
||||
pytest.skip(str(error))
|
||||
|
||||
|
||||
def test_table_xlsx_시트_차례_이름_머리_병합_숫자_칸():
|
||||
from openpyxl import load_workbook
|
||||
|
||||
sheets = _sheets()
|
||||
body = {"title": "수량", "sheets": [{"name": s["name"], "grid": s["grid"]} for s in sheets]}
|
||||
body["sheets"].append({"name": "갑 _ 수량_1_", "grid": sheets[0]["grid"]}) # 같은 이름
|
||||
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))
|
||||
names = wb.sheetnames
|
||||
assert names[0] == "갑 _ 수량_1_" # 금지 글자 = _
|
||||
assert (
|
||||
names[1] == "을_ 원가표가 아주 아주 길어 서른한 자를 넘는 이름입니다만"[:31]
|
||||
and len(names[1]) == 31
|
||||
)
|
||||
assert names[2] == "갑 _ 수량_1_ (2)"
|
||||
ws = wb[names[0]]
|
||||
merged = {str(m) for m in ws.merged_cells.ranges}
|
||||
# 머리 두 줄 — 「물량」 = 옆 둘 · 공종 · 이름 = 아래 둘 / 본문 공종 「토공」 세 줄 세로 병합
|
||||
assert {"C1:D1", "A1:A2", "B1:B2", "A3:A5"} <= merged
|
||||
assert (ws["C1"].value, ws["C2"].value, ws["A1"].value) == ("물량", "수량", "공종")
|
||||
assert ws["A1"].font.b and ws["C2"].font.b # 머리 굵게
|
||||
assert (
|
||||
ws["C4"].value == 12.5
|
||||
and ws["C4"].data_type == "n"
|
||||
and ws["C4"].number_format == "#,##0.00"
|
||||
)
|
||||
assert ws["D6"].value == 2000000 and ws["D6"].number_format == "#,##0"
|
||||
assert ws["B5"].value == "되메우기 <A&B>" and ws["C3"].value is None
|
||||
assert ws["B4"].alignment.indent == 1 and ws["B3"].font.b # 레벨 2 들여씀 · 제목 줄 굵게
|
||||
assert (
|
||||
ws["B7"].value == "합계" and ws["D7"].value == 2001800 and ws["D7"].font.b
|
||||
) # 합계 = 끝 줄
|
||||
assert ws["A3"].border.left.style == "thin"
|
||||
assert ws.freeze_panes == "B3"
|
||||
|
||||
|
||||
def test_table_xlsx_꼴_틀리면_400():
|
||||
res = _client().post(
|
||||
f"/api/projects/{PID}/print/table-xlsx", json={"sheets": [{"name": "a", "grid": {}}]}
|
||||
)
|
||||
assert res.status_code == 400
|
||||
res = _client().post(f"/api/projects/{PID}/print/table-pdf", json={"sheets": []})
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
def test_시트_이름_다듬기():
|
||||
used: set[str] = set()
|
||||
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)" # 대소문자 같은 이름
|
||||
Reference in New Issue
Block a user