Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_3
This commit is contained in:
@@ -33,9 +33,6 @@ from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureListResponse,
|
||||
StructureSaveRequest,
|
||||
StructureSaveResponse,
|
||||
StructureTypesResponse,
|
||||
load_structure_types,
|
||||
registry_schema_version,
|
||||
)
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PipePoint,
|
||||
@@ -45,6 +42,7 @@ from common_util.common_util_drainage_pipes import (
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Options import TypesWithVisible, with_visible
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,13 +57,10 @@ _PROJECT_PATH_MISSING = {
|
||||
}
|
||||
|
||||
|
||||
@router.get("/structure-types", response_model=StructureTypesResponse)
|
||||
async def read_structure_types() -> StructureTypesResponse:
|
||||
"""구조물 타입 레지스트리 정본을 그대로 돌려준다(화면 폼 생성용)."""
|
||||
return StructureTypesResponse(
|
||||
schema_version=registry_schema_version(),
|
||||
types=list(load_structure_types()),
|
||||
)
|
||||
@router.get("/structure-types", response_model=TypesWithVisible)
|
||||
async def read_structure_types() -> TypesWithVisible:
|
||||
"""구조물 타입 레지스트리 정본 + 옵션마다 `visible`(M02 옵션 보이기 · 폼 생성용)."""
|
||||
return with_visible()
|
||||
|
||||
|
||||
async def _project_root(project_id: UUID) -> str | None:
|
||||
|
||||
@@ -250,6 +250,29 @@ export const editMaterialCombo = (
|
||||
export const deleteMaterialCombo = (키: string, 판: string): Promise<unknown> =>
|
||||
call(`/material-combos/${enc(키)}?판=${enc(판)}`, { method: "DELETE" });
|
||||
|
||||
/* --- 구조물 옵션 보이기(마스터 템플릿 `구조물옵션표시.json`) — 좌측 「구조물 옵션 보이기」 줄 --- */
|
||||
export interface OptionKind {
|
||||
type_id: string;
|
||||
group: string;
|
||||
name: string;
|
||||
/** 거짓이면 B05 선택지에 안 나오는 종류 */
|
||||
enabled: boolean;
|
||||
options: { key: string; label: string; phase: "b05" | "detail"; required: boolean }[];
|
||||
}
|
||||
|
||||
/** 판 · 표시(숨긴 것만 `{종류: {옵션: false}}`) · 종류(옵션이 있는 것과 그 옵션) */
|
||||
export interface OptionsDoc {
|
||||
판: string;
|
||||
표시: Record<string, Record<string, false>>;
|
||||
종류: OptionKind[];
|
||||
}
|
||||
|
||||
export const fetchStructureOptions = (): Promise<OptionsDoc> => call("/structure-options");
|
||||
|
||||
/** 고쳐 씀 — 숨긴 것만 보냄 · 낡은 판이면 StaleError · 돌려받는 것은 새 문서 */
|
||||
export const saveStructureOptions = (판: string, 표시: OptionsDoc["표시"]): Promise<OptionsDoc> =>
|
||||
call("/structure-options", { method: "PUT", body: JSON.stringify({ 판, 표시 }) });
|
||||
|
||||
/** 다른 열(`from`) 구조물 도면 내용을 받는 열(`column`)로 가져옴 — 도번 · 열은 받는 쪽 그대로 */
|
||||
export const copyStructure = async (column: string, from: string): Promise<void> => {
|
||||
await call(`/structures/${enc(column)}/copy?from=${enc(from)}`, { method: "POST" });
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""M02 구조물 옵션 보이기 — 구조물 종류마다 B05/B06 폼에 보일 옵션을 사용자가 체크.
|
||||
|
||||
정본 = `resources/master_template/구조물옵션표시.json` = `{type_id: {option_key: false}}`
|
||||
— 없는 종류 · 키는 보임(파일이 없어도 모두 보임).
|
||||
저장은 **숨김(false)만** 남김 — 「모두 보임」 = `{}`.
|
||||
옵션 정의 · 옛 값은 그대로 — 폼이 그리는 것만 갈림(`GET /api/projects/structure-types` `visible`).
|
||||
판 = 파일 sha256 앞 16자(`store.version_of`) · 낡은 판으로 쓰면 409.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Schema import (
|
||||
StructureOptionField,
|
||||
StructureType,
|
||||
StructureTypesResponse,
|
||||
load_structure_types,
|
||||
registry_schema_version,
|
||||
)
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
FILE = "구조물옵션표시.json"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OptionWithVisible(StructureOptionField):
|
||||
"""폼 옵션 한 칸 + 보임 여부(서버가 채움)."""
|
||||
|
||||
visible: bool = True
|
||||
|
||||
|
||||
class TypeWithVisible(StructureType):
|
||||
options: list[OptionWithVisible] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TypesWithVisible(StructureTypesResponse):
|
||||
"""`GET /structure-types` 응답 — 옵션마다 `visible` 칸."""
|
||||
|
||||
types: list[TypeWithVisible]
|
||||
|
||||
|
||||
def _load() -> tuple[dict[str, Any], str]:
|
||||
"""(숨김 표 · 판) — 파일이 없으면 빈 표 · 판 빈 글."""
|
||||
path = store.FOLDER / FILE
|
||||
if not path.is_file():
|
||||
return {}, ""
|
||||
raw = path.read_bytes()
|
||||
try:
|
||||
doc = json.loads(raw)
|
||||
except ValueError as e:
|
||||
raise store.StoreError(500, f"{FILE} 을 못 읽음 — {e}") from e
|
||||
if not isinstance(doc, dict):
|
||||
raise store.StoreError(500, f"{FILE} 은 종류별 묶음이어야 함")
|
||||
return doc, store.version_of(raw)
|
||||
|
||||
|
||||
def _shown(doc: dict[str, Any], type_id: str, key: str) -> bool:
|
||||
row = doc.get(type_id)
|
||||
return not (isinstance(row, dict) and row.get(key) is False)
|
||||
|
||||
|
||||
def with_visible() -> TypesWithVisible:
|
||||
"""레지스트리 정본 + 보임 여부 — 파일이 깨졌으면 모두 보임(폼이 죽지 않게 · 로그만)."""
|
||||
try:
|
||||
doc = _load()[0]
|
||||
except store.StoreError as e:
|
||||
logger.warning("구조물 옵션 표시 표를 못 읽어 모두 보임으로 냄 — %s", e.detail)
|
||||
doc = {}
|
||||
types = [
|
||||
{
|
||||
**t.model_dump(),
|
||||
"options": [
|
||||
{**o.model_dump(), "visible": _shown(doc, t.type_id, o.key)} for o in t.options
|
||||
],
|
||||
}
|
||||
for t in load_structure_types()
|
||||
]
|
||||
return TypesWithVisible.model_validate(
|
||||
{"schema_version": registry_schema_version(), "types": types}
|
||||
)
|
||||
|
||||
|
||||
def listing() -> dict[str, Any]:
|
||||
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션이 있는 종류와 그 옵션."""
|
||||
doc, version = _load()
|
||||
hidden: dict[str, dict[str, bool]] = {}
|
||||
kinds = []
|
||||
for t in load_structure_types():
|
||||
if not t.options:
|
||||
continue
|
||||
off = {o.key: False for o in t.options if not _shown(doc, t.type_id, o.key)}
|
||||
if off:
|
||||
hidden[t.type_id] = off
|
||||
kinds.append(
|
||||
{
|
||||
"type_id": t.type_id,
|
||||
"group": t.group,
|
||||
"name": t.name,
|
||||
"enabled": t.enabled,
|
||||
"options": [
|
||||
{"key": o.key, "label": o.label, "phase": o.phase, "required": o.required}
|
||||
for o in t.options
|
||||
],
|
||||
}
|
||||
)
|
||||
return {"판": version, "표시": hidden, "종류": kinds}
|
||||
|
||||
|
||||
def _clean(shown: Any) -> dict[str, dict[str, bool]]:
|
||||
"""받은 표 검사 → 숨김만 남김. 없는 종류 · 키 · 참거짓 아닌 값은 422."""
|
||||
if not isinstance(shown, dict):
|
||||
raise store.StoreError(400, "표시는 종류별 묶음이어야 함")
|
||||
keys = {t.type_id: {o.key for o in t.options} for t in load_structure_types()}
|
||||
errors: list[str] = []
|
||||
out: dict[str, dict[str, bool]] = {}
|
||||
for type_id, row in shown.items():
|
||||
if type_id not in keys:
|
||||
errors.append(f"없는 구조물 종류 「{type_id}」")
|
||||
elif not isinstance(row, dict):
|
||||
errors.append(f"「{type_id}」 는 옵션별 묶음이어야 함")
|
||||
else:
|
||||
for key, value in row.items():
|
||||
if key not in keys[type_id]:
|
||||
errors.append(f"「{type_id}」 에 없는 옵션 「{key}」")
|
||||
elif not isinstance(value, bool):
|
||||
errors.append(f"「{type_id}.{key}」 는 참 · 거짓이어야 함")
|
||||
elif not value:
|
||||
out.setdefault(type_id, {})[key] = False
|
||||
if errors:
|
||||
raise store.StoreError(422, {"errors": errors})
|
||||
return out
|
||||
|
||||
|
||||
def save(shown: Any, version: str) -> dict[str, Any]:
|
||||
"""고쳐 씀 — 판이 같을 때만. 돌려주는 것 = 새 `listing()`."""
|
||||
fixed = _clean(shown)
|
||||
path = store.FOLDER / FILE
|
||||
with store._LOCK:
|
||||
have = store.version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
raise store.StoreError(409, {"stale": [FILE], "판": have})
|
||||
atomic_write_json(path, fixed)
|
||||
return listing()
|
||||
@@ -0,0 +1,220 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Options.ts
|
||||
* 좌측 「구조물 옵션 보이기」 줄(구조물 집계 양식 아래 별도) — 누르면 메인 칸에
|
||||
* 종류 목록(군별) → 고른 종류의 옵션 체크 목록(라벨 · 키 작은 글 · B06 전용 · 필수 표시) ·
|
||||
* [저장] · [모두 보임]. 체크 안 한 옵션은 B05 · B06 구조물 폼에서 숨음(정의 · 값은 그대로).
|
||||
* 서버 = `M02_MasterTemplete_Options.py`(마스터 템플릿 `구조물옵션표시.json` · 숨긴 것만 저장).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createTag, el, showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchStructureOptions,
|
||||
saveStructureOptions,
|
||||
StaleError,
|
||||
type OptionKind,
|
||||
type OptionsDoc,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { to } from "./M02_MasterTemplete_Options_Text";
|
||||
|
||||
export interface OptionsSectionOptions {
|
||||
isAdmin: boolean;
|
||||
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
/** 메인 칸에 화면을 띄움 — 고침 표시 손잡이를 돌려받음 */
|
||||
show: (label: string, node: HTMLElement) => (dirty: boolean) => void;
|
||||
/** 화면을 열었음 — 좌측 다른 고름 표시를 지움 */
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
export interface OptionsSection {
|
||||
title: string;
|
||||
body: HTMLElement;
|
||||
/** 다른 화면을 열었음 — 고름 표시만 지움 */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const why = (error: unknown): string => (error instanceof Error ? error.message : "");
|
||||
|
||||
export function buildOptionsSection(opt: OptionsSectionOptions): OptionsSection {
|
||||
const row = el("button", {
|
||||
className: "m02-side__item ui-sidebar-row",
|
||||
text: to("Row"),
|
||||
attrs: { type: "button", "data-options-row": "" },
|
||||
});
|
||||
const body = el("div", { className: "m02-side__items ui-sidebar-list", children: [row] });
|
||||
row.addEventListener("click", () => void open());
|
||||
|
||||
/** 이 화면이 열려 있는 동안 참 — 다른 것이 열리면 `clear` 가 내림 */
|
||||
let opened = false;
|
||||
const mark = (on: boolean): void => {
|
||||
opened = on;
|
||||
row.classList.toggle("is-active", on);
|
||||
};
|
||||
|
||||
async function open(): Promise<void> {
|
||||
if (opened || !(await opt.confirmLeave())) return;
|
||||
let doc: OptionsDoc;
|
||||
try {
|
||||
doc = await fetchStructureOptions();
|
||||
} catch (error) {
|
||||
return showToast(to("Load_Failed", why(error)), "error");
|
||||
}
|
||||
draw(doc);
|
||||
}
|
||||
|
||||
/** 메인 칸 화면 — `doc.표시` 를 고칠 사본(저장 전까지 서버에 안 감)으로 쥠 */
|
||||
function draw(doc: OptionsDoc): void {
|
||||
let version = doc.판;
|
||||
const edit = opt.isAdmin;
|
||||
/** 숨긴 옵션 — 종류 → 키 집합 */
|
||||
const hidden = new Map<string, Set<string>>();
|
||||
const load = (src: OptionsDoc["표시"]): void => {
|
||||
hidden.clear();
|
||||
for (const [id, keys] of Object.entries(src)) hidden.set(id, new Set(Object.keys(keys)));
|
||||
};
|
||||
load(doc.표시);
|
||||
let kinds = doc.종류;
|
||||
let current: OptionKind | null = null;
|
||||
|
||||
const kindList = el("div", { className: "m02-opts__kinds" });
|
||||
const optionList = el("div", { className: "m02-opts__list" });
|
||||
const off = (id: string): number => hidden.get(id)?.size ?? 0;
|
||||
|
||||
const paintKinds = (): void => {
|
||||
const parts: HTMLElement[] = [];
|
||||
let group = "";
|
||||
for (const k of kinds) {
|
||||
if (k.group !== group) {
|
||||
group = k.group;
|
||||
parts.push(
|
||||
el("h4", {
|
||||
className: "m02-opts__group",
|
||||
text: group.length === 1 ? to("Group", group) : group,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const n = off(k.type_id);
|
||||
const b = el("button", {
|
||||
className: `m02-opts__kind${k.type_id === current?.type_id ? " is-active" : ""}`,
|
||||
attrs: { type: "button", "data-options-kind": k.type_id },
|
||||
children: [
|
||||
el("span", { text: k.name }),
|
||||
...(n ? [createTag(to("Hidden", String(n)), "warning")] : []),
|
||||
...(k.enabled ? [] : [createTag(to("Off"), "neutral")]),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => {
|
||||
current = k;
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
});
|
||||
parts.push(b);
|
||||
}
|
||||
kindList.replaceChildren(...parts);
|
||||
};
|
||||
|
||||
const paintOptions = (): void => {
|
||||
if (!current) {
|
||||
optionList.replaceChildren(el("p", { className: "m02-side__empty", text: to("Pick") }));
|
||||
return;
|
||||
}
|
||||
const kind = current;
|
||||
optionList.replaceChildren(
|
||||
el("h3", { className: "m02-matset__head", text: kind.name }),
|
||||
...kind.options.map((o) => {
|
||||
const box = el("input", {
|
||||
attrs: { type: "checkbox", "data-options-key": o.key },
|
||||
}) as HTMLInputElement;
|
||||
box.checked = !hidden.get(kind.type_id)?.has(o.key);
|
||||
box.disabled = !edit;
|
||||
box.addEventListener("change", () => {
|
||||
const set = hidden.get(kind.type_id) ?? new Set<string>();
|
||||
if (box.checked) set.delete(o.key);
|
||||
else set.add(o.key);
|
||||
hidden.set(kind.type_id, set);
|
||||
setDirty(true);
|
||||
paintKinds();
|
||||
});
|
||||
const tags: HTMLElement[] = [];
|
||||
if (o.phase === "detail") tags.push(createTag(to("Detail"), "accent"));
|
||||
if (o.required && o.phase === "b05") {
|
||||
const tag = createTag(to("Required"), "danger");
|
||||
tag.title = to("RequiredTip");
|
||||
tags.push(tag);
|
||||
}
|
||||
return el("label", {
|
||||
className: "m02-opts__option",
|
||||
children: [
|
||||
box,
|
||||
el("span", { text: o.label }),
|
||||
el("small", { className: "m02-opts__key", text: o.key }),
|
||||
...tags,
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const save = createButton({ label: to("Save"), variant: "filled" });
|
||||
const all = createButton({ label: to("ShowAll"), variant: "ghost" });
|
||||
all.addEventListener("click", () => {
|
||||
if (![...hidden.values()].some((s) => s.size)) return;
|
||||
hidden.clear();
|
||||
setDirty(true);
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
});
|
||||
save.addEventListener("click", async () => {
|
||||
const 표시: OptionsDoc["표시"] = {};
|
||||
for (const [id, keys] of hidden)
|
||||
if (keys.size) 표시[id] = Object.fromEntries([...keys].map((k) => [k, false as const]));
|
||||
save.disabled = true; // 연달아 눌러도 한 번만
|
||||
try {
|
||||
const done = await saveStructureOptions(version, 표시);
|
||||
version = done.판;
|
||||
load(done.표시);
|
||||
setDirty(false);
|
||||
showToast(to("Saved"), "success");
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
} catch (error) {
|
||||
if (error instanceof StaleError) {
|
||||
showToast(to("Stale"), "error");
|
||||
try {
|
||||
const fresh = await fetchStructureOptions();
|
||||
version = fresh.판;
|
||||
kinds = fresh.종류;
|
||||
load(fresh.표시);
|
||||
setDirty(false);
|
||||
current = kinds.find((k) => k.type_id === current?.type_id) ?? null;
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
} catch (again) {
|
||||
showToast(to("Load_Failed", why(again)), "error");
|
||||
}
|
||||
} else showToast(to("Failed", why(error)), "error");
|
||||
} finally {
|
||||
save.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const node = el("div", {
|
||||
className: "m02-opts",
|
||||
children: [
|
||||
el("p", { className: "m02-side__empty", text: to("Note") }),
|
||||
el("div", {
|
||||
className: "m02-opts__panes",
|
||||
children: [kindList, optionList],
|
||||
}),
|
||||
...(edit ? [el("div", { className: "m02-side__row", children: [save, all] })] : []),
|
||||
],
|
||||
});
|
||||
const setDirty = opt.show(to("Screen"), node);
|
||||
paintKinds();
|
||||
paintOptions();
|
||||
opt.onOpen();
|
||||
mark(true);
|
||||
}
|
||||
|
||||
return { title: to("Title"), body, clear: () => mark(false) };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Options_Text.ts
|
||||
* 좌측 「구조물 옵션 보이기」 줄 · 상세 화면 글자 — [한국어, 영어]
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["구조물 옵션 보이기", "Structure options shown"],
|
||||
Row: ["종류마다 보일 옵션 고르기", "Choose options per type"],
|
||||
Screen: ["구조물 옵션 보이기", "Structure options shown"],
|
||||
Note: [
|
||||
"체크한 옵션만 B05 · B06 구조물 폼에 보임 — 옵션 정의 · 이미 넣은 값은 그대로 · 처음은 모두 보임",
|
||||
"Only checked options appear in the B05 · B06 forms — definitions and stored values stay",
|
||||
],
|
||||
Group: ["{value}군", "Group {value}"],
|
||||
Hidden: ["숨김 {value}", "{value} hidden"],
|
||||
Off: ["안 쓰는 종류", "Not offered"],
|
||||
NoOption: ["옵션 없음", "No options"],
|
||||
Pick: ["왼쪽에서 구조물 종류를 고름", "Pick a structure type on the left"],
|
||||
Detail: ["B06 전용", "B06 only"],
|
||||
Required: ["필수", "Required"],
|
||||
RequiredTip: [
|
||||
"필수 옵션 — 숨기면 B05 폼에서 값을 못 넣어 저장이 막힐 수 있음",
|
||||
"Required — hiding it leaves the B05 form unable to set it",
|
||||
],
|
||||
Save: ["저장", "Save"],
|
||||
ShowAll: ["모두 보임", "Show all"],
|
||||
Saved: ["저장함", "Saved"],
|
||||
Stale: [
|
||||
"그 사이 옵션 표시가 바뀜 — 새로 받았으니 다시 고쳐 주세요",
|
||||
"Changed meanwhile — reloaded, please edit again",
|
||||
],
|
||||
Load_Failed: ["옵션 표시를 못 받음 — {value}", "Could not load option display — {value}"],
|
||||
Failed: ["옵션 표시를 못 저장함 — {value}", "Could not save option display — {value}"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
|
||||
export type OptionsTextKey = keyof typeof TEXT;
|
||||
|
||||
export function to(key: OptionsTextKey, value = ""): string {
|
||||
const entry = TEXT[key];
|
||||
return (entry[currentLanguageIndex as 0 | 1] ?? entry[0]).replace("{value}", value);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ from pydantic import BaseModel
|
||||
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Branch as branch
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Matset as matset
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Options as options
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete"])
|
||||
@@ -41,6 +42,11 @@ class MatsetBody(BaseModel):
|
||||
줄: dict[str, Any]
|
||||
|
||||
|
||||
class OptionsBody(BaseModel):
|
||||
판: str = "" # 옵션 표시 표 판 — 어긋나면 409
|
||||
표시: dict[str, Any] # {종류: {옵션 키: 참 · 거짓}} — 거짓만 남김
|
||||
|
||||
|
||||
def _call(fn, *args):
|
||||
try:
|
||||
return fn(*args)
|
||||
@@ -127,6 +133,19 @@ def delete_material_combo(key: str, 판: str = "") -> dict:
|
||||
return _call(matset.delete, key, 판)
|
||||
|
||||
|
||||
# 구조물 옵션 보이기 — B05/B06 폼에 보일 옵션(마스터 템플릿 `구조물옵션표시.json`)
|
||||
@router.get("/structure-options")
|
||||
def get_structure_options() -> dict:
|
||||
"""`{판, 표시, 종류}` — 표시 = 숨긴 것만 · 종류 = 옵션 있는 종류와 그 옵션."""
|
||||
return _call(options.listing)
|
||||
|
||||
|
||||
@router.put("/structure-options")
|
||||
def put_structure_options(body: OptionsBody) -> dict:
|
||||
"""고쳐 씀(낡은 판이면 409 · 없는 종류 · 키는 422) — 돌려주는 것 = 새 `{판, 표시, 종류}`."""
|
||||
return _call(options.save, body.표시, body.판)
|
||||
|
||||
|
||||
# 복사 가져오기 — 다른 열(`from`) 내용을 받는 열로 · 번호 · 열은 받는 쪽 그대로
|
||||
@router.post("/structures/{column}/copy")
|
||||
def post_structure_copy(column: str, source: str = Query(alias="from")) -> dict:
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type StructureColumn,
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { buildMatsetSection } from "./M02_MasterTemplete_Matset";
|
||||
import { buildOptionsSection } from "./M02_MasterTemplete_Options";
|
||||
import { canEdit, type Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
const KINDS: { kind: Kind; label: () => string }[] = [
|
||||
@@ -122,13 +123,20 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
close: () => opt.onOpen(null),
|
||||
onOpen: () => paint(),
|
||||
onOpen: () => (options.clear(), paint()),
|
||||
});
|
||||
const matsetSection = section(matset.title, matset.body, ...matset.actions);
|
||||
// 펼칠 때마다 새로 받음 — 재료산출 옆 판이 갈라 낸 조합도 바로 보이게
|
||||
matsetSection.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
|
||||
if (matsetSection.classList.contains("is-collapsed")) void matset.refresh();
|
||||
});
|
||||
// 구조물 옵션 보이기 — 구조물 집계 양식 아래 별도 줄 하나(누르면 메인 칸에 종류별 옵션 체크)
|
||||
const options = buildOptionsSection({
|
||||
isAdmin: canEdit(layer, opt.isAdmin),
|
||||
confirmLeave: opt.confirmLeave,
|
||||
show: opt.showView,
|
||||
onOpen: () => (matset.clear(), paint()),
|
||||
});
|
||||
// 구조물 집계 양식 — 집계표 줄 아래 하위 컨테이너 넷(들여 · 같이 접힘) · 처음부터 펼침(집계표가 열려 있음)
|
||||
const summary = section(
|
||||
L("M02_KindTable"),
|
||||
@@ -142,6 +150,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
className: "m02-side__lists ui-sidebar-panel",
|
||||
children: [
|
||||
summary,
|
||||
section(options.title, options.body),
|
||||
section(L("M02_KindDrawing"), bodies.get("drawing")!, ...actionRow("drawing")),
|
||||
],
|
||||
});
|
||||
@@ -217,6 +226,7 @@ export function buildSide(opt: SideOptions): SideHandle {
|
||||
paintColumns();
|
||||
paintButtons();
|
||||
if (opt.getOpen()) matset.clear();
|
||||
if (opt.getOpen()) options.clear();
|
||||
}
|
||||
|
||||
// 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음)
|
||||
|
||||
@@ -123,3 +123,73 @@
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 좌측 「구조물 옵션 보이기」 화면(메인 칸) — 왼쪽 종류(군별) · 오른쪽 옵션 체크 */
|
||||
.m02-opts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
max-width: 64rem;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-opts__panes {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 20rem) 1fr;
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m02-opts__kinds,
|
||||
.m02-opts__list {
|
||||
max-height: calc(100vh - 20rem);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-opts__group {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-opts__kind {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-opts__kind:hover {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-opts__kind.is-active {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.m02-opts__option {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-opts__key {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""M02 구조물 옵션 보이기(`/api/m02/structure-options` · 정본 `구조물옵션표시.json`).
|
||||
|
||||
못박는 것
|
||||
- 파일이 없거나 `{}` 면 `GET /api/projects/structure-types` 옵션이 모두 visible.
|
||||
- 저장한 숨김이 그 응답 visible 에 반영 · 다른 종류 · 옵션은 그대로 · 옵션 정의는 안 바뀜.
|
||||
- 저장은 숨김(false)만 남김 · [모두 보임] = `{}` · 없는 종류 · 키 · 참거짓 아님 422 · 낡은 판 409.
|
||||
- 파일이 깨져도 폼 응답은 모두 보임(죽지 않음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from B05_Profile.B05_Profile_Structures_Router import router as b05_router
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_structure_types
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Options as opt
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_router
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
URL = "/api/m02/structure-options"
|
||||
TYPES = "/api/projects/structure-types"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
app = FastAPI()
|
||||
app.include_router(m02_router)
|
||||
app.include_router(b05_router)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _pick() -> tuple[str, str, str]:
|
||||
"""옵션이 둘 이상인 종류 하나 — (type_id, 숨길 키, 남길 키)."""
|
||||
t = next(t for t in load_structure_types() if len(t.options) >= 2)
|
||||
return t.type_id, t.options[0].key, t.options[1].key
|
||||
|
||||
|
||||
def _visible(client: TestClient) -> dict[str, dict[str, bool]]:
|
||||
types = client.get(TYPES).json()["types"]
|
||||
return {t["type_id"]: {o["key"]: o["visible"] for o in t["options"]} for t in types}
|
||||
|
||||
|
||||
def test_시드_파일은_빈_표이고_없어도_모두_보임(client) -> None:
|
||||
assert (
|
||||
json.loads((ROOT / "resources/master_template/구조물옵션표시.json").read_text("utf-8"))
|
||||
== {}
|
||||
)
|
||||
got = client.get(URL).json()
|
||||
assert got["판"] == "" and got["표시"] == {} # 파일 없음
|
||||
assert all(all(v.values()) for v in _visible(client).values())
|
||||
assert client.get(TYPES).json()["schema_version"] == 1
|
||||
|
||||
|
||||
def test_숨김을_저장하면_폼_응답에_반영_정의는_그대로(client) -> None:
|
||||
tid, off, on = _pick()
|
||||
before = client.get(TYPES).json()["types"]
|
||||
res = client.put(URL, json={"판": "", "표시": {tid: {off: False, on: True}}})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["표시"] == {tid: {off: False}} # 숨김만 남김
|
||||
seen = _visible(client)
|
||||
assert seen[tid][off] is False and seen[tid][on] is True
|
||||
assert sum(not v for row in seen.values() for v in row.values()) == 1 # 다른 것은 그대로
|
||||
after = client.get(TYPES).json()["types"]
|
||||
strip = lambda ts: [ # noqa: E731
|
||||
{**t, "options": [{k: v for k, v in o.items() if k != "visible"} for o in t["options"]]}
|
||||
for t in ts
|
||||
]
|
||||
assert strip(before) == strip(after) # 옵션 정의(라벨 · 필수 · 선택지 …)는 안 바뀜
|
||||
kind = next(k for k in client.get(URL).json()["종류"] if k["type_id"] == tid)
|
||||
assert [o["key"] for o in kind["options"]][:2] == [off, on] and "phase" in kind["options"][0]
|
||||
|
||||
|
||||
def test_모두_보임은_빈_표_판은_저장마다_바뀜(client) -> None:
|
||||
tid, off, _ = _pick()
|
||||
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
|
||||
res = client.put(URL, json={"판": v1, "표시": {}})
|
||||
assert res.status_code == 200 and res.json()["표시"] == {} and res.json()["판"] != v1
|
||||
assert json.loads((store.FOLDER / opt.FILE).read_text("utf-8")) == {}
|
||||
assert all(all(v.values()) for v in _visible(client).values())
|
||||
|
||||
|
||||
def test_없는_종류_키_참거짓_아님은_422_낡은_판은_409(client) -> None:
|
||||
tid, off, _ = _pick()
|
||||
for bad in (
|
||||
{"없는종류": {off: False}},
|
||||
{tid: {"없는키": False}},
|
||||
{tid: {off: "no"}},
|
||||
{tid: [off]},
|
||||
):
|
||||
assert client.put(URL, json={"판": "", "표시": bad}).status_code == 422, bad
|
||||
assert client.put(URL, json={"판": "", "표시": []}).status_code == 422 # 묶음 아님(요청 모양)
|
||||
assert not (store.FOLDER / opt.FILE).exists() # 거절되면 안 씀
|
||||
v1 = client.put(URL, json={"판": "", "표시": {tid: {off: False}}}).json()["판"]
|
||||
assert client.put(URL, json={"판": "낡음", "표시": {}}).status_code == 409
|
||||
assert client.put(URL, json={"판": "", "표시": {}}).status_code == 409 # 처음 판으로 또 쓰기
|
||||
assert client.get(URL).json()["판"] == v1
|
||||
|
||||
|
||||
def test_파일이_깨져도_폼_응답은_모두_보임(client) -> None:
|
||||
(store.FOLDER / opt.FILE).write_text("{깨짐", encoding="utf-8")
|
||||
assert all(all(v.values()) for v in _visible(client).values())
|
||||
assert client.get(URL).status_code == 500 # 관리 화면은 까닭을 그대로 올림
|
||||
|
||||
|
||||
def test_좌측_줄은_구조물_집계_양식_아래_한_줄_같은_한_곳에서_지움() -> None:
|
||||
side = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts").read_text("utf-8")
|
||||
assert "section(options.title, options.body)" in side
|
||||
assert (
|
||||
side.index(" summary,")
|
||||
< side.index("section(options.title")
|
||||
< side.index('section(L("M02_KindDrawing")')
|
||||
)
|
||||
assert "onOpen: () => (options.clear(), paint())" in side # 자재 조합을 열면 이 줄 고름 지움
|
||||
assert "onOpen: () => (matset.clear(), paint())" in side # 이 줄을 열면 자재 조합 고름 지움
|
||||
paint = side.split("function paint(): void {")[1].split("\n }\n")[0]
|
||||
assert "if (opt.getOpen()) options.clear();" in paint # 양식을 열면 이 줄 고름 지움
|
||||
api = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts").read_text("utf-8")
|
||||
assert (
|
||||
'call("/structure-options")' in api and 'call("/structure-options", { method: "PUT"' in api
|
||||
)
|
||||
ui = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Options.ts").read_text("utf-8")
|
||||
assert 'o.phase === "detail"' in ui # B06 전용 표시
|
||||
Reference in New Issue
Block a user