사용자 확정(2026-09-06): 조작 중과 [저장]·[확정]의 계산은 브라우저 몫이고, 서버는 초기값을 만들 때만 같은 코드를 Node 로 돌린다. - 면적 산출을 B06_Section_Structure_Layouts 로 빼 Node 진입점과 브라우저가 같은 한 벌을 쓰게 함(structureAreaRows / applyStructureAreaRows). - [저장]·[확정]이 카드를 그리지 않은 측점까지 면적을 계산해 cross_patch 로 보냄. 유토곡선도 그 위에서 쌓음. - 서버는 저장 때 Node 를 돌리지 않음 — 포장 구간·세월교 노면 하강 보정만 남기고, 그 보정은 편집분을 얹기 전에 돌게 순서를 바꿈. 검증: tsc --noEmit 통과, pytest 387 passed, Node 진입점 스모크 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
200 lines
8.3 KiB
Python
200 lines
8.3 KiB
Python
"""브라우저에서만 돌던 횡단 계산을 **서버가** 돌려 정본에 남긴다(2026-09-06).
|
|
|
|
대상 둘 —
|
|
① 구조물이 선 측점의 절·성토 면적: 기슭막이·세월교·BOX암거가 서면 성토 사면이 벽에서
|
|
끊겨 지반선과 설계선이 이루는 폐회로가 달라진다.
|
|
② 그 면적을 쌓아 만드는 유토곡선.
|
|
|
|
왜 — 사용자가 B06 을 한 번도 안 열어도 **초기값**에는 이 값이 있어야 한다.
|
|
여기(Node 실행)는 **초기값 산출 전용**이다 — 사용자가 화면을 만지는 동안과 [저장]·[확정]
|
|
때의 계산은 브라우저 몫이다(2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」).
|
|
|
|
**계산을 다시 짜지 않는다.** 화면이 쓰는 TS 를 Node 진입점(`B06_Section_Server_Calc_Node.ts`)
|
|
으로 감싸 그대로 돌린다. 파이썬으로 포팅하면 같은 기하가 두 벌이 되어 「그림은 이런데
|
|
수량은 저렇다」가 생긴다.
|
|
|
|
실패는 비치명적이다 — 보정 전(표준) 값이 그대로 남고 화면은 예전처럼 스스로 고친다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
|
from B06_Section.B06_Section_Repository import (
|
|
get_longitudinal_section,
|
|
merge_cross_section_design_patch,
|
|
merge_longitudinal_section_data,
|
|
update_cross_section_design,
|
|
)
|
|
from common_util.common_util_node_bundle import run_bundle_json
|
|
from common_util.common_util_storage import resolve_stored_project_path
|
|
from config.config_db import get_db_pool
|
|
from config.config_system import (
|
|
EARTHWORK_CONVERSION_FACTORS,
|
|
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
|
|
NATURAL_SPOIL_MIN_GROUND_SLOPE,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BUNDLE = ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js"
|
|
_NPM_SCRIPT = "build:server-calc"
|
|
# 정본에 얹는 값만 받는다 — Node 가 다른 키를 내도 설계 데이터에 흘리지 않는다.
|
|
_AREA_KEYS = ("cut_area_m2", "fill_area_m2", "cut_soil_area_m2", "cut_rock_area_m2")
|
|
|
|
|
|
def _mass_haul_context() -> dict[str, Any]:
|
|
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다."""
|
|
return {
|
|
"earthwork_conversion": EARTHWORK_CONVERSION_FACTORS,
|
|
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
|
|
"haul_equipment_limits": [
|
|
{"key": key, "max_distance_m": limit}
|
|
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
|
|
],
|
|
}
|
|
|
|
|
|
def _enforce_stored_designs(
|
|
longitudinal: dict[str, Any],
|
|
sections: list[dict[str, Any]],
|
|
project_root: Path,
|
|
standard: dict[str, Any] | None,
|
|
) -> None:
|
|
"""저장분 설계를 **쓰는 시점에** 바로잡는다 — 포장 구간·세월교 노면 하강.
|
|
|
|
예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로).
|
|
2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다.
|
|
"""
|
|
from B06_Section.B06_Section_Router_Design import (
|
|
enforce_ford_surface_drops,
|
|
enforce_pavement_ranges,
|
|
)
|
|
|
|
enforce_pavement_ranges(longitudinal, sections, project_root, standard)
|
|
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
|
|
|
|
|
|
async def enforce_stored_designs(project_id: UUID | str, route_id: int) -> int:
|
|
"""저장분 설계의 포장 구간·세월교 노면 하강만 바로잡는다(Node 실행 없음).
|
|
|
|
[저장]·[확정]이 부른다. 구조물 면적·유토곡선은 브라우저가 만들어 보내므로 여기서
|
|
다시 만들지 않는다(2026-09-06 사용자 확정). 카드를 한 번도 안 그린 측점의 포장·
|
|
세월교 보정만 서버가 챙긴다.
|
|
"""
|
|
return await _recompute(project_id, route_id, run_node=False)
|
|
|
|
|
|
async def recompute_server_side(project_id: UUID | str, route_id: int) -> int:
|
|
"""초기값 산출 — 위 보정에 더해 구조물 면적·유토곡선까지 Node 로 만들어 저장한다."""
|
|
return await _recompute(project_id, route_id, run_node=True)
|
|
|
|
|
|
async def _recompute(project_id: UUID | str, route_id: int, *, run_node: bool) -> int:
|
|
from B06_Section.B06_Section_Router import get_section_detail
|
|
|
|
project_uuid = UUID(str(project_id))
|
|
response = await get_section_detail(project_uuid, route_id)
|
|
payload = getattr(response, "model_dump", None)
|
|
if payload is None: # JSONResponse = 실패
|
|
logger.warning("서버 재계산: 종횡단 상세를 못 받음 (route_id=%s)", route_id)
|
|
return 0
|
|
detail = payload(mode="json")
|
|
sections = detail.get("cross_sections") or []
|
|
|
|
pool = get_db_pool()
|
|
async with pool.acquire() as connection:
|
|
stored_path = await get_project_storage_relative_path(connection, project_uuid)
|
|
longitudinal_row = await get_longitudinal_section(connection, project_uuid, route_id)
|
|
project_root = Path(resolve_stored_project_path(stored_path))
|
|
from B06_Section.B06_Section_Router_Design import stored_standard_cross_section
|
|
|
|
standard = stored_standard_cross_section(longitudinal_row)
|
|
# 포장 구간·세월교 보정 — 고쳐진 설계 위에서 면적·유토곡선이 나와야 한다.
|
|
before = [json.dumps(item.get("design"), sort_keys=True, default=str) for item in sections]
|
|
await asyncio.to_thread(
|
|
_enforce_stored_designs, detail.get("longitudinal") or {}, sections, project_root, standard
|
|
)
|
|
fixed = [
|
|
item
|
|
for index, item in enumerate(sections)
|
|
if json.dumps(item.get("design"), sort_keys=True, default=str) != before[index]
|
|
]
|
|
|
|
output = (
|
|
await asyncio.to_thread(
|
|
run_bundle_json,
|
|
BUNDLE,
|
|
_NPM_SCRIPT,
|
|
{"detail": detail, "context": _mass_haul_context()},
|
|
)
|
|
if run_node
|
|
else {}
|
|
)
|
|
if not isinstance(output, dict):
|
|
output = {}
|
|
rows = output.get("areas")
|
|
mass_haul = output.get("mass_haul")
|
|
if not fixed and not rows and not mass_haul:
|
|
return 0
|
|
|
|
updated = 0
|
|
async with pool.acquire() as connection:
|
|
# balloon 위치는 **사용자가 끌어 옮긴 화면값**이다 — 서버가 만들지 않으므로
|
|
# 저장분에서 떼어 새 유토곡선에 도로 붙인다(2026-09-06).
|
|
if isinstance(mass_haul, dict):
|
|
existing = await get_longitudinal_section(connection, project_uuid, route_id)
|
|
stored = (existing or {}).get("data") or {}
|
|
offsets = (stored.get("mass_haul") or {}).get("balloon_offsets")
|
|
if offsets is not None:
|
|
mass_haul["balloon_offsets"] = offsets
|
|
|
|
await connection.begin()
|
|
try:
|
|
for item in fixed:
|
|
await update_cross_section_design(
|
|
connection,
|
|
route_id=route_id,
|
|
chainage_m=float(item.get("chainage_m") or 0.0),
|
|
design=item["design"],
|
|
project_id=project_uuid,
|
|
)
|
|
for row in rows if isinstance(rows, list) else []:
|
|
patch: dict[str, Any] = {
|
|
key: float(row[key])
|
|
for key in _AREA_KEYS
|
|
if isinstance(row.get(key), (int, float))
|
|
}
|
|
if not patch:
|
|
continue
|
|
if await merge_cross_section_design_patch(
|
|
connection,
|
|
route_id=route_id,
|
|
chainage_m=float(row["chainage_m"]),
|
|
patch=patch,
|
|
):
|
|
updated += 1
|
|
if isinstance(mass_haul, dict):
|
|
await merge_longitudinal_section_data(
|
|
connection, route_id=route_id, data_patch={"mass_haul": mass_haul}
|
|
)
|
|
await connection.commit()
|
|
except Exception:
|
|
await connection.rollback()
|
|
raise
|
|
logger.info(
|
|
"서버 재계산: route_id=%s 설계 보정 %s곳, 구조물 면적 %s곳, 유토곡선 %s",
|
|
route_id,
|
|
len(fixed),
|
|
updated,
|
|
"갱신" if isinstance(mass_haul, dict) else "없음",
|
|
)
|
|
return updated
|