diff --git a/A00_Common/design/design_calc_sites.ts b/A00_Common/design/design_calc_sites.ts new file mode 100644 index 000000000..7ecd81f8b --- /dev/null +++ b/A00_Common/design/design_calc_sites.ts @@ -0,0 +1,59 @@ +/* ============================================================================= + * design_calc_sites.ts + * 계산 자리 판(PLAN 504-2 · 요구 309-2) — B05 · B06 페이지 열 때 한 번 받아 페이지 수명 동안 씀. + * 판 = 서버 `resources/master_template/calc_sites.json`(`common_util_calc_sites.py`) · 쓰기 = 시스템 관리자 + * 「계산 자리」 창(`B01_Dashboard_UI_CalcSites.ts`). 열린 페이지는 다음 열기부터 · 못 받으면 전부 both. + * ground_sample(G1) server = ground 단계가 격자 대신 서버 표본(`B05_Profile_Api_TerrainGrid.ts`) + * corridor_mesh(D3) server = 3D 미리보기 워커 안 띄움 · 서버 모형만(`B05_Profile_UI_Corridor_Worker_Client.ts`) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +export type CalcSiteValue = "both" | "server"; + +export interface CalcSiteGroup { + key: string; + nos: string[]; + switchable: boolean; + fixed: "both" | "server" | "browser" | null; + why: string | null; +} + +export interface CalcSitesBoard { + version: number; + sites: Record; + groups: CalcSiteGroup[]; +} + +async function request(init: RequestInit = {}): Promise { + const path = init.method === "PUT" ? "/dashboard/admin/calc-sites" : "/dashboard/calc-sites"; + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + const data = (await response.json()) as CalcSitesBoard & { detail?: string }; + if (!response.ok) throw new Error(data.detail ?? `HTTP ${response.status}`); + return data; +} + +export const fetchCalcSites = (): Promise => request(); + +export const saveCalcSites = (sites: Record): Promise => + request({ method: "PUT", body: JSON.stringify({ sites }) }); + +let opened: Promise> | null = null; + +/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림). */ +export function openCalcSites(): Promise> { + opened = fetchCalcSites().then( + (board) => board.sites, + () => ({}), + ); + return opened; +} + +/** 이 묶음을 조작 중에도 서버가 셈? — 받기 전이면 받기를 기다림. */ +export async function calcOnServer(key: string): Promise { + return (await (opened ?? openCalcSites()))[key] === "server"; +} diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 426a02c9a..4f0515c18 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -40,6 +40,7 @@ import { usePipeIds } from "./design_pipe_ids"; import { createFacilityLane } from "./design_facility_lane"; import { pinDraftProject } from "./design_store"; import { pipelineDetail, reconcilePagePipeline } from "./design_pipeline_page"; +import { openCalcSites } from "./design_calc_sites"; import { forgetPageData, onPageBusy } from "./design_page_data"; import { pageRouteId } from "./design_page_route"; import { setPipeSectionsSource } from "./design_structure_pipe_calc"; @@ -119,6 +120,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { root.replaceChildren(page); const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로 + void openCalcSites(); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터 usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id) // 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06) · // 노선 초안 동안 = 계산 줄 새 노선 상세(다시 센 횡단 · 428) diff --git a/A00_Common/m_settings_nav.ts b/A00_Common/m_settings_nav.ts index 432177a8e..73b09f8c7 100644 --- a/A00_Common/m_settings_nav.ts +++ b/A00_Common/m_settings_nav.ts @@ -16,7 +16,7 @@ interface SettingUser { company_id?: number | null; } -/** key = M01 은 「m01」 · M02 는 진입 인자 `open` */ +/** key = M01 은 「m01」 · 계산 자리 창은 「calcSites」(대시보드 카드만 · 504-2) · M02 는 진입 인자 `open` */ export interface SettingLink { key: string; title: string; @@ -57,6 +57,12 @@ export function settingLinks(user: SettingUser, companyId: number | string | nul m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc), m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc), m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc), + { + key: "calcSites", + title: L("B01_CalcSites_Title"), + desc: L("B01_CalcSites_Desc"), + route: ROUTES.B01_ACCOUNT, + }, ]; } // 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙) @@ -76,7 +82,8 @@ export function mountSettingsDrawer( activeKey: string, companyId: number | string | null, ): void { - const links = settingLinks(user, companyId); + // 계산 자리 = 대시보드 창(504-2) — 페이지가 아니라 ☰ 에 안 둠 + const links = settingLinks(user, companyId).filter((link) => link.key !== "calcSites"); const bar = createStepBar( links.map((link) => link.title), links.findIndex((link) => link.key === activeKey), diff --git a/B01_Dashboard/B01_Dashboard_Router.py b/B01_Dashboard/B01_Dashboard_Router.py index 062cfcef8..654e249ec 100644 --- a/B01_Dashboard/B01_Dashboard_Router.py +++ b/B01_Dashboard/B01_Dashboard_Router.py @@ -7,9 +7,12 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, Response from pymysql.err import IntegrityError +from common_util import common_util_calc_sites as calc_sites +from common_util.common_util_audit import record_audit from common_util.common_util_auth import require_company, require_system_admin, verify_session from common_util.common_util_email import send_email from common_util.common_util_project_delete import hard_delete_project +from config.config_db import get_db_pool from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED from .B01_Dashboard_Knowledge_Chat import router as knowledge_chat_router @@ -59,6 +62,7 @@ from .B01_Dashboard_Schema import ( AddMemberRequest, AdminUpdateUserRequest, AssignCompanyRequest, + CalcSitesRequest, ChangeUserRoleRequest, CreateCompanyRequest, InviteMemberRequest, @@ -579,5 +583,37 @@ async def system_projects(session: dict[str, Any] = Depends(require_system_admin return {"status": "success", "projects": await list_all_projects()} +# ── 계산 자리 판(504-2) — 읽기 = 로그인 · 쓰기 = 시스템 관리자 · 바뀐 줄마다 시스템 로그 한 줄 ── +@router.get("/calc-sites") +async def read_calc_sites(session: dict[str, Any] = Depends(verify_session)): + _ = session + return {"status": "success", **calc_sites.read_sites(), "groups": calc_sites.GROUPS} + + +@router.put("/admin/calc-sites") +async def write_calc_sites( + payload: CalcSitesRequest, + request: Request, + session: dict[str, Any] = Depends(require_system_admin), +): + try: + board, changed = calc_sites.write_sites(payload.sites) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + if changed: + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + for key in changed: + await record_audit( + cursor, + actor_id=int(session["user_id"]), + action="CALC_SITE_CHANGE", + resource_type="calc_site", + resource_ref=f"{key}={board['sites'][key]}", + request=request, + ) + await connection.commit() + return {"status": "success", **board, "groups": calc_sites.GROUPS, "changed": changed} + + router.include_router(assets_router) router.include_router(knowledge_chat_router) diff --git a/B01_Dashboard/B01_Dashboard_Schema.py b/B01_Dashboard/B01_Dashboard_Schema.py index 9218ab0d9..fe3346455 100644 --- a/B01_Dashboard/B01_Dashboard_Schema.py +++ b/B01_Dashboard/B01_Dashboard_Schema.py @@ -22,6 +22,12 @@ class UiPrefsRequest(BaseModel): prefs: dict[str, str] = Field(default_factory=dict) +class CalcSitesRequest(BaseModel): + """계산 자리 판 고침(504-2) — 바꿀 줄만 · 줄 · 값 검사는 `common_util_calc_sites`.""" + + sites: dict[str, str] = Field(default_factory=dict) + + class CreateCompanyRequest(BaseModel): name: str = Field(min_length=1, max_length=255) business_registration_number: str = Field(min_length=1, max_length=20) diff --git a/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts b/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts new file mode 100644 index 000000000..7d54714c7 --- /dev/null +++ b/B01_Dashboard/B01_Dashboard_UI_CalcSites.ts @@ -0,0 +1,110 @@ +/* ============================================================================= + * B01_Dashboard_UI_CalcSites.ts + * 「계산 자리」 창(PLAN 504-2 · 요구 309-2) — 시스템 관리자만(카드 = `settingLinks` · 쓰기 = 서버 + * `require_system_admin`). 묶음 8 줄 · 칸 둘(「조작 중 브라우저 셈」 · 「[저장] 서버 정본」 늘 켬 · 잠금). + * 바꿀 수 있는 줄 = 서버 판 `groups[].switchable` · 나머지는 잠긴 체크 + 까닭 글. + * 고침은 창 안 사본에만 · [저장] 때만 바뀐 줄을 PUT(선례 `app_settings.ts`). + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { table } from "@ui/ui_template_general_blocks"; +import { askLeave } from "@ui/ui_template_leave_dialog"; +import { openModal } from "@ui/ui_template_modal"; +import { + fetchCalcSites, + saveCalcSites, + type CalcSiteGroup, + type CalcSiteValue, + type CalcSitesBoard, +} from "../A00_Common/design/design_calc_sites"; +import { L } from "./B01_Dashboard_UI_Common"; + +type LocaleKey = Parameters[0]; +const K = (key: string): string => L(`B01_CalcSites_${key}` as LocaleKey); + +function check(checked: boolean, locked: boolean): HTMLInputElement { + const input = el("input", { attrs: { type: "checkbox" } }); + input.checked = checked; + input.disabled = locked; + return input; +} + +function groupRow( + group: CalcSiteGroup, + draft: Record, + changed: () => void, +): HTMLElement[] { + const name = el("span", { + children: [el("strong", { text: K(group.key) }), ` (${group.nos.join(" ")})`], + }); + const browser = group.switchable + ? check(draft[group.key] !== "server", false) + : check(group.fixed !== "server", true); + browser.dataset.calcSite = group.key; + browser.addEventListener("change", () => { + draft[group.key] = browser.checked ? "both" : "server"; + changed(); + }); + const server = check(group.fixed !== "browser", true); + return [name, browser, server, el("span", { text: K(`Why_${group.why ?? group.key}`) })]; +} + +/** 시스템 설정 「계산 자리」 카드 — 판을 받아 창을 엶. */ +export async function openCalcSitesDialog(): Promise { + let board: CalcSitesBoard; + try { + board = await fetchCalcSites(); + } catch (error) { + showToast(error instanceof Error ? error.message : K("LoadFailed"), "error"); + return; + } + const draft = { ...board.sites }; + const dirty = (): Record => + Object.fromEntries(Object.entries(draft).filter(([key, value]) => board.sites[key] !== value)); + const count = el("p", { className: "b01-dashboard__calc-count" }); + const recount = (): void => { + const n = Object.values(draft).filter((value) => value === "server").length; + count.textContent = K("ServerCount").replace("{value}", String(n)); + }; + recount(); + + const save = async (): Promise => { + const changes = dirty(); + if (!Object.keys(changes).length) return true; + try { + board = await saveCalcSites(changes); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + return false; + } + showToast(K("Saved"), "success"); + return true; + }; + const saveButton = createButton({ + label: L("Common_Btn_Save"), + variant: "filled", + onClick: () => void save().then((ok) => ok && modal.close()), + }); + const modal = openModal({ + title: K("Title"), + closeLabel: L("Common_Btn_Close"), + dialogClass: "b01-dashboard__calc-sites", + wide: true, + actions: [saveButton], + beforeClose: async () => { + if (!Object.keys(dirty()).length) return true; + const choice = await askLeave(K("Leave")); + return choice === "discard" || (choice === "save" && (await save())); + }, + mount: (body) => { + body.append( + count, + el("p", { className: "b01-dashboard__calc-note", text: K("NextOpen") }), + table( + [K("Col_Group"), K("Col_Browser"), K("Col_Server"), K("Col_Why")], + board.groups.map((group) => groupRow(group, draft, recount)), + ), + ); + }, + }); +} diff --git a/B01_Dashboard/B01_Dashboard_UI_Page.ts b/B01_Dashboard/B01_Dashboard_UI_Page.ts index 82515ecfc..c56b848d1 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Page.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Page.ts @@ -33,6 +33,7 @@ import { type ResourceData, } from "./B01_Dashboard_Api_Fetch"; import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin"; +import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites"; import { L, roleLabel } from "./B01_Dashboard_UI_Common"; import { buildCompanyPanel, @@ -165,13 +166,15 @@ function settingCard(title: string, desc: string, go: () => void): HTMLElement { return card; } -/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) */ +/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) · 계산 자리 = 창(504-2) */ function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement { const wrap = document.createElement("div"); wrap.className = "b01-dashboard__setting-grid"; wrap.append( ...settingLinks(user, companyId).map((link) => - settingCard(link.title, link.desc, () => navigateTo(link.route, link.query)), + settingCard(link.title, link.desc, () => + link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query), + ), ), ); return wrap; diff --git a/B05_Profile/B05_Profile_Api_TerrainGrid.ts b/B05_Profile/B05_Profile_Api_TerrainGrid.ts index 14d5387c6..405d627e8 100644 --- a/B05_Profile/B05_Profile_Api_TerrainGrid.ts +++ b/B05_Profile/B05_Profile_Api_TerrainGrid.ts @@ -12,6 +12,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { fetchCachedBytes } from "../A00_Common/b_asset_cache"; +import { calcOnServer } from "../A00_Common/design/design_calc_sites"; import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler"; import type { GroundStageDeps } from "./logic/B05_Profile_Pipeline_RouteGround"; @@ -74,9 +75,12 @@ export function designTerrainGridNow(projectId: string): DesignTerrainGrid | nul const ELEVATION_CHUNK = 4000; /** 계산 묶음 ground 단계에 넣을 격자 · 서버 길(PLAN 346-1). 서버 길도 소수 6자리 — 격자 길과 같은 값. */ -export function groundStageDeps(projectId: string): Pick { +export function groundStageDeps( + projectId: string, +): Pick { return { grid: () => designTerrainGridNow(projectId), + serverOnly: () => calcOnServer("ground_sample"), async remote(points, terrain, signal) { const chunks: Array> = []; for (let i = 0; i < points.length; i += ELEVATION_CHUNK) { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts b/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts index 8d2f43114..fe9cf0958 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts @@ -5,6 +5,7 @@ * 워커는 처음 부를 때 하나 세워 페이지 수명 동안 다시 쓴다. * ========================================================================== */ +import { calcOnServer } from "../A00_Common/design/design_calc_sites"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; @@ -48,12 +49,14 @@ function getWorker(): Worker | null { return worker; } -/** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수). */ +/** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수). + * 계산 자리 판(504-2) D3 = server 면 안 지음(null) — 서버가 [저장] · [확정] 때 지은 모형만. */ export async function buildCorridorOffThread( crossSections: CrossSection[], routePoints: RoutePoint[], designSamples?: Samples, ): Promise { + if (await calcOnServer("corridor_mesh")) return null; const onMain = (): CorridorBuildResult | null => buildCorridor(crossSections, routePoints, designSamples); const target = getWorker(); diff --git a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts index 3d19b2e53..e18bb4642 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts @@ -102,6 +102,8 @@ export interface GroundStageDeps { terrain: string | null, signal: AbortSignal, ): Promise>; + /** 계산 자리 판(504-2) G1 = server — 격자가 있어도 서버 길 · 격자는 cross 도 쓰므로 여기서만 가름. */ + serverOnly?(): boolean | Promise; /** 횡단 반폭(m) — 비면 서버 기본(20 m). */ crossHalfWidthM?(): number | null; } @@ -122,7 +124,7 @@ export function createGroundStage(deps: GroundStageDeps): StageFn<"ground"> { const terrain = ctx.inputs.route?.terrain ?? null; const halfWidth = deps.crossHalfWidthM?.() ?? SECTION_CROSS_HALF_WIDTH_M; const offsets = crossOffsets(halfWidth, SECTION_CROSS_SAMPLE_INTERVAL_M); - const local = deps.grid(); + const local = (await deps.serverOnly?.()) ? null : deps.grid(); const useGrid = local !== null && (terrain === null || terrain === local.design); const tag = terrain ?? "design"; diff --git a/common_util/common_util_calc_sites.py b/common_util/common_util_calc_sites.py new file mode 100644 index 000000000..cc84fe57d --- /dev/null +++ b/common_util/common_util_calc_sites.py @@ -0,0 +1,101 @@ +"""계산 자리 판(PLAN 504-2 · 요구 309-2) — 시스템 관리자 「계산 자리」 창이 읽고 쓰는 판 하나. + +판 = `resources/master_template/calc_sites.json` `{version, sites: {묶음: "both" | "server"}}`. +both = 조작 중 브라우저 셈 + [저장] 서버 정본(지금 등록부 자리) · server = 조작 중에도 서버 길. +판이 없거나 깨지면 기본값(전부 both = 등록부 그대로). [저장] · [확정] 서버 정본 길은 판을 안 읽음. +묶음 8 줄 = 등록부(`design_calc_registry*.ts`) # 전부 · 같은지 시험 `test_309_2_calc_sites.py`. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +PATH = ROOT / "resources" / "master_template" / "calc_sites.json" +VALUES = ("both", "server") + +# 묶음 · 등록부 # · 바꿀 수 있음 · 잠금 줄의 고정 자리(both · server · browser) · 까닭 열쇠 +GROUPS: tuple[dict[str, Any], ...] = ( + {"key": "ground_sample", "nos": ["G1"], "switchable": True, "fixed": None, "why": None}, + {"key": "corridor_mesh", "nos": ["D3"], "switchable": True, "fixed": None, "why": None}, + { + "key": "quantity", + "nos": ["E1", "F7", "F8", "C4"], + "switchable": False, + "fixed": "both", + "why": "later", + }, + {"key": "carry", "nos": ["R9", "F10"], "switchable": False, "fixed": "both", "why": "later"}, + { + "key": "stages", + "nos": ["R1", "R3", "R5", "R6", "R7", "G2", "G4", "P1", "P2", "P3", "P4", "P5", "P6"] + + ["P8", "C1", "C2", "C3", "C5", "C6", "F1", "F2", "F3", "F4"], + "switchable": False, + "fixed": "both", + "why": "pair_later", + }, + { + "key": "structure_pair", + "nos": ["F5", "F6", "F9"], + "switchable": False, + "fixed": "both", + "why": "pair", + }, + { + "key": "fixed_server", + "nos": ["E2", "G3", "D1", "D2", "N1"], + "switchable": False, + "fixed": "server", + "why": "server", + }, + { + "key": "fixed_browser", + "nos": ["R2", "R4", "R8", "P7", "C7", "D4"], + "switchable": False, + "fixed": "browser", + "why": "browser", + }, +) +SWITCHABLE = frozenset(g["key"] for g in GROUPS if g["switchable"]) +LOCKED = frozenset(g["key"] for g in GROUPS if not g["switchable"]) + + +def read_sites() -> dict[str, Any]: + """판 — 없음 · 깨짐 · 모르는 값 = 그 줄 기본값(both).""" + try: + raw = json.loads(PATH.read_text(encoding="utf-8")) + except (OSError, ValueError): + raw = {} + stored = raw.get("sites") if isinstance(raw, dict) else None + stored = stored if isinstance(stored, dict) else {} + sites = {key: stored.get(key) if stored.get(key) in VALUES else "both" for key in SWITCHABLE} + version = raw.get("version") if isinstance(raw, dict) else None + return {"version": version if isinstance(version, int) else 0, "sites": sites} + + +def check_changes(changes: dict[str, str]) -> None: + """잠금 줄 · 모르는 줄 · 모르는 값 = ValueError(창구가 400).""" + for key, value in changes.items(): + if key in LOCKED: + raise ValueError(f"잠긴 줄 — {key}") + if key not in SWITCHABLE: + raise ValueError(f"모르는 줄 — {key}") + if value not in VALUES: + raise ValueError(f"모르는 값 — {key}={value}") + + +def write_sites(changes: dict[str, str]) -> tuple[dict[str, Any], list[str]]: + """검사 → 바뀐 줄만 얹어 판 번호 +1 · 통째 바꿔 씀(반쯤 쓴 판 없음). 돌려줌 = 새 판 · 바뀐 줄.""" + check_changes(changes) + current = read_sites() + changed = sorted(k for k, v in changes.items() if current["sites"][k] != v) + if not changed: + return current, [] + board = {"version": current["version"] + 1, "sites": {**current["sites"], **changes}} + temp = PATH.with_suffix(".json.tmp") + temp.write_text(json.dumps(board, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + os.replace(temp, PATH) + return board, changed diff --git a/resources/master_template/calc_sites.json b/resources/master_template/calc_sites.json new file mode 100644 index 000000000..f88037ca8 --- /dev/null +++ b/resources/master_template/calc_sites.json @@ -0,0 +1,7 @@ +{ + "version": 1, + "sites": { + "ground_sample": "both", + "corridor_mesh": "both" + } +} diff --git a/resources/tester/test_225_drawer_shortcuts.py b/resources/tester/test_225_drawer_shortcuts.py index c7fdd1550..30d355060 100644 --- a/resources/tester/test_225_drawer_shortcuts.py +++ b/resources/tester/test_225_drawer_shortcuts.py @@ -90,7 +90,7 @@ def _settings(user: dict, active: str, company) -> dict: icons = re.search(r"^const ICONS.*$", src, re.M).group(0) script = ( "const ROUTES = { M01_MASTER_DATA: 'm01-master-data'," - " M02_MASTER_TEMPLATE: 'm02-master-template' };\n" + " M02_MASTER_TEMPLATE: 'm02-master-template', B01_ACCOUNT: 'b01-account' };\n" "const L = (k: string) => k;\nlet bar: any = null;\n" "const createStepBar = (titles: string[], active: number, o: any) =>" " ({ titles, active, icons: o.icons, home: o.homeButton, routes: o.routes });\n" @@ -118,8 +118,10 @@ def test_설정_바로가기_역할_셋_지금_칸(): ["구조물조합", M02, {"open": "구조물조합", "root": "personal", "company": "3"}], ["옵션", M02, {"open": "옵션", "root": "master", "company": "3"}], ["도면", M02, {"open": "도면", "root": "master", "company": "3"}], + ["calcSites", "b01-account", None], # 계산 자리(504-2) = 대시보드 카드만 · ☰ 엔 없음 ] bar = admin["bar"] + assert len(bar["titles"]) == 4 assert bar["active"] == 2 and bar["home"] is True assert bar["icons"] == ["🗄️", "🧱", "🎛️", "📑"] assert _settings({"role": "SYSTEM_ADMIN"}, "m01", None)["bar"]["active"] == 0 diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py new file mode 100644 index 000000000..4ec5fa4ec --- /dev/null +++ b/resources/tester/test_309_2_calc_sites.py @@ -0,0 +1,260 @@ +"""PLAN 504-2 (요구 309-2 · 310) — 계산 자리 판 · 시스템 관리자 창구 · G1 · D3 연결. + +못박는 것 + - 묶음 8 줄 = 등록부 # 전부(45 · 겹침 없음) · 바꿀 수 있는 줄(G1 · D3) = browser_now · 고정 줄 = 그 자리. + - 커밋한 판 = 기본값(전부 both = 등록부 그대로) · 판 없음 · 깨짐 = 기본값. + - GET = 로그인 · PUT = 시스템 관리자만(ADMIN · USER 403) · 잠금 · 모르는 줄 · 값 = 400 · 바뀐 줄마다 + 시스템 로그 CALC_SITE_CHANGE 한 줄 · 같은 값 = 판 · 로그 그대로. + - ground 단계 = 판(serverOnly) 따라 격자 · 서버 길 · cross 의 격자는 그대로 · D3 = 워커 안 띄움. + - 카드 = 시스템 관리자 갈래에만. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_309_2_calc_sites.py -q +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B01_Dashboard import B01_Dashboard_Router as dash # noqa: E402 +from common_util import common_util_calc_sites as calc_sites # noqa: E402 +from common_util.common_util_auth import verify_session # noqa: E402 + +REGISTRY = ( + ROOT / "A00_Common/design/logic/design_calc_registry.ts", + ROOT / "A00_Common/design/logic/design_calc_registry_facility.ts", +) +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + + +def _text(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _registry_sites() -> dict[str, str]: + pairs: dict[str, str] = {} + for path in REGISTRY: + src = path.read_text(encoding="utf-8") + for no, site in re.findall(r'no: "(\w+)",[\s\S]*?site: "(\w+)"', src): + pairs[no] = site + return pairs + + +def test_묶음_여덟_줄_등록부_전부(): + sites = _registry_sites() + assert len(sites) == 45 + nos = [no for group in calc_sites.GROUPS for no in group["nos"]] + assert len(calc_sites.GROUPS) == 8 + assert len(nos) == len(set(nos)) and set(nos) == set(sites) + assert calc_sites.SWITCHABLE == {"ground_sample", "corridor_mesh"} + for group in calc_sites.GROUPS: + got = {sites[no] for no in group["nos"]} + if group["switchable"] or group["fixed"] == "both": + assert got == {"browser_now"}, group["key"] + elif group["fixed"] == "server": + assert got <= {"server_canon", "server_only"}, group["key"] + else: + assert got == {"browser_only"}, group["key"] + by_key = {g["key"]: g["nos"] for g in calc_sites.GROUPS} + assert by_key["ground_sample"] == ["G1"] and by_key["corridor_mesh"] == ["D3"] + + +def test_커밋한_판_기본값_없음_깨짐(tmp_path, monkeypatch): + committed = json.loads(calc_sites.PATH.read_text(encoding="utf-8")) + assert committed["sites"] == {"ground_sample": "both", "corridor_mesh": "both"} + assert set(calc_sites.read_sites()["sites"].values()) == {"both"} + monkeypatch.setattr(calc_sites, "PATH", tmp_path / "none.json") + assert calc_sites.read_sites() == { + "version": 0, + "sites": {"ground_sample": "both", "corridor_mesh": "both"}, + } + broken = tmp_path / "broken.json" + broken.write_text('{"sites": {"ground_sample": "server", "corridor_mesh": "x"', "utf-8") + monkeypatch.setattr(calc_sites, "PATH", broken) + assert set(calc_sites.read_sites()["sites"].values()) == {"both"} + + +class _Cursor: + def __init__(self, log: list): + self.log = log + + async def execute(self, sql, args=None): + self.log.append((sql, args)) + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + +class _Conn(_Cursor): + def cursor(self): + return _Cursor(self.log) + + async def commit(self): + self.log.append(("COMMIT", None)) + + +class _Pool: + def __init__(self, log: list): + self.log = log + + def acquire(self): + return _Conn(self.log) + + +@pytest.fixture +def client(tmp_path, monkeypatch): + board = tmp_path / "calc_sites.json" + board.write_text(calc_sites.PATH.read_text(encoding="utf-8"), "utf-8") + monkeypatch.setattr(calc_sites, "PATH", board) + log: list = [] + monkeypatch.setattr(dash, "get_db_pool", lambda: _Pool(log)) + app = FastAPI() + app.include_router(dash.router) + role = {"role": "SYSTEM_ADMIN"} + app.dependency_overrides[verify_session] = lambda: {"role": role["role"], "user_id": 7} + return TestClient(app), role, board, log + + +def test_읽기_로그인_쓰기_시스템_관리자만(client): + http, role, board, log = client + before = board.read_text("utf-8") + for name in ("USER", "ADMIN"): + role["role"] = name + got = http.get("/api/dashboard/calc-sites") + assert got.status_code == 200 + assert got.json()["sites"]["ground_sample"] == "both" + assert [g["key"] for g in got.json()["groups"]][:2] == ["ground_sample", "corridor_mesh"] + put = http.put( + "/api/dashboard/admin/calc-sites", json={"sites": {"ground_sample": "server"}} + ) + assert put.status_code == 403, name + assert board.read_text("utf-8") == before and log == [] + + +def test_잠금_모르는_줄_400_감사_한_줄(client): + http, _role, board, log = client + url = "/api/dashboard/admin/calc-sites" + for bad in ({"stages": "server"}, {"fixed_server": "both"}, {"nope": "server"}): + assert http.put(url, json={"sites": bad}).status_code == 400, bad + assert http.put(url, json={"sites": {"ground_sample": "browser"}}).status_code == 400 + assert log == [] + + done = http.put(url, json={"sites": {"ground_sample": "server"}}) + assert done.status_code == 200 and done.json()["changed"] == ["ground_sample"] + saved = json.loads(board.read_text("utf-8")) + assert saved == {"version": 2, "sites": {"ground_sample": "server", "corridor_mesh": "both"}} + inserts = [args for sql, args in log if "system_audit_logs" in sql] + assert len(inserts) == 1 + assert inserts[0][:5] == (7, "CALC_SITE_CHANGE", "calc_site", None, "ground_sample=server") + assert log[-1][0] == "COMMIT" + + # 같은 값 = 판 번호 · 로그 그대로 + again = http.put(url, json={"sites": {"ground_sample": "server"}}) + assert again.json()["changed"] == [] and json.loads(board.read_text("utf-8"))["version"] == 2 + assert len([1 for sql, _ in log if "system_audit_logs" in sql]) == 1 + # 되돌림 + http.put(url, json={"sites": {"ground_sample": "both"}}) + assert json.loads(board.read_text("utf-8"))["sites"]["ground_sample"] == "both" + + +_RUNNER = """ +import { createGroundStage } from "./B05_Profile/logic/B05_Profile_Pipeline_RouteGround.js"; +const axis = Float64Array.from({ length: 61 }, (_, i) => i); +const grid = { x: axis, y: axis, z: new Float32Array(61 * 61).fill(5), + validBits: new Uint8Array(Math.ceil((61 * 61) / 8)).fill(255) }; +const run = async (serverOnly) => { + const calls = []; + const stage = createGroundStage({ + grid: () => ({ grid, design: "las" }), + remote: async (points) => { calls.push(points.length); return points.map(() => 9); }, + ...(serverOnly === undefined ? {} : { serverOnly }), + }); + const station = { key: "k0", chainageM: 0, x: 30, y: 30, azimuthDeg: 0, left: [0, 1], + kind: "grid", id: null, ground: null }; + const out = await stage({ + generation: 1, range: null, signal: new AbortController().signal, + inputs: { route: null }, out: { stations: [station], route: null }, before: {}, prev: null, + }); + return { calls: calls.length, center: out.stations[0].ground.centerZ }; +}; +console.log(JSON.stringify([ + await run(undefined), await run(() => false), await run(async () => true), await run(() => true), +])); +""" + + +@pytest.mark.skipif(shutil.which("node") is None or not TSC.exists(), reason="node · tsc 없음") +def test_ground_판_따라_길_고름(tmp_path): + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(ROOT / "B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts"), + "--outDir", str(out), "--rootDir", str(ROOT), "--module", "esnext", "--target", "es2022", + "--moduleResolution", "bundler", "--ignoreConfig"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs")], capture_output=True, text=True, timeout=60 + ) + assert result.returncode == 0, result.stderr + grid, off, server_async, server = json.loads(result.stdout) + assert grid == off == {"calls": 0, "center": 5} # 판 없음 · both = 격자 + assert server_async == server and server["calls"] == 1 and server["center"] == 9 + + +def test_연결_자리_cross_격자_그대로(): + deps = _text("B05_Profile/B05_Profile_Api_TerrainGrid.ts") + assert "grid: () => designTerrainGridNow(projectId)," in deps # cross 가 같이 쓰는 격자 + assert 'serverOnly: () => calcOnServer("ground_sample"),' in deps + stage = _text("B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts") + assert "const local = (await deps.serverOnly?.()) ? null : deps.grid();" in stage + page = _text("A00_Common/design/design_pipeline_page.ts") + assert len(page.splitlines()) <= 699 + assert "createCrossStage({ grid: groundDeps.grid })" in page + assert "calc_sites" not in page and "calcOnServer" not in page + worker = _text("B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts") + body = worker.split("export async function buildCorridorOffThread(")[1] + assert body.index('if (await calcOnServer("corridor_mesh")) return null;') < body.index( + "getWorker()" + ) + assert "void openCalcSites();" in _text("A00_Common/design/design_page.ts") + # 저장 길은 판을 안 읽음 + for rel in ("A00_Common/design/design_save.ts", "A00_Common/design/design_save_api.ts"): + assert "calc_sites" not in _text(rel) and "calcOnServer" not in _text(rel) + + +def test_카드_시스템_관리자만_글_다_있음(): + nav = _text("A00_Common/m_settings_nav.ts") + links = nav.split("export function mountSettingsDrawer")[0] + admin, rest = links.split('if (user.role === "SYSTEM_ADMIN") {')[1].split("// 회사 수정 =") + assert 'key: "calcSites"' in admin and "calcSites" not in rest + assert 'settingLinks(user, companyId).filter((link) => link.key !== "calcSites")' in nav + page = _text("B01_Dashboard/B01_Dashboard_UI_Page.ts") + assert 'link.key === "calcSites" ? void openCalcSitesDialog()' in page + locale = _text("ui_template/ui_template_locale_m1.ts") + for group in calc_sites.GROUPS: + assert f"B01_CalcSites_{group['key']}:" in locale, group["key"] + assert f"B01_CalcSites_Why_{group['why'] or group['key']}:" in locale, group["key"] + ui = _text("B01_Dashboard/B01_Dashboard_UI_CalcSites.ts") + assert 'check(group.fixed !== "browser", true)' in ui # 서버 정본 칸 = 늘 잠금 + assert "saveCalcSites(changes)" in ui and "dirty()" in ui # 바뀐 줄만 PUT diff --git a/resources/tester/test_b01_settings_cards.py b/resources/tester/test_b01_settings_cards.py index 7798ddfd2..4de5cb487 100644 --- a/resources/tester/test_b01_settings_cards.py +++ b/resources/tester/test_b01_settings_cards.py @@ -35,11 +35,12 @@ def _cards(user: dict, company_id: int | None = None) -> list: script = ( "interface DashboardUser { role: string; is_master: boolean }\n" "const ROUTES = { M01_MASTER_DATA: 'm01-master-data'," - " M02_MASTER_TEMPLATE: 'm02-master-template' };\n" + " M02_MASTER_TEMPLATE: 'm02-master-template', B01_ACCOUNT: 'b01-account' };\n" "const L = (k: string) => k;\n" "let went = '';\n" "const navigateTo = (r: string, q?: Record) =>" " { went = r + (q ? '?' + new URLSearchParams(q) : ''); };\n" + "const openCalcSitesDialog = async () => { went = 'calc-sites-dialog'; };\n" "const mk = (): any => ({ children: [] as any[], textContent: '', className: ''," " on: null as any," " append(...c: any[]) { this.children.push(...c); }," @@ -66,6 +67,8 @@ def _cards(user: dict, company_id: int | None = None) -> list: M02 = "m02-master-template" +# 계산 자리(504-2) — 시스템 관리자만 · 이동 대신 창 +CALC_SITES = ["B01_CalcSites_Title", "B01_CalcSites_Desc", "calc-sites-dialog"] READ_CO = ( "B01_Dashboard_PermMasterRead · B01_Dashboard_PermCompanyRead · B01_Dashboard_PermPersonalWrite" ) @@ -73,13 +76,14 @@ WRITE_CO = READ_CO.replace("CompanyRead", "CompanyWrite") @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") -def test_시스템_관리자_카드_넷_고른_회사_해시(): +def test_시스템_관리자_카드_다섯_고른_회사_해시(): m = "B01_Dashboard_PermMasterWrite" assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [ ["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"], ["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"], ["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"], ["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"], + CALC_SITES, ] # 회사가 없으면 해시에 안 실음 assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[1][2] == ( @@ -229,6 +233,7 @@ def test_구조물_조합_기본_층_개인(): ["B01_Dashboard_CardStructCombo", m, f"{combo}&company=3"], ["B01_Dashboard_CardStructOption", m, f"{M02}?open=옵션&root=master&company=3"], ["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"], + CALC_SITES, ] assert _cards({"role": "ADMIN", "is_master": False, "company_id": 1}) == [ ["B01_Dashboard_CardStructCombo", WRITE_CO, combo], diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index 6f6e4c761..8c93a946c 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -76,4 +76,54 @@ export const ui_locales_m1 = { M01_GroupExchange: ["환율", "Exchange rates"], M01_GroupRate: ["요율", "Rates"], M01_GroupLogic: ["단가산출 로직", "Unit-price logic"], + B01_CalcSites_Title: ["계산 자리", "Calc sites"], + B01_CalcSites_Desc: ["조작 중 계산 = 브라우저 · 서버 고름", "Browser or server while editing"], + B01_CalcSites_ServerCount: ["지금 서버 {value} 줄", "{value} rows on server now"], + B01_CalcSites_NextOpen: [ + "열린 B05 · B06 페이지는 다음 열기(새로고침)부터 바뀜", + "Open B05 · B06 pages change from the next open (reload)", + ], + B01_CalcSites_Col_Group: ["묶음", "Group"], + B01_CalcSites_Col_Browser: ["조작 중 브라우저 셈", "Browser while editing"], + B01_CalcSites_Col_Server: ["[저장] 서버 정본", "Server canon on [Save]"], + B01_CalcSites_Col_Why: ["까닭", "Why"], + B01_CalcSites_ground_sample: ["지반 표본", "Ground sampling"], + B01_CalcSites_corridor_mesh: ["3D 길 모형", "3D corridor mesh"], + B01_CalcSites_quantity: ["토량 · 구조물 셈", "Earthwork · structure calc"], + B01_CalcSites_carry: ["이월 · 다시 얹기", "Carry · rebase"], + B01_CalcSites_stages: [ + "노선 · 측점 · 종단 · 따라감 · 횡단 단계", + "Route · station · profile · follow · cross stages", + ], + B01_CalcSites_structure_pair: ["구조물 짝", "Structure pairs"], + B01_CalcSites_fixed_server: ["고정 서버", "Fixed server"], + B01_CalcSites_fixed_browser: ["고정 브라우저", "Fixed browser"], + B01_CalcSites_Why_ground_sample: [ + "끄면 격자 대신 서버 표본(조작마다 왕복)", + "Off = server sampling instead of the grid (round trip per edit)", + ], + B01_CalcSites_Why_corridor_mesh: [ + "끄면 미리보기 안 지음 · [저장] · [확정] 때 서버가 지은 모형만", + "Off = no preview · only the model the server built on [Save] · [Confirm]", + ], + B01_CalcSites_Why_later: ["서버 계산 창구 뒤(2단계)", "After the server calc endpoint (step 2)"], + B01_CalcSites_Why_pair_later: [ + "파이썬 짝 섞임 · 짝 모으기(472-4) 뒤", + "Mixed Python pairs · after pair merge (472-4)", + ], + B01_CalcSites_Why_pair: [ + "파이썬 정본 짝 유지 · TS 는 미리보기", + "Python canon pair · TS = preview", + ], + B01_CalcSites_Why_server: [ + "서버 단독(GIS · 래스터 · 값어치)", + "Server only (GIS · raster · value)", + ], + B01_CalcSites_Why_browser: [ + "입력 도구 · 그림 — 서버 셈 없음", + "Input tools · drawing — no server calc", + ], + B01_CalcSites_Saved: ["계산 자리 저장함", "Calc sites saved"], + B01_CalcSites_Leave: ["저장 안 한 계산 자리가 있음", "You have unsaved calc sites"], + B01_CalcSites_LoadFailed: ["계산 자리를 못 읽음", "Failed to load calc sites"], } as const;