260719_11

This commit is contained in:
2026-07-19 19:48:46 +09:00
parent dd13c4e253
commit 5935e0c5ea
5 changed files with 287 additions and 134 deletions
@@ -27,6 +27,9 @@ export interface DesignDrawingListResponse {
drawings: DesignDrawingItem[];
}
/** 수량 산출표 값 (미산정 항목은 null). 백엔드 `_quantity_table`의 키와 대응. */
export type QuantityTable = Record<string, number | null>;
export interface DesignDrawingResponse {
status: string;
project_id: string;
@@ -36,6 +39,7 @@ export interface DesignDrawingResponse {
label: string;
drawing: CadDrawing;
confirmed: boolean;
quantity_table?: QuantityTable | null;
}
export interface DesignDrawingConfirmResponse {
@@ -79,10 +83,11 @@ export function confirmDesignDrawing(
projectId: string,
drawingId: string,
drawing: CadDrawing,
quantityTable?: QuantityTable | null,
): Promise<DesignDrawingConfirmResponse> {
return requestJson(
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/confirm`,
{ method: "PUT", body: JSON.stringify({ drawing }) },
{ method: "PUT", body: JSON.stringify({ drawing, quantity_table: quantityTable ?? null }) },
);
}
@@ -160,129 +160,49 @@ def _line_entity(
}
def _text_entity(
drawing_id: str,
index: int,
label: str,
point: tuple[float, float],
) -> dict[str, Any]:
return {
"id": str(
uuid5(
UUID("8ce96e1d-17e8-457b-b46e-329456701225"),
f"{drawing_id}:{index}",
)
),
"type": "Text",
"lineColor": "#cbd5e1",
"lineWidth": 1,
"layerId": "b07-quantity-table",
"shapeData": {
"label": label,
"basePoint": {"x": point[0], "y": point[1]},
"options": {
"textDirection": {"x": 1, "y": 0},
"textAlign": "left",
"textColor": "#cbd5e1",
"fontSize": 0.32,
"fontFamily": "Noto Sans KR",
},
},
}
# 수량 산출표 항목 키 (프론트 편집 테이블과 1:1 대응). center_z→지반고,
# planned_elevation_m→계획고, cut/fill은 파생값, 나머지는 source["quantities"]에서 읽는다.
_QUANTITY_ITEM_KEYS = (
"cut_soil",
"cut_soft_rock",
"cut_rock",
"tree_removal",
"fill_slope_protection",
"cut_slope_protection",
"ditch_soil",
"ditch_soft_rock",
"ditch_rock",
"embankment",
"grubbing",
"surface_grading",
)
def _quantity_rows(source: dict[str, Any]) -> list[tuple[str, str, str]]:
ground = source.get("center_z")
planned = source.get("planned_elevation_m", source.get("design_elevation_m"))
cut = (
max(float(ground) - float(planned), 0.0)
if isinstance(ground, (int, float)) and isinstance(planned, (int, float))
else None
)
fill = (
max(float(planned) - float(ground), 0.0)
if isinstance(ground, (int, float)) and isinstance(planned, (int, float))
else None
)
def _quantity_table(source: dict[str, Any]) -> dict[str, float | None]:
"""횡단면 원본에서 편집 가능한 수량 산출표 값을 구조화한다.
아직 산정되지 않은 값은 None으로 두어 프론트 입력칸에서 사용자가 채운다.
절토고/성토고(cut/fill)는 지반고·계획고에서 파생한 초기값이다.
"""
def num(value: Any) -> float | None:
return float(value) if isinstance(value, (int, float)) else None
ground = num(source.get("center_z"))
planned = num(source.get("planned_elevation_m", source.get("design_elevation_m")))
cut = max(ground - planned, 0.0) if ground is not None and planned is not None else None
fill = max(planned - ground, 0.0) if ground is not None and planned is not None else None
quantities = source.get("quantities") if isinstance(source.get("quantities"), dict) else {}
def value(number: Any) -> str:
return f"{float(number):.3f}" if isinstance(number, (int, float)) else "-"
return [
("기본", "측점", str(source.get("label", "-"))),
("기본", "지반고", value(ground)),
("기본", "계획고", value(planned)),
("기본", "절토고", value(cut)),
("기본", "성토고", value(fill)),
("흙깎기", "토사", value(quantities.get("cut_soil"))),
("흙깎기", "연암", value(quantities.get("cut_soft_rock"))),
("흙깎기", "보통암", value(quantities.get("cut_rock"))),
("옆도랑파기", "토사", value(quantities.get("ditch_soil"))),
("옆도랑파기", "연암", value(quantities.get("ditch_soft_rock"))),
("옆도랑파기", "보통암", value(quantities.get("ditch_rock"))),
("비탈보호공", "성토면", value(quantities.get("fill_slope_protection"))),
("비탈보호공", "절토면", value(quantities.get("cut_slope_protection"))),
("기타", "지장목제거", value(quantities.get("tree_removal"))),
("기타", "흙쌓기", value(quantities.get("embankment"))),
("기타", "제근", value(quantities.get("grubbing"))),
("기타", "노면고르기", value(quantities.get("surface_grading"))),
]
def _quantity_table_entities(
source: dict[str, Any], drawing_id: str, points: list[tuple[float, float]]
) -> list[dict[str, Any]]:
if not points:
return []
rows = [("구분", "항목", ""), *_quantity_rows(source)]
row_height = 0.75
column_widths = (3.2, 3.2, 3.0)
left = max(point[0] for point in points) + 2.0
top = max(point[1] for point in points)
right = left + sum(column_widths)
bottom = top - row_height * len(rows)
entities: list[dict[str, Any]] = []
for row_index in range(len(rows) + 1):
y = top - row_index * row_height
entities.append(
_line_entity(
f"{drawing_id}:table-h",
row_index,
(left, y),
(right, y),
"b07-quantity-table",
"#64748b",
)
)
x_positions = [left]
for width in column_widths:
x_positions.append(x_positions[-1] + width)
for column_index, x in enumerate(x_positions):
entities.append(
_line_entity(
f"{drawing_id}:table-v",
column_index,
(x, top),
(x, bottom),
"b07-quantity-table",
"#64748b",
)
)
text_index = 0
for row_index, row in enumerate(rows):
y = top - row_index * row_height - 0.5
for column_index, label in enumerate(row):
entities.append(
_text_entity(
drawing_id,
text_index,
label,
(x_positions[column_index] + 0.12, y),
)
)
text_index += 1
return entities
table: dict[str, float | None] = {
"ground": ground,
"planned": planned,
"cut": cut,
"fill": fill,
}
for key in _QUANTITY_ITEM_KEYS:
table[key] = num(quantities.get(key))
return table
def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str, Any]:
@@ -318,8 +238,7 @@ def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str
"children": children,
}
)
if kind == "cross":
entities.extend(_quantity_table_entities(source, drawing_id, points))
# 수량 산출표는 정적 도면 엔티티가 아니라 편집 가능한 HTML 테이블로 분리되었다.
return {
"entities": entities,
"layers": [
@@ -329,25 +248,26 @@ def _cad_drawing(source: dict[str, Any], drawing_id: str, kind: str) -> dict[str
"isVisible": True,
"isLocked": False,
},
{
"id": "b07-quantity-table",
"name": "Quantity Table",
"isVisible": True,
"isLocked": False,
},
],
}
def _read_drawing(
project_root: Path, longitudinal_path: Path, drawing_id: str
) -> tuple[str, str, dict[str, Any], bool]:
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
"""(kind, label, drawing, confirmed, quantity_table)를 반환한다.
quantity_table은 횡단도에서만 채워지며, 확정본은 manifest에 저장된 사용자
편집값을 우선하고 없으면 원본에서 파생한 초기값을 계산한다.
"""
manifest_entry = _read_manifest(project_root)["drawings"].get(drawing_id, {})
saved_path = _design_root(project_root) / "drawings" / f"{drawing_id}.json"
if manifest_entry.get("confirmed") and saved_path.is_file():
kind = "longitudinal" if drawing_id == "longitudinal" else "cross"
label = str(manifest_entry.get("label") or drawing_id)
return kind, label, _read_json(saved_path), True
stored_table = manifest_entry.get("quantity_table")
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
return kind, label, _read_json(saved_path), True, table
if drawing_id == "longitudinal":
source = _read_json(longitudinal_path)
return (
@@ -355,6 +275,7 @@ def _read_drawing(
"종단도 전체",
_cad_drawing(source, drawing_id, "longitudinal"),
False,
None,
)
if not _CROSS_ID.fullmatch(drawing_id):
@@ -364,7 +285,7 @@ def _read_drawing(
raise FileNotFoundError("요청한 횡단도를 찾을 수 없습니다.")
source = _read_json(path)
label = str(source.get("label") or drawing_id)
return "cross", label, _cad_drawing(source, drawing_id, "cross"), False
return "cross", label, _cad_drawing(source, drawing_id, "cross"), False, _quantity_table(source)
def _store_confirmed_drawing(
@@ -372,6 +293,7 @@ def _store_confirmed_drawing(
item: DesignDrawingItem,
drawing: dict[str, Any],
expected_ids: set[str],
quantity_table: dict[str, Any] | None = None,
) -> bool:
if not isinstance(drawing.get("entities"), list) or not isinstance(drawing.get("layers"), list):
raise ValueError("CAD 도면 스키마가 올바르지 않습니다.")
@@ -383,12 +305,15 @@ def _store_confirmed_drawing(
temporary.replace(path)
manifest = _read_manifest(project_root)
manifest["drawings"][item.id] = {
entry: dict[str, Any] = {
"kind": item.kind,
"label": item.label,
"confirmed": True,
"file": f"drawings/{item.id}.json",
}
if item.kind == "cross" and isinstance(quantity_table, dict):
entry["quantity_table"] = quantity_table
manifest["drawings"][item.id] = entry
_write_manifest(project_root, manifest)
confirmed_ids = {
item_id for item_id, entry in manifest["drawings"].items() if entry.get("confirmed")
@@ -434,7 +359,7 @@ async def get_design_drawing(
"""선택한 도면 원본 한 건만 읽어 CAD 스키마로 변환한다."""
try:
route_id, project_root, longitudinal_path = await _confirmed_source(project_id)
kind, label, drawing, confirmed = await asyncio.to_thread(
kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread(
_read_drawing, project_root, longitudinal_path, drawing_id
)
return DesignDrawingResponse(
@@ -445,6 +370,7 @@ async def get_design_drawing(
label=label,
drawing=drawing,
confirmed=confirmed,
quantity_table=quantity_table,
)
except ValueError as exc:
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
@@ -482,6 +408,7 @@ async def confirm_design_drawing(
item,
request.drawing,
{candidate.id for candidate in items},
request.quantity_table,
)
pool = get_db_pool()
@@ -35,12 +35,16 @@ class DesignDrawingResponse(BaseModel):
label: str
drawing: dict[str, Any]
confirmed: bool = False
# 횡단도 편집용 수량 산출표 값 (미산정 항목은 null). 종단도는 None.
quantity_table: dict[str, float | None] | None = None
class DesignDrawingConfirmRequest(BaseModel):
"""CAD 앱에서 직렬화한 현재 편집 도면."""
drawing: dict[str, Any]
# 사용자가 편집한 수량 산출표 값 (횡단도 확정 시 영구 저장).
quantity_table: dict[str, Any] | None = None
class DesignDrawingConfirmResponse(BaseModel):
@@ -35,6 +35,7 @@ import {
type CadDrawing,
type DesignDrawingItem,
} from "./B07_wf4_DesignDetail_Api_Fetch";
import { buildQuantityTable } from "./B07_wf4_DesignDetail_UI_QuantityTable";
/** B07 독립형 CAD 정적 경로 (main.py 마운트, dev는 vite proxy 위임) */
const B07_CAD_APP_URL = "/b07-cad/index.html";
@@ -181,6 +182,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
license.textContent = "Drawing engine based on OpenWebCAD · MIT License";
cadHost.append(frame, license);
// CAD 영역 + 하단 수량 산출표를 세로로 묶는 메인 콘텐츠
const mainContent = document.createElement("div");
mainContent.className = "b07-main-stack";
mainContent.append(cadHost);
let cadReady = false;
let pendingDrawing: CadDrawing | undefined;
let currentDrawing: DesignDrawingItem | undefined;
@@ -218,6 +224,11 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
currentConfirmed = response.confirmed;
confirmButton.disabled = response.confirmed;
sendDrawing(response.drawing);
if (drawing.kind === "cross") {
quantityTable.update(button.textContent ?? drawing.label, response.quantity_table);
} else {
quantityTable.element.hidden = true;
}
} catch (error) {
cadHost.dataset.loading = "false";
cadHost.dataset.error =
@@ -246,7 +257,12 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
showLoadingOverlay();
try {
const drawing = await requestCadDrawing();
const result = await confirmDesignDrawing(projectId, currentDrawing.id, drawing);
const result = await confirmDesignDrawing(
projectId,
currentDrawing.id,
drawing,
currentDrawing.kind === "cross" ? quantityTable.getValues() : null,
);
currentConfirmed = true;
currentDrawing.confirmed = true;
confirmButton.disabled = true;
@@ -0,0 +1,201 @@
/* =============================================================================
* B07_wf4_DesignDetail_UI_QuantityTable.ts
* 횡단도 수량 산출표 (편집 가능). 첨부 양식의 병합셀 구조를 12열 표로 재현한다.
*
* 값은 백엔드 `_quantity_table`가 내려준 초기값으로 채우되, 사용자가 각 칸을
* 직접 수정할 수 있다. 절토고/성토고는 지반고·계획고에서 파생되어 읽기 전용이며
* 입력 변경 시 즉시 재계산된다.
* ========================================================================== */
import type { QuantityTable } from "./B07_wf4_DesignDetail_Api_Fetch";
/** 편집 입력이 있는 항목 키 (cut/fill 제외 — 파생 읽기전용). */
const EDITABLE_KEYS = [
"ground",
"planned",
"cut_soil",
"cut_soft_rock",
"cut_rock",
"tree_removal",
"fill_slope_protection",
"cut_slope_protection",
"ditch_soil",
"ditch_soft_rock",
"ditch_rock",
"embankment",
"grubbing",
"surface_grading",
] as const;
export interface QuantityTableController {
element: HTMLElement;
/** 새 도면 선택 시 값·측점명을 갱신한다. */
update(stationTitle: string, values: QuantityTable | null | undefined): void;
/** 현재 입력값(파생 cut/fill 포함)을 수집한다. */
getValues(): QuantityTable;
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
function formatValue(value: number | null | undefined): string {
return typeof value === "number" && Number.isFinite(value) ? String(round2(value)) : "";
}
function parseValue(raw: string): number | null {
const text = raw.trim();
if (!text) return null;
const parsed = Number(text);
return Number.isFinite(parsed) ? parsed : null;
}
/** 편집 가능한 값 입력 칸을 만든다. */
function valueCell(
key: string,
colSpan: number,
inputs: Map<string, HTMLInputElement>,
options: { readonly?: boolean } = {},
): HTMLTableCellElement {
const cell = document.createElement("td");
cell.colSpan = colSpan;
cell.className = "b07-qtable__value";
const input = document.createElement("input");
input.type = "text";
input.inputMode = "decimal";
input.autocomplete = "off";
input.dataset.key = key;
if (options.readonly) {
input.readOnly = true;
cell.classList.add("b07-qtable__value--derived");
}
inputs.set(key, input);
cell.append(input);
return cell;
}
/** 라벨(헤더) 셀을 만든다. */
function labelCell(
text: string,
colSpan: number,
options: { rowSpan?: number; vertical?: boolean } = {},
): HTMLTableCellElement {
const cell = document.createElement("th");
cell.scope = "row";
cell.colSpan = colSpan;
if (options.rowSpan) cell.rowSpan = options.rowSpan;
cell.className = "b07-qtable__label";
if (options.vertical) cell.classList.add("b07-qtable__label--vertical");
cell.textContent = text;
return cell;
}
/**
* 첨부 양식과 동일한 편집 가능 수량 산출표를 생성한다.
* @param onEdit 사용자가 값을 바꿀 때마다 호출 (확정 상태 롤백 연동용)
*/
export function buildQuantityTable(onEdit: () => void): QuantityTableController {
const inputs = new Map<string, HTMLInputElement>();
const container = document.createElement("section");
container.className = "b07-qtable";
container.hidden = true;
const table = document.createElement("table");
const body = document.createElement("tbody");
// 1행: 측 점 | (측점명)
const titleRow = document.createElement("tr");
titleRow.append(labelCell("측 점", 2));
const titleCell = document.createElement("td");
titleCell.colSpan = 10;
titleCell.className = "b07-qtable__station";
titleRow.append(titleCell);
body.append(titleRow);
// 2행: 지반고 | 계획고 | 절토고 | 성토고
const baseRow = document.createElement("tr");
baseRow.append(labelCell("지반고", 2), valueCell("ground", 1, inputs));
baseRow.append(labelCell("계획고", 2), valueCell("planned", 1, inputs));
baseRow.append(labelCell("절토고", 2), valueCell("cut", 1, inputs, { readonly: true }));
baseRow.append(labelCell("성토고", 2), valueCell("fill", 1, inputs, { readonly: true }));
body.append(baseRow);
// 3행: 흙깎기 토사 | 지장목제거 | 옆도랑파기 토사
const row3 = document.createElement("tr");
row3.append(labelCell("흙깎기", 1, { rowSpan: 3, vertical: true }));
row3.append(labelCell("토사", 1), valueCell("cut_soil", 2, inputs));
row3.append(labelCell("지장목제거", 2), valueCell("tree_removal", 2, inputs));
row3.append(labelCell("옆도랑파기", 1, { rowSpan: 3, vertical: true }));
row3.append(labelCell("토사", 1), valueCell("ditch_soil", 2, inputs));
body.append(row3);
// 4행: 연암 | 비탈보호공 성토면 | 연암
const row4 = document.createElement("tr");
row4.append(labelCell("연암", 1), valueCell("cut_soft_rock", 2, inputs));
row4.append(labelCell("비탈보호공", 1, { rowSpan: 2, vertical: true }));
row4.append(labelCell("성토면", 1), valueCell("fill_slope_protection", 2, inputs));
row4.append(labelCell("연암", 1), valueCell("ditch_soft_rock", 2, inputs));
body.append(row4);
// 5행: 보통암 | 절토면 | 보통암
const row5 = document.createElement("tr");
row5.append(labelCell("보통암", 1), valueCell("cut_rock", 2, inputs));
row5.append(labelCell("절토면", 1), valueCell("cut_slope_protection", 2, inputs));
row5.append(labelCell("보통암", 1), valueCell("ditch_rock", 2, inputs));
body.append(row5);
// 6행: 흙쌓기 | 제근 | 노면고르기
const row6 = document.createElement("tr");
row6.append(labelCell("흙쌓기", 2), valueCell("embankment", 2, inputs));
row6.append(labelCell("제근", 2), valueCell("grubbing", 2, inputs));
row6.append(labelCell("노면고르기", 2), valueCell("surface_grading", 2, inputs));
body.append(row6);
table.append(body);
container.append(table);
const groundInput = inputs.get("ground");
const plannedInput = inputs.get("planned");
const cutInput = inputs.get("cut");
const fillInput = inputs.get("fill");
const recomputeCutFill = () => {
const ground = parseValue(groundInput?.value ?? "");
const planned = parseValue(plannedInput?.value ?? "");
if (ground !== null && planned !== null) {
if (cutInput) cutInput.value = formatValue(Math.max(ground - planned, 0));
if (fillInput) fillInput.value = formatValue(Math.max(planned - ground, 0));
} else {
if (cutInput) cutInput.value = "";
if (fillInput) fillInput.value = "";
}
};
for (const key of EDITABLE_KEYS) {
const input = inputs.get(key);
input?.addEventListener("input", () => {
if (key === "ground" || key === "planned") recomputeCutFill();
onEdit();
});
}
const update: QuantityTableController["update"] = (stationTitle, values) => {
titleCell.textContent = stationTitle;
for (const [key, input] of inputs) {
input.value = formatValue(values?.[key]);
}
recomputeCutFill();
container.hidden = false;
};
const getValues: QuantityTableController["getValues"] = () => {
const result: QuantityTable = {};
for (const [key, input] of inputs) {
result[key] = parseValue(input.value);
}
return result;
};
return { element: container, update, getValues };
}