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> { export function fetchStandardSheets(projectId: string): Promise<StandardSheetsResponse> {
return requestJson(`/projects/${projectId}/standard-sheets`); return requestJson(`/projects/${projectId}/quantity/structure-sheets`);
} }
/** 장 하나의 제원 저장 — 빈 값(null)은 **그 칸을 지우라**는 뜻이다. */ /** 장 하나의 제원 저장 — 빈 값(null)은 **그 칸을 지우라**는 뜻이다. */
@@ -282,7 +283,7 @@ export function putStandardSheetSpec(
blinding_concrete: string | null; blinding_concrete: string | null;
}, },
): Promise<{ status: string; revision: number; changed: number; notes: string[] }> { ): 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", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -37,9 +37,14 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_text_entity, _text_entity,
polyline_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 ( from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
STONE_MASONRY, STONE_MASONRY,
face_slope_ratio,
weep_hole_spec, weep_hole_spec,
) )
from common_util.common_util_excavation import ( from common_util.common_util_excavation import (
@@ -61,11 +66,6 @@ SCALE_MM_PER_M = 40.0
#: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄. #: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄.
FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"}) 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 사용자 지시). 「무엇을 받아야 서는지」까지 적는다. #: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
_NO_FIGURE_REASONS: dict[str, str] = { _NO_FIGURE_REASONS: dict[str, str] = {
@@ -98,31 +98,6 @@ _LABEL_FONT = 4.0
_DIM_FONT = 3.4 _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]: def wall_thickness(height_m: float, back_cm: float) -> tuple[float, float]:
"""(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다.""" """(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다."""
top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"] 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장). 글자로 옮길 뿐이다. 셈이 두 벌이 되면 도면과 수량서가 갈린다(CLAUDE.md 5장).
""" """
@@ -232,18 +232,14 @@ def standard_payload(
⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어 ⚠ 늦게 부른다(함수 안 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_Router_StructureSheet import project_structure_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: try:
structures, names, _skipped = _collect_structures(str(project_root))
# ⚠ 단면유형을 넘겨야 성절토가 갈리고 표준경사 판정이 돈다. 안 넘기면 전 구조물이 # ⚠ 단면유형을 넘겨야 성절토가 갈리고 표준경사 판정이 돈다. 안 넘기면 전 구조물이
# 「가를 근거 없음」으로 떨어져 종전값 1:0.3 으로 선다(2026-09-09 실측). # 「가를 근거 없음」으로 떨어져 종전값 1:0.3 으로 선다(2026-09-09 실측).
return build_standard_sheets( return project_structure_sheets(str(project_root), section_modes)
build_unit_table(structures, names, section_modes), section_modes
)
except Exception: except Exception:
logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root) logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root)
return {"sheets": [], "structure_count": 0} 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_company_templates,
use_title_fields, 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 ( from B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_STANDARD_ID, CROSS_STANDARD_ID,
LANDUSE_ID, LANDUSE_ID,
+2 -1
View File
@@ -50,10 +50,11 @@ import {
} 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";
// 제원 칸은 B08 구조물도로 이관(2026-09-13, PLAN 3장) — 탭 배선 뒤 B07 표준도와 함께 뺌.
import { import {
buildStandardSpecPanel, buildStandardSpecPanel,
type StandardSpecResult, type StandardSpecResult,
} from "./B07_DesignDetail_UI_StandardSpec"; } from "../B08_Quantity/B08_Quantity_UI_StructureSheet_Spec";
/** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */ /** CAD 앱 수량 패널로 넘기는 설계 컨텍스트 (openwebcad DesignMeta와 동일 형식). */
interface DesignMeta { interface DesignMeta {
+1 -70
View File
@@ -337,73 +337,4 @@
font-size: var(--text-caption); font-size: var(--text-caption);
} }
/* 표준도 제원 입력 — 좌측 목록 아래 정보 칸에 선다. 장 하나가 곧 제원 조합 하나다. */ /* 표준도 제원 입력 모양은 B08 로 이관(2026-09-13) — `B08_Quantity_UI_StructureSheet_Spec.ts` 가 스스로 넣음. */
.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);
}
@@ -20,6 +20,10 @@
**단위당 ** 성분 수량을 `billing_quantity` 나눈 것이다. 실무 시트 머리의 `m당` · **단위당 ** 성분 수량을 `billing_quantity` 나눈 것이다. 실무 시트 머리의 `m당` ·
`개소당` · `` 단위이고, **구조물마다 다르다**(통일하지 않음 4-1). `개소당` · `` 단위이고, **구조물마다 다르다**(통일하지 않음 4-1).
**2026-09-13 B07 에서 이관**(PLAN 3 · 사용자 확정 구조물도는 B08 ).
자리 `B07_DesignDetail_Engine_Standard_Sheet.py`.
B07 그림 파일을 부르지 않게 기울기 판정 대상도 함께 옮김.
""" """
from __future__ import annotations from __future__ import annotations
@@ -27,6 +31,8 @@ from __future__ import annotations
import json import json
from typing import Any 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) return json.dumps(payload, ensure_ascii=False, sort_keys=True)
def _slope_or_none(structure: dict[str, Any]) -> float | None: #: **전면 기울기 판정** 대상 — 그림 대상(`FIGURE_TYPE_IDS`)과 **다르다**.
"""돌쌓기 계열이면 판정된 전면 기울기, 아니면 `None`. 판정은 그림 모듈이 가진 한 벌.""" #: 큰돌쌓기는 그림은 못 그려도 기울기는 판정된다(품셈 13-6 「1:0.3 이상」).
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( #: 한 목록으로 묶어 두었더니 큰돌쌓기를 그림에서 뺄 때 **장 제목의 「1:0.3」까지
SLOPE_TYPE_IDS, #: 사라졌다**(2026-09-09 화면 실측에서 잡음).
slope_of, 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: if str(structure.get("type_id") or "") not in SLOPE_TYPE_IDS:
return None return None
@@ -111,7 +144,8 @@ def sheet_title(structure: dict[str, Any]) -> str:
slope = _slope_or_none(structure) slope = _slope_or_none(structure)
if slope is not None: if slope is not None:
parts.append(f"1:{slope:g}") 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: if back:
parts.append(f"뒷길이 {int(back)}") parts.append(f"뒷길이 {int(back)}")
kind = options.get("stone_kind") kind = options.get("stone_kind")
@@ -137,6 +171,8 @@ def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
{ {
"no": index, "no": index,
"name": component.get("name") or "", "name": component.get("name") or "",
# 실무 시트 머리의 「규격」 칸(210 · Ø50 등) — 전개가 이미 싣고 옴(2026-09-13).
"spec": component.get("spec") or "",
# 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다. # 실무 시트의 「산출근거」 칸 — B08 이 이미 사람이 읽는 문구로 낸다.
"basis": component.get("basis") or "", "basis": component.get("basis") or "",
"unit_amount": _unit_amount(amount, quantity), "unit_amount": _unit_amount(amount, quantity),
@@ -16,6 +16,8 @@
**고치면 장이 갈릴 있다** 조합 전부에 같은 값을 넣으므로 장은 통째로 옮겨 가고 **고치면 장이 갈릴 있다** 조합 전부에 같은 값을 넣으므로 장은 통째로 옮겨 가고
쪼개지지 않는다. 개소만 다르게 하려면 구조물을 따로 고쳐야 하고, 그때 조합이 쪼개지지 않는다. 개소만 다르게 하려면 구조물을 따로 고쳐야 하고, 그때 조합이
되어 장이 하나 는다(PLAN 4-5b). 되어 장이 하나 는다(PLAN 4-5b).
**2026-09-13 B07 에서 이관**(PLAN 3). 자리 `B07_DesignDetail_Engine_Standard_Edit.py`.
""" """
from __future__ import annotations 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"` ( 종류·조달·뒷길이· 표준도는 도면이자 **입력 화면**이다(PLAN 4-5b). `phase: "detail"` ( 종류·조달·뒷길이·
전면 기울기) 그리는 화면이 없어(2026-09-09 실측) 자리를 여기가 맡는다. 전면 기울기) 그리는 화면이 없어(2026-09-09 실측) 자리를 여기가 맡는다.
@@ -11,6 +14,7 @@
import asyncio import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any
from uuid import UUID from uuid import UUID
from fastapi import APIRouter 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 B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_storage import resolve_stored_project_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__) 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]: async def _project_root(project_id: UUID) -> str | None:
"""프로젝트에서 노선을 찾아 단면유형 표를 낸다 — 노선을 모르면 빈 표.""" """프로젝트 저장 폴더(절대경로). 못 찾으면 `None`."""
from B06_Section.B06_Section_Repository import get_workflow_route_context
from config.config_db import run_with_connection
try: try:
context = await run_with_connection(get_workflow_route_context, project_id) stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
route_id = int((context or {}).get("route_id") or 0) return str(Path(resolve_stored_project_path(stored_path)).resolve())
except Exception: except Exception:
logger.exception("B07 노선 조회 실패: project_id=%s", project_id) logger.exception("B08 구조물도 — 저장 폴더 조회 실패: project_id=%s", project_id)
return {} return None
return await section_modes_of(route_id) if route_id else {}
async def section_modes_of(route_id: int) -> dict[float, str]: 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 실측). 값이 없는 것이 아니라 ** 넘긴 **이었다. 1:0.3 으로 선다**(2026-09-09 실측). 값이 없는 것이 아니라 ** 넘긴 **이었다.
표를 만드는 셈은 `section_modes_from_designs` 벌을 쓴다 부르는 쪽마다 다시 표를 만드는 셈은 `section_modes_from_designs` 벌을 쓴다 부르는 쪽마다 다시
짜면 B08 갈린다. 짜면 B08 갈린다.
과도기 노선 id 부르는 쪽은 B07 표준도 도면뿐임.
배선 B07 에서 표준도를 빼면 함께 지움.
""" """
from B06_Section.B06_Section_Repository import get_cross_section_designs from B06_Section.B06_Section_Repository import get_cross_section_designs
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import section_modes_from_designs from B08_Quantity.B08_Quantity_Engine_UnitQuantity import section_modes_from_designs
from config.config_db import run_with_connection
try: try:
designs = await run_with_connection(get_cross_section_designs, route_id) designs = await run_with_connection(get_cross_section_designs, route_id)
except Exception: except Exception:
logger.exception("B07 단면유형 조회 실패: route_id=%s", route_id) logger.exception("B08 구조물도 단면유형 조회 실패: route_id=%s", route_id)
return {} return {}
return section_modes_from_designs(designs) 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): class StandardSheetSpecRequest(BaseModel):
"""표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다.""" """표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다."""
@@ -78,8 +129,8 @@ class StandardSheetSpecRequest(BaseModel):
blinding_concrete: str | None = None blinding_concrete: str | None = None
@router.put("/{project_id}/standard-sheets/spec") @router.put("/{project_id}/quantity/structure-sheets/spec")
async def put_standard_sheet_spec( async def put_structure_sheet_spec(
project_id: UUID, payload: StandardSheetSpecRequest project_id: UUID, payload: StandardSheetSpecRequest
) -> JSONResponse: ) -> 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_Repository import load_structures, save_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import standard_payload from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import (
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Edit import (
apply_spec, apply_spec,
clean_spec, clean_spec,
drop_unregistered, drop_unregistered,
) )
try: project_root = await _project_root(project_id)
pool = get_db_pool() if project_root is None:
async with pool.acquire() as connection: return _not_found()
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": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
modes = await section_modes_of_project(project_id) try:
payload_sheets = await asyncio.to_thread(standard_payload, project_root, modes) sheets = (await _sheets_of(project_id, project_root)).get("sheets") or []
sheets = payload_sheets.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) picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None)
if picked is None: if picked is None:
return JSONResponse( return JSONResponse(
@@ -129,13 +173,13 @@ async def put_standard_sheet_spec(
spec, missing = drop_unregistered(type_id, spec, allowed) spec, missing = drop_unregistered(type_id, spec, allowed)
notes.extend(missing) notes.extend(missing)
try: 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) updated, changed = apply_spec(stored, member_ids, spec)
new_revision = await asyncio.to_thread( 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: except Exception as exc:
logger.exception("B07 표준도 제원 저장 실패: project_id=%s", project_id) logger.exception("B08 구조물도 제원 저장 실패: project_id=%s", project_id)
return JSONResponse( return JSONResponse(
status_code=409, status_code=409,
content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"}, content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"},
@@ -154,43 +198,26 @@ async def put_standard_sheet_spec(
) )
@router.get("/{project_id}/standard-sheets") @router.get("/{project_id}/quantity/structure-sheets")
async def get_standard_sheets(project_id: UUID) -> JSONResponse: async def get_structure_sheets(project_id: UUID) -> JSONResponse:
"""표준도(구조물도) **장 목록 + 하단표**. """구조물도(표준도) **장 목록 + 원단위 수량표**.
수량을 여기서 새로 셈하지 않는다 B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고 수량을 여기서 새로 셈하지 않는다 B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고
단위당으로 접기만** 한다(계산 자리는 , CLAUDE.md 5). 단위당으로 접기만** 한다(계산 자리는 , CLAUDE.md 5).
""" """
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets project_root = await _project_root(project_id)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table if project_root is None:
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures return _not_found()
try: try:
pool = get_db_pool() payload = await _sheets_of(project_id, project_root)
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: except Exception:
logger.exception("B07 표준도 조회 실패(경로): project_id=%s", project_id) logger.exception("B08 구조물도 전개 실패: 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)
return JSONResponse( return JSONResponse(
status_code=500, status_code=500,
content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."}, content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."},
) )
payload = build_standard_sheets(unit_table, modes)
payload["status"] = "success" payload["status"] = "success"
payload["project_id"] = str(project_id) payload["project_id"] = str(project_id)
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
payload["skipped_structures"] = skipped
return JSONResponse(content=payload) 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"` ( ··· ) * (PLAN 4-5b) `phase: "detail"` ( ··· )
* (2026-09-09 실측: B06 b05 phase ). B06 * (2026-09-09 실측: B06 b05 phase ). B06
* · m(), . * · m(), .
@@ -53,16 +56,50 @@ const STONE_TYPES = new Set(["masonry_wet", "masonry_dry", "boulder_masonry"]);
/** 벽 두께 칸을 받는 종류 — **큰돌쌓기는 두께 식이 달라** 등록부에 칸이 없다(2026-09-09). */ /** 벽 두께 칸을 받는 종류 — **큰돌쌓기는 두께 식이 달라** 등록부에 칸이 없다(2026-09-09). */
const THICKNESS_TYPES = new Set(["masonry_wet", "masonry_dry"]); 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 { function field(label: string, control: HTMLElement, hint?: string): HTMLLabelElement {
const wrap = document.createElement("label"); const wrap = document.createElement("label");
wrap.className = "b07-spec__field"; wrap.className = "b08-spec__field";
const name = document.createElement("span"); const name = document.createElement("span");
name.className = "b07-spec__label"; name.className = "b08-spec__label";
name.textContent = label; name.textContent = label;
wrap.append(name, control); wrap.append(name, control);
if (hint) { if (hint) {
const help = document.createElement("small"); const help = document.createElement("small");
help.className = "b07-spec__hint"; help.className = "b08-spec__hint";
help.textContent = hint; help.textContent = hint;
wrap.append(help); wrap.append(help);
} }
@@ -75,7 +112,7 @@ function select(
autoLabel: string, autoLabel: string,
): HTMLSelectElement { ): HTMLSelectElement {
const el = document.createElement("select"); const el = document.createElement("select");
el.className = "b07-spec__input"; el.className = "b08-spec__input";
// 첫 보기가 **빈 값** — 「정한 적 없음」이 고를 수 있는 상태여야 한다. // 첫 보기가 **빈 값** — 「정한 적 없음」이 고를 수 있는 상태여야 한다.
const blank = document.createElement("option"); const blank = document.createElement("option");
blank.value = ""; blank.value = "";
@@ -100,20 +137,21 @@ export function buildStandardSpecPanel(
onSave: (result: StandardSpecResult) => Promise<string[]>, onSave: (result: StandardSpecResult) => Promise<string[]>,
initialNotes: string[] = [], initialNotes: string[] = [],
): HTMLDivElement { ): HTMLDivElement {
injectSpecStyles();
const panel = document.createElement("div"); const panel = document.createElement("div");
panel.className = "b07-spec ui-sidebar-section"; panel.className = "b08-spec ui-sidebar-section";
const title = document.createElement("h3"); const title = document.createElement("h3");
title.className = "b07-spec__title"; title.className = "b08-spec__title";
title.textContent = `제원 — ${sheet.title}`; title.textContent = `제원 — ${sheet.title}`;
const scope = document.createElement("p"); const scope = document.createElement("p");
scope.className = "b07-spec__scope"; scope.className = "b08-spec__scope";
scope.textContent = `이 장의 ${sheet.member_count}개소에 함께 걸립니다.`; scope.textContent = `이 장의 ${sheet.member_count}개소에 함께 걸립니다.`;
panel.append(title, scope); panel.append(title, scope);
if (!STONE_TYPES.has(sheet.type_id)) { if (!STONE_TYPES.has(sheet.type_id)) {
const none = document.createElement("p"); const none = document.createElement("p");
none.className = "b07-spec__scope"; none.className = "b08-spec__scope";
none.textContent = "이 종류는 표준도에서 받는 제원 칸이 아직 없습니다."; none.textContent = "이 종류는 표준도에서 받는 제원 칸이 아직 없습니다.";
panel.append(none); panel.append(none);
return panel; return panel;
@@ -129,7 +167,7 @@ export function buildStandardSpecPanel(
const blinding = select(BLINDINGS, options.blinding_concrete, "— 안 정함(넣음) —"); const blinding = select(BLINDINGS, options.blinding_concrete, "— 안 정함(넣음) —");
const slope = document.createElement("input"); const slope = document.createElement("input");
slope.className = "b07-spec__input"; slope.className = "b08-spec__input";
slope.type = "text"; slope.type = "text";
slope.inputMode = "decimal"; slope.inputMode = "decimal";
slope.placeholder = "비우면 자동"; slope.placeholder = "비우면 자동";
@@ -138,7 +176,7 @@ export function buildStandardSpecPanel(
// 벽 두께 — 비우면 실무 구조물도 식(상부 = 뒷길이 + 0.30 · 하부 = 상부 + 0.30×(H1)). // 벽 두께 — 비우면 실무 구조물도 식(상부 = 뒷길이 + 0.30 · 하부 = 상부 + 0.30×(H1)).
const thickness = (key: "thickness_top_m" | "thickness_bottom_m"): HTMLInputElement => { const thickness = (key: "thickness_top_m" | "thickness_bottom_m"): HTMLInputElement => {
const input = document.createElement("input"); const input = document.createElement("input");
input.className = "b07-spec__input"; input.className = "b08-spec__input";
input.type = "text"; input.type = "text";
input.inputMode = "decimal"; input.inputMode = "decimal";
input.placeholder = "비우면 식"; input.placeholder = "비우면 식";
@@ -170,7 +208,7 @@ export function buildStandardSpecPanel(
} }
const notes = document.createElement("ul"); const notes = document.createElement("ul");
notes.className = "b07-spec__notes"; notes.className = "b08-spec__notes";
// ⚠ 저장하면 표·그림을 다시 받으면서 **이 폼이 통째로 새로 그려진다** — 그때 안내가 // ⚠ 저장하면 표·그림을 다시 받으면서 **이 폼이 통째로 새로 그려진다** — 그때 안내가
// 지워지지 않게 밖에서 들고 있다가 다시 넣는다(2026-09-09 실화면에서 잡음). // 지워지지 않게 밖에서 들고 있다가 다시 넣는다(2026-09-09 실화면에서 잡음).
const showNotes = (messages: string[]): void => { const showNotes = (messages: string[]): void => {
@@ -187,7 +225,7 @@ export function buildStandardSpecPanel(
const save = document.createElement("button"); const save = document.createElement("button");
save.type = "button"; save.type = "button";
save.className = "b07-spec__save"; save.className = "b08-spec__save";
save.textContent = "제원 저장"; save.textContent = "제원 저장";
save.addEventListener("click", () => { save.addEventListener("click", () => {
void (async () => { 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 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 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 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
from common_util.common_util_auth import ( 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(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)
app.include_router(b08_structure_sheet_router, dependencies=protected_with_company)
app.include_router(b09_estimation_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] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT)) 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: 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 from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import ( # noqa: E402
build_standard_drawing, 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: def _sheet(type_id: str, **options) -> dict:
@@ -18,7 +18,7 @@ ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path: if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT)) 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, build_standard_sheets,
) )
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table # noqa: E402 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 from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import ( # noqa: E402
build_figure, 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, BLINDING_CHOICES,
EDITABLE_KEYS, EDITABLE_KEYS,
clean_spec, clean_spec,
@@ -110,7 +110,7 @@ def test_여기서_고치기_단추가_고정_액션_줄에_있다() -> None:
assert "specJumpButton.hidden = false" in page assert "specJumpButton.hidden = false" in page
assert "specJumpButton.hidden = true" in page assert "specJumpButton.hidden = true" in page
# 폼에 버림 칸이 섰다 — 끄는 자리가 화면에 있다(확정 8-2). # 폼에 버림 칸이 섰다 — 끄는 자리가 화면에 있다(확정 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" encoding="utf-8"
) )
assert "blinding_concrete" in form 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