diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 07dd518dc..881977f8d 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -111,6 +111,8 @@ export const STATE_REGISTRY = { }, /* ── ② 설계 초안 ─────────────────────────────────────────────────────── */ + /** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */ + "structure-notes": { bucket: "draft", scope: "route" }, /** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */ structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` }, /** 3D 램프로 바꾼 측점 상단측(측구 방향). */ diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index fe0519ad6..67266c563 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -285,3 +285,29 @@ export async function previewCrossDesigns( }, ); } + +/** B07 횡단 수량표 한 장(138-4 · 서버 `cross_quantity_sheet`) — 값 · 글 · 칸 배치. */ +export interface CrossQuantitySheet { + quantity_table: Record; + /** B07 표에 찍히는 글 그대로(값 없음 = 「-」). */ + text: Record; + layout: { + header: Array<[string, string]>; + left: Array<[string | null, string | null, string | null]>; + middle: Array<[string | null, string, string]>; + right: Array<[string | null, string | null]>; + }; +} + +/** 캐시 설계로 B07 횡단 수량표를 받는다 — 표시 전용(정본을 안 씀). */ +export async function fetchCrossQuantityTable( + projectId: string, + routeId: number, + chainageM: number, + design: CrossDesign | null | undefined, +): Promise { + return requestJson( + `/projects/${projectId}/sections/${routeId}/cross-quantity-table`, + { method: "POST", body: JSON.stringify({ chainage_m: chainageM, design: design ?? null }) }, + ); +} diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 64b1be3f5..ed1888143 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -464,6 +464,8 @@ export interface CrossDesign { pavement_thickness_m?: number; /** B05 법정 경사 분석의 포장 제안 여부(사용자 토글과 무관하게 유지). */ pavement_suggested?: boolean; + /** 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸). */ + structure_note?: string; /** 노면(노견 포함) 양 끝점 — 노면 렌더링 기준. */ road_edges: { left: { offset_m: number; elevation_m: number }; @@ -605,4 +607,6 @@ export interface CrossSectionPatch { fill_area_m2?: number; cut_soil_area_m2?: number; cut_rock_area_m2?: number; + /** 측점 구조물 글(138-4) — 빈 글 = 지움. */ + structure_note?: string; } diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index aafbd6091..3328b3526 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -87,6 +87,8 @@ export const USER_TOUCHED_KEYS = [ "berm", // 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다. "cut_slope_ratio_user", + // 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. + "structure_note", ] as const; /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ diff --git a/B06_Section/B06_Section_Router_CrossTable.py b/B06_Section/B06_Section_Router_CrossTable.py new file mode 100644 index 000000000..a8b47e5e8 --- /dev/null +++ b/B06_Section/B06_Section_Router_CrossTable.py @@ -0,0 +1,94 @@ +"""B06 횡단 [상세] 모달의 **B07 횡단 수량표 값** 창구(138-4). + +값은 B07 횡단도가 쓰는 `_quantity_table` 한 벌을 **브라우저 캐시 설계**로 돌린 것이다 — +화면이 TS 로 다시 짜지 않는다(CLAUDE.md 5장 ③ 서버 단독). 칸 배치도 B07 표 정의를 그대로 +실어 보내 화면은 그리기만 한다. **표시 전용** — 정본을 쓰지 않는다. 모달을 열 때 부른다. +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from B06_Section.B06_Section_Repository import get_longitudinal_section +from B06_Section.B06_Section_Router_Design import read_cross_design_inputs +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import _format +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import ( + _CROSS_HEADER_KEYS, + _CROSS_LEFT_ROWS, + _CROSS_MIDDLE_ROWS, + _CROSS_RIGHT_ROWS, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support import _quantity_table +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) + + +class CrossQuantityTableRequest(BaseModel): + chainage_m: float = Field(..., ge=0) + # 브라우저 캐시의 그 측점 설계(조작 반영본) — 없으면 원본 칸만. + design: dict[str, Any] | None = None + + +def cross_quantity_sheet(source: dict[str, Any], design: dict[str, Any] | None) -> dict[str, Any]: + """B07 횡단 수량표 한 장 — 값(숫자) · 글(B07 표와 같은 꼴) · 칸 배치.""" + table = _quantity_table(source, design) + text = { + key: _format(value) if isinstance(value, (int, float)) else "-" + for key, value in table.items() + } + return { + "quantity_table": table, + "text": text, + "layout": { + "header": [list(row) for row in _CROSS_HEADER_KEYS], + "left": [list(row) for row in _CROSS_LEFT_ROWS], + "middle": [list(row) for row in _CROSS_MIDDLE_ROWS], + "right": [list(row) for row in _CROSS_RIGHT_ROWS], + }, + } + + +@router.post("/{project_id}/sections/{route_id}/cross-quantity-table", response_model=None) +async def cross_quantity_table( + project_id: UUID, route_id: int, request: CrossQuantityTableRequest +) -> JSONResponse: + pool = get_db_pool() + async with pool.acquire() as connection: + longitudinal = await get_longitudinal_section(connection, project_id, route_id) + stored_path = await get_project_storage_relative_path(connection, project_id) + if not longitudinal or not stored_path: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, + ) + try: + *_, source = await asyncio.to_thread( + read_cross_design_inputs, + Path(resolve_stored_project_path(stored_path)), + str(longitudinal["longitudinal_file_path"]), + request.chainage_m, + ) + sheet = cross_quantity_sheet(source, request.design) + except (FileNotFoundError, ValueError) as error: + return JSONResponse(status_code=404, content={"status": "error", "message": str(error)}) + except Exception: + logger.exception( + "횡단 수량표 계산 실패: project_id=%s chainage=%s", project_id, request.chainage_m + ) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "횡단 수량표를 만들지 못했습니다."}, + ) + return JSONResponse(content={"status": "success", **sheet}) diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index b12160741..67a8484fb 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -148,6 +148,8 @@ USER_TOUCHED_KEYS = ( # 측점별 암 절토 경사 — 표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 둔다 # (2026-09-07 사용자 확정: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」). "cut_slope_ratio_user", + # 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님. + "structure_note", ) # 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 · diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index dc8d2bf2d..1084a2fd8 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -192,6 +192,8 @@ class CrossSectionPatch(BaseModel): box_length_m: float | None = Field(default=None, ge=0) # B08 자재 집계표 세월교 관 한 련 길이(131) — 그린 관 길이 1 m 올림(m) · 같은 길. ford_pipe_length_m: float | None = Field(default=None, ge=0) + # 측점 구조물 글(138-4 · 횡단 [상세] 모달) — 빈 글 = 지움. + structure_note: str | None = Field(default=None, max_length=2000) class SectionConfirmRequest(BaseModel): diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 813759369..878121f43 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -27,6 +27,7 @@ export { appendRockBoundaryOverlay, } from "./B06_Section_UI_Cross_Design_Surface"; export { appendCrossDesignOverlay } from "./B06_Section_UI_Cross_Design_Line"; +import { openCrossDetail, refreshCrossDetail } from "./B06_Section_UI_Cross_Detail"; /** 넘침 정리 한 장 몫 — 쓰기·읽기·쓰기 세 토막으로 갈라 두어 프레임 단위로 묶는다. */ interface ReflowCard { @@ -455,7 +456,14 @@ export function buildDesignControls( const morePanel = document.createElement("div"); morePanel.className = "b06-design__more-panel"; more.append(moreSummary, morePanel); - bar.append(slopeDirSeg, ...moveable, more); + // [상세](138-4) — 맨 오른쪽 · 넘침 정리(moveable) 밖이라 늘 보임 + const detail = document.createElement("button"); + detail.type = "button"; + detail.className = "b06-design__btn b06-design__detail"; + detail.textContent = L("B06_Design_Detail"); + detail.addEventListener("click", () => openCrossDetail(section)); + bar.append(slopeDirSeg, ...moveable, more, detail); + refreshCrossDetail(section); // 넘침 정리는 **세 토막**으로 나눈다 — 쓰기(reset) → 읽기(measure) → 쓰기(apply). // 카드 안에서 한 장씩 하면 쓰기 뒤 읽기가 카드 수만큼 반복돼 브라우저가 레이아웃을 diff --git a/B06_Section/B06_Section_UI_Cross_Detail.ts b/B06_Section/B06_Section_UI_Cross_Detail.ts new file mode 100644 index 000000000..49de677c9 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Detail.ts @@ -0,0 +1,260 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Detail.ts + * 횡단 카드 측구 줄 맨 오른쪽 [상세] → 모달(138-4). + * + * 맨 위 = 측점 구조물 글(`design.structure_note` · 세션 초안 → [저장]이 patch 로 내보냄) · + * 가운데 = 카드 그림 복제(어두운 바탕 · CAD 꼴) · 아래 = B07 횡단 수량표(값 · 칸 배치 모두 + * 서버 `cross-quantity-table` 한 길 · 캐시 설계로 · 열 때 부름). + * 막은 투명(누름 통과) · 자리는 그 카드를 안 가리는 넓은 쪽(좌/우 → 위/아래). + * 모달이 열린 채 카드를 다시 그리면 그림 · 표를 새로 맞춤. + * ========================================================================== */ + +import "./B06_Section_UI_Style_Cross_Detail.css"; +import { openModal } from "@ui/ui_template_modal"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { fetchCrossQuantityTable } from "./B06_Section_Api_Fetch"; +import type { + CrossQuantitySheet, + CrossSection, + SectionDetailResponse, +} from "./B06_Section_Api_Fetch"; +import { inferStationInterval, stationLabel } from "./B06_Section_UI_Section_Common"; + +/** 페이지가 한 번 걸어 줌(`_UI_Page_Design_Sync`) — 카드 줄은 프로젝트 · 노선을 모름. */ +export interface CrossDetailHost { + projectId: string; + routeId: () => number | null; + detail: () => SectionDetailResponse | null; +} + +let host: CrossDetailHost | null = null; +export function setCrossDetailHost(next: CrossDetailHost): void { + host = next; +} + +/* ── 구조물 글 세션 초안(측점키 0.01 m → 글) ─────────────────────────────── */ + +const noteKey = (chainageM: number): string => chainageM.toFixed(2); + +/** [저장]이 patch 로 싣는 글 — 측점(m) → 글(빈 글 = 지움). */ +export function readStructureNotes(projectId: string, routeId: number): Map { + const raw = readState>("structure-notes", projectId, routeId) ?? {}; + return new Map(Object.entries(raw).map(([key, text]) => [Number(key), String(text)])); +} + +function noteOf(section: CrossSection): string { + const routeId = host?.routeId(); + if (host && routeId !== null && routeId !== undefined) { + const draft = readState>("structure-notes", host.projectId, routeId); + const text = draft?.[noteKey(section.chainage_m)]; + if (text !== undefined) return text; + } + return section.design?.structure_note ?? ""; +} + +function writeNote(section: CrossSection, text: string): void { + // 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음 + if (section.design) section.design.structure_note = text; + const routeId = host?.routeId(); + if (!host || routeId === null || routeId === undefined) return; + const all = readState>("structure-notes", host.projectId, routeId) ?? {}; + all[noteKey(section.chainage_m)] = text; + writeState("structure-notes", all, host.projectId, routeId); +} + +/* ── 자리 ───────────────────────────────────────────────────────────── */ + +const GAP_PX = 12; +const MIN_WIDTH_PX = 360; +const MAX_WIDTH_PX = 720; + +/** 카드를 안 가리는 넓은 쪽에 붙임 — 좌우가 좁으면 위아래. */ +function place(dialog: HTMLElement, card: HTMLElement | null): void { + const vw = window.innerWidth; + const vh = window.innerHeight; + const rect = card?.getBoundingClientRect(); + const style = dialog.style; + style.position = "fixed"; + style.margin = "0"; + if (!rect) { + style.width = `${Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2)}px`; + style.left = `${(vw - Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2)) / 2}px`; + style.top = `${GAP_PX}px`; + style.maxHeight = `${vh - GAP_PX * 2}px`; + return; + } + const left = rect.left; + const right = vw - rect.right; + if (Math.max(left, right) >= MIN_WIDTH_PX + GAP_PX * 2) { + const width = Math.min(MAX_WIDTH_PX, Math.max(left, right) - GAP_PX * 2); + const top = Math.min(Math.max(rect.top, GAP_PX), vh - GAP_PX - 240); + style.width = `${width}px`; + style.left = `${right >= left ? rect.right + GAP_PX : rect.left - GAP_PX - width}px`; + style.top = `${Math.max(top, GAP_PX)}px`; + style.maxHeight = `${vh - Math.max(top, GAP_PX) - GAP_PX}px`; + return; + } + const width = Math.min(MAX_WIDTH_PX, vw - GAP_PX * 2); + style.width = `${width}px`; + style.left = `${Math.min(Math.max(rect.left, GAP_PX), vw - GAP_PX - width)}px`; + const below = vh - rect.bottom; + if (below >= rect.top) { + style.top = `${rect.bottom + GAP_PX}px`; + style.maxHeight = `${Math.max(below - GAP_PX * 2, 160)}px`; + } else { + style.top = `${GAP_PX}px`; + style.maxHeight = `${Math.max(rect.top - GAP_PX * 2, 160)}px`; + } +} + +/* ── 표(B07 칸 배치 그대로) ─────────────────────────────────────────────── */ + +function cell(tag: "th" | "td", text: string, span?: { col?: number; row?: number }): HTMLElement { + const element = document.createElement(tag); + element.textContent = text; + if (span?.col) element.setAttribute("colspan", String(span.col)); + if (span?.row) element.setAttribute("rowspan", String(span.row)); + return element; +} + +/** B07 `_cross_table_entities` 와 같은 꼴 — 제목 · 머리 넷 쌍 · 본문 6행 × (좌 · 중 · 우). */ +export function buildQuantityTable(sheet: CrossQuantitySheet, title: string): HTMLTableElement { + const { layout, text } = sheet; + const value = (key: string | null): string => (key ? (text[key] ?? "-") : ""); + const table = document.createElement("table"); + table.className = "b06-detail__qtable"; + const titleRow = table.insertRow(); + titleRow.append(cell("th", title, { col: 8 })); + const headRow = table.insertRow(); + for (const [label, key] of layout.header) + headRow.append(cell("th", label), cell("td", value(key))); + layout.left.forEach(([groupL, subL, keyL], index) => { + const row = table.insertRow(); + if (groupL !== null && subL !== null) row.append(cell("th", groupL, { row: 2 })); + else if (groupL !== null) row.append(cell("th", groupL, { col: 2 })); + if (subL !== null) row.append(cell("th", subL)); + row.append(cell("td", value(keyL))); + const [groupM, subM, keyM] = layout.middle[index]; + if (groupM !== null) row.append(cell("th", groupM, { row: 2 })); + row.append(cell("th", subM), cell("td", value(keyM))); + const [labelR, keyR] = layout.right[index]; + row.append(cell("th", labelR ?? ""), cell("td", value(keyR))); + }); + return table; +} + +/* ── 모달 ───────────────────────────────────────────────────────────── */ + +interface OpenDetail { + stationId: string; + section: CrossSection; + paint: () => void; + loadTable: () => void; + close: () => void; +} + +let current: OpenDetail | null = null; +let tableTimer = 0; + +const cardOf = (section: CrossSection): HTMLElement | null => + document.getElementById(`cross-${section.station_id}`); + +function titleOf(section: CrossSection): string { + const stations = host?.detail()?.longitudinal.stations ?? []; + return stationLabel(section.chainage_m, inferStationInterval(stations)); +} + +/** [상세] — 이미 열린 것은 닫고 이 측점으로 엶. */ +export function openCrossDetail(section: CrossSection): void { + current?.close(); + const title = titleOf(section); + let tableSeq = 0; + openModal({ + title: `${title} 상세`, + closeLabel: "닫기", + dialogClass: "b06-detail", + backdropClass: "b06-detail__backdrop", + mount(body, close) { + const dialog = body.parentElement as HTMLElement; + const note = document.createElement("textarea"); + note.className = "ui-input b06-detail__note"; + note.rows = 3; + note.placeholder = "구조물 메모 — 이 측점에 남길 글"; + note.value = noteOf(section); + note.addEventListener("input", () => writeNote(state.section, note.value)); + const cad = document.createElement("div"); + cad.className = "b06-detail__cad"; + const tableBox = document.createElement("div"); + tableBox.className = "b06-detail__table"; + body.append(note, cad, tableBox); + + const state: OpenDetail = { + stationId: section.station_id, + section, + paint() { + const card = cardOf(state.section); + const svg = card?.querySelector("svg.b06-section__chart"); + cad.replaceChildren(svg ? svg.cloneNode(true) : document.createTextNode("그림 없음")); + place(dialog, card); + }, + loadTable() { + const projectId = host?.projectId; + const routeId = host?.routeId(); + if (!projectId || routeId === null || routeId === undefined) { + tableBox.textContent = "노선 정보 없음"; + return; + } + const seq = ++tableSeq; + tableBox.classList.add("is-loading"); + fetchCrossQuantityTable( + projectId, + routeId, + state.section.chainage_m, + state.section.design, + ) + .then((sheet) => { + if (seq !== tableSeq || current !== state) return; + tableBox.replaceChildren(buildQuantityTable(sheet, title)); + }) + .catch((error: unknown) => { + if (seq !== tableSeq || current !== state) return; + tableBox.textContent = `수량표를 못 받음 — ${error instanceof Error ? error.message : error}`; + }) + .finally(() => seq === tableSeq && tableBox.classList.remove("is-loading")); + }, + close() { + forget(); + close(); + }, + }; + current = state; + tableBox.textContent = "수량표 불러오는 중…"; + state.paint(); + state.loadTable(); + }, + // 닫기 단추 · Esc 도 같은 정리를 거침 + beforeClose: () => { + forget(); + return true; + }, + }); +} + +/** 열린 모달 기억을 지움(닫힐 때). */ +function forget(): void { + window.clearTimeout(tableTimer); + current = null; +} + +/** + * 카드를 다시 그릴 때마다 부름(`buildDesignControls`) — 그 측점 모달이 열려 있으면 새 카드로 + * 그림을 다시 뜨고 표는 조작이 멈춘 뒤 한 번 다시 받음(최신 응답만). + */ +export function refreshCrossDetail(section: CrossSection): void { + const open = current; + if (!open || open.stationId !== section.station_id) return; + open.section = section; + requestAnimationFrame(() => current === open && open.paint()); + window.clearTimeout(tableTimer); + tableTimer = window.setTimeout(() => current === open && open.loadTable(), 400); +} diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index d63255462..27b27117a 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -23,6 +23,7 @@ import { L } from "./B06_Section_UI_Page_Common"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View"; import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures"; +import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail"; export interface DesignSyncContext { projectId: string | null; @@ -42,6 +43,8 @@ export interface DesignSyncController { export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { const { projectId } = ctx; + // 횡단 [상세] 모달(138-4)이 쓰는 프로젝트 · 노선 · 상세 + if (projectId) setCrossDetailHost({ projectId, routeId: ctx.routeId, detail: ctx.detail }); /** * 측점 설계 버튼 변경 처리: (1) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응), diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 9a0b0cfdf..e5473ae7e 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -29,6 +29,7 @@ import { structureAreaRows, } from "./B06_Section_Structure_Layouts"; import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; +import { readStructureNotes } from "./B06_Section_UI_Cross_Detail"; import type { StandardCrossSection } from "./B06_Section_Api_Fetch"; import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; @@ -386,6 +387,12 @@ export function collectSectionEdits(ctx: SectionPersistContext): { // 자동으로 되돌아가지 않으므로(켬/끔 둘 뿐) 지금은 손해가 없다(2026-09-09). if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice; }); + // 측점 구조물 글(138-4 · [상세] 모달) — 세션 초안 그대로(빈 글 = 지움) + const routeId = ctx.routeId(); + if (ctx.projectId && routeId !== null) + readStructureNotes(ctx.projectId, routeId).forEach((note, chainage) => { + patchFor(chainage).structure_note = note; + }); if (detail) { // 구조물 폐회로 면적 — **카드를 그리지 않은 측점까지** 여기서 다 계산한다 // (2026-09-06 사용자 확정: 「일단은 브라우저 계산으로」). diff --git a/B06_Section/B06_Section_UI_Style_Cross_Detail.css b/B06_Section/B06_Section_UI_Style_Cross_Detail.css new file mode 100644 index 000000000..556ec025c --- /dev/null +++ b/B06_Section/B06_Section_UI_Style_Cross_Detail.css @@ -0,0 +1,89 @@ +/* ============================================================================= + * B06_Section_UI_Style_Cross_Detail.css + * 횡단 카드 [상세] 단추 · 모달(138-4) — 투명 막(누름 통과) · 글 칸 · CAD 꼴 그림 · B07 수량표. + * ========================================================================== */ + +/* 측구 줄 맨 오른쪽 — ⋯ 가 보이면 그 옆, 숨으면 혼자 오른쪽 끝 */ +.b06-design__detail { + flex: 0 0 auto; + margin-left: auto; +} + +.b06-design__more:not([hidden]) ~ .b06-design__detail { + margin-left: 0; +} + +/* 막은 투명 · 누름은 아래 화면으로 — 모달 몸만 받음 */ +.ui-modal__backdrop.b06-detail__backdrop { + background-color: transparent; + pointer-events: none; +} + +.b06-detail__backdrop > .ui-modal.b06-detail { + pointer-events: auto; + overflow: auto; + box-shadow: var(--shadow-lg); +} + +.b06-detail__note { + width: 100%; + box-sizing: border-box; + resize: vertical; + font: inherit; +} + +/* 가벼운 CAD 꼴 — 어두운 바탕 · 카드 그림 복제에 다크 색 토큰을 덮어씀 */ +.b06-detail__cad { + --color-bg: #111111; + --color-surface: #111111; + --color-surface-raised: #1a1a1a; + --color-border: #3a3a3a; + --color-text: #f2f2f2; + --color-text-body: #e6e6e6; + --color-text-secondary: #b8b8b8; + --color-text-muted: #8b8b8b; + --color-primary: #c9a8ff; + --color-warning: #ffd34d; + --color-danger: #ff5f57; + --color-success: #4cd37b; + border-radius: var(--radius-cards); + background: #111111; + color: #f2f2f2; + overflow: hidden; +} + +.b06-detail__cad > svg { + display: block; + width: 100%; + height: auto; +} + +/* B07 횡단 수량 산출표 — 칸 배치 그대로 */ +.b06-detail__table.is-loading { + opacity: 0.6; +} + +.b06-detail__qtable { + width: 100%; + border-collapse: collapse; + font-size: var(--text-caption); + font-variant-numeric: tabular-nums; +} + +.b06-detail__qtable th, +.b06-detail__qtable td { + padding: 2px 4px; + border: 1px solid var(--color-border); + text-align: center; + white-space: nowrap; +} + +.b06-detail__qtable th { + font-weight: 500; + color: var(--color-text-secondary); +} + +.b06-detail__qtable tr:first-child > th { + text-align: left; + color: var(--color-text); +} diff --git a/main.py b/main.py index 7f56d5acd..36adcd159 100644 --- a/main.py +++ b/main.py @@ -60,6 +60,7 @@ from B06_Section.B06_Section_Router_Confirm import ( from B06_Section.B06_Section_Router_HaulPlan import ( router as b06_section_haul_plan_router, ) +from B06_Section.B06_Section_Router_CrossTable import router as b06_section_cross_table_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_PlanKinds import router as b07_plan_kinds_router @@ -645,6 +646,7 @@ app.include_router(b06_section_router, dependencies=protected_with_company) app.include_router(b06_section_stations_router, dependencies=protected_with_company) app.include_router(b06_section_confirm_router, dependencies=protected_with_company) app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company) +app.include_router(b06_section_cross_table_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_plan_kinds_router, dependencies=protected_with_company) diff --git a/resources/tester/test_138_cross_detail.py b/resources/tester/test_138_cross_detail.py new file mode 100644 index 000000000..2944db779 --- /dev/null +++ b/resources/tester/test_138_cross_detail.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""B06 횡단 [상세] 모달(PLAN 138-4) — 구조물 글 새 키 저장 왕복 · B07 수량표 값 POST 한 길. + +- 글(`structure_note`): 브라우저 세션 초안 → [저장] patch → 서버 스키마 → 정본 병합 → + 재계산(포장 · 세월교 · 사토장)에서도 남음. +- 표: `cross-quantity-table` 이 B07 `_quantity_table` 을 캐시 설계로 돌린 값과 칸 배치를 줌. + +./venv/Scripts/python.exe -m pytest resources/tester/test_138_cross_detail.py -q +""" + +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from fastapi import FastAPI # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from B06_Section import B06_Section_Router_CrossTable as table_mod # noqa: E402 +from B06_Section import B06_Section_Router_Design as design_mod # noqa: E402 +from B06_Section.B06_Section_Schema import CrossSectionPatch # noqa: E402 +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import QUANTITY_VALUE_KEYS # noqa: E402 + +# ── 글 저장 왕복 ───────────────────────────────────────────────────────── + + +def test_글은_patch_스키마를_지나_정본_병합_값으로_남는다(): + patch = CrossSectionPatch(chainage_m=40.0, structure_note="L형 측구 · 집수정 보강") + dumped = patch.model_dump() + # 확정 · 저장 경로와 같은 걸름(`_apply_section_edits`) — 최상위 None 만 걷음 + merged = {k: v for k, v in dumped.items() if k != "chainage_m" and v is not None} + assert merged == {"structure_note": "L형 측구 · 집수정 보강"} + # 빈 글 = 지움 — 걸러지지 않고 실림 + empty = CrossSectionPatch(chainage_m=40.0, structure_note="").model_dump() + assert empty["structure_note"] == "" + + +def test_글은_재계산에서도_남는다(monkeypatch): + monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 100.0)]) + monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: []) + monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0) + monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {}) + monkeypatch.setattr( + design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]} + ) + sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note": "메모"}}] + assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1 + assert sections[0]["design"]["structure_note"] == "메모" + + +def test_브라우저가_글을_저장_patch_에_싣는다(): + persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text( + encoding="utf-8" + ) + assert "readStructureNotes(" in persist and ".structure_note = note" in persist + state = (PROJECT_ROOT / "A00_Common" / "b_page_state.ts").read_text(encoding="utf-8") + assert '"structure-notes": { bucket: "draft", scope: "route" }' in state + + +# ── 수량표 값 ──────────────────────────────────────────────────────────── + +SOURCE = {"chainage_m": 40.0, "center_z": 101.5, "samples": []} +DESIGN = { + "design_elevation_m": 100.25, + "cut_soil_area_m2": 1.234, + "cut_rock_area_m2": 0.5, + "fill_area_m2": 2.0, +} + + +def test_표_값은_B07_수량표_한_벌(): + sheet = table_mod.cross_quantity_sheet(SOURCE, DESIGN) + table = sheet["quantity_table"] + assert set(QUANTITY_VALUE_KEYS) <= set(table) + assert table["ground"] == 101.5 and table["planned"] == 100.25 + assert round(table["cut"], 6) == 1.25 and table["fill"] == 0.0 + assert table["cut_soil"] == 1.234 and table["embankment"] == 2.0 + # 글은 B07 표에 찍히는 꼴 그대로 · 값 없음 = 「-」 + assert sheet["text"]["cut_soil"] == "1.23" + assert sheet["text"]["fence"] == "-" + layout = sheet["layout"] + assert [row[1] for row in layout["header"]] == ["ground", "planned", "fill", "cut"] + assert len(layout["left"]) == len(layout["middle"]) == len(layout["right"]) == 6 + + +def test_설계_없으면_원본_칸만(): + sheet = table_mod.cross_quantity_sheet(SOURCE, None) + assert sheet["quantity_table"]["ground"] == 101.5 + assert sheet["quantity_table"]["planned"] is None + assert sheet["text"]["cut_soil"] == "-" + + +class _Pool: + def acquire(self): + pool = self + + class _Ctx: + async def __aenter__(self): + return pool + + async def __aexit__(self, *exc): + return False + + return _Ctx() + + +def test_POST_한_길이_캐시_설계로_표를_낸다(monkeypatch): + seen = {} + + async def longitudinal(connection, project_id, route_id): + return {"longitudinal_file_path": "B05_Profile/route/longitudinal.json"} + + async def storage(connection, project_id): + return "proj" + + def read_inputs(root, long_path, chainage): + seen["chainage"] = chainage + return [], 100.0, False, SOURCE + + monkeypatch.setattr(table_mod, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(table_mod, "get_longitudinal_section", longitudinal) + monkeypatch.setattr(table_mod, "get_project_storage_relative_path", storage) + monkeypatch.setattr(table_mod, "resolve_stored_project_path", lambda rel: "C:/x") + monkeypatch.setattr(table_mod, "read_cross_design_inputs", read_inputs) + app = FastAPI() + app.include_router(table_mod.router) + client = TestClient(app) + url = "/api/projects/00000000-0000-0000-0000-000000000001/sections/7/cross-quantity-table" + response = client.post(url, json={"chainage_m": 40.0, "design": DESIGN}) + assert response.status_code == 200, response.text + body = response.json() + assert seen["chainage"] == 40.0 + assert body["quantity_table"]["cut_soil"] == 1.234 + assert body["text"]["planned"] == "100.25" + + # 측점 파일이 없으면 404 · 사유 그대로 + def missing(*args): + raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.") + + monkeypatch.setattr(table_mod, "read_cross_design_inputs", missing) + response = client.post(url, json={"chainage_m": 41.0, "design": None}) + assert response.status_code == 404 + assert "횡단 상세 파일" in response.json()["message"] diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index a96d3e121..b7ef06fa2 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -474,6 +474,7 @@ export const ui_locales_b2 = { ], B06_Notice_PaveSuggested: ["포장 권장", "Pavement recommended"], B06_Cross_Columns: ["한 줄 카드 수(1~6)", "Cards per row (1-6)"], + B06_Design_Detail: ["상세", "Detail"], B06_Design_Paved_Suggested: [ "종단경사 법정 상한 초과 — 포장 권장 (임도설치 및 관리 등에 관한 규정 별표 1-2)", "Grade exceeds legal limit — pavement recommended (Forest Road Regulation, Annex 1-2)",