feat(b08): 구조물 집계표 칸 고치기 — 정본에 바로 쓰고 손댄 칸을 표시

- 칸 조작은 캐시(sessionStorage)에, [저장] 때 structures.json·pipe_points.json 에 바로 씀
- 산출 조건에 손댄 칸 표(고치기 전 값) · ↺ 로 되돌리면 자동으로 돌아감
- 상세 칸만 고침 — 자리·길이·높이·관경은 시·종점과 한 벌이라 구조물 놓기(B05) 몫
- 틀린 값·놓기 칸·판번호 어긋남은 아무것도 안 씀 · 값 꼴은 구조물도 제원 저장과 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
This commit is contained in:
2026-09-13 21:55:50 +09:00
co-authored by Claude Opus 5
parent d35c5082d9
commit 46b582762c
4 changed files with 573 additions and 30 deletions
@@ -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
@@ -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)이 받아야 할 **구조물 몫** — 채집석 공제 · 구조물 잔토.
+227 -28
View File
@@ -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<string, SummaryCell>;
}
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<Source, string> = {
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<string, Edit>();
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<void> => {
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;
}
@@ -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"]}