Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1

This commit is contained in:
2026-10-21 12:23:31 +09:00
16 changed files with 667 additions and 10 deletions
+59
View File
@@ -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<string, CalcSiteValue>;
groups: CalcSiteGroup[];
}
async function request(init: RequestInit = {}): Promise<CalcSitesBoard> {
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<CalcSitesBoard> => request();
export const saveCalcSites = (sites: Record<string, CalcSiteValue>): Promise<CalcSitesBoard> =>
request({ method: "PUT", body: JSON.stringify({ sites }) });
let opened: Promise<Record<string, CalcSiteValue>> | null = null;
/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림). */
export function openCalcSites(): Promise<Record<string, CalcSiteValue>> {
opened = fetchCalcSites().then(
(board) => board.sites,
() => ({}),
);
return opened;
}
/** 이 묶음을 조작 중에도 서버가 셈? — 받기 전이면 받기를 기다림. */
export async function calcOnServer(key: string): Promise<boolean> {
return (await (opened ?? openCalcSites()))[key] === "server";
}
+2
View File
@@ -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)
+9 -2
View File
@@ -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),
+36
View File
@@ -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)
+6
View File
@@ -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)
+110
View File
@@ -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<typeof L>[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<string, CalcSiteValue>,
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<void> {
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<string, CalcSiteValue> =>
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<boolean> => {
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)),
),
);
},
});
}
+5 -2
View File
@@ -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;
+5 -1
View File
@@ -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<GroundStageDeps, "grid" | "remote"> {
export function groundStageDeps(
projectId: string,
): Pick<GroundStageDeps, "grid" | "remote" | "serverOnly"> {
return {
grid: () => designTerrainGridNow(projectId),
serverOnly: () => calcOnServer("ground_sample"),
async remote(points, terrain, signal) {
const chunks: Array<Array<[number, number]>> = [];
for (let i = 0; i < points.length; i += ELEVATION_CHUNK) {
@@ -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<CorridorBuildResult | null> {
if (await calcOnServer("corridor_mesh")) return null;
const onMain = (): CorridorBuildResult | null =>
buildCorridor(crossSections, routePoints, designSamples);
const target = getWorker();
@@ -102,6 +102,8 @@ export interface GroundStageDeps {
terrain: string | null,
signal: AbortSignal,
): Promise<Array<number | null>>;
/** 계산 자리 판(504-2) G1 = server — 격자가 있어도 서버 길 · 격자는 cross 도 쓰므로 여기서만 가름. */
serverOnly?(): boolean | Promise<boolean>;
/** 횡단 반폭(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";
+101
View File
@@ -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
@@ -0,0 +1,7 @@
{
"version": 1,
"sites": {
"ground_sample": "both",
"corridor_mesh": "both"
}
}
@@ -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
+260
View File
@@ -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
+7 -2
View File
@@ -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<string, string>) =>"
" { 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],
+50
View File
@@ -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;