feat(504-2 계산 자리 스위치): 시스템 관리자 「계산 자리」 카드 · 창 · 판 · G1 · D3 연결

- 판 = resources/master_template/calc_sites.json(기본값 = 등록부 자리 그대로 · 전부 both)
- common_util_calc_sites — 묶음 8 줄(등록부 45 #) · 읽기 · 검사(잠금 · 모르는 줄 · 값 = 400) · 통째 쓰기
- GET /api/dashboard/calc-sites(로그인) · PUT /api/dashboard/admin/calc-sites(시스템 관리자) · 바뀐 줄마다 CALC_SITE_CHANGE 시스템 로그
- 대시보드 시스템 설정 「계산 자리」 카드(시스템 관리자만 · ☰ 엔 없음) · 체크 창(칸 둘 · 잠긴 줄 까닭 · 지금 서버 N 줄 · 다음 열기부터)
- design_calc_sites — B05 · B06 열 때 한 번 받음 · G1 server = ground 단계 안에서만 서버 표본(cross 격자 그대로) · D3 server = 워커 안 띄움
- 시험 test_309_2_calc_sites 새 · test_b01_settings_cards · test_225_drawer_shortcuts 덧

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 12:23:11 +09:00
co-authored by Claude Opus 5.5
parent 163ab72881
commit 282b481f81
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 { createFacilityLane } from "./design_facility_lane";
import { pinDraftProject } from "./design_store"; import { pinDraftProject } from "./design_store";
import { pipelineDetail, reconcilePagePipeline } from "./design_pipeline_page"; import { pipelineDetail, reconcilePagePipeline } from "./design_pipeline_page";
import { openCalcSites } from "./design_calc_sites";
import { forgetPageData, onPageBusy } from "./design_page_data"; import { forgetPageData, onPageBusy } from "./design_page_data";
import { pageRouteId } from "./design_page_route"; import { pageRouteId } from "./design_page_route";
import { setPipeSectionsSource } from "./design_structure_pipe_calc"; import { setPipeSectionsSource } from "./design_structure_pipe_calc";
@@ -119,6 +120,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
root.replaceChildren(page); root.replaceChildren(page);
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로 pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로
void openCalcSites(); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id) usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
// 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06) · // 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06) ·
// 노선 초안 동안 = 계산 줄 새 노선 상세(다시 센 횡단 · 428) // 노선 초안 동안 = 계산 줄 새 노선 상세(다시 센 횡단 · 428)
+9 -2
View File
@@ -16,7 +16,7 @@ interface SettingUser {
company_id?: number | null; company_id?: number | null;
} }
/** key = M01 은 「m01」 · M02 는 진입 인자 `open` */ /** key = M01 은 「m01」 · 계산 자리 창은 「calcSites」(대시보드 카드만 · 504-2) · M02 는 진입 인자 `open` */
export interface SettingLink { export interface SettingLink {
key: string; key: string;
title: 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_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc), m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "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 와 같은 규칙) // 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙)
@@ -76,7 +82,8 @@ export function mountSettingsDrawer(
activeKey: string, activeKey: string,
companyId: number | string | null, companyId: number | string | null,
): void { ): void {
const links = settingLinks(user, companyId); // 계산 자리 = 대시보드 창(504-2) — 페이지가 아니라 ☰ 에 안 둠
const links = settingLinks(user, companyId).filter((link) => link.key !== "calcSites");
const bar = createStepBar( const bar = createStepBar(
links.map((link) => link.title), links.map((link) => link.title),
links.findIndex((link) => link.key === activeKey), 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 fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, Response
from pymysql.err import IntegrityError 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_auth import require_company, require_system_admin, verify_session
from common_util.common_util_email import send_email from common_util.common_util_email import send_email
from common_util.common_util_project_delete import hard_delete_project 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 config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED
from .B01_Dashboard_Knowledge_Chat import router as knowledge_chat_router from .B01_Dashboard_Knowledge_Chat import router as knowledge_chat_router
@@ -59,6 +62,7 @@ from .B01_Dashboard_Schema import (
AddMemberRequest, AddMemberRequest,
AdminUpdateUserRequest, AdminUpdateUserRequest,
AssignCompanyRequest, AssignCompanyRequest,
CalcSitesRequest,
ChangeUserRoleRequest, ChangeUserRoleRequest,
CreateCompanyRequest, CreateCompanyRequest,
InviteMemberRequest, 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()} 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(assets_router)
router.include_router(knowledge_chat_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) 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): class CreateCompanyRequest(BaseModel):
name: str = Field(min_length=1, max_length=255) name: str = Field(min_length=1, max_length=255)
business_registration_number: str = Field(min_length=1, max_length=20) 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, type ResourceData,
} from "./B01_Dashboard_Api_Fetch"; } from "./B01_Dashboard_Api_Fetch";
import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin"; import { auditLogTable, userTable } from "./B01_Dashboard_UI_Admin";
import { openCalcSitesDialog } from "./B01_Dashboard_UI_CalcSites";
import { L, roleLabel } from "./B01_Dashboard_UI_Common"; import { L, roleLabel } from "./B01_Dashboard_UI_Common";
import { import {
buildCompanyPanel, buildCompanyPanel,
@@ -165,13 +166,15 @@ function settingCard(title: string, desc: string, go: () => void): HTMLElement {
return card; return card;
} }
/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) */ /** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) · 계산 자리 = 창(504-2) */
function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement { function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement {
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = "b01-dashboard__setting-grid"; wrap.className = "b01-dashboard__setting-grid";
wrap.append( wrap.append(
...settingLinks(user, companyId).map((link) => ...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; return wrap;
+5 -1
View File
@@ -12,6 +12,7 @@
import { API_BASE_URL } from "@config/config_frontend"; import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes } from "../A00_Common/b_asset_cache"; 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 DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler";
import type { GroundStageDeps } from "./logic/B05_Profile_Pipeline_RouteGround"; import type { GroundStageDeps } from "./logic/B05_Profile_Pipeline_RouteGround";
@@ -74,9 +75,12 @@ export function designTerrainGridNow(projectId: string): DesignTerrainGrid | nul
const ELEVATION_CHUNK = 4000; const ELEVATION_CHUNK = 4000;
/** 계산 묶음 ground 단계에 넣을 격자 · 서버 길(PLAN 346-1). 서버 길도 소수 6자리 — 격자 길과 같은 값. */ /** 계산 묶음 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 { return {
grid: () => designTerrainGridNow(projectId), grid: () => designTerrainGridNow(projectId),
serverOnly: () => calcOnServer("ground_sample"),
async remote(points, terrain, signal) { async remote(points, terrain, signal) {
const chunks: Array<Array<[number, number]>> = []; const chunks: Array<Array<[number, number]>> = [];
for (let i = 0; i < points.length; i += ELEVATION_CHUNK) { 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 { RoutePoint } from "./B05_Profile_Api_Fetch";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
@@ -48,12 +49,14 @@ function getWorker(): Worker | null {
return worker; return worker;
} }
/** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수). */ /** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수).
* 계산 자리 판(504-2) D3 = server 면 안 지음(null) — 서버가 [저장] · [확정] 때 지은 모형만. */
export async function buildCorridorOffThread( export async function buildCorridorOffThread(
crossSections: CrossSection[], crossSections: CrossSection[],
routePoints: RoutePoint[], routePoints: RoutePoint[],
designSamples?: Samples, designSamples?: Samples,
): Promise<CorridorBuildResult | null> { ): Promise<CorridorBuildResult | null> {
if (await calcOnServer("corridor_mesh")) return null;
const onMain = (): CorridorBuildResult | null => const onMain = (): CorridorBuildResult | null =>
buildCorridor(crossSections, routePoints, designSamples); buildCorridor(crossSections, routePoints, designSamples);
const target = getWorker(); const target = getWorker();
@@ -102,6 +102,8 @@ export interface GroundStageDeps {
terrain: string | null, terrain: string | null,
signal: AbortSignal, signal: AbortSignal,
): Promise<Array<number | null>>; ): Promise<Array<number | null>>;
/** 계산 자리 판(504-2) G1 = server — 격자가 있어도 서버 길 · 격자는 cross 도 쓰므로 여기서만 가름. */
serverOnly?(): boolean | Promise<boolean>;
/** 횡단 반폭(m) — 비면 서버 기본(20 m). */ /** 횡단 반폭(m) — 비면 서버 기본(20 m). */
crossHalfWidthM?(): number | null; crossHalfWidthM?(): number | null;
} }
@@ -122,7 +124,7 @@ export function createGroundStage(deps: GroundStageDeps): StageFn<"ground"> {
const terrain = ctx.inputs.route?.terrain ?? null; const terrain = ctx.inputs.route?.terrain ?? null;
const halfWidth = deps.crossHalfWidthM?.() ?? SECTION_CROSS_HALF_WIDTH_M; const halfWidth = deps.crossHalfWidthM?.() ?? SECTION_CROSS_HALF_WIDTH_M;
const offsets = crossOffsets(halfWidth, SECTION_CROSS_SAMPLE_INTERVAL_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 useGrid = local !== null && (terrain === null || terrain === local.design);
const tag = terrain ?? "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) icons = re.search(r"^const ICONS.*$", src, re.M).group(0)
script = ( script = (
"const ROUTES = { M01_MASTER_DATA: 'm01-master-data'," "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 L = (k: string) => k;\nlet bar: any = null;\n"
"const createStepBar = (titles: string[], active: number, o: any) =>" "const createStepBar = (titles: string[], active: number, o: any) =>"
" ({ titles, active, icons: o.icons, home: o.homeButton, routes: o.routes });\n" " ({ 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": "personal", "company": "3"}],
["옵션", M02, {"open": "옵션", "root": "master", "company": "3"}], ["옵션", M02, {"open": "옵션", "root": "master", "company": "3"}],
["도면", M02, {"open": "도면", "root": "master", "company": "3"}], ["도면", M02, {"open": "도면", "root": "master", "company": "3"}],
["calcSites", "b01-account", None], # 계산 자리(504-2) = 대시보드 카드만 · ☰ 엔 없음
] ]
bar = admin["bar"] bar = admin["bar"]
assert len(bar["titles"]) == 4
assert bar["active"] == 2 and bar["home"] is True assert bar["active"] == 2 and bar["home"] is True
assert bar["icons"] == ["🗄️", "🧱", "🎛️", "📑"] assert bar["icons"] == ["🗄️", "🧱", "🎛️", "📑"]
assert _settings({"role": "SYSTEM_ADMIN"}, "m01", None)["bar"]["active"] == 0 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 = ( script = (
"interface DashboardUser { role: string; is_master: boolean }\n" "interface DashboardUser { role: string; is_master: boolean }\n"
"const ROUTES = { M01_MASTER_DATA: 'm01-master-data'," "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" "const L = (k: string) => k;\n"
"let went = '';\n" "let went = '';\n"
"const navigateTo = (r: string, q?: Record<string, string>) =>" "const navigateTo = (r: string, q?: Record<string, string>) =>"
" { went = r + (q ? '?' + new URLSearchParams(q) : ''); };\n" " { went = r + (q ? '?' + new URLSearchParams(q) : ''); };\n"
"const openCalcSitesDialog = async () => { went = 'calc-sites-dialog'; };\n"
"const mk = (): any => ({ children: [] as any[], textContent: '', className: ''," "const mk = (): any => ({ children: [] as any[], textContent: '', className: '',"
" on: null as any," " on: null as any,"
" append(...c: any[]) { this.children.push(...c); }," " 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" M02 = "m02-master-template"
# 계산 자리(504-2) — 시스템 관리자만 · 이동 대신 창
CALC_SITES = ["B01_CalcSites_Title", "B01_CalcSites_Desc", "calc-sites-dialog"]
READ_CO = ( READ_CO = (
"B01_Dashboard_PermMasterRead · B01_Dashboard_PermCompanyRead · B01_Dashboard_PermPersonalWrite" "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 가 없음") @pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_시스템_관리자_카드_넷_고른_회사_해시(): def test_시스템_관리자_카드_다섯_고른_회사_해시():
m = "B01_Dashboard_PermMasterWrite" m = "B01_Dashboard_PermMasterWrite"
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [ assert _cards({"role": "SYSTEM_ADMIN", "is_master": False}, 3) == [
["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"], ["B01_Dashboard_MasterElements", "B01_Dashboard_PermSourceRead", "m01-master-data"],
["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"], ["B01_Dashboard_CardStructCombo", m, f"{M02}?open=구조물조합&root=master&company=3"],
["B01_Dashboard_CardStructOption", 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"], ["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"],
CALC_SITES,
] ]
# 회사가 없으면 해시에 안 실음 # 회사가 없으면 해시에 안 실음
assert _cards({"role": "SYSTEM_ADMIN", "is_master": False})[1][2] == ( 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_CardStructCombo", m, f"{combo}&company=3"],
["B01_Dashboard_CardStructOption", 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"], ["B01_Dashboard_CardDrawing", m, f"{M02}?open=도면&root=master&company=3"],
CALC_SITES,
] ]
assert _cards({"role": "ADMIN", "is_master": False, "company_id": 1}) == [ assert _cards({"role": "ADMIN", "is_master": False, "company_id": 1}) == [
["B01_Dashboard_CardStructCombo", WRITE_CO, combo], ["B01_Dashboard_CardStructCombo", WRITE_CO, combo],
+50
View File
@@ -76,4 +76,54 @@ export const ui_locales_m1 = {
M01_GroupExchange: ["환율", "Exchange rates"], M01_GroupExchange: ["환율", "Exchange rates"],
M01_GroupRate: ["요율", "Rates"], M01_GroupRate: ["요율", "Rates"],
M01_GroupLogic: ["단가산출 로직", "Unit-price logic"], 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; } as const;