feat(B07): 표준도를 「보여주기」에서 「고치기」로 — 제원 네 칸 입력
`phase: "detail"` 칸(돌 종류·조달·뒷길이·전면 기울기)을 **그리는 화면이 없었음**
(2026-09-09 실측: B06 구조물 폼은 b05 phase 열 칸만 그림. 화면 글자 전수에서 「조달」·
「뒷길이」·「돌 종류」 0건). 그 자리를 표준도가 맡음 — PLAN 4-5b 「표준도는 입력 화면이기도 함」.
B06 은 「어디에·몇 m」(배치), 표준도는 「어떤 제원」.
**장 하나 = 제원 조합 하나**라 한 번 고치면 그 조합의 개소 전부에 걸림.
지킨 것
· **자동값을 저장에 박지 않음** — 빈 칸이 「정한 적 없음」의 뜻(확정 ⑨). 비우면 그 키를
지움. 판정된 기울기는 칸이 아니라 **회색 도움말**로만 비춤.
· **막지 않음** — 실무 도면에 1:0.7·1:0.8 이 실재(구조물도 53장). 품셈 범위(0.20~0.50)
밖이어도 값은 받고 안내만.
· **값을 여기서 셈하지 않음** — 정본에 적기만 하고 표·그림은 다음 조회에서 그 정본으로
다시 섬. 표준도가 두 번째 정본이 되면 안 됨.
⚠ 만들다 잡은 것 셋
① `face_slope_ratio` 가 **구조물 등록부에 없어** 저장이 통째로 거절됐음
(`ValueError: 돌쌓기(찰)에 정의되지 않은 옵션입니다`). 한 칸 때문에 전부 못 저장되는
것은 나쁨 — 등록부에 없는 칸은 **빼고 이름으로 알림**(조용히 버리면 저장된 줄 앎).
그 칸 신설은 랩탑 메인 몫.
② 저장 뒤 표·그림을 다시 받으면서 **폼이 새로 그려져 안내가 지워졌음.** 밖에서 들고
있다가 다시 넣음.
③ **장 제목에 제원이 들어 있는데 좌측 단추 글자가 안 따라왔음.** 목록을 통째로 다시
받지 않고 표준도 줄만 갈아 끼움.
실화면 확인 (프로젝트 936be972, 개발 우회로로 열어서)
「표준도 2장」에서 돌 종류 견치돌·뒷길이 55 로 저장 →
단추 글자가 「…뒷길이 35㎝ 야면석·호박돌」 → 「…뒷길이 55㎝ 견치돌」로 **즉시 바뀜**
기울기 0.7 저장 → 안내 셋: 「1개소에 반영했습니다」 · 「1:0.7 는 품셈 표준경사 표
범위(1:0.2~1:0.5) 밖입니다 — 값은 그대로 씁니다」 · 「「전면 기울기」 칸이 masonry_dry
등록부에 아직 없어 저장하지 못했습니다」
조달 「구입」 저장 → 그 장의 **2개소에만** 들어가고 다른 장은 그대로(정본 실측)
확인 뒤 **지정받은 제원으로 되돌리고 우회로도 닫음**(목록 409 복귀).
700줄 제한으로 표준도 라우터를 `B07_DesignDetail_Router_Standard.py` 로 뗌(579 + 153).
자체검증 — 새 시험 7건 + 회귀 580 통과 · 0 실패, `tsc --noEmit` 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -245,3 +245,45 @@ export function resetFrameTemplate(projectId: string): Promise<void> {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 표준도 장 목록 — 제원 입력 칸이 쓰는 것만 추린 꼴. */
|
||||||
|
export interface StandardSheetsResponse {
|
||||||
|
status: string;
|
||||||
|
sheet_count: number;
|
||||||
|
structure_count: number;
|
||||||
|
sheets: {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
type_id: string;
|
||||||
|
member_count: number;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchStandardSheets(projectId: string): Promise<StandardSheetsResponse> {
|
||||||
|
return requestJson(`/projects/${projectId}/standard-sheets`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 장 하나의 제원 저장 — 빈 값(null)은 **그 칸을 지우라**는 뜻이다. */
|
||||||
|
export function putStandardSheetSpec(
|
||||||
|
projectId: string,
|
||||||
|
body: {
|
||||||
|
sheet_key: string;
|
||||||
|
base_revision: number;
|
||||||
|
stone_kind: string | null;
|
||||||
|
stone_supply: string | null;
|
||||||
|
back_len_cm: string | null;
|
||||||
|
face_slope_ratio: string | null;
|
||||||
|
},
|
||||||
|
): Promise<{ status: string; revision: number; changed: number; notes: string[] }> {
|
||||||
|
return requestJson(`/projects/${projectId}/standard-sheets/spec`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구조물 정본 판번호 — 제원을 저장할 때 함께 보내야 다른 창 덮어쓰기를 막는다. */
|
||||||
|
export function fetchStructureRevision(projectId: string): Promise<{ revision: number }> {
|
||||||
|
return requestJson(`/projects/${projectId}/route/structures`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""표준도 **입력** — 장 하나의 제원을 고쳐 그 조합의 구조물 전부에 반영한다.
|
||||||
|
|
||||||
|
왜 여기가 입력 자리인가 (PLAN 4-5b · 2026-09-09)
|
||||||
|
`phase: "detail"` 칸(돌 종류·조달·뒷길이·전면 기울기)을 **그리는 화면이 없었다**
|
||||||
|
(2026-09-09 실측: B06 구조물 폼은 b05 phase 열 칸만 그림). 그 값들이 곧 표준도가
|
||||||
|
받는 값이고, **장 하나 = 제원 조합 하나**라 여기서 한 번 고치면 그 조합 전부에 걸린다.
|
||||||
|
B06 폼은 「어디에·몇 m」(배치), 표준도는 「어떤 제원」 — 축이 갈린다.
|
||||||
|
|
||||||
|
⚠ **자동값을 저장에 박지 않는다.** 빈 칸은 「정한 적 없음」의 뜻이다. 기울기를 비우면
|
||||||
|
품셈 표준경사 판정이 돌고, 채우면 그 값이 이긴다(확정 ⑨). 그래서 빈 값이 오면
|
||||||
|
**키를 지운다** — 0 이나 판정값을 적어 두면 그 구별이 사라진다.
|
||||||
|
|
||||||
|
⚠ **막지 않는다.** 실무 도면에 `S0.7`·`0.8` 이 실재하는데 품셈 표준경사 범위는 0.20~0.50 이다
|
||||||
|
(2026-09-09 구조물도 53장 확인). 범위 밖이면 **안내만** 하고 값은 받는다.
|
||||||
|
|
||||||
|
⚠ **고치면 장이 갈릴 수 있다** — 그 조합 전부에 같은 값을 넣으므로 장은 통째로 옮겨 가고
|
||||||
|
쪼개지지 않는다. 한 개소만 다르게 하려면 그 구조물을 따로 고쳐야 하고, 그때 새 조합이
|
||||||
|
되어 장이 하나 는다(PLAN 4-5b).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
|
||||||
|
|
||||||
|
#: 표준도에서 받는 칸 — `키 → (이름, 검사)`. 여기 없는 칸은 표준도가 안 만진다.
|
||||||
|
EDITABLE_KEYS: tuple[str, ...] = ("stone_kind", "stone_supply", "back_len_cm", "face_slope_ratio")
|
||||||
|
|
||||||
|
#: 품셈 13-4-3·13-4-4 [주]① 의 일곱 규격. 그 밖의 값은 계수가 없어 물량이 안 선다.
|
||||||
|
BACK_LENGTH_CHOICES: tuple[int, ...] = (25, 30, 35, 45, 55, 60, 75)
|
||||||
|
|
||||||
|
#: 품셈 표준경사 표가 덮는 범위. **막는 선이 아니라 안내 선**이다.
|
||||||
|
SLOPE_TABLE_RANGE: tuple[float, float] = (0.20, 0.50)
|
||||||
|
|
||||||
|
#: 안내 문구에 쓸 사람 말.
|
||||||
|
FIELD_LABELS: dict[str, str] = {
|
||||||
|
"stone_kind": "돌 종류",
|
||||||
|
"stone_supply": "조달",
|
||||||
|
"back_len_cm": "뒷길이",
|
||||||
|
"face_slope_ratio": "전면 기울기",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_slope(value: Any) -> tuple[float | None, str | None]:
|
||||||
|
"""전면 기울기 — `(값, 안내)`. 비면 `(None, None)` 이고 그것이 「자동」의 뜻이다."""
|
||||||
|
if value in (None, ""):
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
ratio = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, f"전면 기울기 「{value}」를 숫자로 읽지 못했습니다 — 비워 두면 자동입니다."
|
||||||
|
if ratio <= 0:
|
||||||
|
return None, "전면 기울기는 0보다 커야 합니다 — 비워 두면 자동입니다."
|
||||||
|
low, high = SLOPE_TABLE_RANGE
|
||||||
|
if not (low <= ratio <= high):
|
||||||
|
return ratio, (
|
||||||
|
f"1:{ratio:g} 는 품셈 표준경사 표 범위(1:{low:g}~1:{high:g}) 밖입니다 — "
|
||||||
|
"실무 도면에 1:0.7·1:0.8 이 실재하므로 값은 그대로 씁니다."
|
||||||
|
)
|
||||||
|
return ratio, None
|
||||||
|
|
||||||
|
|
||||||
|
def clean_spec(spec: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||||
|
"""받은 제원을 **저장할 꼴**로 다듬는다 — `(고칠 값, 안내 문구)`.
|
||||||
|
|
||||||
|
값이 `None`(또는 빈 문자열)이면 **그 키를 지우라**는 뜻으로 `None` 을 담아 돌려준다.
|
||||||
|
"""
|
||||||
|
cleaned: dict[str, Any] = {}
|
||||||
|
notes: list[str] = []
|
||||||
|
|
||||||
|
if "stone_kind" in spec:
|
||||||
|
kind = spec.get("stone_kind")
|
||||||
|
cleaned["stone_kind"] = str(kind) if kind not in (None, "") else None
|
||||||
|
|
||||||
|
if "stone_supply" in spec:
|
||||||
|
supply = spec.get("stone_supply")
|
||||||
|
cleaned["stone_supply"] = str(supply) if supply not in (None, "") else None
|
||||||
|
|
||||||
|
if "back_len_cm" in spec:
|
||||||
|
raw = spec.get("back_len_cm")
|
||||||
|
if raw in (None, ""):
|
||||||
|
cleaned["back_len_cm"] = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
back = int(float(raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
back = None
|
||||||
|
notes.append(f"뒷길이 「{raw}」를 숫자로 읽지 못했습니다.")
|
||||||
|
if back is not None:
|
||||||
|
cleaned["back_len_cm"] = back
|
||||||
|
if back not in BACK_LENGTH_CHOICES:
|
||||||
|
notes.append(
|
||||||
|
f"뒷길이 {back}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 "
|
||||||
|
"물량이 서지 않습니다."
|
||||||
|
)
|
||||||
|
|
||||||
|
if "face_slope_ratio" in spec:
|
||||||
|
ratio, note = _clean_slope(spec.get("face_slope_ratio"))
|
||||||
|
cleaned["face_slope_ratio"] = ratio
|
||||||
|
if note:
|
||||||
|
notes.append(note)
|
||||||
|
|
||||||
|
return cleaned, notes
|
||||||
|
|
||||||
|
|
||||||
|
def drop_unregistered(
|
||||||
|
type_id: str, spec: dict[str, Any], allowed: set[str]
|
||||||
|
) -> tuple[dict[str, Any], list[str]]:
|
||||||
|
"""등록부에 **칸이 없는** 제원은 빼고 알린다 — `(남긴 값, 안내)`.
|
||||||
|
|
||||||
|
⚠ 저장소가 「정의되지 않은 옵션」을 거절하므로, 그대로 넘기면 **한 칸 때문에 전부**
|
||||||
|
저장이 안 된다(2026-09-09 실측: `face_slope_ratio` 가 등록부에 없어 저장 전체 실패).
|
||||||
|
한 칸을 못 받는 것과 아무것도 못 받는 것은 다르다 — 나머지는 살리고 **못 받은 칸을
|
||||||
|
이름으로 말한다**. 조용히 버리면 사용자는 저장된 줄 안다.
|
||||||
|
"""
|
||||||
|
kept: dict[str, Any] = {}
|
||||||
|
notes: list[str] = []
|
||||||
|
for key, value in spec.items():
|
||||||
|
if key in allowed:
|
||||||
|
kept[key] = value
|
||||||
|
continue
|
||||||
|
if value is None:
|
||||||
|
# 지우라는 뜻인데 칸 자체가 없다 — 이미 없으므로 조용히 넘어간다.
|
||||||
|
continue
|
||||||
|
notes.append(
|
||||||
|
f"「{FIELD_LABELS.get(key, key)}」 칸이 {type_id} 등록부에 아직 없어 "
|
||||||
|
"저장하지 못했습니다 — 다른 칸은 저장했습니다."
|
||||||
|
)
|
||||||
|
return kept, notes
|
||||||
|
|
||||||
|
|
||||||
|
def apply_spec(
|
||||||
|
structures: list[StructureInstance], member_ids: set[str], spec: dict[str, Any]
|
||||||
|
) -> tuple[list[StructureInstance], int]:
|
||||||
|
"""그 장에 속한 구조물마다 제원을 갈아 끼운다 — `(새 목록, 바뀐 개소 수)`.
|
||||||
|
|
||||||
|
⚠ **개소 id 로 고른다** — 장 이름(`sheet_key`)을 여기서 다시 셈하지 않는다. 그 이름은
|
||||||
|
B08 이 편 결과(`height_m` 이 위로 올라온 꼴) 위에서 나오는데, 정본
|
||||||
|
`StructureInstance` 는 높이가 `options` 안에 있어 **같은 이름이 안 나온다**.
|
||||||
|
장 목록이 이미 `members[].structure_id` 를 실어 주므로 그것을 그대로 쓴다.
|
||||||
|
"""
|
||||||
|
changed = 0
|
||||||
|
out: list[StructureInstance] = []
|
||||||
|
for item in structures:
|
||||||
|
payload = item.model_dump()
|
||||||
|
if str(payload.get("structure_id") or "") not in member_ids:
|
||||||
|
out.append(item)
|
||||||
|
continue
|
||||||
|
options = dict(payload.get("options") or {})
|
||||||
|
for key, value in spec.items():
|
||||||
|
if value is None:
|
||||||
|
# ⚠ 지운다 — 「정한 적 없음」과 「그 값으로 정함」이 구별돼야 한다.
|
||||||
|
options.pop(key, None)
|
||||||
|
else:
|
||||||
|
options[key] = value
|
||||||
|
payload["options"] = options
|
||||||
|
out.append(StructureInstance.model_validate(payload))
|
||||||
|
changed += 1
|
||||||
|
return out, changed
|
||||||
@@ -262,47 +262,6 @@ async def _designs_by_chainage(route_id: int) -> dict[int, dict[str, Any]]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/standard-sheets")
|
|
||||||
async def get_standard_sheets(project_id: UUID) -> JSONResponse:
|
|
||||||
"""표준도(구조물도) **장 목록 + 하단표**.
|
|
||||||
|
|
||||||
⚠ 수량을 여기서 새로 셈하지 않는다 — B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고
|
|
||||||
단위당으로 접기만** 한다(계산 자리는 한 곳, CLAUDE.md 5장).
|
|
||||||
"""
|
|
||||||
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets
|
|
||||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
|
|
||||||
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
|
|
||||||
|
|
||||||
try:
|
|
||||||
pool = get_db_pool()
|
|
||||||
async with pool.acquire() as connection:
|
|
||||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
|
||||||
project_root = str(Path(resolve_stored_project_path(stored_path)).resolve())
|
|
||||||
except Exception:
|
|
||||||
logger.exception("B07 표준도 조회 실패(경로): project_id=%s", project_id)
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=404,
|
|
||||||
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
structures, names, skipped = await asyncio.to_thread(_collect_structures, project_root)
|
|
||||||
unit_table = await asyncio.to_thread(build_unit_table, structures, names)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("B07 표준도 전개 실패: project_id=%s", project_id)
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."},
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = build_standard_sheets(unit_table)
|
|
||||||
payload["status"] = "success"
|
|
||||||
payload["project_id"] = str(project_id)
|
|
||||||
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
|
|
||||||
payload["skipped_structures"] = skipped
|
|
||||||
return JSONResponse(content=payload)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/design-drawings", response_model=DesignDrawingListResponse)
|
@router.get("/{project_id}/design-drawings", response_model=DesignDrawingListResponse)
|
||||||
async def get_design_drawing_list(
|
async def get_design_drawing_list(
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""B07 표준도(구조물도) 라우터 — 장 목록 조회와 **제원 입력**.
|
||||||
|
|
||||||
|
표준도는 도면이자 **입력 화면**이다(PLAN 4-5b). `phase: "detail"` 칸(돌 종류·조달·뒷길이·
|
||||||
|
전면 기울기)을 그리는 화면이 없어(2026-09-09 실측) 그 자리를 여기가 맡는다.
|
||||||
|
**장 하나 = 제원 조합 하나**라 한 번 고치면 그 조합의 개소 전부에 걸린다.
|
||||||
|
|
||||||
|
⚠ 값을 여기서 셈하지 않는다 — 정본(`structures.json`)에 적기만 하고 표·그림은 다음 조회에서
|
||||||
|
그 정본으로 다시 선다. 표준도가 두 번째 정본이 되면 안 된다(CLAUDE.md 5장).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||||
|
from common_util.common_util_storage import resolve_stored_project_path
|
||||||
|
from config.config_db import get_db_pool
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
||||||
|
|
||||||
|
|
||||||
|
class StandardSheetSpecRequest(BaseModel):
|
||||||
|
"""표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
sheet_key: str
|
||||||
|
base_revision: int = Field(ge=0)
|
||||||
|
stone_kind: str | None = None
|
||||||
|
stone_supply: str | None = None
|
||||||
|
back_len_cm: int | str | None = None
|
||||||
|
face_slope_ratio: float | str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}/standard-sheets/spec")
|
||||||
|
async def put_standard_sheet_spec(
|
||||||
|
project_id: UUID, payload: StandardSheetSpecRequest
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""장 하나의 제원을 고쳐 **그 조합의 구조물 전부**에 반영한다.
|
||||||
|
|
||||||
|
⚠ 값을 여기서 셈하지 않는다 — 정본(`structures.json`)에 적기만 하고, 표·그림은 다음
|
||||||
|
조회에서 그 정본으로 다시 선다. 표준도가 두 번째 정본이 되면 안 된다(CLAUDE.md 5장).
|
||||||
|
"""
|
||||||
|
from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures
|
||||||
|
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
|
||||||
|
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import standard_payload
|
||||||
|
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Edit import (
|
||||||
|
apply_spec,
|
||||||
|
clean_spec,
|
||||||
|
drop_unregistered,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pool = get_db_pool()
|
||||||
|
async with pool.acquire() as connection:
|
||||||
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||||
|
project_root = Path(resolve_stored_project_path(stored_path)).resolve()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("B07 표준도 제원 저장 실패(경로): project_id=%s", project_id)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
||||||
|
)
|
||||||
|
|
||||||
|
sheets = (await asyncio.to_thread(standard_payload, project_root)).get("sheets") or []
|
||||||
|
picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None)
|
||||||
|
if picked is None:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"status": "error", "message": "그 표준도 장을 찾지 못했습니다."},
|
||||||
|
)
|
||||||
|
member_ids = {
|
||||||
|
str(m.get("structure_id")) for m in picked.get("members") or [] if m.get("structure_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
spec, notes = clean_spec(payload.model_dump(exclude={"sheet_key", "base_revision"}))
|
||||||
|
# ⚠ 등록부에 없는 칸은 저장소가 거절한다 — 한 칸 때문에 **전부** 못 저장되지 않게 거른다.
|
||||||
|
type_id = str(picked.get("type_id") or "")
|
||||||
|
definition = structure_type_map().get(type_id)
|
||||||
|
allowed = {field.key for field in definition.options} if definition else set()
|
||||||
|
spec, missing = drop_unregistered(type_id, spec, allowed)
|
||||||
|
notes.extend(missing)
|
||||||
|
try:
|
||||||
|
revision, stored = await asyncio.to_thread(load_structures, str(project_root))
|
||||||
|
updated, changed = apply_spec(stored, member_ids, spec)
|
||||||
|
new_revision = await asyncio.to_thread(
|
||||||
|
save_structures, str(project_root), updated, base_revision=payload.base_revision
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("B07 표준도 제원 저장 실패: project_id=%s", project_id)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=409,
|
||||||
|
content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"status": "success",
|
||||||
|
"project_id": str(project_id),
|
||||||
|
"revision": new_revision,
|
||||||
|
"previous_revision": revision,
|
||||||
|
"changed": changed,
|
||||||
|
# 범위 밖 값·표에 없는 규격은 **막지 않고 알린다**(실무에 1:0.7 이 실재).
|
||||||
|
"notes": notes,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/standard-sheets")
|
||||||
|
async def get_standard_sheets(project_id: UUID) -> JSONResponse:
|
||||||
|
"""표준도(구조물도) **장 목록 + 하단표**.
|
||||||
|
|
||||||
|
⚠ 수량을 여기서 새로 셈하지 않는다 — B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고
|
||||||
|
단위당으로 접기만** 한다(계산 자리는 한 곳, CLAUDE.md 5장).
|
||||||
|
"""
|
||||||
|
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
|
||||||
|
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
|
||||||
|
|
||||||
|
try:
|
||||||
|
pool = get_db_pool()
|
||||||
|
async with pool.acquire() as connection:
|
||||||
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||||
|
project_root = str(Path(resolve_stored_project_path(stored_path)).resolve())
|
||||||
|
except Exception:
|
||||||
|
logger.exception("B07 표준도 조회 실패(경로): project_id=%s", project_id)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
structures, names, skipped = await asyncio.to_thread(_collect_structures, project_root)
|
||||||
|
unit_table = await asyncio.to_thread(build_unit_table, structures, names)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("B07 표준도 전개 실패: project_id=%s", project_id)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = build_standard_sheets(unit_table)
|
||||||
|
payload["status"] = "success"
|
||||||
|
payload["project_id"] = str(project_id)
|
||||||
|
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
|
||||||
|
payload["skipped_structures"] = skipped
|
||||||
|
return JSONResponse(content=payload)
|
||||||
@@ -38,14 +38,22 @@ import {
|
|||||||
exportDrawing,
|
exportDrawing,
|
||||||
fetchDesignDrawing,
|
fetchDesignDrawing,
|
||||||
fetchDesignDrawingList,
|
fetchDesignDrawingList,
|
||||||
|
fetchStandardSheets,
|
||||||
|
fetchStructureRevision,
|
||||||
invalidateDesignDrawing,
|
invalidateDesignDrawing,
|
||||||
|
putStandardSheetSpec,
|
||||||
type CadDrawing,
|
type CadDrawing,
|
||||||
type DesignDrawingItem,
|
type DesignDrawingItem,
|
||||||
type DesignDrawingResponse,
|
type DesignDrawingResponse,
|
||||||
type QuantityTable,
|
type QuantityTable,
|
||||||
|
type StandardSheetsResponse,
|
||||||
} from "./B07_DesignDetail_Api_Fetch";
|
} from "./B07_DesignDetail_Api_Fetch";
|
||||||
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
|
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
|
||||||
import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
|
import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
|
||||||
|
import {
|
||||||
|
buildStandardSpecPanel,
|
||||||
|
type StandardSpecResult,
|
||||||
|
} from "./B07_DesignDetail_UI_StandardSpec";
|
||||||
|
|
||||||
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
|
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
|
||||||
interface DesignMeta {
|
interface DesignMeta {
|
||||||
@@ -94,6 +102,10 @@ const CAD_TOAST_ACTION_MESSAGE = "aislo:b08:toast-action";
|
|||||||
* -------------------------------------------------------------------------- */
|
* -------------------------------------------------------------------------- */
|
||||||
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||||
|
/** 표준도 장 목록 — 제원 폼이 쓰는 값. 진입 때 한 번 받고 저장 뒤 다시 받는다. */
|
||||||
|
let standardSheets: StandardSheetsResponse["sheets"] = [];
|
||||||
|
/** 저장 뒤 폼이 다시 그려질 때 이어서 보여 줄 안내. */
|
||||||
|
let specNotes: string[] = [];
|
||||||
let workflowState: WorkflowState | undefined;
|
let workflowState: WorkflowState | undefined;
|
||||||
let drawings: DesignDrawingItem[] = [];
|
let drawings: DesignDrawingItem[] = [];
|
||||||
let drawingError: string | undefined;
|
let drawingError: string | undefined;
|
||||||
@@ -107,6 +119,14 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
|||||||
if (drawingResult.status === "fulfilled") {
|
if (drawingResult.status === "fulfilled") {
|
||||||
drawings = drawingResult.value.drawings;
|
drawings = drawingResult.value.drawings;
|
||||||
devBypass = drawingResult.value.dev_bypass === true;
|
devBypass = drawingResult.value.dev_bypass === true;
|
||||||
|
if (drawings.some((item) => item.kind === "standard") && projectId) {
|
||||||
|
// 표준도 제원 폼이 쓸 장 목록 — 실패해도 도면은 열려야 하므로 조용히 넘어간다.
|
||||||
|
try {
|
||||||
|
standardSheets = (await fetchStandardSheets(projectId)).sheets;
|
||||||
|
} catch {
|
||||||
|
standardSheets = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
} else
|
} else
|
||||||
drawingError =
|
drawingError =
|
||||||
drawingResult.reason instanceof Error
|
drawingResult.reason instanceof Error
|
||||||
@@ -193,7 +213,52 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
|||||||
const infoPanelHost = document.createElement("div");
|
const infoPanelHost = document.createElement("div");
|
||||||
infoPanelHost.className = "b07-info-host";
|
infoPanelHost.className = "b07-info-host";
|
||||||
|
|
||||||
|
const showStandardSpec = (drawing: DesignDrawingItem): void => {
|
||||||
|
const index = Number(/_(\d+)$/.exec(drawing.id)?.[1] ?? "1") - 1;
|
||||||
|
const sheet = standardSheets[index];
|
||||||
|
if (!projectId || !sheet) {
|
||||||
|
infoPanelHost.replaceChildren();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 판정된 기울기는 **칸에 적지 않고 도움말로만** 비춘다 — 적어 두면 「안 정함」이 사라진다.
|
||||||
|
const judged = /1:([\d.]+)/.exec(drawing.label)?.[1] ?? null;
|
||||||
|
infoPanelHost.replaceChildren(
|
||||||
|
buildStandardSpecPanel(
|
||||||
|
sheet,
|
||||||
|
judged,
|
||||||
|
async (result: StandardSpecResult) => {
|
||||||
|
const { revision } = await fetchStructureRevision(projectId);
|
||||||
|
const saved = await putStandardSheetSpec(projectId, {
|
||||||
|
...result,
|
||||||
|
base_revision: revision,
|
||||||
|
});
|
||||||
|
// 정본이 바뀌었으니 표·그림을 **다시 받아** 그린다 — 화면이 두 번째 정본이 되면 안 된다.
|
||||||
|
drawingCache.delete(drawing.id);
|
||||||
|
standardSheets = (await fetchStandardSheets(projectId)).sheets;
|
||||||
|
// ⚠ 장 제목에 제원이 들어 있다 — 고쳤으면 **좌측 단추 글자도 따라가야** 한다.
|
||||||
|
// 목록을 통째로 다시 받지 않고 표준도 줄만 갈아 끼운다(2026-09-09 실화면에서 잡음).
|
||||||
|
for (const [order, item] of drawings.filter((d) => d.kind === "standard").entries()) {
|
||||||
|
const fresh = standardSheets[order];
|
||||||
|
if (!fresh) continue;
|
||||||
|
item.label = `표준도 ${order + 1}장 (${fresh.title})`;
|
||||||
|
const name = findButton(item.id)?.querySelector(".b07-drawing-button__name");
|
||||||
|
if (name) name.textContent = item.label;
|
||||||
|
}
|
||||||
|
specNotes = [`${saved.changed}개소에 반영했습니다.`, ...(saved.notes ?? [])];
|
||||||
|
await loadDrawing(drawing, currentIndex);
|
||||||
|
return specNotes;
|
||||||
|
},
|
||||||
|
specNotes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
specNotes = [];
|
||||||
|
};
|
||||||
|
|
||||||
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
|
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
|
||||||
|
if (drawing.kind === "standard") {
|
||||||
|
showStandardSpec(drawing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (drawing.kind !== "cross") {
|
if (drawing.kind !== "cross") {
|
||||||
infoPanelHost.replaceChildren();
|
infoPanelHost.replaceChildren();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B07_DesignDetail_UI_StandardSpec.ts
|
||||||
|
* 표준도 **제원 입력 칸** — 장 하나가 곧 제원 조합 하나이므로 여기서 고치면 그 조합 전부.
|
||||||
|
*
|
||||||
|
* 왜 여기인가 (PLAN 4-5b) — `phase: "detail"` 칸(돌 종류·조달·뒷길이·전면 기울기)을 그리는
|
||||||
|
* 화면이 없었다(2026-09-09 실측: B06 구조물 폼은 b05 phase 열 칸만 그림). B06 은
|
||||||
|
* 「어디에·몇 m」(배치), 표준도는 「어떤 제원」이다.
|
||||||
|
*
|
||||||
|
* ⚠ **빈 칸이 「자동」의 뜻**이다(확정 ⑨). 기울기를 비우면 품셈 표준경사 판정이 돌고,
|
||||||
|
* 채우면 그 값이 이긴다. 그래서 화면도 **빈 칸을 기본으로** 두고, 판정된 값은 회색
|
||||||
|
* 도움말로만 비춘다 — 칸에 미리 적어 두면 「정한 적 없음」이 사라진다.
|
||||||
|
* ⚠ **막지 않는다.** 실무 도면에 1:0.7·1:0.8 이 실재하므로 범위 밖 값도 받고 안내만 띄운다.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
const STONE_KINDS = ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"] as const;
|
||||||
|
const SUPPLIES = ["채집", "구입"] as const;
|
||||||
|
const BACK_LENGTHS = ["25", "30", "35", "45", "55", "60", "75"] as const;
|
||||||
|
|
||||||
|
/** 표준도 장 하나 — 서버가 낸 것 중 이 폼이 쓰는 것만. */
|
||||||
|
export interface StandardSheetSpec {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
type_id: string;
|
||||||
|
member_count: number;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StandardSpecResult {
|
||||||
|
sheet_key: string;
|
||||||
|
stone_kind: string | null;
|
||||||
|
stone_supply: string | null;
|
||||||
|
back_len_cm: string | null;
|
||||||
|
face_slope_ratio: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이 종류가 돌쌓기 계열인가 — 옹벽·집수정에는 이 칸들이 뜻이 없다. */
|
||||||
|
const STONE_TYPES = new Set(["masonry_wet", "masonry_dry", "boulder_masonry"]);
|
||||||
|
|
||||||
|
function field(label: string, control: HTMLElement, hint?: string): HTMLLabelElement {
|
||||||
|
const wrap = document.createElement("label");
|
||||||
|
wrap.className = "b07-spec__field";
|
||||||
|
const name = document.createElement("span");
|
||||||
|
name.className = "b07-spec__label";
|
||||||
|
name.textContent = label;
|
||||||
|
wrap.append(name, control);
|
||||||
|
if (hint) {
|
||||||
|
const help = document.createElement("small");
|
||||||
|
help.className = "b07-spec__hint";
|
||||||
|
help.textContent = hint;
|
||||||
|
wrap.append(help);
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(
|
||||||
|
choices: readonly string[],
|
||||||
|
current: unknown,
|
||||||
|
autoLabel: string,
|
||||||
|
): HTMLSelectElement {
|
||||||
|
const el = document.createElement("select");
|
||||||
|
el.className = "b07-spec__input";
|
||||||
|
// 첫 보기가 **빈 값** — 「정한 적 없음」이 고를 수 있는 상태여야 한다.
|
||||||
|
const blank = document.createElement("option");
|
||||||
|
blank.value = "";
|
||||||
|
blank.textContent = autoLabel;
|
||||||
|
el.append(blank);
|
||||||
|
for (const choice of choices) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = choice;
|
||||||
|
option.textContent = choice;
|
||||||
|
el.append(option);
|
||||||
|
}
|
||||||
|
el.value = current == null ? "" : String(current);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표준도 제원 폼. `onSave` 는 서버에 넘길 값을 받는다 — 빈 문자열은 **그 칸을 지우라**는 뜻.
|
||||||
|
*/
|
||||||
|
export function buildStandardSpecPanel(
|
||||||
|
sheet: StandardSheetSpec,
|
||||||
|
judgedSlope: string | null,
|
||||||
|
onSave: (result: StandardSpecResult) => Promise<string[]>,
|
||||||
|
initialNotes: string[] = [],
|
||||||
|
): HTMLDivElement {
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.className = "b07-spec ui-sidebar-section";
|
||||||
|
|
||||||
|
const title = document.createElement("h3");
|
||||||
|
title.className = "b07-spec__title";
|
||||||
|
title.textContent = `제원 — ${sheet.title}`;
|
||||||
|
const scope = document.createElement("p");
|
||||||
|
scope.className = "b07-spec__scope";
|
||||||
|
scope.textContent = `이 장의 ${sheet.member_count}개소에 함께 걸립니다.`;
|
||||||
|
panel.append(title, scope);
|
||||||
|
|
||||||
|
if (!STONE_TYPES.has(sheet.type_id)) {
|
||||||
|
const none = document.createElement("p");
|
||||||
|
none.className = "b07-spec__scope";
|
||||||
|
none.textContent = "이 종류는 표준도에서 받는 제원 칸이 아직 없습니다.";
|
||||||
|
panel.append(none);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = sheet.options ?? {};
|
||||||
|
const kind = select(STONE_KINDS, options.stone_kind, "— 안 정함 —");
|
||||||
|
const supply = select(SUPPLIES, options.stone_supply, "— 안 정함(기본 채집) —");
|
||||||
|
const back = select(BACK_LENGTHS, options.back_len_cm, "— 안 정함 —");
|
||||||
|
|
||||||
|
const slope = document.createElement("input");
|
||||||
|
slope.className = "b07-spec__input";
|
||||||
|
slope.type = "text";
|
||||||
|
slope.inputMode = "decimal";
|
||||||
|
slope.placeholder = "비우면 자동";
|
||||||
|
slope.value = options.face_slope_ratio == null ? "" : String(options.face_slope_ratio);
|
||||||
|
|
||||||
|
panel.append(
|
||||||
|
field("돌 종류", kind),
|
||||||
|
field("조달", supply, "비우면 「캔다」로 봅니다."),
|
||||||
|
field("뒷길이 (㎝)", back, "품셈 일곱 규격 밖이면 물량이 서지 않습니다."),
|
||||||
|
field(
|
||||||
|
"전면 기울기 1:n",
|
||||||
|
slope,
|
||||||
|
judgedSlope ? `비우면 자동 — 지금 판정값 1:${judgedSlope}` : "비우면 자동으로 판정합니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const notes = document.createElement("ul");
|
||||||
|
notes.className = "b07-spec__notes";
|
||||||
|
// ⚠ 저장하면 표·그림을 다시 받으면서 **이 폼이 통째로 새로 그려진다** — 그때 안내가
|
||||||
|
// 지워지지 않게 밖에서 들고 있다가 다시 넣는다(2026-09-09 실화면에서 잡음).
|
||||||
|
const showNotes = (messages: string[]): void => {
|
||||||
|
notes.replaceChildren(
|
||||||
|
...messages.map((text) => {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.textContent = text;
|
||||||
|
return item;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
notes.hidden = messages.length === 0;
|
||||||
|
};
|
||||||
|
showNotes(initialNotes);
|
||||||
|
|
||||||
|
const save = document.createElement("button");
|
||||||
|
save.type = "button";
|
||||||
|
save.className = "b07-spec__save";
|
||||||
|
save.textContent = "제원 저장";
|
||||||
|
save.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
save.disabled = true;
|
||||||
|
save.textContent = "저장 중…";
|
||||||
|
try {
|
||||||
|
showNotes(
|
||||||
|
await onSave({
|
||||||
|
sheet_key: sheet.key,
|
||||||
|
stone_kind: kind.value || null,
|
||||||
|
stone_supply: supply.value || null,
|
||||||
|
back_len_cm: back.value || null,
|
||||||
|
face_slope_ratio: slope.value.trim() || null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// 실패도 같은 자리에 적는다 — 조용히 끝나면 사용자는 저장된 줄 안다.
|
||||||
|
showNotes([error instanceof Error ? error.message : "제원을 저장하지 못했습니다."]);
|
||||||
|
} finally {
|
||||||
|
save.disabled = false;
|
||||||
|
save.textContent = "제원 저장";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.append(save, notes);
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
@@ -336,3 +336,74 @@
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: var(--text-caption);
|
font-size: var(--text-caption);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 표준도 제원 입력 — 좌측 목록 아래 정보 칸에 선다. 장 하나가 곧 제원 조합 하나다. */
|
||||||
|
.b07-spec {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
padding: var(--spacing-12);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b07-spec__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-body-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b07-spec__scope {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b07-spec__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b07-spec__label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b07-spec__input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--spacing-4) var(--spacing-8);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-buttons);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 「비우면 자동」처럼 **칸의 뜻**을 적는 자리 — 값이 아니라 규칙을 말한다. */
|
||||||
|
.b07-spec__hint {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.b07-spec__save {
|
||||||
|
padding: var(--spacing-8);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-buttons);
|
||||||
|
background: var(--color-primary, #7c3aed);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b07-spec__save:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 저장 뒤 안내 — 막지 않고 알리는 자리(품셈 범위 밖 기울기 등). */
|
||||||
|
.b07-spec__notes {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: var(--spacing-16);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,20 +58,21 @@ from B06_Section.B06_Section_Router_HaulPlan import (
|
|||||||
)
|
)
|
||||||
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
|
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
|
||||||
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
|
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
|
||||||
|
from B07_DesignDetail.B07_DesignDetail_Router_Standard import router as b07_standard_router
|
||||||
from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router
|
from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router
|
||||||
from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router
|
from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router
|
||||||
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
|
from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router
|
||||||
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
|
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
|
||||||
from common_util.common_util_audit import note_api_call, record_call_burst
|
from common_util.common_util_audit import note_api_call, record_call_burst
|
||||||
|
|
||||||
# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가
|
|
||||||
# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조).
|
|
||||||
from common_util.common_util_dev_unlock_router import router as dev_unlock_router
|
|
||||||
from common_util.common_util_auth import (
|
from common_util.common_util_auth import (
|
||||||
require_company,
|
require_company,
|
||||||
require_project_access,
|
require_project_access,
|
||||||
verify_session,
|
verify_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가
|
||||||
|
# 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조).
|
||||||
|
from common_util.common_util_dev_unlock_router import router as dev_unlock_router
|
||||||
from common_util.common_util_resource_monitor import sample_resources_loop
|
from common_util.common_util_resource_monitor import sample_resources_loop
|
||||||
from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop
|
from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop
|
||||||
from config.config_db import close_db_pool, get_db_pool, init_db_pool
|
from config.config_db import close_db_pool, get_db_pool, init_db_pool
|
||||||
@@ -541,6 +542,7 @@ app.include_router(b06_section_confirm_router, dependencies=protected_with_compa
|
|||||||
app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company)
|
app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company)
|
||||||
app.include_router(b07_design_router, dependencies=protected_with_company)
|
app.include_router(b07_design_router, dependencies=protected_with_company)
|
||||||
app.include_router(b07_frame_router, dependencies=protected_with_company)
|
app.include_router(b07_frame_router, dependencies=protected_with_company)
|
||||||
|
app.include_router(b07_standard_router, dependencies=protected_with_company)
|
||||||
app.include_router(b08_quantity_router, dependencies=protected_with_company)
|
app.include_router(b08_quantity_router, dependencies=protected_with_company)
|
||||||
app.include_router(b08_earthwork_router, dependencies=protected_with_company)
|
app.include_router(b08_earthwork_router, dependencies=protected_with_company)
|
||||||
app.include_router(b08_material_router, dependencies=protected_with_company)
|
app.include_router(b08_material_router, dependencies=protected_with_company)
|
||||||
|
|||||||
Reference in New Issue
Block a user