diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts index 2de0b8895..05eff5feb 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Source.ts @@ -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 }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 5f9415e7b..5f8b7821c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -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 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py index 4dfbcc95f..d31f40c65 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Combos.py @@ -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) diff --git a/resources/tester/test_m01_logic_source.py b/resources/tester/test_m01_logic_source.py index 5c595c771..6d23437af 100644 --- a/resources/tester/test_m01_logic_source.py +++ b/resources/tester/test_m01_logic_source.py @@ -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