feat(b08): 구조물도 엔진·창구·화면 조각을 B07 에서 B08 로 이관

- 장 나눔·제원 입력 엔진과 기울기 판정 대상을 B08 로 옮김
- 창구를 /quantity/structure-sheets 로 옮기고 기초잡석 두께·지반 갈래를 함께 넘김
  (옛 B07 창구는 두께를 안 넘겨 원단위 탭과 값이 갈렸음)
- 구조물도 탭 조각 renderStructureSheets 신설 — 탭 등록은 브레인 몫이라 안 붙임
- B07 표준도 목록은 탭 배선 날까지 B08 엔진·창구를 불러 그대로 둠

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-13 14:48:13 +09:00
co-authored by Claude Opus 5
parent a70cfc9180
commit f20213592c
17 changed files with 629 additions and 200 deletions
@@ -260,8 +260,9 @@ export interface StandardSheetsResponse {
}[];
}
// 창구는 B08 구조물도 — 2026-09-13 이관(PLAN 3장). 탭 배선 뒤 B07 표준도와 함께 지움.
export function fetchStandardSheets(projectId: string): Promise<StandardSheetsResponse> {
return requestJson(`/projects/${projectId}/standard-sheets`);
return requestJson(`/projects/${projectId}/quantity/structure-sheets`);
}
/** 장 하나의 제원 저장 — 빈 값(null)은 **그 칸을 지우라**는 뜻이다. */
@@ -282,7 +283,7 @@ export function putStandardSheetSpec(
blinding_concrete: string | null;
},
): Promise<{ status: string; revision: number; changed: number; notes: string[] }> {
return requestJson(`/projects/${projectId}/standard-sheets/spec`, {
return requestJson(`/projects/${projectId}/quantity/structure-sheets/spec`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -37,9 +37,14 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_text_entity,
polyline_entity,
)
# 기울기 판정은 구조물도 엔진이 가진 한 벌 — 2026-09-13 B08 로 이관(PLAN 3장). 받아 쓰기만 함.
from B08_Quantity.B08_Quantity_Engine_StructureSheet import ( # noqa: F401
SLOPE_TYPE_IDS,
slope_of,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
STONE_MASONRY,
face_slope_ratio,
weep_hole_spec,
)
from common_util.common_util_excavation import (
@@ -61,11 +66,6 @@ SCALE_MM_PER_M = 40.0
#: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄.
FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"})
#: **전면 기울기 판정** 대상 — 그림 대상과 **다르다**. 큰돌쌓기는 그림은 못 그려도
#: 기울기는 판정된다(품셈 13-6 「1:0.3 이상」). 한 목록으로 묶어 두었더니 큰돌쌓기를
#: 그림에서 뺄 때 **장 제목의 「1:0.3」까지 사라졌다**(2026-09-09 화면 실측에서 잡음).
SLOPE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"})
#: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다
#: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
_NO_FIGURE_REASONS: dict[str, str] = {
@@ -98,31 +98,6 @@ _LABEL_FONT = 4.0
_DIM_FONT = 3.4
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
"""장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다.
메/찰은 종류에서 온다. 큰돌쌓기는 `bond` 칸(메쌓기/찰쌓기)이 그것이고, 안 고르면
찰쌓기로 본다 — 품셈 13-6 은 둘 다 「1:0.3 **이상**」이라 그림 기울기가 갈리지 않는다.
"""
options = sheet.get("options") or {}
type_id = str(sheet.get("type_id") or "")
if type_id == "masonry_dry":
wet = False
elif type_id == "boulder_masonry":
wet = options.get("bond") != "메쌓기"
else:
wet = True
return face_slope_ratio(
options,
wet=wet,
height_m=float(sheet.get("height_m") or 0.0),
# ⚠ 표를 만든 그 판정을 그대로 넘긴다 — 여기서 다시 가르면 근거를 못 받아
# 종전값으로 떨어지고 **표와 갈린다**(2026-09-09 실측).
face=sheet.get("face"),
face_reason=str(sheet.get("face_reason") or ""),
)
def wall_thickness(height_m: float, back_cm: float) -> tuple[float, float]:
"""(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다."""
top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"]
@@ -10,7 +10,7 @@
계획평면도가 쓰는 장 나눔 본을 따라야 하고 목록·라우터 네 곳이 함께 움직여야 한다(㉢).
지금은 **값이 눈에 보이는 것**이 먼저라 한 장에 쌓고, 장 나눔은 그 다음이다.
⚠ 값을 여기서 셈하지 않는다 — `_Engine_Standard_Sheet.build_standard_sheets` 가 낸 것을
⚠ 값을 여기서 셈하지 않는다 — `B08_Quantity_Engine_StructureSheet.build_standard_sheets` 가 낸 것을
글자로 옮길 뿐이다. 셈이 두 벌이 되면 도면과 수량서가 갈린다(CLAUDE.md 5장).
"""
@@ -232,18 +232,14 @@ def standard_payload(
⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어
모듈 맨 위에서 부르면 맞물린다.
⚠ 장 목록은 **B08 구조물도 창구와 한 벌**(2026-09-13 이관) — 따로 전개하면 기초잡석 두께가 갈림.
"""
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
from B08_Quantity.B08_Quantity_Router_StructureSheet import project_structure_sheets
try:
structures, names, _skipped = _collect_structures(str(project_root))
# ⚠ 단면유형을 넘겨야 성절토가 갈리고 표준경사 판정이 돈다. 안 넘기면 전 구조물이
# 「가를 근거 없음」으로 떨어져 종전값 1:0.3 으로 선다(2026-09-09 실측).
return build_standard_sheets(
build_unit_table(structures, names, section_modes), section_modes
)
return project_structure_sheets(str(project_root), section_modes)
except Exception:
logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root)
return {"sheets": [], "structure_count": 0}
+1 -1
View File
@@ -31,7 +31,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
use_company_templates,
use_title_fields,
)
from B07_DesignDetail.B07_DesignDetail_Router_Standard import section_modes_of
from B08_Quantity.B08_Quantity_Router_StructureSheet import section_modes_of
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_STANDARD_ID,
LANDUSE_ID,
+2 -1
View File
@@ -50,10 +50,11 @@ import {
} from "./B07_DesignDetail_Api_Fetch";
import { appendStructureEntities } from "./B07_DesignDetail_UI_Cad_Structures";
import { createFrameTemplateEditor } from "./B07_DesignDetail_UI_FrameEdit";
// 제원 칸은 B08 구조물도로 이관(2026-09-13, PLAN 3장) — 탭 배선 뒤 B07 표준도와 함께 뺌.
import {
buildStandardSpecPanel,
type StandardSpecResult,
} from "./B07_DesignDetail_UI_StandardSpec";
} from "../B08_Quantity/B08_Quantity_UI_StructureSheet_Spec";
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
interface DesignMeta {
+1 -70
View File
@@ -337,73 +337,4 @@
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);
}
/* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */
@@ -20,6 +20,10 @@
**단위당 ** 성분 수량을 `billing_quantity` 나눈 것이다. 실무 시트 머리의 `m당` ·
`개소당` · `` 단위이고, **구조물마다 다르다**(통일하지 않음 4-1).
**2026-09-13 B07 에서 이관**(PLAN 3 · 사용자 확정 구조물도는 B08 ).
자리 `B07_DesignDetail_Engine_Standard_Sheet.py`.
B07 그림 파일을 부르지 않게 기울기 판정 대상도 함께 옮김.
"""
from __future__ import annotations
@@ -27,6 +31,8 @@ from __future__ import annotations
import json
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import BACK_LENGTH_KEYS, face_slope_ratio
#: 장을 가르지 **않는** 제원 칸 — 개소마다 다를 뿐 그림·단위수량을 안 바꾼다.
#: 여기 없는 칸은 전부 장 나눔에 들어간다(모르는 칸을 빠뜨려 두 장이 한 장으로 합쳐지는 것보다,
#: 장이 하나 더 서는 쪽이 안전하다 — 합쳐지면 값이 조용히 틀린다).
@@ -86,13 +92,40 @@ def sheet_key(structure: dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, sort_keys=True)
def _slope_or_none(structure: dict[str, Any]) -> float | None:
"""돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`. 판정은 그림 모듈이 가진 한 벌."""
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import (
SLOPE_TYPE_IDS,
slope_of,
#: **전면 기울기 판정** 대상 — 그림 대상(`FIGURE_TYPE_IDS`)과 **다르다**.
#: 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다(품셈 13-6 「1:0.3 이상」).
#: 한 목록으로 묶어 두었더니 큰돌쌓기를 그림에서 뺄 때 **장 제목의 「1:0.3」까지
#: 사라졌다**(2026-09-09 화면 실측에서 잡음).
SLOPE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"})
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
"""장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다.
/찰은 종류에서 온다. 큰돌쌓기는 `bond` (메쌓기/찰쌓기) 그것이고, 고르면
찰쌓기로 본다 품셈 13-6 1:0.3 **이상**이라 그림 기울기가 갈리지 않는다.
"""
options = sheet.get("options") or {}
type_id = str(sheet.get("type_id") or "")
if type_id == "masonry_dry":
wet = False
elif type_id == "boulder_masonry":
wet = options.get("bond") != "메쌓기"
else:
wet = True
return face_slope_ratio(
options,
wet=wet,
height_m=float(sheet.get("height_m") or 0.0),
# ⚠ 표를 만든 그 판정을 그대로 넘긴다 — 여기서 다시 가르면 근거를 못 받아
# 종전값으로 떨어지고 **표와 갈린다**(2026-09-09 실측).
face=sheet.get("face"),
face_reason=str(sheet.get("face_reason") or ""),
)
def _slope_or_none(structure: dict[str, Any]) -> float | None:
"""돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`."""
# ⚠ **그림 대상 목록을 쓰지 않는다** — 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다.
if str(structure.get("type_id") or "") not in SLOPE_TYPE_IDS:
return None
@@ -111,7 +144,8 @@ def sheet_title(structure: dict[str, Any]) -> str:
slope = _slope_or_none(structure)
if slope is not None:
parts.append(f"1:{slope:g}")
back = options.get("back_len_cm") or options.get("stone_back_length_cm")
# 옛 저장분 키까지 읽는 차례는 전개의 한 벌(`BACK_LENGTH_KEYS`) — 키 이름을 다시 적지 않음.
back = next((options[key] for key in BACK_LENGTH_KEYS if options.get(key)), None)
if back:
parts.append(f"뒷길이 {int(back)}")
kind = options.get("stone_kind")
@@ -137,6 +171,8 @@ def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
{
"no": index,
"name": component.get("name") or "",
# 실무 시트 머리의 「규격」 칸(210 · Ø50 등) — 전개가 이미 싣고 옴(2026-09-13).
"spec": component.get("spec") or "",
# 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다.
"basis": component.get("basis") or "",
"unit_amount": _unit_amount(amount, quantity),
@@ -16,6 +16,8 @@
**고치면 장이 갈릴 있다** 조합 전부에 같은 값을 넣으므로 장은 통째로 옮겨 가고
쪼개지지 않는다. 개소만 다르게 하려면 구조물을 따로 고쳐야 하고, 그때 조합이
되어 장이 하나 는다(PLAN 4-5b).
**2026-09-13 B07 에서 이관**(PLAN 3). 자리 `B07_DesignDetail_Engine_Standard_Edit.py`.
"""
from __future__ import annotations
@@ -1,4 +1,7 @@
"""B07 표준도(구조물도) 라우터 — 장 목록 조회와 **제원 입력**.
"""B08 구조물도(표준도) 라우터 — 장 목록 조회와 **제원 입력**.
**2026-09-13 B07 에서 이관**(PLAN 3 · 사용자 확정 구조물도는 B08 ).
자리 `B07_DesignDetail_Router_Standard.py` · 주소 `/standard-sheets`.
표준도는 도면이자 **입력 화면**이다(PLAN 4-5b). `phase: "detail"` ( 종류·조달·뒷길이·
전면 기울기) 그리는 화면이 없어(2026-09-09 실측) 자리를 여기가 맡는다.
@@ -11,6 +14,7 @@
import asyncio
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
@@ -19,24 +23,20 @@ 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
from config.config_db import run_with_connection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
router = APIRouter(prefix="/api/projects", tags=["B08 Quantity"])
async def section_modes_of_project(project_id: UUID) -> dict[float, str]:
"""프로젝트에서 노선을 찾아 단면유형 표를 낸다 — 노선을 모르면 빈 표."""
from B06_Section.B06_Section_Repository import get_workflow_route_context
from config.config_db import run_with_connection
async def _project_root(project_id: UUID) -> str | None:
"""프로젝트 저장 폴더(절대경로). 못 찾으면 `None`."""
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
return str(Path(resolve_stored_project_path(stored_path)).resolve())
except Exception:
logger.exception("B07 노선 조회 실패: project_id=%s", project_id)
return {}
return await section_modes_of(route_id) if route_id else {}
logger.exception("B08 구조물도 — 저장 폴더 조회 실패: project_id=%s", project_id)
return None
async def section_modes_of(route_id: int) -> dict[float, str]:
@@ -46,19 +46,70 @@ async def section_modes_of(route_id: int) -> dict[float, str]:
1:0.3 으로 선다**(2026-09-09 실측). 값이 없는 것이 아니라 ** 넘긴 **이었다.
표를 만드는 셈은 `section_modes_from_designs` 벌을 쓴다 부르는 쪽마다 다시
짜면 B08 갈린다.
과도기 노선 id 부르는 쪽은 B07 표준도 도면뿐임.
배선 B07 에서 표준도를 빼면 함께 지움.
"""
from B06_Section.B06_Section_Repository import get_cross_section_designs
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import section_modes_from_designs
from config.config_db import run_with_connection
try:
designs = await run_with_connection(get_cross_section_designs, route_id)
except Exception:
logger.exception("B07 단면유형 조회 실패: route_id=%s", route_id)
logger.exception("B08 구조물도 단면유형 조회 실패: route_id=%s", route_id)
return {}
return section_modes_from_designs(designs)
def project_structure_sheets(
project_root: str,
section_modes: dict[float, str] | None,
ground_types: dict[float, str] | None = None,
) -> dict[str, Any]:
"""구조물도 장 목록 — **원단위 탭(`material-summary`)과 같은 입력**으로 전개해 접는다.
기초잡석 두께를 넘기면 산출 조건에서 바꿔도 **구조물도만 두께(0.2)** 선다
(2026-09-13 이관 잡음 B07 창구가 두께·지반 갈래를 넘겼음).
늦게 부른다(함수 import) B08 B05 부르고 B05 다시 B07 부를 있어
모듈 위에서 부르면 맞물린다.
"""
from B08_Quantity.B08_Quantity_Engine_StructureSheet 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
from common_util.common_util_project_settings import quantity_settings
structures, names, skipped = _collect_structures(project_root)
unit_table = build_unit_table(
structures,
names,
section_modes,
ground_types,
quantity_settings(project_root).get("rubble_base_thickness_m"),
)
payload = build_standard_sheets(unit_table, section_modes)
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
payload["skipped_structures"] = skipped
return payload
async def _sheets_of(project_id: UUID, project_root: str) -> dict[str, Any]:
"""조회·저장이 **같은 장 목록**을 보게 하는 한 문.
단면유형·지반 갈래는 원단위 창구(`B08_Quantity_Router_Material`) 그대로 .
"""
from B08_Quantity.B08_Quantity_Router_Material import _ground_types, _section_modes
modes = await _section_modes(project_id)
ground = await _ground_types(project_id)
return await asyncio.to_thread(project_structure_sheets, project_root, modes, ground)
def _not_found() -> JSONResponse:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
class StandardSheetSpecRequest(BaseModel):
"""표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다."""
@@ -78,8 +129,8 @@ class StandardSheetSpecRequest(BaseModel):
blinding_concrete: str | None = None
@router.put("/{project_id}/standard-sheets/spec")
async def put_standard_sheet_spec(
@router.put("/{project_id}/quantity/structure-sheets/spec")
async def put_structure_sheet_spec(
project_id: UUID, payload: StandardSheetSpecRequest
) -> JSONResponse:
"""장 하나의 제원을 고쳐 **그 조합의 구조물 전부**에 반영한다.
@@ -89,28 +140,21 @@ async def put_standard_sheet_spec(
"""
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 (
from B08_Quantity.B08_Quantity_Engine_StructureSheet_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": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
project_root = await _project_root(project_id)
if project_root is None:
return _not_found()
modes = await section_modes_of_project(project_id)
payload_sheets = await asyncio.to_thread(standard_payload, project_root, modes)
sheets = payload_sheets.get("sheets") or []
try:
sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
except Exception:
logger.exception("B08 구조물도 제원 저장 실패(장 목록): project_id=%s", project_id)
sheets = []
picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None)
if picked is None:
return JSONResponse(
@@ -129,13 +173,13 @@ async def put_standard_sheet_spec(
spec, missing = drop_unregistered(type_id, spec, allowed)
notes.extend(missing)
try:
revision, stored = await asyncio.to_thread(load_structures, str(project_root))
revision, stored = await asyncio.to_thread(load_structures, 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
save_structures, project_root, updated, base_revision=payload.base_revision
)
except Exception as exc:
logger.exception("B07 표준도 제원 저장 실패: project_id=%s", project_id)
logger.exception("B08 구조물도 제원 저장 실패: project_id=%s", project_id)
return JSONResponse(
status_code=409,
content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"},
@@ -154,43 +198,26 @@ async def put_standard_sheet_spec(
)
@router.get("/{project_id}/standard-sheets")
async def get_standard_sheets(project_id: UUID) -> JSONResponse:
"""표준도(구조물도) **장 목록 + 하단표**.
@router.get("/{project_id}/quantity/structure-sheets")
async def get_structure_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
project_root = await _project_root(project_id)
if project_root is None:
return _not_found()
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())
payload = await _sheets_of(project_id, project_root)
except Exception:
logger.exception("B07 표준도 조회 실패(경로): project_id=%s", project_id)
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
modes = await section_modes_of_project(project_id)
try:
structures, names, skipped = await asyncio.to_thread(_collect_structures, project_root)
unit_table = await asyncio.to_thread(build_unit_table, structures, names, modes)
except Exception:
logger.exception("B07 표준도 전개 실패: project_id=%s", project_id)
logger.exception("B08 구조물도 전개 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."},
)
payload = build_standard_sheets(unit_table, modes)
payload["status"] = "success"
payload["project_id"] = str(project_id)
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
payload["skipped_structures"] = skipped
return JSONResponse(content=payload)
@@ -0,0 +1,314 @@
/* =============================================================================
* B08_Quantity_UI_StructureSheet.ts
* 구조물도 탭 — 제원 조합 하나 = 한 장. 장마다 하위 탭 · 원단위 수량표 · 제원 칸 (PLAN 3장).
*
* ⛔ 탭 등록은 이 파일이 하지 않음 — `B08_Quantity_UI_Page.ts` 탭 배선은 브레인 몫(PLAN 0장 충돌 막이).
* 부르는 법: `{ label: "구조물도", build: () => renderStructureSheets(projectId) }`
* ⚠ 값을 셈하지 않음 — 서버(`/quantity/structure-sheets`)가 낸 단위당 값을 표기 자리수로만 접음.
* ⚠ 상단 그림·하단 일위대가는 뒤 일감 — 지금은 가운데 원단위 수량표만.
* ⚠ 제원 저장은 칸 옆 [제원 저장] 한 번에 정본(`structures.json`)으로 감 — 옛 B07 폼 규약 그대로.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid";
import { injectEarthworkGridStyles } from "./B08_Quantity_UI_EarthworkGrid_Style";
import {
buildStandardSpecPanel,
type StandardSheetSpec,
type StandardSpecResult,
} from "./B08_Quantity_UI_StructureSheet_Spec";
export interface StructureSheetRow {
no: number;
name: string;
spec: string;
basis: string;
/** 단위당 값 — 단위 수량을 못 정한 장은 `null`(0 으로 때우지 않음). */
unit_amount: number | null;
amount: number;
unit: string;
basis_kind: string;
source: string;
}
export interface StructureSheet extends StandardSheetSpec {
height_m: number;
unit_label: string;
billing_unit: string;
billing_total: number;
rows: StructureSheetRow[];
members: {
structure_id: string | null;
name: string;
start_m: number | null;
end_m: number | null;
length_m: number;
billing_quantity: number;
}[];
notes: string[];
unpriced_rows: string[];
}
export interface StructureSheetsResponse {
sheets: StructureSheet[];
sheet_count: number;
structure_count: number;
skipped_structures: string[];
pending_choices: { label: string; effect?: string }[];
}
const STYLE_ID = "b08-structure-sheet-style";
const CSS = `
.b08-sheet { display: flex; gap: 12px; align-items: flex-start; min-height: 0; flex: 1 1 auto; }
.b08-sheet__main { display: flex; flex-direction: column; gap: 8px; flex: 1 1 auto; min-width: 0; min-height: 0; }
.b08-sheet__aside { flex: 0 0 17rem; max-height: 100%; overflow: auto; }
.b08-sheet__head { display: flex; justify-content: space-between; gap: 8px; margin: 0; font-size: 13px; color: var(--color-text); }
.b08-sheet__tabs { flex-wrap: wrap; }
.b08-sheet .b08-grid__table--summary td:nth-child(5) { text-align: center; }
/* 산출 근거는 길다 — 접지 않으면 수량 칸이 화면 밖으로 밀림(2026-09-13 화면 실측). */
.b08-sheet__rows td:nth-child(3) { white-space: normal; min-width: 16rem; }
@media (max-width: 900px) {
.b08-sheet { flex-direction: column; }
.b08-sheet__aside { flex-basis: auto; width: 100%; }
}
`;
function injectStyles(): void {
injectEarthworkGridStyles();
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.append(style);
}
async function fetchStructureSheets(projectId: string): Promise<StructureSheetsResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`structure sheets failed: ${response.status}`);
return (await response.json()) as StructureSheetsResponse;
}
async function putStructureSheetSpec(
projectId: string,
body: StandardSpecResult & { base_revision: number },
): Promise<{ changed: number; notes: string[] }> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-sheets/spec`,
{
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
const payload = (await response.json().catch(() => ({}))) as {
changed?: number;
notes?: string[];
message?: string;
};
// 실패 사유(판번호 충돌 등)는 폼 안내 칸에 그대로 뜬다 — 조용히 끝나면 저장된 줄 앎.
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return { changed: payload.changed ?? 0, notes: payload.notes ?? [] };
}
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
text = "",
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
node.className = className;
node.textContent = text;
return node;
}
function num(value: number | null | undefined, digits: number): string {
if (value === null || value === undefined || Number.isNaN(value)) return "-";
return value.toLocaleString("ko-KR", {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
function warn(title: string, items: string[]): HTMLElement | null {
if (!items.length) return null;
return el("p", "b08-grid__caption b08-grid__caption--warn", `${title}: ${items.join(" · ")}`);
}
function table(head: string[], rows: string[][], extraClass = ""): HTMLElement {
const scroller = el("div", "b08-grid__scroll");
const grid = el("table", `b08-grid__table b08-grid__table--summary ${extraClass}`.trim());
const thead = document.createElement("thead");
const headRow = document.createElement("tr");
for (const label of head) headRow.append(el("th", "", label));
thead.append(headRow);
const tbody = document.createElement("tbody");
for (const cells of rows) {
const tr = document.createElement("tr");
for (const text of cells) tr.append(el("td", "", text));
tbody.append(tr);
}
grid.append(thead, tbody);
scroller.append(grid);
return scroller;
}
/** 장 한 벌의 가운데 — 머리 · 원단위 수량표 · 막힌 사유 · 개소 목록. */
function sheetBody(sheet: StructureSheet): HTMLElement {
const main = el("div", "b08-sheet__main");
const head = el("p", "b08-sheet__head");
const total = sheet.billing_total
? ` · 합 ${num(sheet.billing_total, 2)}${sheet.billing_unit}`
: "";
head.append(
el("span", "", `${sheet.title}${sheet.member_count}개소${total}`),
// 실무 시트 머리의 「m당」·「개소당」 — 종류마다 다름(통일하지 않음).
el("span", "b08-grid__caption", sheet.unit_label),
);
main.append(head);
if (!sheet.rows.length) {
main.append(
el("p", "b08-quantity__message", "이 제원은 원단위 줄이 서지 않음 — 아래 사유 참고"),
);
} else {
main.append(
table(
["공종", "규격", "산출 근거", "수량", "단위", "비고"],
sheet.rows.map((row) => [
row.name,
row.spec,
// 근거 문구의 `**강조**` 는 서버 문서용 표기 — 표에서는 떼고 보임.
row.basis.replace(/\*\*/g, ""),
num(row.unit_amount, 3),
row.unit,
row.basis_kind === "observed" ? "실무 관측" : "치수 전개",
]),
"b08-sheet__rows",
),
);
}
for (const notice of [
warn("단위당을 못 낸 줄", sheet.unpriced_rows),
warn(
"사유",
sheet.notes.map((note) => note.replace(/\*\*/g, "")),
),
]) {
if (notice) main.append(notice);
}
main.append(
el("p", "b08-grid__caption", "이 장에 묶인 개소"),
table(
["구조물", "구간", "연장(m)", `수량(${sheet.billing_unit})`],
sheet.members.map((member) => {
const { start_m: start, end_m: end } = member;
const span =
typeof start === "number" && typeof end === "number"
? start === end
? stationLabel(start)
: `${stationLabel(start)} ~ ${stationLabel(end)}`
: "";
return [member.name, span, num(member.length_m, 1), num(member.billing_quantity, 2)];
}),
),
);
return main;
}
/** 구조물도 탭 본문. 받아 오는 동안 안내를 띄우고, 제원을 저장하면 **정본에서 다시 받아** 그림. */
export function renderStructureSheets(projectId: string | null): HTMLElement {
injectStyles();
const wrap = el("div", "b08-grid");
if (!projectId) {
wrap.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것"));
return wrap;
}
const paint = (response: StructureSheetsResponse, memberId: string | null, notes: string[]) => {
const sheets = response.sheets ?? [];
const caption = el(
"p",
"b08-grid__caption",
`구조물도 ${sheets.length}장 · 구조물 ${response.structure_count}개 · 제원 조합 하나가 한 장 · 할증 전 값`,
);
const nodes: HTMLElement[] = [caption];
const skipped = warn("건너뛴 구조물", response.skipped_structures ?? []);
if (skipped) nodes.push(skipped);
for (const choice of response.pending_choices ?? []) {
const effect = choice.effect ? ` · ${choice.effect.replace(/\*\*/g, "")}` : "";
nodes.push(el("p", "b08-quantity__notice", `⚠ 미확정: ${choice.label}${effect}`));
}
if (!sheets.length) {
nodes.push(
el(
"p",
"b08-quantity__message",
response.structure_count
? "구조물이 모두 다른 단계에서 셈되어 구조물도에 실리지 않음"
: "배치된 구조물이 없음 — 구조물을 먼저 배치할 것",
),
);
wrap.replaceChildren(...nodes);
return;
}
// 저장 뒤에는 **같은 개소가 든 장**을 다시 연다 — 제원이 바뀌면 장 이름(key)도 바뀌기 때문.
const found = sheets.findIndex((sheet) =>
sheet.members.some((member) => memberId && member.structure_id === memberId),
);
const tabs = el("div", "b08-quantity__tabs b08-sheet__tabs");
const pane = el("div", "b08-sheet");
const buttons: HTMLButtonElement[] = [];
const show = (index: number, initialNotes: string[] = []): void => {
buttons.forEach((button, i) => button.classList.toggle("is-active", i === index));
const sheet = sheets[index];
const aside = el("div", "b08-sheet__aside");
// 판정된 기울기는 **칸에 적지 않고 도움말로만** — 적어 두면 「안 정함」이 사라진다.
const judged = /1:([\d.]+)/.exec(sheet.title)?.[1] ?? null;
aside.append(
buildStandardSpecPanel(
sheet,
judged,
async (result) => {
const { revision } = await fetchStructures(projectId);
const saved = await putStructureSheetSpec(projectId, {
...result,
base_revision: revision,
});
const after = [`${saved.changed}개소에 반영했습니다.`, ...saved.notes];
// 정본이 바뀌었으니 표를 **다시 받아** 그린다 — 화면이 두 번째 정본이 되면 안 됨.
await load(sheet.members[0]?.structure_id ?? null, after);
return after;
},
initialNotes,
),
);
pane.replaceChildren(sheetBody(sheet), aside);
};
sheets.forEach((sheet, index) => {
const button = el("button", "b08-quantity__tab", `${index + 1}. ${sheet.title}`);
button.type = "button";
button.addEventListener("click", () => show(index));
buttons.push(button);
tabs.append(button);
});
wrap.replaceChildren(...nodes, tabs, pane);
show(found >= 0 ? found : 0, notes);
};
const load = async (memberId: string | null = null, notes: string[] = []): Promise<void> => {
try {
paint(await fetchStructureSheets(projectId), memberId, notes);
} catch {
wrap.replaceChildren(el("p", "b08-quantity__message", "구조물도를 불러오지 못함"));
}
};
wrap.append(el("p", "b08-quantity__message", "구조물도를 불러오는 중…"));
void load();
return wrap;
}
@@ -1,7 +1,10 @@
/* =============================================================================
* B07_DesignDetail_UI_StandardSpec.ts
* B08_Quantity_UI_StructureSheet_Spec.ts
* ** ** .
*
* 2026-09-13 B07 (PLAN 3) `B07_DesignDetail_UI_StandardSpec.ts`.
* B07 (CSS) ** **.
*
* (PLAN 4-5b) `phase: "detail"` ( ··· )
* (2026-09-09 실측: B06 b05 phase ). B06
* · m(), .
@@ -53,16 +56,50 @@ const STONE_TYPES = new Set(["masonry_wet", "masonry_dry", "boulder_masonry"]);
/** 벽 두께 칸을 받는 종류 — **큰돌쌓기는 두께 식이 달라** 등록부에 칸이 없다(2026-09-09). */
const THICKNESS_TYPES = new Set(["masonry_wet", "masonry_dry"]);
const STYLE_ID = "b08-spec-style";
/* 옛 B07 페이지 CSS(`B07_DesignDetail_UI_Style.css`)에서 옮긴 것 — 두 페이지가 같은 모양을 보게. */
const CSS = `
.b08-spec { display: flex; flex-direction: column; gap: var(--spacing-8); padding: var(--spacing-12); border-radius: var(--radius-lg); }
.b08-spec__title { margin: 0; font-size: var(--text-body-sm); font-weight: 600; }
.b08-spec__scope { margin: 0; color: var(--color-text-muted); font-size: var(--text-caption); }
.b08-spec__field { display: flex; flex-direction: column; gap: 2px; }
.b08-spec__label { color: var(--color-text-muted); font-size: var(--text-caption); }
.b08-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;
}
/* 「비우면 자동」처럼 **칸의 뜻**을 적는 자리 — 값이 아니라 규칙을 말한다. */
.b08-spec__hint { color: var(--color-text-muted); font-size: var(--text-caption); }
.b08-spec__save {
padding: var(--spacing-8); border: 1px solid transparent; border-radius: var(--radius-buttons);
background: var(--color-primary, #7c3aed); color: #fff; cursor: pointer;
}
.b08-spec__save:disabled { opacity: 0.6; cursor: default; }
/* 저장 뒤 안내 — 막지 않고 알리는 자리(품셈 범위 밖 기울기 등). */
.b08-spec__notes { margin: 0; padding-left: var(--spacing-16); color: var(--color-text-muted); font-size: var(--text-caption); }
`;
/** 모양을 한 번만 넣는다 — 폼을 다시 그려도 겹치지 않음. */
function injectSpecStyles(): void {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = CSS;
document.head.append(style);
}
function field(label: string, control: HTMLElement, hint?: string): HTMLLabelElement {
const wrap = document.createElement("label");
wrap.className = "b07-spec__field";
wrap.className = "b08-spec__field";
const name = document.createElement("span");
name.className = "b07-spec__label";
name.className = "b08-spec__label";
name.textContent = label;
wrap.append(name, control);
if (hint) {
const help = document.createElement("small");
help.className = "b07-spec__hint";
help.className = "b08-spec__hint";
help.textContent = hint;
wrap.append(help);
}
@@ -75,7 +112,7 @@ function select(
autoLabel: string,
): HTMLSelectElement {
const el = document.createElement("select");
el.className = "b07-spec__input";
el.className = "b08-spec__input";
// 첫 보기가 **빈 값** — 「정한 적 없음」이 고를 수 있는 상태여야 한다.
const blank = document.createElement("option");
blank.value = "";
@@ -100,20 +137,21 @@ export function buildStandardSpecPanel(
onSave: (result: StandardSpecResult) => Promise<string[]>,
initialNotes: string[] = [],
): HTMLDivElement {
injectSpecStyles();
const panel = document.createElement("div");
panel.className = "b07-spec ui-sidebar-section";
panel.className = "b08-spec ui-sidebar-section";
const title = document.createElement("h3");
title.className = "b07-spec__title";
title.className = "b08-spec__title";
title.textContent = `제원 — ${sheet.title}`;
const scope = document.createElement("p");
scope.className = "b07-spec__scope";
scope.className = "b08-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.className = "b08-spec__scope";
none.textContent = "이 종류는 표준도에서 받는 제원 칸이 아직 없습니다.";
panel.append(none);
return panel;
@@ -129,7 +167,7 @@ export function buildStandardSpecPanel(
const blinding = select(BLINDINGS, options.blinding_concrete, "— 안 정함(넣음) —");
const slope = document.createElement("input");
slope.className = "b07-spec__input";
slope.className = "b08-spec__input";
slope.type = "text";
slope.inputMode = "decimal";
slope.placeholder = "비우면 자동";
@@ -138,7 +176,7 @@ export function buildStandardSpecPanel(
// 벽 두께 — 비우면 실무 구조물도 식(상부 = 뒷길이 + 0.30 · 하부 = 상부 + 0.30×(H1)).
const thickness = (key: "thickness_top_m" | "thickness_bottom_m"): HTMLInputElement => {
const input = document.createElement("input");
input.className = "b07-spec__input";
input.className = "b08-spec__input";
input.type = "text";
input.inputMode = "decimal";
input.placeholder = "비우면 식";
@@ -170,7 +208,7 @@ export function buildStandardSpecPanel(
}
const notes = document.createElement("ul");
notes.className = "b07-spec__notes";
notes.className = "b08-spec__notes";
// ⚠ 저장하면 표·그림을 다시 받으면서 **이 폼이 통째로 새로 그려진다** — 그때 안내가
// 지워지지 않게 밖에서 들고 있다가 다시 넣는다(2026-09-09 실화면에서 잡음).
const showNotes = (messages: string[]): void => {
@@ -187,7 +225,7 @@ export function buildStandardSpecPanel(
const save = document.createElement("button");
save.type = "button";
save.className = "b07-spec__save";
save.className = "b08-spec__save";
save.textContent = "제원 저장";
save.addEventListener("click", () => {
void (async () => {
+2 -2
View File
@@ -60,10 +60,10 @@ 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_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_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_StructureSheet import router as b08_structure_sheet_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_auth import (
@@ -631,10 +631,10 @@ 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(b07_design_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_earthwork_router, dependencies=protected_with_company)
app.include_router(b08_material_router, dependencies=protected_with_company)
app.include_router(b08_structure_sheet_router, dependencies=protected_with_company)
app.include_router(b09_estimation_router, dependencies=protected_with_company)
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
# 그 위에 서버가 환경까지 한 번 더 본다.
+1 -1
View File
@@ -12,7 +12,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import sheet_key # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_key # noqa: E402
def _stone(**options) -> dict:
@@ -27,7 +27,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( # noq
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import ( # noqa: E402
build_standard_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import sheet_title # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructureSheet import sheet_title # noqa: E402
def _sheet(type_id: str, **options) -> dict:
@@ -18,7 +18,7 @@ ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import ( # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructureSheet import ( # noqa: E402
build_standard_sheets,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402
@@ -24,7 +24,7 @@ if str(ROOT) not in sys.path:
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( # noqa: E402
build_figure,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Edit import ( # noqa: E402
from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import ( # noqa: E402
BLINDING_CHOICES,
EDITABLE_KEYS,
clean_spec,
@@ -110,7 +110,7 @@ def test_여기서_고치기_단추가_고정_액션_줄에_있다() -> None:
assert "specJumpButton.hidden = false" in page
assert "specJumpButton.hidden = true" in page
# 폼에 버림 칸이 섰다 — 끄는 자리가 화면에 있다(확정 8-2).
form = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_UI_StandardSpec.ts").read_text(
form = (ROOT / "B08_Quantity" / "B08_Quantity_UI_StructureSheet_Spec.ts").read_text(
encoding="utf-8"
)
assert "blinding_concrete" in form
@@ -0,0 +1,108 @@
"""구조물도 창구(B08) — B07 에서 옮긴 뒤 조회·제원 저장이 한 벌로 도는지 (2026-09-13, PLAN 3장 ②).
겨누는 것 셋
① 조회가 장을 냄 — 옛 B07 `/standard-sheets` 자리
② ⚠ 기초잡석 두께가 **산출 조건 값을 따름** — 옛 B07 창구는 두께를 안 넘겨 늘 0.2 로 섰음
(원단위 탭과 구조물도가 같은 구조물에서 다른 값을 냈음)
③ 제원 저장이 그 장의 개소에 걸리고, 다시 조회하면 새 제원으로 섬
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
import B08_Quantity.B08_Quantity_Router_Material as material_module # noqa: E402
import B08_Quantity.B08_Quantity_Router_StructureSheet as router_module # noqa: E402
from B05_Profile.B05_Profile_Structures_Repository import save_structures # noqa: E402
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402
PROJECT_ID = "22222222-2222-2222-2222-222222222222"
SHEETS = f"/api/projects/{PROJECT_ID}/quantity/structure-sheets"
@pytest.fixture()
def project(tmp_path: Path) -> Path:
root = tmp_path / "project"
root.mkdir()
wall = StructureInstance.model_validate(
{
"type_id": "masonry_wet",
"placement": "interval",
"start_m": 100.0,
"end_m": 110.0,
"options": {"height_m": 2.5, "back_len_cm": 45},
}
)
save_structures(str(root), [wall], base_revision=0)
return root
@pytest.fixture()
def client(project: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
async def fake_root(project_id):
return str(project)
async def no_route(project_id):
return {}
monkeypatch.setattr(router_module, "_project_root", fake_root)
monkeypatch.setattr(material_module, "_section_modes", no_route)
monkeypatch.setattr(material_module, "_ground_types", no_route)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def _sheet(client: TestClient) -> dict:
response = client.get(SHEETS)
assert response.status_code == 200, response.text
sheets = response.json()["sheets"]
assert len(sheets) == 1, sheets
return sheets[0]
def _unit_amount(sheet: dict, name: str) -> float:
return next(row["unit_amount"] for row in sheet["rows"] if row["name"] == name)
def test_조회가_장을_낸다(client: TestClient) -> None:
sheet = _sheet(client)
assert sheet["type_id"] == "masonry_wet"
assert sheet["member_count"] == 1
assert sheet["billing_unit"] == "m" and sheet["billing_total"] == pytest.approx(10.0)
assert all("spec" in row for row in sheet["rows"])
def test_기초잡석_두께가_산출_조건을_따른다(client: TestClient, project: Path) -> None:
기본 = _unit_amount(_sheet(client), "기초잡석")
(project / "project_settings.json").write_text(
json.dumps({"quantity": {"rubble_base_thickness_m": 0.3}}), encoding="utf-8"
)
바꿈 = _unit_amount(_sheet(client), "기초잡석")
# 기본 0.2 → 0.3 — 버림 폭에 두께 비를 곱하는 식이라 1.5 배.
assert 바꿈 == pytest.approx(기본 * 1.5)
def test_제원_저장이_그_장에_걸린다(client: TestClient) -> None:
sheet = _sheet(client)
saved = client.put(
f"{SHEETS}/spec",
json={"sheet_key": sheet["key"], "base_revision": 1, "back_len_cm": "55"},
)
assert saved.status_code == 200, saved.text
assert saved.json()["changed"] == 1
assert _sheet(client)["options"]["back_len_cm"] == 55
def test_없는_장이면_404(client: TestClient) -> None:
response = client.put(f"{SHEETS}/spec", json={"sheet_key": "없는 장", "base_revision": 1})
assert response.status_code == 404