fix(B07): 표준도가 단면유형을 안 넘겨 기울기 판정이 통째로 꺼져 있던 것

표준도가 `build_table` 을 직접 부르면서 `section_modes` 를 안 넘겨, 성절토를 가를 근거가
없다며 **전 구조물이 종전값 1:0.3** 으로 섰음. 값이 없는 게 아니라 **안 넘긴 것**이었음.

`section_modes_from_designs` 한 벌을 그대로 씀(부르는 쪽마다 다시 짜면 B08 과 갈림).
노선은 `get_workflow_route_context` 로 찾고, 못 찾으면 빈 표 — 성토로 눅이지 않음.

⚠ 고치고 나니 **제목·그림이 표와 갈렸음** — 표는 판정된 1:0.35, 제목은 다시 판정하며
근거를 못 받아 1:0.3. 실측으로 잡았음. 그래서 **표를 만든 그 판정을 시트에 실어**
제목·그림이 같은 값을 쓰게 함(`face`·`face_reason`). 자리를 고르는 것도 B08 이 쓰는
`_section_mode_at` 과 판정 한 벌 `structure_face_role` 을 그대로 부름.

실측 (프로젝트 936be972) — 제목과 표가 **같은 값**
```
큰돌쌓기  H=2.5  제목 1:0.3   표 0.3    면적 2.6101
돌쌓기(메) H=2.0  제목 1:0.35  표 0.35   면적 2.1190   ← 메쌓기가 갈림
돌쌓기(찰) H=2.5  제목 1:0.3   표 0.3    면적 2.6101
옹벽      H=2.0  기울기 없음(판정 대상 아님)
```
표 산출근거에 판정 경로가 그대로 뜸 — 「품셈 13-4-4 [주]⑪ 표준경사 · 메쌓기 성토 ·
직고 2m ≤3m → 1:0.35 · right_cut · 자동(성토 쪽) → 성토면」.

앞서 `face_slope_ratio({}, wet=…)` 로 확인한 「근거 없음」은 `face` 를 안 준 호출이라
**정상 동작**이었음 — 함수가 성토로 안 눅이게 짠 결과.

자체검증 — 회귀 580 통과 · 0 실패. 세 라우터 모두 700줄 안(588 · 190 · 692).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-08 21:01:46 +09:00
co-authored by Claude Opus 5
parent 223ff86397
commit 918e7c12da
6 changed files with 100 additions and 16 deletions
@@ -58,7 +58,15 @@ def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
wet = options.get("bond") != "메쌓기"
else:
wet = True
return face_slope_ratio(options, wet=wet, height_m=float(sheet.get("height_m") or 0.0))
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 section_points(height_m: float, slope_ratio: float) -> list[tuple[float, float]]:
@@ -204,7 +204,9 @@ def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any])
}
def standard_payload(project_root: Path) -> dict[str, Any]:
def standard_payload(
project_root: Path, section_modes: dict[float, str] | None = None
) -> dict[str, Any]:
"""프로젝트 구조물을 제원 조합으로 묶은 장 목록. 실패해도 **빈 목록**을 낸다.
⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어
@@ -216,18 +218,24 @@ def standard_payload(project_root: Path) -> dict[str, Any]:
try:
structures, names, _skipped = _collect_structures(str(project_root))
return build_standard_sheets(build_unit_table(structures, names))
# ⚠ 단면유형을 넘겨야 성절토가 갈리고 표준경사 판정이 돈다. 안 넘기면 전 구조물이
# 「가를 근거 없음」으로 떨어져 종전값 1:0.3 으로 선다(2026-09-09 실측).
return build_standard_sheets(
build_unit_table(structures, names, section_modes), section_modes
)
except Exception:
logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root)
return {"sheets": [], "structure_count": 0}
def sheet_items(project_root: Path) -> list[tuple[str, str]]:
def sheet_items(
project_root: Path, section_modes: dict[float, str] | None = None
) -> list[tuple[str, str]]:
"""좌측 목록에 설 `(도면 id, 이름)`. 장이 없으면 **빈 장 하나**를 남긴다.
구조물이 없다고 단추가 통째로 사라지면 「없어진 것」처럼 보인다 — 눌러서 사유를 읽게 한다.
"""
sheets = standard_payload(project_root).get("sheets") or []
sheets = standard_payload(project_root, section_modes).get("sheets") or []
if not sheets:
return [(SHEET_ID_PREFIX, "표준도")]
total = len(sheets)
@@ -237,7 +245,12 @@ def sheet_items(project_root: Path) -> list[tuple[str, str]]:
]
def standard_drawing_for(project_root: Path, drawing_id: str, label: str) -> dict[str, Any]:
def standard_drawing_for(
project_root: Path,
drawing_id: str,
label: str,
section_modes: dict[float, str] | None = None,
) -> dict[str, Any]:
"""프로젝트 구조물을 읽어 표준도 한 장을 만든다 — 라우터가 부르는 문.
⚠ 실패해도 도면은 연다 — 구조물 정본을 못 읽었다고 화면이 비면 사용자는 「고장」으로
@@ -245,7 +258,7 @@ def standard_drawing_for(project_root: Path, drawing_id: str, label: str) -> dic
⚠ 늦게 부른다(함수 안 import) — B08 은 B05 를 부르고 B05 는 다시 B07 을 부를 수 있어
모듈 맨 위에서 부르면 맞물린다.
"""
payload = standard_payload(project_root)
payload = standard_payload(project_root, section_modes)
sheets = payload.get("sheets") or []
# 이 도면 id 가 가리키는 장 하나만 남긴다 — 한 면에 한 장(2026-09-09, 그림이 붙으면서).
total = len(sheets)
@@ -139,13 +139,24 @@ def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
return rows
def build_standard_sheets(unit_table: dict[str, Any]) -> dict[str, Any]:
def build_standard_sheets(
unit_table: dict[str, Any], section_modes: dict[float, str] | None = None
) -> dict[str, Any]:
"""B08 원단위 전개(`build_unit_table` 결과)를 **표준도 장 목록**으로 접는다.
⚠ 계산을 다시 하지 않는다 — 들어온 전개를 제원 조합으로 묶고 단위당으로 나눌 뿐이다.
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import _section_mode_at
from common_util.common_util_structure_face_role import structure_face_role
groups: dict[str, dict[str, Any]] = {}
for structure in unit_table.get("structures") or []:
# ⚠ 성절토는 **표를 만든 그 판정**을 그대로 물고 온다 — 제목·그림이 다시 판정하면
# 근거를 못 받아 종전값으로 떨어져 **표와 갈린다**(2026-09-09 실측: 표 1:0.35 ·
# 제목 1:0.3). 자리를 고르는 것도 B08 이 쓰는 그 함수를 쓴다.
mode = _section_mode_at(structure, section_modes)
face, face_reason = structure_face_role(mode, (structure.get("options") or {}).get("side"))
structure = {**structure, "face": face, "face_reason": face_reason}
key = sheet_key(structure)
sheet = groups.get(key)
if sheet is None:
@@ -156,6 +167,9 @@ def build_standard_sheets(unit_table: dict[str, Any]) -> dict[str, Any]:
"type_id": structure.get("type_id") or "",
"height_m": float(structure.get("height_m") or 0.0),
"options": _sheet_options(structure),
# 판정 근거 — 제목·그림이 표와 **같은 기울기**를 쓰게 하는 열쇠.
"face": structure.get("face"),
"face_reason": structure.get("face_reason") or "",
# 실무 시트 머리의 「m당」·「개소당」·「㎡당」. 종류마다 다르다 — 통일하지 않는다.
"unit_label": f"{unit}",
"billing_unit": unit,
+11 -2
View File
@@ -31,6 +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 B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_STANDARD_ID,
LANDUSE_ID,
@@ -270,7 +271,10 @@ async def get_design_drawing_list(
try:
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
designs = await _designs_by_chainage(route_id)
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
modes = await section_modes_of(route_id)
drawings = await asyncio.to_thread(
_drawing_list, project_root, longitudinal_path, designs, modes
)
return DesignDrawingListResponse(
project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass
)
@@ -363,7 +367,12 @@ async def get_design_drawing(
longitudinal = await asyncio.to_thread(_read_json, longitudinal_path)
source_design = await asyncio.to_thread(plan_source, context, longitudinal, drawing_id)
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
_read_drawing, project_root, longitudinal_path, drawing_id, source_design
_read_drawing,
project_root,
longitudinal_path,
drawing_id,
source_design,
await section_modes_of(route_id),
)
return DesignDrawingResponse(
project_id=str(project_id),
@@ -25,6 +25,40 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
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
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
except Exception:
logger.exception("B07 노선 조회 실패: project_id=%s", project_id)
return {}
return await section_modes_of(route_id) if route_id else {}
async def section_modes_of(route_id: int) -> dict[float, str]:
"""측점별 단면유형(`left_cut` 등) — 구조물이 **성토면인가 절토면인가**를 가르는 근거.
⚠ 이것을 안 넘기면 판정이 통째로 「가를 근거 없음」으로 떨어져 **전 구조물이 종전값
1:0.3 으로 선다**(2026-09-09 실측). 값이 없는 것이 아니라 **안 넘긴 것**이었다.
⚠ 표를 만드는 셈은 `section_modes_from_designs` 한 벌을 쓴다 — 부르는 쪽마다 다시
짜면 B08 과 갈린다.
"""
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)
return {}
return section_modes_from_designs(designs)
class StandardSheetSpecRequest(BaseModel):
"""표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다."""
@@ -68,7 +102,9 @@ async def put_standard_sheet_spec(
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
sheets = (await asyncio.to_thread(standard_payload, project_root)).get("sheets") or []
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 []
picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None)
if picked is None:
return JSONResponse(
@@ -135,9 +171,10 @@ async def get_standard_sheets(project_id: UUID) -> JSONResponse:
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)
unit_table = await asyncio.to_thread(build_unit_table, structures, names, modes)
except Exception:
logger.exception("B07 표준도 전개 실패: project_id=%s", project_id)
return JSONResponse(
@@ -145,7 +182,7 @@ async def get_standard_sheets(project_id: UUID) -> JSONResponse:
content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."},
)
payload = build_standard_sheets(unit_table)
payload = build_standard_sheets(unit_table, modes)
payload["status"] = "success"
payload["project_id"] = str(project_id)
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
@@ -113,6 +113,7 @@ def _drawing_list(
project_root: Path,
longitudinal_path: Path,
designs: dict[int, dict[str, Any]] | None = None,
section_modes: dict[float, str] | None = None,
) -> list[DesignDrawingItem]:
longitudinal = _read_json(longitudinal_path)
station_by_chainage = _station_map(longitudinal)
@@ -206,7 +207,7 @@ def _drawing_list(
)
# 표준도 — **제원 조합마다 한 장**이라 장이 나뉜다(2026-09-09). 구조물이 없어도 한 장은
# 남긴다: 단추가 사라지면 「없어진 것」처럼 보이고 사유를 읽을 자리도 없어진다.
for drawing_id, label in sheet_items(project_root):
for drawing_id, label in sheet_items(project_root, section_modes):
drawings.append(DesignDrawingItem(id=drawing_id, kind="standard", label=label))
_store_drawing_numbers(project_root, drawings)
return drawings
@@ -409,6 +410,7 @@ def _read_drawing(
longitudinal_path: Path,
drawing_id: str,
stored_design: dict[str, Any] | None = None,
section_modes: dict[float, str] | None = None,
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
"""(kind, label, drawing, confirmed, quantity_table)를 반환한다.
@@ -442,8 +444,9 @@ def _read_drawing(
return kind, label, saved, True, table
if drawing_id == SHEET_ID_PREFIX or drawing_id.startswith(f"{SHEET_ID_PREFIX}_"):
# 표준도 — 제원 조합 한 벌이 한 장. 도각은 두르지 않는다(2026-09-08 사용자 지시).
label = dict(sheet_items(project_root)).get(drawing_id, "표준도")
return "standard", label, standard_drawing_for(project_root, drawing_id, label), False, None
label = dict(sheet_items(project_root, section_modes)).get(drawing_id, "표준도")
drawing = standard_drawing_for(project_root, drawing_id, label, section_modes)
return "standard", label, drawing, False, None
if drawing_id == COVER_ID:
# 표지는 설계 자료를 쓰지 않는다 — 템플릿 한 장이 곧 도면이다.