diff --git a/B08_Quantity/B08_Quantity_Engine_StructureSummary.py b/B08_Quantity/B08_Quantity_Engine_StructureSummary.py index 156d7a7f..3020f057 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureSummary.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureSummary.py @@ -13,10 +13,14 @@ ⚠ 손댄 칸의 정본 값이 뒤에 B05 에서 바뀌면 그 칸은 자동으로 돌아가되 **조용히 말고** 줄에 알림. ⚠ 값을 여기서 짓지 않음 — 읽어 줄 세우기만. 연장이 없는 관은 빈칸(0 으로 안 채움). ⚠ 관 지점 정본 타입(`managed_by`)은 `structures.json` 에 있어도 안 셈 — `pipe_points.json` 이 주인. +⛔ 고칠 수 있는 칸은 **상세 칸(`phase: detail`)만** — 자리·길이·높이·관경 같은 놓기 칸(`b05`)은 + 시·종점·횡단 설계와 **한 벌로 움직여**(B05 에서 길이 = 전 + 후, 시·종점이 따라 섬) 여기서 한 칸만 + 고치면 쪼개짐(지침 5장). 그 칸은 구조물 놓기(B05)에서 고침. """ from __future__ import annotations +import math from collections.abc import Iterable from typing import Any @@ -168,7 +172,7 @@ def build_summary( add( type_id, { - "id": f"pipe@{chainage:.3f}", + "id": pipe_row_id(point), "origin": "pipe_points", "chainage_m": chainage, "start_m": point.get("start_m"), @@ -188,7 +192,14 @@ def build_summary( continue rows.sort(key=lambda row: float(row.get("chainage_m") or row.get("start_m") or 0.0)) columns = [ - {"key": f.key, "label": f.label, "unit": f.unit or "", "input": f.input} + { + "key": f.key, + "label": f.label, + "unit": f.unit or "", + "input": f.input, + "choices": list(f.choices), + "editable": f.phase == "detail" and f.enabled, + } for f in definition.options ] averages = {} @@ -223,6 +234,123 @@ def build_summary( return {"tables": tables, "notes": notes} +def pipe_row_id(point: dict[str, Any]) -> str: + """계곡 통과 시설 줄 id — 관 지점엔 식별자가 없어 기준점(㎜ 자리)으로 가름.""" + return f"pipe@{float(point.get('chainage_m') or 0.0):.3f}" + + +def _type_of(target: dict[str, Any], is_pipe: bool) -> str: + if is_pipe: + facility = str(target.get("facility") or "pipe") + return FACILITY_TYPES.get(facility, facility) + return str(target.get("type_id") or "") + + +def coerce(field: Any, value: Any, name: str) -> Any: + """칸 값 꼴 맞추기 — B05 저장 관문과 같은 규칙(수 칸은 0 이상 수 · 고르기 칸은 보기 안). + + 빈 값(None·"")은 `None` = 그 칸을 지움. 틀리면 `ValueError`. + """ + if _blank(value): + return None + if field.input == "number": + number = _number(value) + if number is None or not math.isfinite(number) or number < 0: + raise ValueError(f"{name} {field.label}: 0 이상의 수여야 함 — {value!r}") + return int(number) if number.is_integer() else number + # 수로 온 보기 값(뒷길이 35 → 35.0)은 보기 글과 같은 꼴로 — 「35.0」은 보기 「35」와 안 맞음. + whole = isinstance(value, float) and value.is_integer() + text = str(int(value)) if whole else str(value).strip() + if field.input == "select" and field.choices and text not in field.choices: + raise ValueError(f"{name} {field.label}: 보기에 없는 값 — {text}") + return text + + +def apply_edits( + structures: list[dict[str, Any]], + points: list[dict[str, Any]], + types: dict[str, Any], + edits: Iterable[dict[str, Any]], + marks: dict[str, dict[str, Any]], +) -> tuple[list[str], dict[str, dict[str, Any]]]: + """손 고침을 **정본 사본에 바로** 얹음(제자리 바꿈) — (바뀐 줄 id, 새 손댄 칸 표). + + 고치기 전 값(`was`)은 처음 손댄 때의 정본 값을 지킴 — 그 값으로 되돌리면 표에서 빠져 「자동」. + 놓기 칸·없는 줄·틀린 값은 `LookupError`/`ValueError` — 부르는 쪽이 아무것도 안 씀. + """ + from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import EDITABLE_KEYS as SHEET_KEYS + from B08_Quantity.B08_Quantity_Engine_StructureSheet_Edit import clean_spec + + rows = {str(item.get("structure_id")): (item, False) for item in structures} + rows |= {pipe_row_id(point): (point, True) for point in points} + marks = {row_id: dict(cells) for row_id, cells in marks.items()} + changed: list[str] = [] + for edit in edits: + row_id, key = str(edit.get("id")), str(edit.get("key")) + if row_id not in rows: + raise LookupError(f"집계표 줄을 못 찾음: {row_id}") + target, is_pipe = rows[row_id] + definition = types.get(_type_of(target, is_pipe)) + field = next((f for f in definition.options if f.key == key), None) if definition else None + if field is None: + raise ValueError(f"{row_id}: 그 종류에 없는 칸 — {key}") + if field.phase != "detail": + raise ValueError( + f"{definition.name} {field.label}: 자리·길이·높이 같은 놓기 칸은 " + "시·종점과 한 벌이라 구조물 놓기(B05)에서 고침" + ) + value = coerce(field, edit.get("value"), definition.name) + if value is not None and key in SHEET_KEYS: + # 구조물도 [제원 저장]과 **같은 꼴**로 적음(뒷길이는 정수 등) — 두 길의 꼴이 같아야 함. + # ⚠ 검사(`coerce`)를 먼저 — `clean_spec` 은 못 읽은 값을 빼 버려 「지우기」로 읽힘. + value = clean_spec({key: value})[0].get(key, value) + options = dict(target.get("options") or {}) + old = None if _blank(options.get(key)) else options[key] + if (value is None and old is None) or (value is not None and _same(old, value)): + continue + if value is None: + options.pop(key, None) + else: + options[key] = value + target["options"] = options + cells = marks.setdefault(row_id, {}) + mark = cells.get(key) + # 옛 손 표가 정본과 같을 때만 그 「고치기 전」을 이어 씀 — B05 가 바꾼 뒤면 지금 값이 기준. + was = mark.get("was") if mark and _same(mark.get("value"), old) else old + if _same(was, value): + cells.pop(key, None) + else: + cells[key] = {"value": value, "was": was} + if not cells: + marks.pop(row_id) + if row_id not in changed: + changed.append(row_id) + return changed, marks + + +def prune_marks( + marks: dict[str, dict[str, Any]], + structures: Iterable[dict[str, Any]], + points: Iterable[dict[str, Any]], +) -> dict[str, dict[str, Any]]: + """[저장] 때 손댄 칸 표 정리 — 없어진 줄과 **B05 가 이미 바꾼 칸**(알림을 본 뒤)은 뺌.""" + current = {str(item.get("structure_id")): item for item in structures} + current |= {pipe_row_id(point): point for point in points} + kept: dict[str, dict[str, Any]] = {} + for row_id, cells in marks.items(): + if row_id not in current: + continue + options = current[row_id].get("options") or {} + alive = { + key: mark + for key, mark in cells.items() + if _same(None if _blank(options.get(key)) else options[key], mark.get("value")) + } + if alive: + kept[row_id] = alive + return kept + + def pipe_lengths_from_designs(designs: Iterable[dict[str, Any]]) -> dict[float, float]: """저장된 횡단 설계 → `{측점: 관 연장}` — 배수관 물량과 같은 읽기.""" from B08_Quantity.B08_Quantity_Engine_Pipe import _length_by_chainage diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index f349c321..99070be4 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -23,6 +23,7 @@ from uuid import UUID from fastapi import APIRouter from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict, Field from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B06_Section.B06_Section_Repository import get_cross_section_designs @@ -246,6 +247,109 @@ async def _designs(project_id: UUID) -> list[dict[str, Any]]: return [] +class StructureSummaryEdit(BaseModel): + """집계표 칸 하나 — 빈 값(null·"")은 그 칸을 지움.""" + + model_config = ConfigDict(extra="forbid") + + id: str = Field(min_length=1, max_length=200) + key: str = Field(min_length=1, max_length=100) + value: float | str | None = None + + +class StructureSummaryEditRequest(BaseModel): + """[저장] 한 번에 보내는 손 고침 — 읽어 간 구조물 판번호와 함께.""" + + model_config = ConfigDict(extra="forbid") + + base_revision: int = Field(ge=0) + edits: list[StructureSummaryEdit] = Field(max_length=500) + + +def _summary_error(status: int, message: str) -> JSONResponse: + return JSONResponse(status_code=status, content={"status": "error", "message": message}) + + +@router.put("/{project_id}/quantity/structure-summary") +async def put_structure_summary( + project_id: UUID, payload: StructureSummaryEditRequest +) -> JSONResponse: + """집계표 손 고침을 **정본에 바로 씀**(브레인 판정) + 산출 조건에 「손댄 칸」 표. + + ⚠ 덮개층을 두지 않음 — `structures.json`·`pipe_points.json` 이 곧 도면·수량의 한 값(지침 5장). + ⚠ 다 맞춰 본 뒤에 씀 — 한 칸이라도 틀리면 아무것도 안 씀(구조물 판번호가 어긋나도 409). + ⛔ 놓기 칸(자리·길이·높이·관경)은 거절 — 시·종점과 한 벌이라 B05 몫. + """ + import asyncio + + from B05_Profile.B05_Profile_Structures_Repository import ( + StructureRevisionConflict, + save_structures, + ) + from B05_Profile.B05_Profile_Structures_Schema import StructureInstance + from B08_Quantity.B08_Quantity_Engine_StructureSummary import ( + USER_CELLS_KEY, + apply_edits, + pipe_row_id, + prune_marks, + ) + from common_util.common_util_drainage_pipes import pipe_points_path_in + from common_util.common_util_json import atomic_write_json + from common_util.common_util_project_settings import save_section + + try: + stored_path = await run_with_connection(get_project_storage_relative_path, project_id) + project_root = resolve_stored_project_path(stored_path) + revision, items = load_structures(project_root) + path = pipe_points_path_in(Path(project_root)) + document = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {} + except Exception: + logger.exception("B08 구조물 집계표 저장 — 정본 읽기 실패: project_id=%s", project_id) + return _summary_error(500, "구조물 정본을 읽지 못했습니다.") + if revision != payload.base_revision: + return _summary_error(409, "그 사이 구조물이 바뀌었습니다 — 표를 다시 불러와 고칠 것.") + + structures = [item.model_dump() for item in items] + points = [point for point in document.get("points") or [] if isinstance(point, dict)] + settings = quantity_settings(project_root) + try: + changed, marks = apply_edits( + structures, + points, + structure_type_map(), + [edit.model_dump() for edit in payload.edits], + settings.get(USER_CELLS_KEY) or {}, + ) + instances = [StructureInstance.model_validate(item) for item in structures] + except (LookupError, ValueError) as exc: + return _summary_error(422, str(exc)) + + new_revision = revision + pipe_ids = {pipe_row_id(point) for point in points} + try: + if any(row_id not in pipe_ids for row_id in changed): + new_revision = await asyncio.to_thread( + save_structures, project_root, instances, base_revision=revision + ) + if any(row_id in pipe_ids for row_id in changed): + # 관 지점 파일은 옵션만 바꿔 **그대로** 씀 — 노선 지문·좌표·다른 칸은 손대지 않음. + await asyncio.to_thread(atomic_write_json, path, {**document, "points": points}) + except StructureRevisionConflict: + return _summary_error(409, "그 사이 구조물이 바뀌었습니다 — 표를 다시 불러와 고칠 것.") + except ValueError as exc: + return _summary_error(422, str(exc)) + await asyncio.to_thread( + save_section, + project_root, + "quantity", + {USER_CELLS_KEY: prune_marks(marks, structures, points)}, + replace_keys=[USER_CELLS_KEY], + ) + return JSONResponse( + content={"status": "success", "revision": new_revision, "changed_rows": len(changed)} + ) + + async def project_haul_inputs(project_id: UUID) -> dict[str, Any]: """유토곡선(B06)이 받아야 할 **구조물 몫** — 채집석 공제 · 구조물 잔토. diff --git a/B08_Quantity/B08_Quantity_UI_StructureSummary.ts b/B08_Quantity/B08_Quantity_UI_StructureSummary.ts index b1ef37a1..cf239bbc 100644 --- a/B08_Quantity/B08_Quantity_UI_StructureSummary.ts +++ b/B08_Quantity/B08_Quantity_UI_StructureSummary.ts @@ -1,10 +1,12 @@ /* ============================================================================= * B08_Quantity_UI_StructureSummary.ts - * **구조물 집계표** 탭 — 측점별 구조물 한 줄 · 종류별 표 (PLAN 2장). + * **구조물 집계표** 탭 — 측점별 구조물 한 줄 · 종류별 표 · 칸 고치기 (PLAN 2장). * * ⚠ 값을 셈하지 않음 — 서버(`…/quantity/structure-summary`)가 정본을 읽어 세운 줄을 적기만. * ⚠ 칸마다 출처 표시 — 자동(정본) · 사용자(이 표에서 고침) · 라이브러리(양식 기본값) · 빈칸. * ⚠ 이 표에서 고친 값을 B05 가 바꿔 자동으로 돌아간 칸은 **조용히 넘기지 않고** 줄에 알림. + * ⚠ 고치기는 데이터 3층 — 칸 조작은 캐시(sessionStorage)에만, [저장] 때 서버가 **정본에** 씀. + * 상세 칸만 고침 — 자리·길이·높이·관경 같은 놓기 칸은 시·종점과 한 벌이라 B05 몫(회색). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -12,12 +14,13 @@ import { stationLabel } from "./B08_Quantity_UI_EarthworkGrid"; import { el, num } from "./B08_Quantity_UI_StructureSheet_Formula"; type Source = "auto" | "user" | "library" | "empty"; +type CellValue = number | string | null; interface SummaryCell { - value: number | string | null; + value: CellValue; source: Source; - was?: number | string | null; - replaced_user_value?: number | string | null; + was?: CellValue; + replaced_user_value?: CellValue; } interface SummaryRow { @@ -35,12 +38,19 @@ interface SummaryRow { cells: Record; } +interface SummaryColumn { + key: string; + label: string; + unit: string; + input: string; + choices: string[]; + editable: boolean; +} + interface SummaryTable { type_id: string; name: string; - group: string; - placement: string; - columns: { key: string; label: string; unit: string; input: string }[]; + columns: SummaryColumn[]; rows: SummaryRow[]; count: number; length_total_m: number | null; @@ -49,11 +59,18 @@ interface SummaryTable { } interface SummaryResponse { + revision: number; tables: SummaryTable[]; notes: string[]; message?: string; } +interface Edit { + id: string; + key: string; + value: CellValue; +} + const SOURCE_LABELS: Record = { auto: "자동 — 구조물 놓기(B05)·계곡 시설 정본 값", user: "사용자 — 이 표에서 고친 값(정본에 적힘)", @@ -70,7 +87,12 @@ const CSS = ` .b08-sum__cell--library { box-shadow: inset 3px 0 0 var(--color-success, #5cb85c); } .b08-sum__cell--empty { color: var(--color-text-muted, #999); } .b08-sum__cell--replaced { outline: 2px solid var(--color-danger, #d9534f); outline-offset: -2px; } +.b08-sum td.is-changed { background: color-mix(in srgb, var(--color-accent, #6c8ebf) 18%, transparent); } .b08-sum .b08-grid__table td { white-space: nowrap; } +.b08-sum td input, .b08-sum td select { font-size: 12px; max-width: 8rem; } +.b08-sum td input[type=number] { width: 5rem; } +.b08-sum td button { font-size: 11px; padding: 0 4px; margin-left: 2px; cursor: pointer; } +.b08-sum__actions { display: flex; gap: 8px; align-items: center; } `; function injectStyles(): void { @@ -82,9 +104,8 @@ function injectStyles(): void { } /** 칸 글 — 정수는 그대로(뒷길이 45), 소수는 둘째 자리까지. */ -function cellText(cell: SummaryCell | undefined): string { - if (!cell || cell.value === null || cell.value === "") return ""; - const value = cell.value; +function cellText(value: CellValue | undefined): string { + if (value === null || value === undefined || value === "") return ""; return typeof value === "number" && !Number.isInteger(value) ? num(value, 2) : String(value); } @@ -95,7 +116,126 @@ function station(row: SummaryRow): string { return row.chainage_m === null ? "" : stationLabel(row.chainage_m); } -function renderTable(table: SummaryTable): HTMLElement { +/** 정본에 지금 적힌 값 — 라이브러리·빈칸은 정본이 빈 것. */ +function storedValue(cell: SummaryCell | undefined): CellValue { + return cell && (cell.source === "auto" || cell.source === "user") ? cell.value : null; +} + +function same(a: CellValue, b: CellValue): boolean { + return String(a ?? "") === String(b ?? ""); +} + +/** 고친 칸 캐시 — [저장] 전까지 이 탭(sessionStorage)에만 둠. 판번호가 다르면 버림. */ +class Draft { + private readonly storageKey: string; + readonly edits = new Map(); + onChange: () => void = () => undefined; + + constructor( + projectId: string, + readonly revision: number, + ) { + this.storageKey = `b08-structure-summary-draft:${projectId}`; + try { + const saved = JSON.parse(sessionStorage.getItem(this.storageKey) ?? "null") as { + revision: number; + edits: Edit[]; + } | null; + if (saved?.revision === revision) { + for (const edit of saved.edits) this.edits.set(`${edit.id}|${edit.key}`, edit); + } + } catch { + // 캐시를 못 읽으면 빈 초안 — 정본은 그대로. + } + } + + get(id: string, key: string): Edit | undefined { + return this.edits.get(`${id}|${key}`); + } + + set(edit: Edit, stored: CellValue): void { + const slot = `${edit.id}|${edit.key}`; + if (same(edit.value, stored)) this.edits.delete(slot); + else this.edits.set(slot, edit); + this.persist(); + } + + clear(): void { + this.edits.clear(); + this.persist(); + } + + private persist(): void { + try { + if (this.edits.size) { + const body = { revision: this.revision, edits: [...this.edits.values()] }; + sessionStorage.setItem(this.storageKey, JSON.stringify(body)); + } else { + sessionStorage.removeItem(this.storageKey); + } + } catch { + // 저장소가 막혀도 화면 초안은 살아 있음. + } + this.onChange(); + } +} + +function editor( + column: SummaryColumn, + row: SummaryRow, + cell: SummaryCell | undefined, + draft: Draft, + td: HTMLTableCellElement, +): HTMLElement[] { + const stored = storedValue(cell); + const pending = draft.get(row.id, column.key); + const current = pending ? pending.value : stored; + let control: HTMLInputElement | HTMLSelectElement; + if (column.input === "select" && column.choices.length) { + const select = document.createElement("select"); + select.append(new Option("—", "")); + for (const choice of column.choices) select.append(new Option(choice, choice)); + select.value = current === null ? "" : String(current); + control = select; + } else { + const input = document.createElement("input"); + input.type = column.input === "number" ? "number" : "text"; + if (column.input === "number") { + input.min = "0"; + input.step = "any"; + } + input.value = current === null ? "" : String(current); + control = input; + } + if (cell?.source === "library") control.title = `비우면 양식 기본값 ${cellText(cell.value)}`; + if (control instanceof HTMLInputElement && cell?.source === "library") { + control.placeholder = cellText(cell.value); + } + const mark = (): void => { + td.classList.toggle("is-changed", Boolean(draft.get(row.id, column.key))); + }; + control.addEventListener("change", () => { + const raw = control.value.trim(); + const value = raw === "" ? null : column.input === "number" ? Number(raw) : raw; + draft.set({ id: row.id, key: column.key, value }, stored); + mark(); + }); + mark(); + const parts: HTMLElement[] = [control]; + if (cell?.source === "user" && cell.was !== undefined) { + const undo = el("button", "", "↺"); + undo.type = "button"; + undo.title = `고치기 전 값(${cellText(cell.was) || "빈칸"})으로 — [저장]하면 「자동」으로 돌아감`; + undo.addEventListener("click", () => { + control.value = cell.was === null || cell.was === undefined ? "" : String(cell.was); + control.dispatchEvent(new Event("change")); + }); + parts.push(undo); + } + return parts; +} + +function renderTable(table: SummaryTable, draft: Draft): HTMLElement { const wrap = el("div", "b08-grid"); const length = table.length_total_m === null ? "" : ` · 연장 합 ${num(table.length_total_m, 2)} m`; @@ -104,9 +244,13 @@ function renderTable(table: SummaryTable): HTMLElement { const scroller = el("div", "b08-grid__scroll"); const grid = el("table", "b08-grid__table"); const head = document.createElement("tr"); - for (const label of ["측점", "연장(m)", ...table.columns.map((c) => c.label), "비고"]) { - head.append(el("th", "", label)); + head.append(el("th", "", "측점"), el("th", "", "연장(m)")); + for (const column of table.columns) { + const th = el("th", "", column.label); + if (!column.editable) th.title = "놓기 칸 — 시·종점과 한 벌이라 구조물 놓기(B05)에서 고침"; + head.append(th); } + head.append(el("th", "", "비고")); const thead = document.createElement("thead"); thead.append(head); const tbody = document.createElement("tbody"); @@ -117,14 +261,16 @@ function renderTable(table: SummaryTable): HTMLElement { tr.append(el("td", "", station(row)), lengthCell); for (const column of table.columns) { const cell = row.cells[column.key]; - const td = el("td", `b08-sum__cell--${cell?.source ?? "empty"}`, cellText(cell)); + const td = el("td", `b08-sum__cell--${cell?.source ?? "empty"}`); + if (column.editable) td.append(...editor(column, row, cell, draft, td)); + else td.textContent = cellText(cell?.value); td.title = cell ? SOURCE_LABELS[cell.source] : ""; - if (cell?.source === "user" && cell.was !== undefined && cell.was !== null) { - td.title += ` · 고치기 전 ${cell.was}`; + if (cell?.source === "user" && cell.was !== undefined) { + td.title += ` · 고치기 전 ${cellText(cell.was) || "빈칸"}`; } if (cell?.replaced_user_value !== undefined) { td.classList.add("b08-sum__cell--replaced"); - td.title = `이 표에서 ${cell.replaced_user_value}(으)로 고쳤으나 B05 가 바꿔 자동값으로 돌아감`; + td.title = `이 표에서 ${cellText(cell.replaced_user_value) || "빈칸"}(으)로 고쳤으나 B05 가 바꿔 자동값으로 돌아감`; } tr.append(td); } @@ -150,7 +296,7 @@ function renderTable(table: SummaryTable): HTMLElement { return wrap; } -/** 탭 본문 — 받는 동안 안내, 오면 종류별 표. */ +/** 탭 본문 — 받는 동안 안내, 오면 종류별 표. [저장] 뒤엔 다시 받음. */ export function renderStructureSummary(projectId: string | null): HTMLElement { injectStyles(); const root = el("div", "b08-sum"); @@ -158,20 +304,71 @@ export function renderStructureSummary(projectId: string | null): HTMLElement { root.append(el("p", "b08-quantity__message", "프로젝트를 먼저 고를 것")); return root; } - root.append(el("p", "b08-grid__caption", "구조물 집계표 불러오는 중…")); - void (async () => { + const url = `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-summary`; + let draft: Draft | null = null; + // 저장 안 한 칸이 조용히 사라지지 않게 — 나갈 때 물음(자동저장은 안 만듦, CLAUDE.md 5장). + window.addEventListener("beforeunload", (event) => { + if (!draft?.edits.size || !root.isConnected) return; + event.preventDefault(); + event.returnValue = "저장 안 한 칸이 있음"; + }); + + const load = async (): Promise => { + root.replaceChildren(el("p", "b08-grid__caption", "구조물 집계표 불러오는 중…")); try { - const response = await fetch( - `${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/quantity/structure-summary`, - { credentials: "include" }, - ); + const response = await fetch(url, { credentials: "include" }); const payload = (await response.json().catch(() => ({}))) as SummaryResponse; if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); + const active = new Draft(projectId, payload.revision); + draft = active; const legend = el("div", "b08-sum__legend"); for (const source of ["auto", "user", "library", "empty"] as Source[]) { - const chip = el("span", `b08-sum__cell--${source}`, ` ${SOURCE_LABELS[source]} `); - legend.append(chip); + legend.append(el("span", `b08-sum__cell--${source}`, ` ${SOURCE_LABELS[source]} `)); } + const save = el("button", "b08-spec__save", "저장"); + save.type = "button"; + const discard = el("button", "b08-quantity__tab", "고친 것 버리기"); + discard.type = "button"; + const status = el("span", "b08-grid__caption"); + active.onChange = () => { + const count = active.edits.size; + save.disabled = count === 0; + discard.disabled = count === 0; + status.textContent = count + ? `고친 칸 ${count} — [저장]해야 정본에 적힘(구조물도·원단위가 그 값을 씀)` + : "상세 칸만 고침 · 자리·길이·높이·관경은 구조물 놓기(B05)에서"; + }; + active.onChange(); + save.addEventListener("click", () => { + void (async () => { + save.disabled = true; + status.textContent = "저장 중…"; + try { + const result = await fetch(url, { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + base_revision: active.revision, + edits: [...active.edits.values()], + }), + }); + const body = (await result.json().catch(() => ({}))) as { message?: string }; + if (!result.ok) throw new Error(body.message ?? `HTTP ${result.status}`); + active.clear(); + await load(); + } catch (error) { + status.textContent = `저장 못 함 — ${error instanceof Error ? error.message : ""}`; + save.disabled = false; + } + })(); + }); + discard.addEventListener("click", () => { + active.clear(); + void load(); + }); + const actions = el("div", "b08-sum__actions"); + actions.append(save, discard, status); const total = payload.tables.reduce((sum, table) => sum + table.count, 0); root.replaceChildren( el( @@ -180,9 +377,10 @@ export function renderStructureSummary(projectId: string | null): HTMLElement { `구조물 집계표 · ${payload.tables.length}종 · ${total}개소 (측점별 실치수 — 구조물도가 이 값을 씀)`, ), legend, + actions, ...payload.notes.map((note) => el("p", "b08-grid__caption b08-grid__caption--warn", note)), ...(payload.tables.length - ? payload.tables.map(renderTable) + ? payload.tables.map((table) => renderTable(table, active)) : [el("p", "b08-grid__caption", "놓인 구조물이 없음")]), ); } catch (error) { @@ -194,6 +392,7 @@ export function renderStructureSummary(projectId: string | null): HTMLElement { ), ); } - })(); + }; + void load(); return root; } diff --git a/resources/tester/test_b08_structure_summary.py b/resources/tester/test_b08_structure_summary.py index 7532b0da..8112d63c 100644 --- a/resources/tester/test_b08_structure_summary.py +++ b/resources/tester/test_b08_structure_summary.py @@ -6,6 +6,8 @@ ③ 계곡 통과 시설은 관 지점 정본에서 — 배수관과 세월교는 **다른 표**(브레인 판정) · 관 연장은 B06 횡단 값(0.5m 안) · 없으면 빈칸(0 아님) ④ 손댄 칸은 「사용자」 · 그 값을 B05 가 바꾸면 자동으로 돌아가되 **알림이 남음** + ⑤ [저장] — 정본에 바로 씀(덮개층 없음) · 상세 칸만 · 놓기 칸·틀린 값·판번호 어긋남은 + 아무것도 안 씀 · 고치기 전 값으로 되돌리면 「자동」 · 관 지점 파일은 옵션만 바뀜 """ from __future__ import annotations @@ -144,3 +146,113 @@ def test_창구가_정본_둘과_횡단_관_연장을_읽어_표를_낸다(clien pipes = next(table for table in body["tables"] if table["type_id"] == "pipe") assert pipes["rows"][0]["length_m"] == pytest.approx(8.0) assert body["revision"] == 1 + columns = {c["key"]: c for c in pipes["columns"]} + assert columns["pipe_diameter_mm"]["editable"] is False # 놓기 칸 + assert columns["wing_wall_type"]["editable"] is True # 상세 칸 + + +SUMMARY = f"/api/projects/{PROJECT_ID}/quantity/structure-summary" + + +def _cell(client: TestClient, type_id: str, row_id: str, key: str) -> dict: + tables = client.get(SUMMARY).json()["tables"] + table = next(t for t in tables if t["type_id"] == type_id) + return next(r for r in table["rows"] if r["id"] == row_id)["cells"][key] + + +def test_저장은_정본에_바로_쓰고_손댄_칸을_표시한다(client: TestClient, tmp_path: Path) -> None: + from B05_Profile.B05_Profile_Structures_Repository import load_structures + from common_util.common_util_project_settings import quantity_settings + + root = tmp_path / "project" + saved = client.put( + SUMMARY, + json={ + "base_revision": 1, + "edits": [ + {"id": "w1", "key": "back_len_cm", "value": "55"}, + {"id": "w2", "key": "stone_kind", "value": "야면석·호박돌"}, + {"id": "pipe@60.200", "key": "wing_wall_type", "value": "A-TYPE"}, + ], + }, + ) + assert saved.status_code == 200, saved.text + assert saved.json()["revision"] == 2 and saved.json()["changed_rows"] == 3 + + # 정본이 곧 값 — 구조물도·원단위가 읽는 자리에 그대로 적힘. + _rev, items = load_structures(str(root)) + assert {i.structure_id: i.options.get("back_len_cm") for i in items}["w1"] == 55 + document = json.loads(pipe_points_path_in(root).read_text(encoding="utf-8")) + assert document["points"][0]["options"] == { + "pipe_kind": "흄관", + "pipe_diameter_mm": 800, + "wing_wall_type": "A-TYPE", + } + assert document["route_signature"] == "" and len(document["points"]) == 3 + assert _cell(client, "masonry_wet", "w1", "back_len_cm") == { + "value": 55, + "source": "user", + "was": 35, + } + + # 고치기 전 값으로 되돌리면 손 표가 빠지고 「자동」. + back = client.put( + SUMMARY, + json={"base_revision": 2, "edits": [{"id": "w1", "key": "back_len_cm", "value": 35}]}, + ) + assert back.status_code == 200, back.text + assert _cell(client, "masonry_wet", "w1", "back_len_cm")["source"] == "auto" + marks = quantity_settings(str(root))["structure_summary_user_cells"] + assert "w1" not in marks and "w2" in marks + + +def test_놓기_칸이나_틀린_값이나_판번호_어긋남은_아무것도_안_쓴다( + client: TestClient, tmp_path: Path +) -> None: + from B05_Profile.B05_Profile_Structures_Repository import structures_file_path + + root = tmp_path / "project" + + def files() -> tuple[str, str]: + return ( + Path(structures_file_path(str(root))).read_text(encoding="utf-8"), + pipe_points_path_in(root).read_text(encoding="utf-8"), + ) + + before = files() + + def put(edits: list[dict], revision: int = 1): + return client.put(SUMMARY, json={"base_revision": revision, "edits": edits}) + + good = {"id": "pipe@60.200", "key": "wing_wall_type", "value": "A-TYPE"} + height = put([good, {"id": "w1", "key": "height_m", "value": 3}]) + assert height.status_code == 422 and "구조물 놓기(B05)" in height.json()["message"] + assert put([good, {"id": "w1", "key": "back_len_cm", "value": -1}]).status_code == 422 + assert put([good, {"id": "w1", "key": "stone_kind", "value": "없는 돌"}]).status_code == 422 + assert put([good, {"id": "nope", "key": "back_len_cm", "value": 1}]).status_code == 422 + assert put([good], revision=0).status_code == 409 + assert files() == before + + +def test_B05_가_바꾼_칸은_알림_뒤_저장하면_손_표에서_빠진다() -> None: + from B08_Quantity.B08_Quantity_Engine_StructureSummary import apply_edits, prune_marks + + walls = [dict(w, options=dict(w["options"])) for w in WALLS] + types = structure_type_map() + changed, marks = apply_edits( + walls, [], types, [{"id": "w1", "key": "back_len_cm", "value": 55}], {} + ) + assert changed == ["w1"] and marks == {"w1": {"back_len_cm": {"value": 55, "was": 35}}} + walls[0]["options"]["back_len_cm"] = 45 # B05 가 바꿈 + assert _tables_from(walls, marks)["masonry_wet"]["rows"][1]["replaced"] == ["뒷길이"] + assert prune_marks(marks, walls, []) == {} + # B05 가 바꾼 뒤 다시 고치면 「고치기 전」은 B05 값(45). + _, again = apply_edits( + walls, [], types, [{"id": "w1", "key": "back_len_cm", "value": 60}], marks + ) + assert again["w1"]["back_len_cm"] == {"value": 60, "was": 45} + + +def _tables_from(walls: list[dict], marks: dict) -> dict: + body = build_summary(walls, [], structure_type_map(), {}, {}, marks) + return {table["type_id"]: table for table in body["tables"]}