Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { logicApiPaths } from "./M01_MasterData_UI_Logic_Api";
|
||||
|
||||
/** 줄·표 한 개 — 파일 그대로(한글 칸). */
|
||||
export type Row = Record<string, unknown>;
|
||||
@@ -89,8 +90,12 @@ export type SaveResult =
|
||||
| { status: 422; errors: string[] }
|
||||
| { status: number; detail: string };
|
||||
|
||||
async function call<T>(path: string, init: RequestInit = {}): Promise<{ status: number; body: T }> {
|
||||
const response = await fetch(`${API_BASE_URL}/m01${path}`, {
|
||||
async function call<T>(
|
||||
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<T>(path: string, init: RequestInit = {}): Promise<{ status:
|
||||
return { status: response.status, body: (await response.json()) as T };
|
||||
}
|
||||
|
||||
async function get<T>(path: string, params: Record<string, string | number>): Promise<T> {
|
||||
async function get<T>(
|
||||
path: string,
|
||||
params: Record<string, string | number>,
|
||||
base = "/m01",
|
||||
): Promise<T> {
|
||||
const query = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)]));
|
||||
const { status, body } = await call<T & { detail?: unknown }>(`${path}?${query}`);
|
||||
const { status, body } = await call<T & { detail?: unknown }>(`${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<RowsPage> =>
|
||||
get<RowsPage>("/rows", { file, page, size, q, unlinked: unlinked ? 1 : 0, sub, detail, state });
|
||||
get<RowsPage>(
|
||||
"/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 (
|
||||
|
||||
@@ -190,8 +190,22 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, body?: unknown): Promise<T> {
|
||||
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<T>(path: string, body?: unknown, base = "/m01"): Promise<T> {
|
||||
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<T>(path: string, body?: unknown): Promise<T> {
|
||||
const query = (params: Record<string, string>): string => new URLSearchParams(params).toString();
|
||||
|
||||
export const fetchLogics = (): Promise<LogicSummary[]> =>
|
||||
request<{ logics: LogicSummary[] }>("/logics").then((d) => d.logics);
|
||||
request<{ logics: LogicSummary[] }>("/logics", undefined, paths.read).then((d) => d.logics);
|
||||
|
||||
/** 왼쪽 트리 목록 — 서버가 준 구분·상세구분(장 차례대로) */
|
||||
export const fetchLogicSubs = (): Promise<SubBrief[]> =>
|
||||
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<LogicOne> => request(`/logic?${query({ key })}`);
|
||||
export const fetchLogic = (key: string): Promise<LogicOne> =>
|
||||
request(`/logic?${query({ key })}`, undefined, paths.read);
|
||||
|
||||
export const fetchLogicFiles = (): Promise<LogicFile[]> =>
|
||||
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<Record<string, unknown>> =>
|
||||
request<{ 값?: Record<string, unknown> }>(`/logic/auto?${query({ key })}`)
|
||||
request<{ 값?: Record<string, unknown> }>(`/logic/auto?${query({ key })}`, undefined, paths.read)
|
||||
.then((d) => d.값 ?? {})
|
||||
.catch(() => ({}));
|
||||
|
||||
@@ -250,7 +267,7 @@ export const runCalc = (body: {
|
||||
file?: string;
|
||||
/** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */
|
||||
고름?: Record<string, string>;
|
||||
}): Promise<CalcAnswer> => request("/calc", body);
|
||||
}): Promise<CalcAnswer> => request("/calc", body, paths.calc);
|
||||
|
||||
export const runText = (body: {
|
||||
key: string;
|
||||
@@ -259,7 +276,7 @@ export const runText = (body: {
|
||||
file?: string;
|
||||
/** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */
|
||||
고름?: Record<string, string>;
|
||||
}): Promise<TextAnswer> => request("/text", body);
|
||||
}): Promise<TextAnswer> => request("/text", body, paths.calc);
|
||||
|
||||
export const saveFiles = (
|
||||
files: SaveFile[],
|
||||
|
||||
@@ -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<string, Promise<TableRow | null>>();
|
||||
|
||||
async function getJson<T>(path: string): Promise<T | null> {
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
})();
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user