feat(M02): 원문 다운로드 — 제목 행 단추 · pdf 통째 길(책 이름만) · 원문 읽기 M02 대리 길(85-4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
2026-10-08 12:09:49 +09:00
co-authored by Claude Opus 5.5
parent 084b52001e
commit eaeff398dc
4 changed files with 121 additions and 7 deletions
@@ -1,7 +1,8 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Source.ts
* 원문 보기(품셈 본문 절) — 출처 글로 서버가 찾음(`GET /m01/logic/source`) · 늦게 온 답은 버림
* M01 로직 상세 「원문」 패널 · M02 일위대가 상세 「원문」 패널(47-4)이 같이 씀
* M01 로직 상세 「원문」 패널 · M02 일위대가 상세 「원문」 패널(47-4)이 같이 씀 ·
* 길은 부르는 쪽이 줌(85-4 · M02 = `/m02/logic-lib/source` + 제목 행 [원문 다운로드] pdf 통째)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
@@ -16,6 +17,7 @@ const TEXT = {
From: ["출처", "Source"],
NoSource: ["출처 없음", "No source"],
Near: ["{v} 절이 없어 위 절", "No {v} section — showing the parent"],
Download: ["원문 다운로드", "Download source"],
} as const;
const ts = (key: keyof typeof TEXT, v = ""): string =>
@@ -70,15 +72,38 @@ export interface SourceView {
show(src: string): void;
}
export function createSourceView(): SourceView {
/** 출처 글 → 원문 pdf 책(85-4) — 모르면 null */
const bookOf = (src: string): string | null =>
src.startsWith("건설품셈") ? "건설" : src.startsWith("산림품셈") ? "산림" : null;
export interface SourceViewOptions {
/** 원문 절 길(기본 M01 `/m01/logic/source` — 시스템 관리자) */
path?: string;
/** 원문 pdf 길(`?book=`) — 주면 제목 행 오른쪽 [원문 다운로드] */
pdf?: string;
}
export function createSourceView(opts: SourceViewOptions = {}): SourceView {
const body = el("div", { className: "m01lab-src__body" });
const title = el("strong", { className: "m02-dfp__title" });
const download = opts.pdf
? (el("a", {
className: "ui-btn ui-btn--ghost ui-btn--sm",
text: ts("Download"),
attrs: { download: "", "data-source-pdf": "" },
}) as HTMLAnchorElement)
: null;
let shown: string | null = null;
const show = (src: string): void => {
if (shown === src) return;
shown = src;
title.textContent = src || ts("NoSource");
if (download) {
const book = bookOf(src);
download.hidden = !book;
download.href = book ? `${API_BASE_URL}${opts.pdf}?${new URLSearchParams({ book })}` : "";
}
body.replaceChildren(el("p", { className: "m01-logic__muted", text: ts("Loading") }));
const none = (): void =>
body.replaceChildren(
@@ -86,9 +111,12 @@ export function createSourceView(): SourceView {
el("p", { className: "m01-logic__muted", text: `${ts("From")} · ${src || "—"}` }),
);
if (!src.trim()) return none();
void fetch(`${API_BASE_URL}/m01/logic/source?${new URLSearchParams({ src })}`, {
credentials: "include",
})
void fetch(
`${API_BASE_URL}${opts.path ?? "/m01/logic/source"}?${new URLSearchParams({ src })}`,
{
credentials: "include",
},
)
.then((r) => (r.ok ? (r.json() as Promise<{ found: Found[] }>) : { found: [] }))
.catch(() => ({ found: [] as Found[] }))
.then(({ found }) => {
@@ -115,5 +143,8 @@ export function createSourceView(): SourceView {
});
};
return { root: el("div", { className: "m01lab-src", children: [title, body] }), show };
const head = download
? el("div", { className: "m02-dfp__head", children: [title, download] })
: title;
return { root: el("div", { className: "m01lab-src", children: [head, body] }), show };
}
@@ -131,7 +131,11 @@ export function mountLaborDetail(
});
/** 원문 패널(47-4 · 카드 갈래엔 없음) — 열려 있을 때만 찾음 · 고른 줄 없으면 안내 한 줄 */
const source = createSourceView();
// 85-4 — M02 길(로그인 사용자 모두) · 제목 행 [원문 다운로드]
const source = createSourceView({
path: "/m02/logic-lib/source",
pdf: "/m02/logic-lib/source-pdf",
});
const sourceHost = el("div", { attrs: { "data-labor-source": "" } });
const side = opt.card
? null
@@ -7,14 +7,19 @@
담을 로직 고르기 · 일위대가 입력 모달이 읽는 단가산출 로직(M01 길은 시스템 관리자 전용)은
`/api/m02/logic-lib/…` 읽기만(로그인) — 모양은 M01 길과 같음.
39-6 — 일위대가 조합 = 실무문서 · 조합 길 전부 시스템 관리자만(`_door` · 담을 로직 읽기 뺌).
85-4 — 원문 패널 읽기(`/logic-lib/source` · M01 길과 같은 몸) · 원문 pdf 통째
(`/logic-lib/source-pdf` · 책 이름만 받음 · 경로 안 받음)도 로그인만.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import Response
from common_util.common_util_auth import verify_session
from common_util.common_util_http_cache import cached_file_response
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Make as make
from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs
from M02_MasterTemplete import M02_Layer_Access as access
from M02_MasterTemplete import M02_MasterTemplete_Combo_Store as combos
@@ -100,3 +105,27 @@ def get_logic_auto(key: str) -> dict:
def get_logic_inputs(keys: str = "") -> dict:
"""일위대가 입력 모달 — 로직 키들(쉼표)의 입력 목록 + 자동값(`M02_Labor_Inputs`)."""
return _read(labor_inputs.logic_inputs, keys.split(","))
#: 원문 pdf(85-4) — 책 이름 → 파일(쪽 자르기 없음)
SOURCE_PDF = {
"건설": make.BODY / "건설공사_표준품셈" / "2026년_건설공사_표준품셈.pdf",
"산림": make.BODY / "산림_표준품셈" / "첨부" / "(산림청고시 제2025-82호) 산림사업 표준품셈.pdf",
}
@router.get("/logic-lib/source", dependencies=[Depends(verify_session)])
def get_logic_source(src: str) -> dict:
"""원문 패널 — 출처 글로 찾은 품셈 본문 절(M01 `/logic/source` 와 같은 몸 · 로그인만)."""
return _read(make.source, src)
@router.get("/logic-lib/source-pdf", dependencies=[Depends(verify_session)])
def get_source_pdf(request: Request, book: str) -> Response:
"""원문 pdf 통째 내려받기 — `book` = 건설 | 산림(그 밖 400)."""
path = SOURCE_PDF.get(book)
if path is None:
raise HTTPException(status_code=400, detail="책은 건설 · 산림 중 하나")
if not path.is_file():
raise HTTPException(status_code=404, detail="원문 pdf 없음")
return cached_file_response(request, path, "application/pdf", filename=path.name)
+50
View File
@@ -104,3 +104,53 @@ def test_item_all_marked() -> None:
total += 1
missing += not any(f.get("item") for f in make.source(src)["found"])
assert total >= 30 and missing == 0, (total, missing)
def _m02_client(role: str = "USER") -> TestClient:
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos
app = FastAPI()
app.include_router(combos.router)
app.dependency_overrides[verify_session] = lambda: {"user_id": 42, "role": role}
return TestClient(app)
def test_m02_원문_대리_길과_pdf() -> None:
"""85-4 — 일반 사용자도 원문(M01 길과 같은 몸) · pdf 통째(책 이름만 · 그 밖 400 · 한글 파일명)."""
from M02_MasterTemplete import M02_MasterTemplete_Router_Combos as combos
client = _m02_client()
got = client.get("/api/m02/logic-lib/source", params={"src": "산림품셈 3-1"})
assert got.status_code == 200 and got.json() == make.source("산림품셈 3-1")
for book in ("건설", "산림"):
res = client.get("/api/m02/logic-lib/source-pdf", params={"book": book})
assert res.status_code == 200 and res.headers["content-type"] == "application/pdf"
assert (
len(res.content) == combos.SOURCE_PDF[book].stat().st_size
and res.content[:4] == b"%PDF"
)
assert "filename*=utf-8''" in res.headers["content-disposition"]
again = client.get(
"/api/m02/logic-lib/source-pdf",
params={"book": book},
headers={"if-none-match": res.headers["etag"]},
)
assert again.status_code == 304
for bad in ("../../main.py", "건설품셈", ""):
assert client.get("/api/m02/logic-lib/source-pdf", params={"book": bad}).status_code == 400
def test_원문_패널_길_주입() -> None:
"""85-4 — M01 은 옛 길 그대로(기본값) · M02 일위대가 상세는 M02 길 + pdf."""
from pathlib import Path
root = Path(__file__).resolve().parents[2]
src = (root / "M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts").read_text(encoding="utf-8")
assert 'opts.path ?? "/m01/logic/source"' in src
side = (root / "M01_MasterData/M01_MasterData_UI_LogicLab_Side.ts").read_text(encoding="utf-8")
assert "createSourceView()" in side
labor = (root / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text(
encoding="utf-8"
)
assert 'path: "/m02/logic-lib/source"' in labor and 'pdf: "/m02/logic-lib/source-pdf"' in labor