feat(B06): 횡단 카드 [상세] 모달 — 구조물 글 · CAD 꼴 그림 · B07 횡단 수량표(138-4)

- 측구 줄 맨 오른쪽 [상세] · 투명 막 · 카드 안 가리는 넓은 쪽 · 카드 다시 그리면 갱신
- 구조물 글 structure_note — USER_TOUCHED_KEYS 두 벌 · CrossSectionPatch · 세션 초안 → [저장]
- 표 값 = 서버 cross-quantity-table 한 길(B07 _quantity_table 을 캐시 설계로) · 칸 배치 B07 그대로
- test_138_cross_detail

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
2026-10-09 15:01:01 +09:00
co-authored by Claude Opus 5.5
parent cb81e13b29
commit 48c4f7d7d4
15 changed files with 648 additions and 1 deletions
+2
View File
@@ -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 램프로 바꾼 측점 상단측(측구 방향). */
+26
View File
@@ -285,3 +285,29 @@ export async function previewCrossDesigns(
},
);
}
/** B07 횡단 수량표 한 장(138-4 · 서버 `cross_quantity_sheet`) — 값 · 글 · 칸 배치. */
export interface CrossQuantitySheet {
quantity_table: Record<string, number | null>;
/** B07 표에 찍히는 글 그대로(값 없음 = 「-」). */
text: Record<string, string>;
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<CrossQuantitySheet> {
return requestJson<CrossQuantitySheet>(
`/projects/${projectId}/sections/${routeId}/cross-quantity-table`,
{ method: "POST", body: JSON.stringify({ chainage_m: chainageM, design: design ?? null }) },
);
}
+4
View File
@@ -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;
}
+2
View File
@@ -87,6 +87,8 @@ export const USER_TOUCHED_KEYS = [
"berm",
// 측점별 암 절토 경사(2026-09-07) — 표준을 바꿔도 개별로 고친 측점은 그대로 둔다.
"cut_slope_ratio_user",
// 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
] as const;
/** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */
@@ -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})
+2
View File
@@ -148,6 +148,8 @@ USER_TOUCHED_KEYS = (
# 측점별 암 절토 경사 — 표준 횡단면 설정을 바꿔도 개별로 고친 측점은 그대로 둔다
# (2026-09-07 사용자 확정: 「사용자가 기본값을 사용하지 않는 값들은 변경되면 안됨」).
"cut_slope_ratio_user",
# 측점 구조물 글(138-4 · 횡단 [상세] 모달 맨 위 칸) — 계산 입력이 아님.
"structure_note",
)
# 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 ·
+2
View File
@@ -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):
+9 -1
View File
@@ -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).
// 카드 안에서 한 장씩 하면 쓰기 뒤 읽기가 카드 수만큼 반복돼 브라우저가 레이아웃을
+260
View File
@@ -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<number, string> {
const raw = readState<Record<string, string>>("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<Record<string, string>>("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<Record<string, string>>("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);
}
@@ -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) 선택을 즉시 로컬 반영해 해당 카드만 리프레시(버튼 즉시 반응),
@@ -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 사용자 확정: 「일단은 브라우저 계산으로」).
@@ -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);
}
+2
View File
@@ -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)
+145
View File
@@ -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"]
+1
View File
@@ -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)",