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:
@@ -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)이 받아야 할 **구조물 몫** — 채집석 공제 · 구조물 잔토.
|
||||
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
Reference in New Issue
Block a user