From 69d989e5c804b1914652a592e47f540d33c58b68 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 15:39:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(717-1):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=83=81=EC=84=B8=20=EB=B6=80=ED=92=88=20=EA=B8=B8?= =?UTF-8?q?=20=EC=A3=BC=EC=9E=85=20=C2=B7=20M02=20logic-lib=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=85=88=20=EA=B8=B8=20=EB=84=93=ED=9E=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Logic_Api.ts · Api_Fetch.ts · Logic_Note.ts — `/m01` 박힘 대신 setLogicApiPaths({ read, calc }) 길 묶음 · 안 넘기면 /m01 그대로 · 저장 · 만들기 · 관리자 길은 늘 /m01 - 새 M02_Logic_Lib_Read.py — /api/m02/logic-lib/ 로그인 길(logic/auto · logic/flow · elements · pick · materials · rows(인력 · 재료 · 기계만) · table(소요량 · 계수만) · calc · text) · M01 길 함수 그대로 부름 · row = 저장 안 한 줄 셈만 · 쓰기 없음 - main.py 등록(로그인만) · 지킴 시험 test_717_1_logic_lib.py + helper Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Api_Fetch.ts | 26 ++++-- M01_MasterData/M01_MasterData_UI_Logic_Api.ts | 39 +++++--- .../M01_MasterData_UI_Logic_Note.ts | 4 +- M02_MasterTemplete/M02_Logic_Lib_Read.py | 73 +++++++++++++++ main.py | 5 + .../tester/helper_717_1_logic_api_paths.cjs | 47 ++++++++++ resources/tester/test_717_1_logic_lib.py | 93 +++++++++++++++++++ 7 files changed, 268 insertions(+), 19 deletions(-) create mode 100644 M02_MasterTemplete/M02_Logic_Lib_Read.py create mode 100644 resources/tester/helper_717_1_logic_api_paths.cjs create mode 100644 resources/tester/test_717_1_logic_lib.py diff --git a/M01_MasterData/M01_MasterData_Api_Fetch.ts b/M01_MasterData/M01_MasterData_Api_Fetch.ts index c2eaeb337..05f9f82d2 100644 --- a/M01_MasterData/M01_MasterData_Api_Fetch.ts +++ b/M01_MasterData/M01_MasterData_Api_Fetch.ts @@ -4,6 +4,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { logicApiPaths } from "./M01_MasterData_UI_Logic_Api"; /** 줄·표 한 개 — 파일 그대로(한글 칸). */ export type Row = Record; @@ -89,8 +90,12 @@ export type SaveResult = | { status: 422; errors: string[] } | { status: number; detail: string }; -async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: T }> { - const response = await fetch(`${API_BASE_URL}/m01${path}`, { +async function call( + path: string, + init: RequestInit = {}, + base = "/m01", +): Promise<{ status: number; body: T }> { + const response = await fetch(`${API_BASE_URL}${base}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, @@ -98,9 +103,13 @@ async function call(path: string, init: RequestInit = {}): Promise<{ status: return { status: response.status, body: (await response.json()) as T }; } -async function get(path: string, params: Record): Promise { +async function get( + path: string, + params: Record, + base = "/m01", +): Promise { const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])); - const { status, body } = await call(`${path}?${query}`); + const { status, body } = await call(`${path}?${query}`, {}, base); if (status !== 200) throw new Error(String(body.detail ?? `HTTP ${status}`)); return body; } @@ -121,7 +130,11 @@ export const fetchRows = ( detail = "", state = "", ): Promise => - get("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state }); + get( + "/rows", + { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state }, + logicApiPaths().read, // 고르기 표 · 품목 찾기 — 상세 부품 길(717-1) + ); /** 한 테이블 파일은 `file` · 장 그룹(소요량 · 계수 · 로직)은 `group` 으로 부름. */ export const fetchSubsAll = (where: { @@ -174,7 +187,8 @@ export interface PickItem { export const fetchPick = ( kind: PickKind, q: string, -): Promise<{ total: number; items: PickItem[] }> => get("/pick", { kind, q, limit: 50 }); +): Promise<{ total: number; items: PickItem[] }> => + get("/pick", { kind, q, limit: 50 }, logicApiPaths().read); /** 자재 찾기 — `/pick?kind=price` 감쌈 · 이름·규격 낱말이 모두 든 줄 */ export const searchMaterials = async ( diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 947a8a044..2ebcfbef4 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -190,8 +190,22 @@ export class ApiError extends Error { } } -async function request(path: string, body?: unknown): Promise { - const response = await fetch(`${API_BASE_URL}/m01${path}`, { +/** 상세 부품이 부르는 길 앞머리(717-1) — 읽기(로직 · 요소 · 재료 · 표 · 줄) · 셈(`/calc` · `/text`) + * · 기본 = M01(`/m01` · 시스템 관리자) · M02 = `{ read: "/m02/logic-lib", calc: "/m02/logic-lib" }`(로그인) */ +export interface LogicApiPaths { + read: string; + calc: string; +} +const M01_PATHS: LogicApiPaths = { read: "/m01", calc: "/m01" }; +let paths = M01_PATHS; +/** 상세를 그리기 전에 한 번 — 안 넘기면 M01 길로 되돌림 · 저장 · 만들기 길은 늘 `/m01` */ +export const setLogicApiPaths = (next?: LogicApiPaths): void => { + paths = next ?? M01_PATHS; +}; +export const logicApiPaths = (): LogicApiPaths => paths; + +async function request(path: string, body?: unknown, base = "/m01"): Promise { + const response = await fetch(`${API_BASE_URL}${base}${path}`, { method: body === undefined ? "GET" : "POST", credentials: "include", headers: { "Content-Type": "application/json" }, @@ -205,13 +219,16 @@ async function request(path: string, body?: unknown): Promise { const query = (params: Record): string => new URLSearchParams(params).toString(); export const fetchLogics = (): Promise => - request<{ logics: LogicSummary[] }>("/logics").then((d) => d.logics); + request<{ logics: LogicSummary[] }>("/logics", undefined, paths.read).then((d) => d.logics); /** 왼쪽 트리 목록 — 서버가 준 구분·상세구분(장 차례대로) */ export const fetchLogicSubs = (): Promise => - request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`).then((d) => d.subs); + request<{ subs: SubBrief[] }>(`/subs?${query({ group: "로직" })}`, undefined, paths.read).then( + (d) => d.subs, + ); -export const fetchLogic = (key: string): Promise => request(`/logic?${query({ key })}`); +export const fetchLogic = (key: string): Promise => + request(`/logic?${query({ key })}`, undefined, paths.read); export const fetchLogicFiles = (): Promise => request<{ files: LogicFile[] }>(`/groups/${encodeURIComponent("로직")}/files`).then( @@ -222,10 +239,10 @@ export const searchElements = ( group: string, q: string, ): Promise<{ total: number; items: ElementBrief[] }> => - request(`/elements?${query({ group, q, limit: "100" })}`); + request(`/elements?${query({ group, q, limit: "100" })}`, undefined, paths.read); export const searchPrice = (q: string): Promise<{ total: number; items: ElementBrief[] }> => - request(`/pick?${query({ kind: "price", q, limit: "100" })}`); + request(`/pick?${query({ kind: "price", q, limit: "100" })}`, undefined, paths.read); export const fetchMaterials = (cond: { sub: string; @@ -235,11 +252,11 @@ export const fetchMaterials = (cond: { /** 호표 줄 단위 — 주면 그 단위 줄만 */ unit?: string; }): Promise<{ total: number; 기본: string | null; items: ElementBrief[] }> => - request(`/materials?${query({ ...cond, limit: "100" })}`); + request(`/materials?${query({ ...cond, limit: "100" })}`, undefined, paths.read); /** 자동값 길(`GET /logic/auto`) — 입력 이름 → 견본값 · 서버에 아직 없거나 값이 없으면 빈 채로(화면은 그냥 안 채움) */ export const fetchAuto = (key: string): Promise> => - request<{ 값?: Record }>(`/logic/auto?${query({ key })}`) + request<{ 값?: Record }>(`/logic/auto?${query({ key })}`, undefined, paths.read) .then((d) => d.값 ?? {}) .catch(() => ({})); @@ -250,7 +267,7 @@ export const runCalc = (body: { file?: string; /** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */ 고름?: Record; -}): Promise => request("/calc", body); +}): Promise => request("/calc", body, paths.calc); export const runText = (body: { key: string; @@ -259,7 +276,7 @@ export const runText = (body: { file?: string; /** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */ 고름?: Record; -}): Promise => request("/text", body); +}): Promise => request("/text", body, paths.calc); export const saveFiles = ( files: SaveFile[], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Note.ts b/M01_MasterData/M01_MasterData_UI_Logic_Note.ts index 40e41ed6f..f9936df40 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Note.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Note.ts @@ -9,7 +9,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { el } from "@ui/ui_template_elements"; -import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api"; +import { logicApiPaths, type ElementBrief, type NamedFormula } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { tx } from "./M01_MasterData_UI_Logic_Text"; @@ -31,7 +31,7 @@ const tableCache = new Map>(); async function getJson(path: string): Promise { try { - const res = await fetch(`${API_BASE_URL}/m01${path}`); + const res = await fetch(`${API_BASE_URL}${logicApiPaths().read}${path}`); if (!res.ok) return null; return (await res.json()) as T; } catch { diff --git a/M02_MasterTemplete/M02_Logic_Lib_Read.py b/M02_MasterTemplete/M02_Logic_Lib_Read.py new file mode 100644 index 000000000..ae6231d52 --- /dev/null +++ b/M02_MasterTemplete/M02_Logic_Lib_Read.py @@ -0,0 +1,73 @@ +"""단가산출 로직 상세 읽기 · 셈 길(PLAN 717-1) — `/api/m02/logic-lib/…` · 로그인만(`main.py`). + +M01 상세 부품(`M01_MasterData_UI_Logic_Api.ts` · `Api_Fetch.ts` · `Logic_Note.ts`)이 길 앞머리만 +`/m02/logic-lib` 로 바꿔 부름 — 길 · 몸 · 답 = M01 `/api/m01/…` 와 같음(M01 길 함수를 부름). +쓰기 없음 — `/calc` · `/text` 의 `row`(저장 안 한 고친 줄)는 메모리 사본에서만 셈(`Store._swap`). +`/logics` · `/subs` · `/logic` · 원문은 `M02_MasterTemplete_Router_Combos.py` 에 있음. +""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException + +from M01_MasterData import M01_MasterData_Router as m01 + +router = APIRouter(prefix="/api/m02/logic-lib", tags=["M02 Logic Lib Read"]) + +#: `/rows` 로 줄을 내줄 요소 파일 앞머리 — 인력 · 재료 · 기계(고르기 표 · 품목 찾기) +ROW_FILES = ("인력", "재료", "기계") +#: `/table` 로 내줄 표 파일 앞머리 — 소요량 · 계수(설명 카드 원문 표) +TABLE_FILES = ("소요량_", "계수_") + + +@router.get("/logic/auto") +def get_auto(key: str) -> dict: + return m01.get_logic_auto(key) + + +@router.get("/logic/flow") +def get_flow(key: str, file: str = "") -> dict: + """흐름 보기(읽기 · 변환해도 파일 안 바뀜).""" + return m01.get_logic_flow(key, file) + + +@router.get("/elements") +def get_elements(group: str, q: str = "", limit: int = 50) -> dict: + return m01.get_elements(group, q, min(limit, 200)) + + +@router.get("/pick") +def get_pick(kind: str, q: str = "", limit: int = 50) -> dict: + return m01.get_pick(kind, q, min(limit, 200)) + + +@router.get("/materials") +def get_materials( + sub: str, detail: str = "", spec: str = "", region: str = "", unit: str = "", limit: int = 50 +) -> dict: + return m01.get_materials(sub, detail, spec, region, unit, min(limit, 200)) + + +@router.get("/rows") +def get_rows(file: str, page: int = 1, size: int = 50, q: str = "", sub: str = "") -> dict: + if not file.startswith(ROW_FILES): + raise HTTPException(status_code=403, detail="요소 파일(인력 · 재료 · 기계)만 읽음") + return m01.get_rows(file, page, size, q, False, sub, "", "") + + +@router.get("/table") +def get_table(file: str, key: str) -> dict: + if not file.startswith(TABLE_FILES): + raise HTTPException(status_code=403, detail="소요량 · 계수 표만 읽음") + return m01.get_table(file, key) + + +@router.post("/calc") +def post_calc(body: m01.CalcBody) -> dict: + """M01 `/calc` 와 같음 — `row` = 저장 안 한 고친 줄(셈만 · 쓰기 없음).""" + return m01.post_calc(body) + + +@router.post("/text") +def post_text(body: m01.CalcBody) -> dict: + return m01.post_text(body) diff --git a/main.py b/main.py index d375c0544..982dfdf54 100644 --- a/main.py +++ b/main.py @@ -563,6 +563,11 @@ app.include_router(m02_items_router, dependencies=protected) app.include_router(m02_combos_router, dependencies=protected) app.include_router(m02_drawing_router, dependencies=protected) app.include_router(m02_labor_router, dependencies=protected) +# 단가산출 로직 상세 읽기 · 셈(PLAN 717-1) — 로그인만 · 쓰기 없음 +app.include_router( + __import__("M02_MasterTemplete.M02_Logic_Lib_Read", fromlist=["router"]).router, + dependencies=protected, +) # 단가산출(SK) 셈 길(PLAN 715-1b) — 로그인 · 층 읽기 권한은 길 안에서 app.include_router( __import__("M02_MasterTemplete.M02_Sangeun_Router", fromlist=["router"]).router, diff --git a/resources/tester/helper_717_1_logic_api_paths.cjs b/resources/tester/helper_717_1_logic_api_paths.cjs new file mode 100644 index 000000000..b67d706d8 --- /dev/null +++ b/resources/tester/helper_717_1_logic_api_paths.cjs @@ -0,0 +1,47 @@ +/* 717-1 헬퍼 — 상세 부품 길 주입 · 가짜 fetch 로 부른 주소만 모음. 짝 = `test_717_1_logic_lib.py`. */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const { load } = makeLoader({ + root: ROOT, + stubs: { M01_MasterData_UI_Logic_Edit: {}, M01_MasterData_UI_Logic_Text: {} }, +}); +const urls = []; +global.fetch = async (url) => { + urls.push(String(url).replace("http://fake", "")); + return { + ok: true, + status: 200, + statusText: "", + json: async () => ({ logics: [], subs: [], items: [], rows: [] }), + }; +}; + +const api = load("M01_MasterData", "M01_MasterData_UI_Logic_Api.ts"); +const rows = load("M01_MasterData", "M01_MasterData_Api_Fetch.ts"); +const note = load("M01_MasterData", "M01_MasterData_UI_Logic_Note.ts"); + +async function round(tag) { + await api.fetchLogic("K"); + await api.fetchAuto("K"); + await api.searchElements("재료", "q"); + await api.fetchMaterials({ sub: "s" }); + await api.runCalc({ key: "K", inputs: {} }); + await api.runText({ key: "K", inputs: {} }); + await rows.fetchRows("기계.json", 1, 5, "q"); + await rows.fetchPick("price", "q"); + await note.loadTable(`T-${tag}`); + await rows.fetchGroups(); // 관리자 길 — 늘 /m01 + await api.saveFiles([]); // 저장 — 늘 /m01 + return urls.splice(0).map((u) => u.split("?")[0]); +} + +(async () => { + const out = { before: await round("a") }; + api.setLogicApiPaths({ read: "/m02/logic-lib", calc: "/m02/calc-x" }); + out.injected = await round("b"); + api.setLogicApiPaths(); + out.after = await round("c"); + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_717_1_logic_lib.py b/resources/tester/test_717_1_logic_lib.py new file mode 100644 index 000000000..aa97c99c2 --- /dev/null +++ b/resources/tester/test_717_1_logic_lib.py @@ -0,0 +1,93 @@ +"""717-1 상세 부품 공용 — 길 주입(TS) · M02 `logic-lib` 읽기 · 셈 길(로그인 · 쓰기 없음). + +TS = `helper_717_1_logic_api_paths.cjs`(가짜 fetch 주소) · 서버 = 정본 읽기만(파일 지문 같음). +""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from M01_MasterData import M01_MasterData_Store as store +from M02_MasterTemplete import M02_Logic_Lib_Read as lib + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).parent / "helper_717_1_logic_api_paths.cjs" +KEY = "GF000001" # 경계표시 — 보통인부 한 줄 +BASE = "/api/m02/logic-lib" + + +@pytest.mark.skipif(not (ROOT / "config" / "node_modules").is_dir(), reason="의존성 미설치") +def test_길_주입_없으면_m01_있으면_넘긴_길() -> None: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], cwd=str(ROOT), capture_output=True, text=True, encoding="utf-8" + ) + assert done.returncode == 0, done.stderr + out = json.loads(done.stdout) + assert out["before"] == out["after"] # 안 넘기면 지금 그대로 + assert all(u.startswith("/m01/") for u in out["before"]) + shared, admin = out["injected"][:-2], out["injected"][-2:] + assert all(u.startswith(("/m02/logic-lib/", "/m02/calc-x/")) for u in shared) + assert [u for u in shared if u.startswith("/m02/calc-x/")] == [ + "/m02/calc-x/calc", + "/m02/calc-x/text", + ] + assert admin == ["/m01/groups", "/m01/save"] # 관리자 · 저장 길은 늘 M01 + + +@pytest.fixture(scope="module") +def user() -> TestClient: + """main.py 와 같은 등록(로그인만) · 일반 사용자.""" + app = FastAPI() + app.include_router(lib.router, dependencies=[Depends(verify_session)]) + app.dependency_overrides[verify_session] = lambda: { + "user_id": 42, "company_id": 7, "role": "USER", "is_master": False, + } # fmt: skip + return TestClient(app) + + +def _prints() -> dict[str, str]: + return {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in store.FOLDER.glob("*.json")} + + +def test_일반_계정_읽기(user: TestClient) -> None: + assert user.get(f"{BASE}/logic/auto", params={"key": KEY}).status_code == 200 + assert user.get(f"{BASE}/elements", params={"group": "인력", "q": "LB000002"}).json()["items"] + assert user.get(f"{BASE}/pick", params={"kind": "price", "q": "시멘트"}).status_code == 200 + assert user.get(f"{BASE}/rows", params={"file": "기계.json", "size": 3}).json()["rows"] + assert user.get(f"{BASE}/materials", params={"sub": "시멘트"}).status_code == 200 + flow = user.get(f"{BASE}/logic/flow", params={"key": KEY}) + assert flow.status_code == 200 and "flow" in flow.json() + found = user.get(f"{BASE}/elements", params={"group": "소요량", "q": "QF000025"}).json() + file = next(i["file"] for i in found["items"] if i["ref"] == "QF000025") + assert user.get(f"{BASE}/table", params={"file": file, "key": "QF000025"}).json()["table"] + + +def test_요소_표_밖_파일은_막음(user: TestClient) -> None: + logic_file = store.logics("", "", "", None, "")[0]["file"] + assert user.get(f"{BASE}/rows", params={"file": logic_file}).status_code == 403 + assert user.get(f"{BASE}/table", params={"file": "기계.json", "key": "x"}).status_code == 403 + assert user.get(f"{BASE}/rows", params={"file": "재료_../x.json"}).status_code == 404 + + +def test_저장_안_한_줄_셈_쓰기_없음(user: TestClient) -> None: + before = _prints() + saved = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}}).json() + row = store.logic(KEY)["logic"] + same = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row}).json() + line = row["호표"][0] + row2 = {**row, "호표": [{**line, "수량": f"({line['수량']}) * 2"}, *row["호표"][1:]]} + double = user.post(f"{BASE}/calc", json={"key": KEY, "inputs": {}, "row": row2}).json() + text = user.post(f"{BASE}/text", json={"key": KEY, "inputs": {}, "row": row2}).json() + assert saved["ok"] and same["sums"] == saved["sums"] + assert double["sums"]["노무비"] == pytest.approx(saved["sums"]["노무비"] * 2) + assert text["ok"] and text["계"] == pytest.approx(double["sums"]["계"], rel=1e-6) + assert store.logic(KEY)["logic"]["호표"][0]["수량"] == line["수량"] # 정본 그대로 + assert _prints() == before